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"
/>
This commit is contained in:
Víctor Falcón 2026-02-23 13:59:10 +01:00 committed by GitHub
parent faddd59537
commit 299b8a56d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2768 additions and 94 deletions

View File

@ -213,7 +213,7 @@ class ResetDemoAccountCommand extends Command
'type' => $accountData['type'],
]);
$this->createBalanceHistory($account, $accountData['current_balance'], $accountData['monthly_variance']);
$this->createBalanceHistory($account, $accountData['current_balance'], $accountData['monthly_variance'], $accountData['type']);
$createdAccounts[] = $account;
@ -235,7 +235,7 @@ class ResetDemoAccountCommand extends Command
return (int) (floor($base / 100) * 100 + $cents);
}
private function createBalanceHistory(Account $account, int $currentBalance, int $monthlyVariance): void
private function createBalanceHistory(Account $account, int $currentBalance, int $monthlyVariance, AccountType $type): void
{
$targetFirstMonthBalance = (int) ($currentBalance / (1 + self::MIN_BALANCE_GROWTH_PERCENTAGE));
$balance = $currentBalance;
@ -273,11 +273,20 @@ class ResetDemoAccountCommand extends Command
}
}
foreach ($balances as $balanceData) {
$account->balances()->create([
$trackInvestedAmount = $type->supportsInvestedAmount();
foreach ($balances as $i => $balanceData) {
$attributes = [
'balance_date' => $balanceData['date']->format('Y-m-d'),
'balance' => $balanceData['balance'],
]);
];
if ($trackInvestedAmount) {
$gainPercentage = 0.05 + (0.20 - 0.05) * ($i / 12);
$attributes['invested_amount'] = (int) ($balanceData['balance'] / (1 + $gainPercentage));
}
$account->balances()->create($attributes);
}
}

View File

@ -13,7 +13,8 @@ class SyncBankingConnections extends Command
{
protected $signature = 'banking:sync
{--user= : Filter by user email address}
{--connection= : Filter by banking connection ID}';
{--connection= : Filter by banking connection ID}
{--sync : Run synchronously instead of dispatching to the queue}';
protected $description = 'Sync transactions and balances for all active banking connections';
@ -21,8 +22,15 @@ class SyncBankingConnections extends Command
{
$userEmail = $this->option('user');
$connectionId = $this->option('connection');
$sync = $this->option('sync');
if (! $userEmail && ! $connectionId) {
if ($sync) {
$this->error('The --sync option requires --user and/or --connection filters.');
return Command::FAILURE;
}
SyncAllBankingConnectionsJob::dispatch();
$this->info('Banking sync jobs dispatched for all active connections.');
@ -61,11 +69,18 @@ class SyncBankingConnections extends Command
return Command::SUCCESS;
}
$connections->each(function (BankingConnection $connection) {
SyncBankingConnectionJob::dispatch($connection);
$connections->each(function (BankingConnection $connection) use ($sync) {
if ($sync) {
$this->info("Syncing {$connection->provider} connection {$connection->id}...");
SyncBankingConnectionJob::dispatchSync($connection);
$this->info("Finished syncing {$connection->provider} connection {$connection->id}.");
} else {
SyncBankingConnectionJob::dispatch($connection);
}
});
$this->info("Banking sync jobs dispatched for {$connections->count()} connection(s).");
$verb = $sync ? 'synced' : 'dispatched';
$this->info("Banking sync jobs {$verb} for {$connections->count()} connection(s).");
return Command::SUCCESS;
}

View File

@ -11,4 +11,12 @@ enum AccountType: string
case Retirement = 'retirement';
case Savings = 'savings';
case Others = 'others';
/**
* Whether this account type supports tracking invested amount and gains/losses.
*/
public function supportsInvestedAmount(): bool
{
return in_array($this, [self::Investment, self::Retirement], true);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreAccountBalanceRequest;
use App\Http\Requests\UpdateCurrentAccountBalanceRequest;
use App\Models\Account;
use App\Models\AccountBalance;
@ -29,14 +30,11 @@ class AccountBalanceController extends Controller
/**
* Store a new balance for an account.
*/
public function store(Account $account): JsonResponse
public function store(StoreAccountBalanceRequest $request, Account $account): JsonResponse
{
$this->authorize('update', $account);
$validated = request()->validate([
'balance' => 'required|numeric',
'balance_date' => 'required|date',
]);
$validated = $request->validated();
$balance = AccountBalance::updateOrCreate(
[
@ -45,6 +43,9 @@ class AccountBalanceController extends Controller
],
[
'balance' => $validated['balance'],
...array_key_exists('invested_amount', $validated)
? ['invested_amount' => $validated['invested_amount']]
: [],
]
);
@ -61,6 +62,7 @@ class AccountBalanceController extends Controller
$this->authorize('update', $account);
$today = now()->toDateString();
$validated = $request->validated();
$balance = AccountBalance::updateOrCreate(
[
@ -68,7 +70,10 @@ class AccountBalanceController extends Controller
'balance_date' => $today,
],
[
'balance' => $request->validated()['balance'],
'balance' => $validated['balance'],
...array_key_exists('invested_amount', $validated)
? ['invested_amount' => $validated['invested_amount']]
: [],
]
);

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Transaction;
use App\Services\BalanceLookup;
use App\Services\ExchangeRateService;
use App\Services\PeriodComparator;
use Carbon\Carbon;
@ -85,6 +86,12 @@ class DashboardAnalyticsController extends Controller
->with(['bank:id,name,logo'])
->get();
$accountIds = $accounts->pluck('id');
// Include "now" in the range end so accountsConfig invested_amount lookups are covered
$lookupEnd = Carbon::now()->gt($end) ? Carbon::now() : $end->copy();
$lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $lookupEnd);
$points = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
@ -97,7 +104,7 @@ class DashboardAnalyticsController extends Controller
];
foreach ($accounts as $account) {
$originalBalance = $this->getBalanceAt($account->id, $date);
$originalBalance = $lookup->getBalanceAt($account->id, $date);
$convertedBalance = $this->convertBalance(
$originalBalance,
$account->currency_code,
@ -113,24 +120,39 @@ class DashboardAnalyticsController extends Controller
'currency_code' => $account->currency_code,
];
}
if ($account->type->supportsInvestedAmount()) {
$investedAmount = $lookup->getInvestedAmountAt($account->id, $date);
$point[$account->id.'_invested'] = $investedAmount !== null
? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $date->toDateString())
: null;
}
}
$points[] = $point;
$current->addMonth();
}
$accountsConfig = $accounts->mapWithKeys(function ($account) {
return [
$account->id => [
'id' => $account->id,
'name' => $account->name,
'name_iv' => $account->name_iv,
'encrypted' => $account->encrypted,
'type' => $account->type,
'currency_code' => $account->currency_code,
'bank' => $account->bank,
],
$now = Carbon::now();
$accountsConfig = $accounts->mapWithKeys(function ($account) use ($userCurrency, $lookup, $now) {
$config = [
'id' => $account->id,
'name' => $account->name,
'name_iv' => $account->name_iv,
'encrypted' => $account->encrypted,
'type' => $account->type,
'currency_code' => $account->currency_code,
'bank' => $account->bank,
];
if ($account->type->supportsInvestedAmount()) {
$investedAmount = $lookup->getInvestedAmountAt($account->id, $now);
$config['invested_amount'] = $investedAmount !== null
? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $now->toDateString())
: null;
}
return [$account->id => $config];
});
return response()->json([
@ -154,17 +176,25 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$lookup = BalanceLookup::forAccounts([$account->id], $start->copy()->startOfMonth(), $end);
$points = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$date = $current->copy()->endOfMonth();
$points[] = [
$point = [
'month' => $date->format('Y-m'),
'timestamp' => $date->timestamp,
'value' => $this->getBalanceAt($account->id, $date),
'value' => $lookup->getBalanceAt($account->id, $date),
];
if ($account->type->supportsInvestedAmount()) {
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
}
$points[] = $point;
$current->addMonth();
}
@ -195,16 +225,24 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$lookup = BalanceLookup::forAccounts([$account->id], $start, $end);
$points = [];
$current = $start->copy();
while ($current->lte($end)) {
$date = $current->copy();
$points[] = [
$point = [
'date' => $date->format('Y-m-d'),
'timestamp' => $date->endOfDay()->timestamp,
'value' => $this->getBalanceAt($account->id, $date),
'value' => $lookup->getBalanceAt($account->id, $date),
];
if ($account->type->supportsInvestedAmount()) {
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
}
$points[] = $point;
$current->addDay();
}
@ -238,6 +276,9 @@ class DashboardAnalyticsController extends Controller
->with(['bank:id,name,logo'])
->get();
$accountIds = $accounts->pluck('id');
$lookup = BalanceLookup::forAccounts($accountIds, $start, $end);
$points = [];
$current = $start->copy();
@ -249,7 +290,7 @@ class DashboardAnalyticsController extends Controller
];
foreach ($accounts as $account) {
$originalBalance = $this->getBalanceAt($account->id, $date);
$originalBalance = $lookup->getBalanceAt($account->id, $date);
$convertedBalance = $this->convertBalance(
$originalBalance,
$account->currency_code,
@ -374,6 +415,20 @@ class DashboardAnalyticsController extends Controller
->value('balance') ?? 0;
}
/**
* Get the invested amount at a given date for an account.
* Returns null if no invested amount data is available.
*/
private function getInvestedAmountAt(string $accountId, Carbon $date): ?int
{
return AccountBalance::query()
->where('account_id', $accountId)
->where('balance_date', '<=', $date->toDateString())
->whereNotNull('invested_amount')
->orderBy('balance_date', 'desc')
->value('invested_amount');
}
/**
* Convert a balance from one currency to another, skipping conversion when currencies match.
*/

View File

@ -3,7 +3,6 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreAccountBalanceRequest extends FormRequest
{
@ -15,23 +14,15 @@ class StoreAccountBalanceRequest extends FormRequest
public function rules(): array
{
return [
'id' => ['sometimes', 'uuid'],
'account_id' => [
'required',
Rule::exists('accounts', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id);
}),
],
'balance_date' => ['required', 'date'],
'balance' => ['required', 'integer'],
'invested_amount' => ['nullable', 'integer'],
];
}
public function messages(): array
{
return [
'account_id.required' => 'The account is required.',
'account_id.exists' => 'The selected account does not exist.',
'balance_date.required' => 'The balance date is required.',
'balance_date.date' => 'The balance date must be a valid date.',
'balance.required' => 'The balance is required.',

View File

@ -15,6 +15,7 @@ class UpdateCurrentAccountBalanceRequest extends FormRequest
{
return [
'balance' => ['required', 'integer'],
'invested_amount' => ['nullable', 'integer'],
];
}

View File

@ -16,12 +16,15 @@ class AccountBalance extends Model
'account_id',
'balance_date',
'balance',
'invested_amount',
];
protected function casts(): array
{
return [
'balance_date' => 'date',
'balance' => 'integer',
'invested_amount' => 'integer',
];
}

View File

@ -0,0 +1,172 @@
<?php
namespace App\Services;
use App\Models\AccountBalance;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class BalanceLookup
{
/**
* Sorted balance records grouped by account ID.
* Each account maps to a list of ['date' => string, 'balance' => int, 'invested_amount' => ?int].
*
* @var array<string, list<array{date: string, balance: int, invested_amount: ?int}>>
*/
private array $balancesByAccount = [];
/**
* Sorted invested-amount-only records grouped by account ID.
* Filters out null invested_amount entries for carry-forward lookup.
*
* @var array<string, list<array{date: string, invested_amount: int}>>
*/
private array $investedByAccount = [];
/**
* Preload all balance data for a set of accounts covering the given date range.
*
* Executes exactly 2 queries:
* 1. The most recent balance record before the range start for each account (carry-forward seeds).
* 2. All balance records within the range.
*
* @param Collection<int, string>|array<string> $accountIds
*/
public static function forAccounts(Collection|array $accountIds, Carbon $rangeStart, Carbon $rangeEnd): self
{
$instance = new self;
if (empty($accountIds) || (is_countable($accountIds) && count($accountIds) === 0)) {
return $instance;
}
$accountIdList = $accountIds instanceof Collection ? $accountIds->all() : $accountIds;
$startDate = $rangeStart->toDateString();
$endDate = $rangeEnd->toDateString();
// Query 1: Get the latest balance record before the range start for each account.
// Uses a correlated subquery to find the max balance_date < rangeStart per account.
$carryForwardRecords = AccountBalance::query()
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->whereRaw('balance_date = (
SELECT MAX(ab2.balance_date)
FROM account_balances ab2
WHERE ab2.account_id = account_balances.account_id
AND ab2.balance_date < ?
)', [$startDate])
->get(['account_id', 'balance_date', 'balance', 'invested_amount']);
// For invested_amount carry-forward, we also need the latest non-null invested_amount
// before the range start, which might be on a different date than the latest balance.
$investedCarryForwardRecords = AccountBalance::query()
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->whereNotNull('invested_amount')
->whereRaw('balance_date = (
SELECT MAX(ab3.balance_date)
FROM account_balances ab3
WHERE ab3.account_id = account_balances.account_id
AND ab3.balance_date < ?
AND ab3.invested_amount IS NOT NULL
)', [$startDate])
->get(['account_id', 'balance_date', 'invested_amount']);
// Query 2: All balance records within the range.
$rangeRecords = AccountBalance::query()
->whereIn('account_id', $accountIdList)
->whereBetween('balance_date', [$startDate, $endDate])
->orderBy('balance_date')
->get(['account_id', 'balance_date', 'balance', 'invested_amount']);
// Build the per-account sorted arrays
foreach ($accountIdList as $accountId) {
$entries = [];
$investedEntries = [];
// Add carry-forward seed
$seed = $carryForwardRecords->firstWhere('account_id', $accountId);
if ($seed) {
$entries[] = [
'date' => $seed->balance_date->toDateString(),
'balance' => $seed->balance,
'invested_amount' => $seed->invested_amount,
];
}
// Add invested carry-forward seed
$investedSeed = $investedCarryForwardRecords->firstWhere('account_id', $accountId);
if ($investedSeed) {
$investedEntries[] = [
'date' => $investedSeed->balance_date->toDateString(),
'invested_amount' => $investedSeed->invested_amount,
];
}
// Add range records
foreach ($rangeRecords->where('account_id', $accountId) as $record) {
$dateStr = $record->balance_date->toDateString();
$entries[] = [
'date' => $dateStr,
'balance' => $record->balance,
'invested_amount' => $record->invested_amount,
];
if ($record->invested_amount !== null) {
$investedEntries[] = [
'date' => $dateStr,
'invested_amount' => $record->invested_amount,
];
}
}
$instance->balancesByAccount[$accountId] = $entries;
$instance->investedByAccount[$accountId] = $investedEntries;
}
return $instance;
}
/**
* Get the balance at a given date for an account (carry-forward semantics).
* Returns the most recent balance on or before the given date, or 0 if none exists.
*/
public function getBalanceAt(string $accountId, Carbon $date): int
{
$dateStr = $date->toDateString();
$entries = $this->balancesByAccount[$accountId] ?? [];
$result = 0;
foreach ($entries as $entry) {
if ($entry['date'] <= $dateStr) {
$result = $entry['balance'];
} else {
break;
}
}
return $result;
}
/**
* Get the invested amount at a given date for an account (carry-forward semantics).
* Returns null if no invested amount data is available on or before the given date.
*/
public function getInvestedAmountAt(string $accountId, Carbon $date): ?int
{
$dateStr = $date->toDateString();
$entries = $this->investedByAccount[$accountId] ?? [];
$result = null;
foreach ($entries as $entry) {
if ($entry['date'] <= $dateStr) {
$result = $entry['invested_amount'];
} else {
break;
}
}
return $result;
}
}

View File

@ -28,6 +28,9 @@ class BinanceBalanceSyncService
private const SNAPSHOT_WINDOW_DAYS = 30;
/** Max days per deposit/withdrawal history request window */
private const DEPOSIT_WINDOW_DAYS = 90;
/** Seconds to wait between API calls to avoid hitting Binance rate limits */
private const THROTTLE_SECONDS = 1;
@ -50,13 +53,17 @@ class BinanceBalanceSyncService
Sleep::for(self::THROTTLE_SECONDS)->seconds();
}
$this->syncCurrentBalance($account, $client);
$investedAmountCents = $this->calculateInvestedAmount($account, $client);
Sleep::for(self::THROTTLE_SECONDS)->seconds();
$this->syncCurrentBalance($account, $client, $investedAmountCents);
}
/**
* Sync today's balance using live ticker prices.
*/
public function syncCurrentBalance(Account $account, BinanceClient $client): void
public function syncCurrentBalance(Account $account, BinanceClient $client, ?int $investedAmountCents = null): void
{
$accountData = $client->getAccount();
$balances = $accountData['balances'] ?? [];
@ -73,7 +80,10 @@ class BinanceBalanceSyncService
$account->balances()->updateOrCreate(
['balance_date' => now()->toDateString()],
['balance' => $totalValueCents],
[
'balance' => $totalValueCents,
...($investedAmountCents !== null ? ['invested_amount' => $investedAmountCents] : []),
],
);
}
@ -282,4 +292,171 @@ class BinanceBalanceSyncService
return 0.0;
}
/**
* Calculate the net invested amount by fetching all deposit and withdrawal history.
* Net invested = sum of completed deposits - sum of completed withdrawals, converted to fiat.
* Fetches history in 90-day windows going as far back as possible.
*/
private function calculateInvestedAmount(Account $account, BinanceClient $client): ?int
{
$targetCurrency = strtoupper($account->currency_code);
$deposits = $this->fetchAllDepositHistory($client);
Sleep::for(self::THROTTLE_SECONDS)->seconds();
$withdrawals = $this->fetchAllWithdrawHistory($client);
if (empty($deposits) && empty($withdrawals)) {
return null;
}
$totalDeposited = $this->sumTransactionAmounts($deposits, $targetCurrency, 'deposit');
$totalWithdrawn = $this->sumTransactionAmounts($withdrawals, $targetCurrency, 'withdrawal');
return (int) round(($totalDeposited - $totalWithdrawn) * 100);
}
/**
* Fetch all deposit history by paginating through 90-day windows.
* Goes back up to 2 years (8 windows of 90 days).
*
* @return array<int, array>
*/
private function fetchAllDepositHistory(BinanceClient $client): array
{
return $this->fetchHistoryWindows(
fn (int $start, int $end, int $offset) => $client->getDepositHistory($start, $end, $offset),
);
}
/**
* Fetch all withdrawal history by paginating through 90-day windows.
* Goes back up to 2 years (8 windows of 90 days).
*
* @return array<int, array>
*/
private function fetchAllWithdrawHistory(BinanceClient $client): array
{
return $this->fetchHistoryWindows(
fn (int $start, int $end, int $offset) => $client->getWithdrawHistory($start, $end, $offset),
);
}
/**
* Generic method to fetch transaction history in 90-day windows going back up to 2 years.
* Paginates within each window using offset when the API returns the maximum number of records.
*
* @param callable(int, int, int): array $fetcher
* @return array<int, array>
*/
private function fetchHistoryWindows(callable $fetcher): array
{
$allRecords = [];
$endDate = now();
$maxWindows = 8; // ~2 years of 90-day windows
$limit = 1000;
$isFirst = true;
for ($i = 0; $i < $maxWindows; $i++) {
$windowEnd = $endDate->copy()->subDays($i * self::DEPOSIT_WINDOW_DAYS);
$windowStart = $windowEnd->copy()->subDays(self::DEPOSIT_WINDOW_DAYS);
$offset = 0;
do {
if (! $isFirst) {
Sleep::for(self::THROTTLE_SECONDS)->seconds();
}
$isFirst = false;
$records = $fetcher(
$windowStart->getTimestampMs(),
$windowEnd->getTimestampMs(),
$offset,
);
if (empty($records)) {
break;
}
foreach ($records as $record) {
$allRecords[] = $record;
}
$offset += count($records);
} while (count($records) >= $limit);
}
return $allRecords;
}
/**
* Sum transaction amounts, converting crypto amounts to fiat using the currency conversion service.
* Only includes completed transactions (status=1 for deposits, status=6 for withdrawals)
* and excludes internal transfers (transferType=1).
*
* @param array<int, array> $transactions
*/
private function sumTransactionAmounts(array $transactions, string $targetCurrency, string $type): float
{
$successStatus = $type === 'deposit' ? 1 : 6;
$total = 0.0;
foreach ($transactions as $transaction) {
$status = $transaction['status'] ?? -1;
$transferType = $transaction['transferType'] ?? 0;
$amount = (float) ($transaction['amount'] ?? 0);
$coin = $transaction['coin'] ?? '';
// Skip non-completed or internal transfers
if ($status !== $successStatus || $transferType === 1 || $amount <= 0) {
continue;
}
$date = $this->getTransactionDate($transaction, $type);
// For stablecoins pegged to USD, convert directly
if (in_array($coin, self::USD_STABLECOINS, true)) {
$coin = 'USD';
}
if (strtoupper($coin) === $targetCurrency) {
$total += $amount;
} else {
$converted = $this->currencyConverter->convert($coin, $targetCurrency, $amount, $date);
$total += $converted;
}
}
return $total;
}
/**
* Extract the transaction date string from a deposit or withdrawal record.
*/
private function getTransactionDate(array $transaction, string $type): string
{
if ($type === 'deposit') {
// Deposit uses insertTime (timestamp in milliseconds)
$timestamp = $transaction['insertTime'] ?? $transaction['completeTime'] ?? null;
if ($timestamp) {
return Carbon::createFromTimestampMs($timestamp)->toDateString();
}
} else {
// Withdrawal uses completeTime or applyTime (string format)
$completeTime = $transaction['completeTime'] ?? null;
if ($completeTime) {
return Carbon::parse($completeTime)->toDateString();
}
$applyTime = $transaction['applyTime'] ?? null;
if ($applyTime) {
return Carbon::parse($applyTime)->toDateString();
}
}
return now()->toDateString();
}
}

View File

@ -59,6 +59,48 @@ class BinanceClient
]);
}
/**
* Get deposit history with optional time range and pagination.
* Default time range is 90 days. Max window is 90 days per request.
*
* @return array<int, array{id: string, amount: string, coin: string, network: string, status: int, insertTime: int, transferType: int}>
*/
public function getDepositHistory(?int $startTime = null, ?int $endTime = null, int $offset = 0, int $limit = 1000): array
{
$params = ['offset' => $offset, 'limit' => $limit];
if ($startTime !== null) {
$params['startTime'] = $startTime;
}
if ($endTime !== null) {
$params['endTime'] = $endTime;
}
return $this->signedRequest('/sapi/v1/capital/deposit/hisrec', $params);
}
/**
* Get withdrawal history with optional time range and pagination.
* Default time range is 90 days. Max window is 90 days per request.
*
* @return array<int, array{id: string, amount: string, coin: string, network: string, status: int, applyTime: string, transferType: int}>
*/
public function getWithdrawHistory(?int $startTime = null, ?int $endTime = null, int $offset = 0, int $limit = 1000): array
{
$params = ['offset' => $offset, 'limit' => $limit];
if ($startTime !== null) {
$params['startTime'] = $startTime;
}
if ($endTime !== null) {
$params['endTime'] = $endTime;
}
return $this->signedRequest('/sapi/v1/capital/withdraw/history', $params);
}
/**
* Execute a signed request with fresh timestamp on each retry attempt.
*/

View File

@ -10,6 +10,7 @@ class BitpandaBalanceSyncService
/**
* Sync the total portfolio value for a Bitpanda account.
* Uses Bitpanda's own ticker prices to match the values shown in the Bitpanda dashboard.
* Also calculates the invested amount from fiat deposit/withdrawal history.
*/
public function sync(Account $account, BitpandaClient $client): void
{
@ -17,14 +18,15 @@ class BitpandaBalanceSyncService
return;
}
$this->syncCurrentBalance($account, $client);
$investedAmountCents = $this->calculateInvestedAmount($client, strtoupper($account->currency_code));
$this->syncCurrentBalance($account, $client, $investedAmountCents);
}
/**
* Sync today's balance by fetching all wallets and converting to target currency
* using Bitpanda's own ticker prices.
*/
public function syncCurrentBalance(Account $account, BitpandaClient $client): void
public function syncCurrentBalance(Account $account, BitpandaClient $client, ?int $investedAmountCents = null): void
{
$targetCurrency = strtoupper($account->currency_code);
$ticker = $client->getTickerPrices();
@ -37,7 +39,10 @@ class BitpandaBalanceSyncService
$account->balances()->updateOrCreate(
['balance_date' => now()->toDateString()],
['balance' => $totalValueCents],
[
'balance' => $totalValueCents,
...($investedAmountCents !== null ? ['invested_amount' => $investedAmountCents] : []),
],
);
}
@ -125,4 +130,60 @@ class BitpandaBalanceSyncService
return (float) $price;
}
/**
* Calculate net invested amount from fiat deposit and withdrawal history.
* Net invested = total deposits - total withdrawals (in cents).
*/
private function calculateInvestedAmount(BitpandaClient $client, string $targetCurrency): ?int
{
$deposits = $client->getAllFiatTransactions('deposit');
$withdrawals = $client->getAllFiatTransactions('withdrawal');
$totalDeposited = $this->sumFiatTransactions($deposits, $targetCurrency);
$totalWithdrawn = $this->sumFiatTransactions($withdrawals, $targetCurrency);
if ($totalDeposited === 0.0 && $totalWithdrawn === 0.0) {
return null;
}
return (int) round(($totalDeposited - $totalWithdrawn) * 100);
}
/**
* Sum the amounts of fiat transactions in the target currency.
* Only considers finished transactions whose fiat_id matches the target currency.
*
* @param array<int, array{type: string, id: string, attributes: array}> $transactions
*/
private function sumFiatTransactions(array $transactions, string $targetCurrency): float
{
$total = 0.0;
foreach ($transactions as $transaction) {
$attributes = $transaction['attributes'] ?? [];
$status = $attributes['status'] ?? '';
$amount = (float) ($attributes['amount'] ?? 0);
if ($status !== 'finished' || $amount <= 0) {
continue;
}
$fiatId = strtoupper($attributes['fiat_id'] ?? '');
if ($fiatId !== $targetCurrency) {
Log::warning('Bitpanda fiat transaction in different currency than target', [
'fiat_id' => $fiatId,
'target_currency' => $targetCurrency,
'amount' => $amount,
]);
continue;
}
$total += $amount;
}
return $total;
}
}

View File

@ -77,6 +77,60 @@ class BitpandaClient
return $this->get('/trades', $params);
}
/**
* List fiat wallet transactions with optional type filtering and cursor-based pagination.
*
* @param string|null $type Filter by type: buy, sell, deposit, withdrawal, transfer, refund
* @param string|null $cursor Cursor for pagination
* @param int $pageSize Number of results per page
* @return array{data: array<int, array{type: string, id: string, attributes: array}>, meta: array, links: array}
*/
public function getFiatTransactions(?string $type = null, ?string $cursor = null, int $pageSize = 25): array
{
$params = ['page_size' => $pageSize];
if ($type) {
$params['type'] = $type;
}
if ($cursor) {
$params['cursor'] = $cursor;
}
return $this->get('/fiatwallets/transactions', $params);
}
/**
* Fetch all fiat transactions of a given type by paginating through the cursor-based API.
* Transactions are returned newest-first from the API but this method
* reverses them to chronological order (oldest first).
*
* @param string $type Transaction type: deposit, withdrawal, etc.
* @return array<int, array{type: string, id: string, attributes: array}>
*/
public function getAllFiatTransactions(string $type): array
{
$allTransactions = [];
$cursor = null;
do {
$response = $this->getFiatTransactions($type, $cursor, 100);
$transactions = $response['data'] ?? [];
if (empty($transactions)) {
break;
}
foreach ($transactions as $transaction) {
$allTransactions[] = $transaction;
}
$cursor = $response['meta']['next_cursor'] ?? null;
} while ($cursor);
return array_reverse($allTransactions);
}
/**
* Fetch all trades by paginating through the cursor-based API.
* Trades are returned newest-first from the API but this method

View File

@ -39,9 +39,15 @@ class IndexaCapitalBalanceSyncService
continue;
}
$balanceCents = (int) round(floatval($value) * 100);
$investedAmountCents = $this->calculateInvestedAmount($entry);
$account->balances()->updateOrCreate(
['balance_date' => $date],
['balance' => (int) round(floatval($value) * 100)],
[
'balance' => $balanceCents,
...($investedAmountCents !== null ? ['invested_amount' => $investedAmountCents] : []),
],
);
$count++;
@ -52,4 +58,31 @@ class IndexaCapitalBalanceSyncService
'days_synced' => $count,
]);
}
/**
* Calculate invested amount from portfolio entry data.
*
* Uses instruments_cost + cash_amount when available (cost basis approach).
* Falls back to total_amount - return if the return field is present.
*
* @param array<string, mixed> $entry
*/
private function calculateInvestedAmount(array $entry): ?int
{
$instrumentsCost = $entry['instruments_cost'] ?? null;
$cashAmount = $entry['cash_amount'] ?? null;
if ($instrumentsCost !== null && $cashAmount !== null) {
return (int) round((floatval($instrumentsCost) + floatval($cashAmount)) * 100);
}
$totalAmount = $entry['total_amount'] ?? null;
$returnValue = $entry['return'] ?? null;
if ($totalAmount !== null && $returnValue !== null) {
return (int) round((floatval($totalAmount) - floatval($returnValue)) * 100);
}
return null;
}
}

View File

@ -57,12 +57,12 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,emails\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://whisper.money.local/stripe/webhook\" --names=server,queue,logs,vite,stripe"
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=emails\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://whisper.money.local/stripe/webhook\" --names=server,emails-queue,logs,vite,stripe"
],
"dev:ssr": [
"bun run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,emails\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr --runtime=bun\" --names=server,queue,logs,ssr,stripe --kill-others"
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=emails\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr --runtime=bun\" --names=server,emails-queue,logs,ssr,stripe --kill-others"
],
"test": [
"@php artisan config:clear --ansi",

View File

@ -22,4 +22,17 @@ class AccountBalanceFactory extends Factory
'balance' => fake()->numberBetween(100000, 10000000),
];
}
/**
* Indicate that the balance has an invested amount.
*/
public function withInvestedAmount(?int $investedAmount = null): static
{
return $this->state(fn (array $attributes) => [
'invested_amount' => $investedAmount ?? fake()->numberBetween(
(int) ($attributes['balance'] * 0.5),
$attributes['balance']
),
]);
}
}

View File

@ -0,0 +1,28 @@
<?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::table('account_balances', function (Blueprint $table) {
$table->bigInteger('invested_amount')->nullable()->after('balance');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('account_balances', function (Blueprint $table) {
$table->dropColumn('invested_amount');
});
}
};

View File

@ -28,19 +28,102 @@ import {
} from '@/hooks/use-chart-views';
import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile';
import { Account } from '@/types/account';
import { Account, supportsInvestedAmount } from '@/types/account';
import { formatDayFromDate, formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { format, subDays, subMonths } from 'date-fns';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Area, AreaChart, Bar, BarChart, XAxis } from 'recharts';
import {
Area,
AreaChart,
Bar,
BarChart,
ComposedChart,
Line,
XAxis,
} from 'recharts';
const DAILY_DAYS = 30;
function InvestmentTooltipContent({
active,
payload,
valueFormatter,
}: {
active?: boolean;
payload?: Array<{
dataKey?: string | number;
name?: string;
value?: number | string;
color?: string;
payload?: Record<string, unknown>;
}>;
valueFormatter: (value: number) => string;
}) {
if (!active || !payload?.length) return null;
const balanceItem = payload.find((p) => p.dataKey === 'value');
const investedItem = payload.find((p) => p.dataKey === 'invested_amount');
const balance =
typeof balanceItem?.value === 'number' ? balanceItem.value : null;
const invested =
typeof investedItem?.value === 'number' ? investedItem.value : null;
const gain =
balance !== null && invested !== null ? balance - invested : null;
return (
<div className="grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="grid gap-1.5">
{payload.map((item) => (
<div
key={String(item.dataKey)}
className="flex w-full items-center gap-2"
>
<div
className="size-2.5 rounded-xs"
style={{ backgroundColor: item.color }}
/>
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{item.dataKey === 'value'
? __('Balance')
: __('Invested')}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === 'number'
? valueFormatter(item.value)
: item.value}
</span>
</div>
</div>
))}
{gain !== null && (
<div className="flex w-full items-center gap-2 border-t border-border/50 pt-1.5">
<div className="size-2.5" />
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Gain/loss')}
</span>
<span
className={`font-mono font-medium tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0 ? '+' : ''}
{valueFormatter(gain)}
</span>
</div>
</div>
)}
</div>
</div>
);
}
interface BalanceDataPoint {
month: string;
timestamp: number;
value: number;
invested_amount?: number | null;
}
interface AccountBalanceData {
@ -58,6 +141,7 @@ interface DailyBalanceDataPoint {
date: string;
timestamp: number;
value: number;
invested_amount?: number | null;
}
interface AccountDailyBalanceData {
@ -135,6 +219,7 @@ function normalizeDailyData(data: DailyBalanceDataPoint[]): BalanceDataPoint[] {
month: point.date,
timestamp: point.timestamp,
value: point.value,
invested_amount: point.invested_amount,
}));
}
@ -194,11 +279,20 @@ export function AccountBalanceChart({
fetchBalanceData(granularity);
}, [fetchBalanceData, granularity, refreshKey]);
const { chartData, currentBalance, shortTrend, longTrend } = useMemo(() => {
const showInvestmentBenefits = supportsInvestedAmount(account);
const {
chartData,
currentBalance,
currentInvestedAmount,
shortTrend,
longTrend,
} = useMemo(() => {
if (!balanceData?.data?.length) {
return {
chartData: [],
currentBalance: 0,
currentInvestedAmount: null as number | null,
shortTrend: null,
longTrend: null,
};
@ -207,9 +301,22 @@ export function AccountBalanceChart({
const data = balanceData.data;
const current = data[data.length - 1]?.value ?? 0;
// Find the most recent non-null invested_amount
let invested: number | null = null;
for (let i = data.length - 1; i >= 0; i--) {
if (
data[i].invested_amount !== null &&
data[i].invested_amount !== undefined
) {
invested = data[i].invested_amount!;
break;
}
}
return {
chartData: data,
currentBalance: current,
currentInvestedAmount: invested,
shortTrend: calculateTrend(data, 1),
longTrend: calculateTrend(data, data.length - 1),
};
@ -240,6 +347,14 @@ export function AccountBalanceChart({
color: 'var(--color-chart-2)',
},
...(showInvestmentBenefits
? {
invested_amount: {
label: __('Invested'),
color: 'var(--color-chart-4)',
},
}
: {}),
};
const formatXAxisLabel = useMemo(
@ -407,10 +522,20 @@ export function AccountBalanceChart({
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
showInvestmentBenefits ? (
<InvestmentTooltipContent
valueFormatter={
valueFormatter
}
/>
) : (
<ChartTooltipContent
hideLabel
valueFormatter={
valueFormatter
}
/>
)
}
/>
<Area
@ -423,7 +548,56 @@ export function AccountBalanceChart({
activeDot={{ r: 5 }}
fillOpacity={1}
/>
{showInvestmentBenefits &&
currentInvestedAmount !== null && (
<Line
dataKey="invested_amount"
type="monotone"
stroke="var(--color-chart-6)"
strokeWidth={1.5}
strokeDasharray="2 2"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
)}
</AreaChart>
) : showInvestmentBenefits &&
currentInvestedAmount !== null ? (
<ComposedChart
accessibilityLayer
data={chartData.slice(1)}
>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<InvestmentTooltipContent
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
<Line
dataKey="invested_amount"
type="monotone"
stroke="var(--color-chart-6)"
strokeWidth={1.5}
strokeDasharray="2 2"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</ComposedChart>
) : (
<BarChart
accessibilityLayer

View File

@ -5,6 +5,7 @@ import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indica
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
@ -123,22 +124,87 @@ export function AccountListCard({
const data = payload[0].payload as {
date: string;
value: number;
investedAmount?: number | null;
};
const invested = supportsInvestedAmount(
account,
)
? (data.investedAmount ?? null)
: null;
const gain =
invested !== null
? data.value - invested
: null;
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-sm shadow-xl">
<p className="mb-0.5 text-muted-foreground">
<p className="mb-1 text-muted-foreground">
{data.date}
</p>
<p className="font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
account.currency_code
}
/>
</p>
{invested !== null ? (
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
<span className="text-muted-foreground">
{__('Balance')}
</span>
<span className="text-right font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
account.currency_code
}
/>
</span>
<span className="text-muted-foreground">
{__('Invested')}
</span>
<span className="text-right font-mono text-muted-foreground tabular-nums">
<AmountDisplay
amountInCents={
invested
}
currencyCode={
account.currency_code
}
/>
</span>
{gain !== null && (
<>
<span className="text-muted-foreground">
{__(
'Gain/loss',
)}
</span>
<span
className={`text-right font-mono tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0
? '+'
: ''}
<AmountDisplay
amountInCents={
gain
}
currencyCode={
account.currency_code
}
/>
</span>
</>
)}
</div>
) : (
<p className="font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
account.currency_code
}
/>
</p>
)}
</div>
);
}}
@ -151,6 +217,17 @@ export function AccountListCard({
strokeWidth={2}
dot={false}
/>
{supportsInvestedAmount(account) && (
<Line
type="monotone"
dataKey="investedAmount"
stroke="var(--color-chart-6)"
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
connectNulls
/>
)}
</LineChart>
</ResponsiveContainer>
</div>

View File

@ -34,6 +34,7 @@ import {
TableRow,
} from '@/components/ui/table';
import type { Account, AccountBalance } from '@/types/account';
import { supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Pencil, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@ -70,6 +71,9 @@ export function BalancesModal({
);
const [editDate, setEditDate] = useState('');
const [editAmount, setEditAmount] = useState(0);
const [editInvestedAmount, setEditInvestedAmount] = useState<number | null>(
null,
);
const [isEditSubmitting, setIsEditSubmitting] = useState(false);
const [deletingBalance, setDeletingBalance] =
@ -81,6 +85,8 @@ export function BalancesModal({
currency: account.currency_code,
});
const showInvestedAmount = supportsInvestedAmount(account);
const fetchBalances = useCallback(
async (page: number) => {
setIsLoading(true);
@ -127,6 +133,7 @@ export function BalancesModal({
setEditingBalance(balance);
setEditDate(balance.balance_date.split('T')[0]);
setEditAmount(balance.balance);
setEditInvestedAmount(balance.invested_amount);
}
async function handleEditSubmit(e: React.FormEvent) {
@ -150,6 +157,9 @@ export function BalancesModal({
body: JSON.stringify({
balance_date: editDate,
balance: editAmount,
...(showInvestedAmount && editInvestedAmount !== null
? { invested_amount: editInvestedAmount }
: {}),
}),
});
@ -217,7 +227,13 @@ export function BalancesModal({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogContent
className={
showInvestedAmount
? 'sm:max-w-[700px]'
: 'sm:max-w-[600px]'
}
>
<DialogHeader>
<DialogTitle>{__('Balance History')}</DialogTitle>
<DialogDescription>
@ -242,6 +258,11 @@ export function BalancesModal({
<TableHead className="text-right">
{__('Balance')}
</TableHead>
{showInvestedAmount && (
<TableHead className="text-right">
{__('Invested')}
</TableHead>
)}
<TableHead className="w-[100px] text-right">
{__('Actions')}
</TableHead>
@ -251,7 +272,9 @@ export function BalancesModal({
{isLoading ? (
<TableRow>
<TableCell
colSpan={3}
colSpan={
showInvestedAmount ? 4 : 3
}
className="h-24 text-center"
>
{__('Loading...')}
@ -260,7 +283,9 @@ export function BalancesModal({
) : balances.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
colSpan={
showInvestedAmount ? 4 : 3
}
className="h-24 text-center"
>
{__(
@ -281,6 +306,17 @@ export function BalancesModal({
balance.balance / 100,
)}
</TableCell>
{showInvestedAmount && (
<TableCell className="text-right font-mono text-muted-foreground">
{balance.invested_amount !==
null
? formatter.format(
balance.invested_amount /
100,
)
: '—'}
</TableCell>
)}
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
@ -393,6 +429,23 @@ export function BalancesModal({
/>
</div>
{showInvestedAmount && (
<div className="space-y-2">
<Label htmlFor="edit-invested-amount">
{__('Invested amount')}
</Label>
<AmountInput
id="edit-invested-amount"
className="mt-1"
value={editInvestedAmount ?? 0}
onChange={(value) =>
setEditInvestedAmount(value || null)
}
currencyCode={account.currency_code}
/>
</div>
)}
<DialogFooter>
<Button
type="button"

View File

@ -19,7 +19,7 @@ import {
parseFile,
} from '@/lib/file-parser';
import { type SharedData } from '@/types';
import { type Account } from '@/types/account';
import { supportsInvestedAmount, type Account } from '@/types/account';
import {
BalanceImportStep,
type BalanceColumnMapping,
@ -86,6 +86,7 @@ export function ImportBalancesDrawer({
columnMapping: {
balance_date: null,
balance: null,
invested_amount: null,
},
dateFormat: DateFormat.YearMonthDay,
dateFormatDetected: false,
@ -115,6 +116,7 @@ export function ImportBalancesDrawer({
columnMapping: {
balance_date: null,
balance: null,
invested_amount: null,
},
dateFormat: DateFormat.YearMonthDay,
dateFormatDetected: false,
@ -139,6 +141,7 @@ export function ImportBalancesDrawer({
const mapping: BalanceColumnMapping = {
balance_date: null,
balance: null,
invested_amount: null,
};
if (!headers || headers.length === 0) {
@ -170,6 +173,16 @@ export function ImportBalancesDrawer({
'total',
];
const investedPatterns = [
'invested',
'invested_amount',
'invested amount',
'cost',
'cost basis',
'invertido',
'coste',
];
for (let i = 0; i < lowerHeaders.length; i++) {
const header = lowerHeaders[i];
const originalHeader = headers[i];
@ -191,6 +204,13 @@ export function ImportBalancesDrawer({
) {
mapping.balance = originalHeader;
}
if (
!mapping.invested_amount &&
investedPatterns.some((p) => header.includes(p))
) {
mapping.invested_amount = originalHeader;
}
}
return mapping;
@ -339,9 +359,22 @@ export function ImportBalancesDrawer({
const formattedDate = date.toISOString().split('T')[0];
let investedAmount: number | null = null;
if (state.columnMapping.invested_amount) {
const rawInvested = parseAmount(
row[state.columnMapping.invested_amount] as
| string
| number,
);
if (rawInvested !== null) {
investedAmount = Math.round(rawInvested * 100);
}
}
parsedBalances.push({
balance_date: formattedDate,
balance: Math.round(balance * 100),
invested_amount: investedAmount,
});
}
@ -413,6 +446,12 @@ export function ImportBalancesDrawer({
body: JSON.stringify({
balance_date: balance.balance_date,
balance: balance.balance,
...(balance.invested_amount !== null
? {
invested_amount:
balance.invested_amount,
}
: {}),
}),
},
);
@ -551,6 +590,10 @@ export function ImportBalancesDrawer({
};
const renderStep = () => {
const showInvestedAmount = selectedAccount
? supportsInvestedAmount(selectedAccount)
: false;
switch (state.step) {
case BalanceImportStep.SelectAccount:
return (
@ -580,6 +623,7 @@ export function ImportBalancesDrawer({
dateFormatDetected={state.dateFormatDetected}
parsedData={state.parsedData}
currencyCode={selectedAccount?.currency_code || 'USD'}
showInvestedAmount={showInvestedAmount}
onMappingChange={handleMappingChange}
onDateFormatChange={handleDateFormatChange}
onNext={handlePreviewBalances}
@ -592,6 +636,7 @@ export function ImportBalancesDrawer({
<ImportBalanceStepPreview
balances={state.balances}
currencyCode={selectedAccount?.currency_code || 'USD'}
showInvestedAmount={showInvestedAmount}
onConfirm={handleConfirmImport}
onBack={handleBack}
isImporting={isImporting}

View File

@ -24,6 +24,7 @@ interface ImportBalanceStepMappingProps {
dateFormatDetected: boolean;
parsedData: ParsedRow[];
currencyCode: string;
showInvestedAmount: boolean;
onMappingChange: (field: keyof BalanceColumnMapping, value: string) => void;
onDateFormatChange: (format: DateFormat) => void;
onNext: () => void;
@ -37,6 +38,7 @@ export function ImportBalanceStepMapping({
dateFormatDetected,
parsedData,
currencyCode,
showInvestedAmount,
onMappingChange,
onDateFormatChange,
onNext,
@ -44,6 +46,11 @@ export function ImportBalanceStepMapping({
}: ImportBalanceStepMappingProps) {
const isValid = columnMapping.balance_date && columnMapping.balance;
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
});
const previewBalances = parsedData.slice(0, 3).map((row) => {
const date = columnMapping.balance_date
? parseDate(
@ -55,6 +62,17 @@ export function ImportBalanceStepMapping({
? parseAmount(row[columnMapping.balance] as string | number)
: null;
let investedAmount: string | null = null;
if (showInvestedAmount && columnMapping.invested_amount) {
const invested = parseAmount(
row[columnMapping.invested_amount] as string | number,
);
investedAmount =
invested !== null
? currencyFormatter.format(invested)
: 'Invalid amount';
}
return {
date: date
? date.toLocaleDateString('en-US', {
@ -65,11 +83,9 @@ export function ImportBalanceStepMapping({
: 'Invalid date',
balance:
balance !== null
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(balance)
? currencyFormatter.format(balance)
: 'Invalid amount',
investedAmount,
};
});
@ -136,6 +152,38 @@ export function ImportBalanceStepMapping({
</Select>
</div>
{showInvestedAmount && (
<div className="space-y-2">
<Label htmlFor="invested-column">
{__('Invested Amount')}
</Label>
<Select
value={columnMapping.invested_amount || ''}
onValueChange={(value) =>
onMappingChange('invested_amount', value)
}
>
<SelectTrigger id="invested-column">
<SelectValue
placeholder={__(
'Select invested amount column (optional)',
)}
/>
</SelectTrigger>
<SelectContent>
{columnOptions.map((option, index) => (
<SelectItem
key={`invested-${option.value}-${index}`}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isValid && previewBalances.length > 0 && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<Label className="pl-2 text-xs font-light tracking-widest uppercase opacity-50">
@ -150,9 +198,16 @@ export function ImportBalanceStepMapping({
<span className="whitespace-nowrap text-muted-foreground">
{balance.date}
</span>
<span className="font-mono font-medium whitespace-nowrap">
{balance.balance}
</span>
<div className="flex items-center gap-3">
{balance.investedAmount && (
<span className="font-mono whitespace-nowrap text-muted-foreground">
{balance.investedAmount}
</span>
)}
<span className="font-mono font-medium whitespace-nowrap">
{balance.balance}
</span>
</div>
</div>
))}
</div>

View File

@ -15,6 +15,7 @@ import { __ } from '@/utils/i18n';
interface ImportBalanceStepPreviewProps {
balances: ParsedBalance[];
currencyCode: string;
showInvestedAmount: boolean;
onConfirm: () => void;
onBack: () => void;
isImporting: boolean;
@ -23,6 +24,7 @@ interface ImportBalanceStepPreviewProps {
export function ImportBalanceStepPreview({
balances,
currencyCode,
showInvestedAmount,
onConfirm,
onBack,
isImporting,
@ -37,6 +39,10 @@ export function ImportBalanceStepPreview({
}).format(balance / 100);
};
const hasInvestedData =
showInvestedAmount && balances.some((b) => b.invested_amount !== null);
const colSpan = hasInvestedData ? 3 : 2;
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border bg-muted/50 p-4">
@ -51,6 +57,11 @@ export function ImportBalanceStepPreview({
<TableHeader>
<TableRow>
<TableHead>{__('Date')}</TableHead>
{hasInvestedData && (
<TableHead className="text-right">
{__('Invested')}
</TableHead>
)}
<TableHead className="text-right">
{__('Balance')}
</TableHead>
@ -60,7 +71,7 @@ export function ImportBalanceStepPreview({
{balances.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
colSpan={colSpan}
className="text-center text-muted-foreground"
>
No valid balances found
@ -75,6 +86,15 @@ export function ImportBalanceStepPreview({
locale,
)}
</TableCell>
{hasInvestedData && (
<TableCell className="text-right font-mono text-muted-foreground">
{balance.invested_amount !== null
? formatBalance(
balance.invested_amount,
)
: '—'}
</TableCell>
)}
<TableCell className="text-right font-mono">
{formatBalance(balance.balance)}
</TableCell>

View File

@ -14,7 +14,11 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { Account, AccountBalance } from '@/types/account';
import {
type Account,
type AccountBalance,
supportsInvestedAmount,
} from '@/types/account';
import { __ } from '@/utils/i18n';
import { useEffect, useRef, useState } from 'react';
@ -42,10 +46,12 @@ export function UpdateBalanceDialog({
}: UpdateBalanceDialogProps) {
const [date, setDate] = useState(getTodayDate());
const [balance, setBalance] = useState(0);
const [investedAmount, setInvestedAmount] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoadingLastBalance, setIsLoadingLastBalance] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const showInvestedAmount = supportsInvestedAmount(account);
useEffect(() => {
async function fetchLastBalance() {
@ -69,12 +75,15 @@ export function UpdateBalanceDialog({
const data: PaginatedBalanceResponse = await response.json();
if (data.data.length > 0) {
setBalance(data.data[0].balance);
setInvestedAmount(data.data[0].invested_amount);
} else {
setBalance(0);
setInvestedAmount(null);
}
} catch (err) {
console.error('Failed to fetch last balance:', err);
setBalance(0);
setInvestedAmount(null);
} finally {
setIsLoadingLastBalance(false);
}
@ -127,6 +136,9 @@ export function UpdateBalanceDialog({
body: JSON.stringify({
balance_date: date,
balance: balance,
...(showInvestedAmount && investedAmount !== null
? { invested_amount: investedAmount }
: {}),
}),
});
@ -178,6 +190,36 @@ export function UpdateBalanceDialog({
)}
</div>
{showInvestedAmount && (
<div className="space-y-2">
<Label htmlFor="invested-amount">
{__('Invested amount')}
</Label>
<p className="text-xs text-muted-foreground">
{__(
'Total money you put into this account. Used to calculate gains/losses.',
)}
</p>
{isLoadingLastBalance ? (
<div className="flex h-10 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
{__('Loading...')}
</div>
) : (
<AmountInput
id="invested-amount"
className="mt-1"
value={investedAmount ?? 0}
onChange={(value) =>
setInvestedAmount(
value === 0 ? null : value,
)
}
currencyCode={account.currency_code}
/>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="balance-date">{__('Date')}</Label>
<Input

View File

@ -5,6 +5,7 @@ import { BankLogo } from '@/components/bank-logo';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
@ -108,22 +109,87 @@ export function AccountBalanceCard({
const data = payload[0].payload as {
date: string;
value: number;
investedAmount?: number | null;
};
const invested = supportsInvestedAmount(
account,
)
? (data.investedAmount ?? null)
: null;
const gain =
invested !== null
? data.value - invested
: null;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<p className="mb-0.5 text-muted-foreground">
<p className="mb-1 text-muted-foreground">
{data.date}
</p>
<p className="font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
account.currency_code
}
/>
</p>
{invested !== null ? (
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
<span className="text-muted-foreground">
{__('Balance')}
</span>
<span className="text-right font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
account.currency_code
}
/>
</span>
<span className="text-muted-foreground">
{__('Invested')}
</span>
<span className="text-right font-mono text-muted-foreground tabular-nums">
<AmountDisplay
amountInCents={
invested
}
currencyCode={
account.currency_code
}
/>
</span>
{gain !== null && (
<>
<span className="text-muted-foreground">
{__(
'Gain/loss',
)}
</span>
<span
className={`text-right font-mono tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0
? '+'
: ''}
<AmountDisplay
amountInCents={
gain
}
currencyCode={
account.currency_code
}
/>
</span>
</>
)}
</div>
) : (
<p className="font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
account.currency_code
}
/>
</p>
)}
</div>
);
}}
@ -136,6 +202,17 @@ export function AccountBalanceCard({
strokeWidth={2}
dot={false}
/>
{supportsInvestedAmount(account) && (
<Line
type="monotone"
dataKey="investedAmount"
stroke="var(--color-chart-6)"
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
connectNulls
/>
)}
</LineChart>
</ResponsiveContainer>
</div>

View File

@ -11,6 +11,7 @@ export interface NetWorthEvolutionAccount {
type: AccountType;
currency_code: string;
bank: Bank;
invested_amount?: number | null;
}
export interface OriginalAmount {
@ -28,7 +29,12 @@ export interface AccountWithMetrics extends Account {
currentBalance: number;
previousBalance: number;
diff: number;
history: Array<{ date: string; value: number }>;
history: Array<{
date: string;
value: number;
investedAmount?: number | null;
}>;
investedAmount: number | null;
}
export interface DashboardData {
@ -53,9 +59,14 @@ function deriveAccountMetrics(
}
return Object.values(accounts).map((account) => {
const investedKey = account.id + '_invested';
const history = data.map((point) => ({
date: formatMonth(point.month as string),
value: (point[account.id] as number) ?? 0,
investedAmount:
investedKey in point
? (point[investedKey] as number | null)
: undefined,
}));
const currentBalance = history[history.length - 1]?.value ?? 0;
@ -73,6 +84,7 @@ function deriveAccountMetrics(
previousBalance,
diff: currentBalance - previousBalance,
history,
investedAmount: account.invested_amount ?? null,
} as AccountWithMetrics;
});
}

View File

@ -42,6 +42,7 @@ export default function AccountsIndex({ accounts }: Props) {
previousBalance: metrics?.previousBalance ?? 0,
diff: metrics?.diff ?? 0,
history: metrics?.history ?? [],
investedAmount: metrics?.investedAmount ?? null,
};
});
}, [accounts, accountMetrics]);

View File

@ -53,6 +53,7 @@ export interface AccountBalance {
account_id: UUID;
balance_date: string;
balance: number;
invested_amount: number | null;
created_at: string;
updated_at: string;
}
@ -79,6 +80,12 @@ export function isTransactionalAccount(account: Account): boolean {
return !NON_TRANSACTIONAL_ACCOUNT_TYPES.includes(account.type);
}
export function supportsInvestedAmount(
account: Pick<Account, 'type'>,
): boolean {
return NON_TRANSACTIONAL_ACCOUNT_TYPES.includes(account.type);
}
export function filterTransactionalAccounts<T extends { type: AccountType }>(
accounts: T[],
): T[] {

View File

@ -11,11 +11,13 @@ export enum BalanceImportStep {
export interface BalanceColumnMapping {
balance_date: string | null;
balance: string | null;
invested_amount: string | null;
}
export interface ParsedBalance {
balance_date: string;
balance: number;
invested_amount: number | null;
}
export interface ColumnOption {

View File

@ -188,3 +188,159 @@ it('returns 404 when deleting balance from wrong account', function () {
'id' => $balance->id,
]);
});
it('can update current balance with invested_amount', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", [
'balance' => 500000,
'invested_amount' => 400000,
]);
$response->assertSuccessful()
->assertJsonPath('data.balance', 500000)
->assertJsonPath('data.invested_amount', 400000);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 500000,
'invested_amount' => 400000,
]);
});
it('can update current balance without invested_amount', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", [
'balance' => 500000,
]);
$response->assertSuccessful()
->assertJsonPath('data.balance', 500000);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 500000,
'invested_amount' => null,
]);
});
it('validates invested_amount must be an integer', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", [
'balance' => 500000,
'invested_amount' => 'not-a-number',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['invested_amount']);
});
it('can store balance with invested_amount', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->postJson("/api/accounts/{$account->id}/balances", [
'balance' => 300000,
'balance_date' => now()->subDays(5)->toDateString(),
'invested_amount' => 250000,
]);
$response->assertStatus(201)
->assertJsonPath('data.balance', 300000)
->assertJsonPath('data.invested_amount', 250000);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 300000,
'invested_amount' => 250000,
]);
});
it('can store balance without invested_amount', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->postJson("/api/accounts/{$account->id}/balances", [
'balance' => 300000,
'balance_date' => now()->subDays(5)->toDateString(),
]);
$response->assertStatus(201)
->assertJsonPath('data.balance', 300000);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 300000,
'invested_amount' => null,
]);
});
it('validates store balance must be an integer', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->postJson("/api/accounts/{$account->id}/balances", [
'balance' => 'not-a-number',
'balance_date' => now()->subDays(5)->toDateString(),
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['balance']);
});
it('rejects decimal balance on store', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->postJson("/api/accounts/{$account->id}/balances", [
'balance' => 123.45,
'balance_date' => now()->subDays(5)->toDateString(),
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['balance']);
});
it('rejects decimal invested_amount on store', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$response = $this->actingAs($user)->postJson("/api/accounts/{$account->id}/balances", [
'balance' => 300000,
'balance_date' => now()->subDays(5)->toDateString(),
'invested_amount' => 250.99,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['invested_amount']);
});
it('preserves existing invested_amount when updating balance without it', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
AccountBalance::factory()->for($account)->create([
'balance_date' => now()->toDateString(),
'balance' => 500000,
'invested_amount' => 400000,
]);
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", [
'balance' => 600000,
]);
$response->assertSuccessful()
->assertJsonPath('data.balance', 600000);
$this->assertDatabaseCount('account_balances', 1);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 600000,
'invested_amount' => 400000,
]);
});

View File

@ -0,0 +1,229 @@
<?php
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\User;
use App\Services\BalanceLookup;
use Carbon\Carbon;
beforeEach(function () {
$this->user = User::factory()->create();
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
});
test('getBalanceAt returns carry-forward balance from before range', function () {
AccountBalance::factory()->create([
'account_id' => $this->account->id,
'balance_date' => '2025-12-15',
'balance' => 100000,
]);
$lookup = BalanceLookup::forAccounts(
[$this->account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-15')))->toBe(100000);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-31')))->toBe(100000);
});
test('getBalanceAt returns zero when no balance exists', function () {
$lookup = BalanceLookup::forAccounts(
[$this->account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-15')))->toBe(0);
});
test('getBalanceAt returns latest balance on or before date within range', function () {
AccountBalance::factory()->create([
'account_id' => $this->account->id,
'balance_date' => '2026-01-05',
'balance' => 100000,
]);
AccountBalance::factory()->create([
'account_id' => $this->account->id,
'balance_date' => '2026-01-20',
'balance' => 200000,
]);
$lookup = BalanceLookup::forAccounts(
[$this->account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-03')))->toBe(0);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-05')))->toBe(100000);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-15')))->toBe(100000);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-20')))->toBe(200000);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-31')))->toBe(200000);
});
test('getBalanceAt works with multiple accounts', function () {
$account2 = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $this->account->id,
'balance_date' => '2026-01-10',
'balance' => 100000,
]);
AccountBalance::factory()->create([
'account_id' => $account2->id,
'balance_date' => '2026-01-10',
'balance' => 500000,
]);
$lookup = BalanceLookup::forAccounts(
[$this->account->id, $account2->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-15')))->toBe(100000);
expect($lookup->getBalanceAt($account2->id, Carbon::parse('2026-01-15')))->toBe(500000);
});
test('getInvestedAmountAt returns null when no invested data exists', function () {
AccountBalance::factory()->create([
'account_id' => $this->account->id,
'balance_date' => '2026-01-10',
'balance' => 100000,
'invested_amount' => null,
]);
$lookup = BalanceLookup::forAccounts(
[$this->account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getInvestedAmountAt($this->account->id, Carbon::parse('2026-01-15')))->toBeNull();
});
test('getInvestedAmountAt carries forward last known invested amount across null gaps', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-01-05',
'balance' => 500000,
'invested_amount' => 400000,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-01-20',
'balance' => 550000,
'invested_amount' => null,
]);
$lookup = BalanceLookup::forAccounts(
[$account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getInvestedAmountAt($account->id, Carbon::parse('2026-01-05')))->toBe(400000);
// After the null entry on Jan 20, invested_amount should still carry forward from Jan 5
expect($lookup->getInvestedAmountAt($account->id, Carbon::parse('2026-01-25')))->toBe(400000);
});
test('getInvestedAmountAt carries forward from before range', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2025-12-15',
'balance' => 500000,
'invested_amount' => 400000,
]);
$lookup = BalanceLookup::forAccounts(
[$account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getInvestedAmountAt($account->id, Carbon::parse('2026-01-15')))->toBe(400000);
});
test('getInvestedAmountAt carry-forward seed uses latest non-null invested amount before range', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'currency_code' => 'USD',
]);
// Invested amount set early
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2025-12-01',
'balance' => 400000,
'invested_amount' => 300000,
]);
// Latest balance before range has null invested_amount
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2025-12-20',
'balance' => 450000,
'invested_amount' => null,
]);
$lookup = BalanceLookup::forAccounts(
[$account->id],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
// Should carry forward balance from Dec 20 (latest before range)
expect($lookup->getBalanceAt($account->id, Carbon::parse('2026-01-10')))->toBe(450000);
// Should carry forward invested_amount from Dec 1 (latest non-null before range)
expect($lookup->getInvestedAmountAt($account->id, Carbon::parse('2026-01-10')))->toBe(300000);
});
test('forAccounts handles empty account list', function () {
$lookup = BalanceLookup::forAccounts(
[],
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getBalanceAt('nonexistent', Carbon::parse('2026-01-15')))->toBe(0);
expect($lookup->getInvestedAmountAt('nonexistent', Carbon::parse('2026-01-15')))->toBeNull();
});
test('forAccounts accepts a Collection of account IDs', function () {
AccountBalance::factory()->create([
'account_id' => $this->account->id,
'balance_date' => '2026-01-10',
'balance' => 100000,
]);
$lookup = BalanceLookup::forAccounts(
collect([$this->account->id]),
Carbon::parse('2026-01-01'),
Carbon::parse('2026-01-31'),
);
expect($lookup->getBalanceAt($this->account->id, Carbon::parse('2026-01-15')))->toBe(100000);
});

View File

@ -621,6 +621,222 @@ test('net worth daily evolution fills gaps with last known balance', function ()
expect($data['data'][2][$account->id])->toBe(80000);
});
test('account daily balance evolution includes invested_amount for investment accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'name' => 'My Portfolio',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subDays(1),
'balance' => 500000,
'invested_amount' => 400000,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 550000,
'invested_amount' => 420000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
'from' => now()->subDays(1)->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->toHaveKey('invested_amount');
expect($data['data'][0]['invested_amount'])->toBe(400000);
expect($data['data'][1]['invested_amount'])->toBe(420000);
});
test('account daily balance evolution does not include invested_amount for non-investment accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'My Checking',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 500000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
'from' => now()->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->not->toHaveKey('invested_amount');
});
test('account daily balance evolution carries forward last known invested_amount across gaps', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'currency_code' => 'USD',
]);
// invested_amount recorded 5 days ago
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subDays(5),
'balance' => 500000,
'invested_amount' => 400000,
]);
// Balance today with no invested_amount
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 550000,
'invested_amount' => null,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/daily-balance-evolution?'.http_build_query([
'from' => now()->subDays(1)->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// Both days should carry forward the 400000 invested_amount from 5 days ago
expect($data['data'][0]['invested_amount'])->toBe(400000);
expect($data['data'][1]['invested_amount'])->toBe(400000);
});
test('account balance evolution includes invested_amount for retirement accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Retirement,
'name' => 'Pension Fund',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->endOfMonth(),
'balance' => 1000000,
'invested_amount' => 800000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->toHaveKey('invested_amount');
expect($data['data'][0]['invested_amount'])->toBe(800000);
});
test('account balance evolution does not include invested_amount for savings accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'name' => 'My Savings',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->endOfMonth(),
'balance' => 1000000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->not->toHaveKey('invested_amount');
});
test('net worth evolution includes invested_amount in accountsConfig for investment accounts', function () {
$investment = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'name' => 'My Portfolio',
'currency_code' => 'USD',
]);
$checking = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'My Checking',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $investment->id,
'balance_date' => now(),
'balance' => 500000,
'invested_amount' => 400000,
]);
AccountBalance::factory()->create([
'account_id' => $checking->id,
'balance_date' => now(),
'balance' => 300000,
]);
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
// Investment account should have invested_amount in its config
expect($data['accounts'][$investment->id])->toHaveKey('invested_amount');
expect($data['accounts'][$investment->id]['invested_amount'])->toBe(400000);
// Checking account should NOT have invested_amount
expect($data['accounts'][$checking->id])->not->toHaveKey('invested_amount');
});
test('net worth evolution returns null invested_amount when no invested data exists', function () {
$investment = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Investment,
'name' => 'Empty Portfolio',
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $investment->id,
'balance_date' => now(),
'balance' => 500000,
'invested_amount' => null,
]);
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['accounts'][$investment->id])->toHaveKey('invested_amount');
expect($data['accounts'][$investment->id]['invested_amount'])->toBeNull();
});
test('net worth daily evolution returns account metadata including bank', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,

View File

@ -25,6 +25,8 @@ test('syncs binance balance using direct EUR pair', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -61,6 +63,8 @@ test('syncs binance balance using USDT fallback conversion', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -97,6 +101,8 @@ test('handles USD stablecoins as 1:1 when target is USD', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -130,6 +136,8 @@ test('includes locked balances in total', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -167,6 +175,8 @@ test('updates existing balance for same date', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -199,6 +209,8 @@ test('handles empty balances gracefully', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [],
@ -245,6 +257,8 @@ test('first sync fetches historical snapshots and converts using currency API',
$twoDaysAgo = now()->subDays(2);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response([
'snapshotVos' => [
[
@ -323,6 +337,8 @@ test('subsequent sync only fetches snapshots since last balance date', function
$yesterday = now()->subDay();
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response([
'snapshotVos' => [
[
@ -383,6 +399,8 @@ test('historical sync converts assets using currency API', function () {
$yesterday = now()->subDay();
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response([
'snapshotVos' => [
[
@ -420,3 +438,407 @@ test('historical sync converts assets using currency API', function () {
$yesterdayBalance = $account->balances()->where('balance_date', $yesterday->toDateString())->first();
expect($yesterdayBalance->balance)->toBe(100000);
});
test('calculates invested_amount from deposit and withdrawal history', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::sequence()
->push([
[
'id' => 'dep-1',
'amount' => '0.5',
'coin' => 'BTC',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(30)->getTimestampMs(),
],
[
'id' => 'dep-2',
'amount' => '1000.00',
'coin' => 'USDT',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(20)->getTimestampMs(),
],
])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::sequence()
->push([
[
'id' => 'wd-1',
'amount' => '200.00',
'coin' => 'USDT',
'status' => 6,
'transferType' => 0,
'completeTime' => now()->subDays(10)->toDateTimeString(),
],
])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
'eur' => [
'btc' => 0.00002, // 1 EUR = 0.00002 BTC → 1 BTC = 50000 EUR
],
]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'BTC', 'free' => '0.5', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([
['symbol' => 'BTCEUR', 'price' => '50000.00'],
]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Deposits: 0.5 BTC → converted via CurrencyConversionService (0.5 / 0.00002 = 25000 EUR)
// 1000 USDT → treated as USD and converted (1000 USD via currency API)
// Withdrawals: 200 USDT → treated as USD and converted (200 USD via currency API)
// The exact amount depends on the currency conversion API response
expect($balance->invested_amount)->not->toBeNull();
expect($balance->invested_amount)->toBeGreaterThan(0);
});
test('excludes internal transfers from invested_amount calculation', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::sequence()
->push([
[
'id' => 'dep-1',
'amount' => '500.00',
'coin' => 'EUR',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(30)->getTimestampMs(),
],
[
'id' => 'dep-internal',
'amount' => '1000.00',
'coin' => 'EUR',
'status' => 1,
'transferType' => 1, // Internal transfer — should be excluded
'insertTime' => now()->subDays(20)->getTimestampMs(),
],
])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::sequence()
->push([])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'EUR', 'free' => '500.00', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Only the external deposit of 500 EUR should count, internal 1000 EUR is excluded
expect($balance->invested_amount)->toBe(50000); // 500 EUR → 50000 cents
});
test('filters deposits by status 1 and withdrawals by status 6', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::sequence()
->push([
[
'id' => 'dep-completed',
'amount' => '1000.00',
'coin' => 'EUR',
'status' => 1, // Completed
'transferType' => 0,
'insertTime' => now()->subDays(30)->getTimestampMs(),
],
[
'id' => 'dep-pending',
'amount' => '2000.00',
'coin' => 'EUR',
'status' => 0, // Pending — should be excluded
'transferType' => 0,
'insertTime' => now()->subDays(20)->getTimestampMs(),
],
])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::sequence()
->push([
[
'id' => 'wd-completed',
'amount' => '300.00',
'coin' => 'EUR',
'status' => 6, // Completed
'transferType' => 0,
'completeTime' => now()->subDays(10)->toDateTimeString(),
],
[
'id' => 'wd-processing',
'amount' => '500.00',
'coin' => 'EUR',
'status' => 4, // Processing — should be excluded
'transferType' => 0,
'applyTime' => now()->subDays(5)->toDateTimeString(),
],
])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'EUR', 'free' => '700.00', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Deposits: 1000 EUR (completed) — pending 2000 excluded
// Withdrawals: 300 EUR (completed) — processing 500 excluded
// Net: 1000 - 300 = 700 EUR → 70000 cents
expect($balance->invested_amount)->toBe(70000);
});
test('returns null invested_amount when no deposit or withdrawal history', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([
['symbol' => 'BTCEUR', 'price' => '50000.00'],
]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
expect($balance->balance)->toBe(5000000);
expect($balance->invested_amount)->toBeNull();
});
test('converts stablecoin deposits to fiat for invested_amount', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::sequence()
->push([
[
'id' => 'dep-1',
'amount' => '1000.00',
'coin' => 'USDT',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(30)->getTimestampMs(),
],
[
'id' => 'dep-2',
'amount' => '500.00',
'coin' => 'USDC',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(20)->getTimestampMs(),
],
])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::sequence()
->push([])
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
// CurrencyConversionService will convert USD to EUR
'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
'eur' => [
'usd' => 1.10, // 1 EUR = 1.10 USD → 1 USD = 0.909 EUR
],
]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'USDT', 'free' => '1500.00', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([
['symbol' => 'EURUSDT', 'price' => '1.10'],
]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Stablecoins USDT and USDC are treated as USD
// 1000 USD + 500 USD = 1500 USD total deposited
// Converted to EUR: 1500 / 1.10 = 1363.636... EUR → 136364 cents
expect($balance->invested_amount)->toBe(136364);
});
test('paginates within a window when deposit history hits the 1000-record limit', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
// Build exactly 1000 deposits for the first page (triggers offset pagination)
$firstPage = collect(range(1, 1000))->map(fn ($i) => [
'id' => "dep-{$i}",
'amount' => '1.00',
'coin' => 'EUR',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(10)->getTimestampMs(),
])->all();
// Second page has the remaining deposits (< 1000, so pagination stops)
$secondPage = [
[
'id' => 'dep-1001',
'amount' => '500.00',
'coin' => 'EUR',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(5)->getTimestampMs(),
],
];
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::sequence()
->push($firstPage) // Window 1, offset 0: 1000 records
->push($secondPage) // Window 1, offset 1000: 1 record (stops pagination)
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'EUR', 'free' => '1500.00', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// 1000 deposits of 1 EUR + 1 deposit of 500 EUR = 1500 EUR → 150000 cents
expect($balance->invested_amount)->toBe(150000);
});
test('fetches deposits from older windows when recent window is empty', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->binance()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'binance-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
// First window (most recent 90 days) returns empty, second window has a deposit
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::sequence()
->push([]) // Window 1: no deposits in last 90 days
->push([
[
'id' => 'dep-old',
'amount' => '1000.00',
'coin' => 'EUR',
'status' => 1,
'transferType' => 0,
'insertTime' => now()->subDays(120)->getTimestampMs(),
],
]) // Window 2: deposit from ~120 days ago
->whenEmpty(Http::response([])),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'EUR', 'free' => '1000.00', 'locked' => '0.0'],
],
]),
'api.binance.com/api/v3/ticker/price' => Http::response([]),
]);
$client = new BinanceClient('test-key', 'test-secret');
$service = app(BinanceBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// The deposit from the older window should be found: 1000 EUR → 100000 cents
expect($balance->invested_amount)->toBe(100000);
});

View File

@ -39,6 +39,11 @@ test('syncs bitpanda balance with crypto and fiat wallets', function () {
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [
[
@ -99,6 +104,11 @@ test('syncs bitpanda balance with crypto only', function () {
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
@ -173,6 +183,11 @@ test('syncs bitpanda balance including bitpanda-specific indices', function () {
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
@ -229,6 +244,11 @@ test('updates existing balance for same date', function () {
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
@ -260,6 +280,11 @@ test('handles empty wallets gracefully', function () {
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
@ -305,6 +330,11 @@ test('skips deleted crypto wallets', function () {
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
@ -352,6 +382,11 @@ test('skips zero balance fiat wallets', function () {
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [
[
@ -419,6 +454,11 @@ test('uses correct currency from ticker when account is USD', function () {
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
@ -432,3 +472,246 @@ test('uses correct currency from ticker when account is USD', function () {
// Should use USD price: 1 BTC * 55000 USD = 5500000 cents
expect($account->balances()->first()->balance)->toBe(5500000);
});
test('calculates invested_amount from fiat deposit and withdrawal history', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'bitpanda-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.bitpanda.com/v1/ticker' => Http::response([
'BTC' => ['EUR' => '50000.00'],
]),
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [
[
'type' => 'wallet',
'attributes' => [
'cryptocoin_id' => '1',
'cryptocoin_symbol' => 'BTC',
'balance' => '0.10000000',
'is_default' => true,
'name' => 'BTC wallet',
'deleted' => false,
],
'id' => 'wallet-uuid-1',
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence()
->push([
'data' => [
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-1',
'attributes' => [
'amount' => '1000.00',
'status' => 'finished',
'fiat_id' => 'EUR',
],
],
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-2',
'attributes' => [
'amount' => '500.00',
'status' => 'finished',
'fiat_id' => 'EUR',
],
],
],
'meta' => ['next_cursor' => null],
'links' => [],
])
->push([
'data' => [
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-3',
'attributes' => [
'amount' => '200.00',
'status' => 'finished',
'fiat_id' => 'EUR',
],
],
],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [],
]),
]);
$client = new BitpandaClient('test-key');
$service = app(BitpandaBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Balance: 0.1 BTC * 50000 = 5000 EUR → 500000 cents
expect($balance->balance)->toBe(500000);
// Invested: deposits(1000 + 500) - withdrawals(200) = 1300 EUR → 130000 cents
expect($balance->invested_amount)->toBe(130000);
});
test('only counts finished fiat transactions for invested_amount', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'bitpanda-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.bitpanda.com/v1/ticker' => Http::response([]),
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence()
->push([
'data' => [
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-1',
'attributes' => [
'amount' => '1000.00',
'status' => 'finished',
'fiat_id' => 'EUR',
],
],
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-2',
'attributes' => [
'amount' => '500.00',
'status' => 'pending',
'fiat_id' => 'EUR',
],
],
],
'meta' => ['next_cursor' => null],
'links' => [],
])
->push([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
]);
$client = new BitpandaClient('test-key');
$service = app(BitpandaBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Only the finished deposit of 1000 should count, pending 500 is ignored
expect($balance->invested_amount)->toBe(100000); // 1000 EUR → 100000 cents
});
test('only counts fiat transactions matching target currency for invested_amount', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'bitpanda-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.bitpanda.com/v1/ticker' => Http::response([]),
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence()
->push([
'data' => [
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-1',
'attributes' => [
'amount' => '1000.00',
'status' => 'finished',
'fiat_id' => 'EUR',
],
],
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-2',
'attributes' => [
'amount' => '500.00',
'status' => 'finished',
'fiat_id' => 'USD',
],
],
[
'type' => 'fiat_wallet_transaction',
'id' => 'tx-3',
'attributes' => [
'amount' => '250.00',
'status' => 'finished',
'fiat_id' => 'EUR',
],
],
],
'meta' => ['next_cursor' => null],
'links' => [],
])
->push([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
]);
$client = new BitpandaClient('test-key');
$service = app(BitpandaBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
// Only EUR transactions should count: 1000 + 250 = 1250 EUR → 125000 cents
// The 500 USD deposit should be excluded
expect($balance->invested_amount)->toBe(125000);
});
test('returns null invested_amount when no fiat transactions exist', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->bitpanda()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'bitpanda-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.bitpanda.com/v1/ticker' => Http::response([]),
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([
'data' => [],
'meta' => ['next_cursor' => null],
'links' => [],
]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
]);
$client = new BitpandaClient('test-key');
$service = app(BitpandaBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
expect($balance->invested_amount)->toBeNull();
});

View File

@ -163,3 +163,111 @@ test('handles empty portfolios array gracefully', function () {
expect($account->balances()->count())->toBe(0);
});
test('stores invested_amount from instruments_cost and cash_amount', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'IC-001',
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
[
'date' => now()->toDateString(),
'total_amount' => 15000.00,
'instruments_cost' => 12000.00,
'instruments_amount' => 14700.00,
'cash_amount' => 300.00,
],
[
'date' => now()->subDay()->toDateString(),
'total_amount' => 14500.00,
'instruments_cost' => 12000.00,
'instruments_amount' => 14200.00,
'cash_amount' => 300.00,
],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = new IndexaCapitalBalanceSyncService;
$service->sync($account, $client);
expect($account->balances()->count())->toBe(2);
$latest = $account->balances()->orderBy('balance_date', 'desc')->first();
// invested_amount = instruments_cost + cash_amount = 12000 + 300 = 12300 → 1230000 cents
expect($latest->balance)->toBe(1500000);
expect($latest->invested_amount)->toBe(1230000);
$previous = $account->balances()->orderBy('balance_date', 'asc')->first();
// invested_amount = 12000 + 300 = 12300 → 1230000 cents
expect($previous->invested_amount)->toBe(1230000);
});
test('stores null invested_amount when cost fields are missing', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'IC-001',
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
['date' => now()->toDateString(), 'total_amount' => 15000.00],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = new IndexaCapitalBalanceSyncService;
$service->sync($account, $client);
expect($account->balances()->count())->toBe(1);
$balance = $account->balances()->first();
expect($balance->balance)->toBe(1500000);
expect($balance->invested_amount)->toBeNull();
});
test('falls back to total_amount minus return when instruments_cost is missing', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'IC-001',
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
// No instruments_cost/cash_amount, but has return field
['date' => now()->toDateString(), 'total_amount' => 8000.00, 'return' => -500.00],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = new IndexaCapitalBalanceSyncService;
$service->sync($account, $client);
$balance = $account->balances()->first();
expect($balance->balance)->toBe(800000);
// invested_amount = total_amount - return = 8000 - (-500) = 8500 → 850000 cents
expect($balance->invested_amount)->toBe(850000);
});

View File

@ -405,6 +405,8 @@ test('binance first sync gets current balance immediately and dispatches histori
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
@ -446,6 +448,8 @@ test('binance subsequent sync does not dispatch historical job', function () {
]);
Http::fake([
'api.binance.com/sapi/v1/capital/deposit/hisrec*' => Http::response([]),
'api.binance.com/sapi/v1/capital/withdraw/history*' => Http::response([]),
'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -496,6 +500,7 @@ test('bitpanda sync calls balance sync service and updates last_synced_at', func
],
],
]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets' => Http::response([
'data' => [
[
@ -546,6 +551,7 @@ test('bitpanda connections do not expire', function () {
Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/ticker' => Http::response([]),
]);
@ -578,6 +584,7 @@ test('bitpanda sync does not send email', function () {
Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response(['data' => []]),
'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
'api.bitpanda.com/v1/ticker' => Http::response([]),
]);

View File

@ -0,0 +1,21 @@
<?php
use App\Enums\AccountType;
it('supports invested amount for investment accounts', function () {
expect(AccountType::Investment->supportsInvestedAmount())->toBeTrue();
});
it('supports invested amount for retirement accounts', function () {
expect(AccountType::Retirement->supportsInvestedAmount())->toBeTrue();
});
it('does not support invested amount for non-investment account types', function (AccountType $type) {
expect($type->supportsInvestedAmount())->toBeFalse();
})->with([
'checking' => AccountType::Checking,
'credit card' => AccountType::CreditCard,
'loan' => AccountType::Loan,
'savings' => AccountType::Savings,
'others' => AccountType::Others,
]);