diff --git a/app/Console/Commands/ResetDemoAccountCommand.php b/app/Console/Commands/ResetDemoAccountCommand.php index 109c6138..bcfcf1e9 100644 --- a/app/Console/Commands/ResetDemoAccountCommand.php +++ b/app/Console/Commands/ResetDemoAccountCommand.php @@ -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); } } diff --git a/app/Console/Commands/SyncBankingConnections.php b/app/Console/Commands/SyncBankingConnections.php index 642d0523..849b8edd 100644 --- a/app/Console/Commands/SyncBankingConnections.php +++ b/app/Console/Commands/SyncBankingConnections.php @@ -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; } diff --git a/app/Enums/AccountType.php b/app/Enums/AccountType.php index 9417aba4..59b230ab 100644 --- a/app/Enums/AccountType.php +++ b/app/Enums/AccountType.php @@ -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); + } } diff --git a/app/Http/Controllers/AccountBalanceController.php b/app/Http/Controllers/AccountBalanceController.php index 1941d68f..03f460d2 100644 --- a/app/Http/Controllers/AccountBalanceController.php +++ b/app/Http/Controllers/AccountBalanceController.php @@ -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']] + : [], ] ); diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index c44163fa..9328233e 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -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. */ diff --git a/app/Http/Requests/StoreAccountBalanceRequest.php b/app/Http/Requests/StoreAccountBalanceRequest.php index a4ffe046..a75ccbdd 100644 --- a/app/Http/Requests/StoreAccountBalanceRequest.php +++ b/app/Http/Requests/StoreAccountBalanceRequest.php @@ -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.', diff --git a/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php b/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php index a8033a8e..08ee4bcc 100644 --- a/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php +++ b/app/Http/Requests/UpdateCurrentAccountBalanceRequest.php @@ -15,6 +15,7 @@ class UpdateCurrentAccountBalanceRequest extends FormRequest { return [ 'balance' => ['required', 'integer'], + 'invested_amount' => ['nullable', 'integer'], ]; } diff --git a/app/Models/AccountBalance.php b/app/Models/AccountBalance.php index 602947fe..9b2e3223 100644 --- a/app/Models/AccountBalance.php +++ b/app/Models/AccountBalance.php @@ -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', ]; } diff --git a/app/Services/BalanceLookup.php b/app/Services/BalanceLookup.php new file mode 100644 index 00000000..3dddff88 --- /dev/null +++ b/app/Services/BalanceLookup.php @@ -0,0 +1,172 @@ + string, 'balance' => int, 'invested_amount' => ?int]. + * + * @var array> + */ + private array $balancesByAccount = []; + + /** + * Sorted invested-amount-only records grouped by account ID. + * Filters out null invested_amount entries for carry-forward lookup. + * + * @var array> + */ + 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|array $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; + } +} diff --git a/app/Services/Banking/BinanceBalanceSyncService.php b/app/Services/Banking/BinanceBalanceSyncService.php index 19dbc40a..8c80185f 100644 --- a/app/Services/Banking/BinanceBalanceSyncService.php +++ b/app/Services/Banking/BinanceBalanceSyncService.php @@ -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 + */ + 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 + */ + 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 + */ + 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 $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(); + } } diff --git a/app/Services/Banking/BinanceClient.php b/app/Services/Banking/BinanceClient.php index 189637f4..bad4e2b2 100644 --- a/app/Services/Banking/BinanceClient.php +++ b/app/Services/Banking/BinanceClient.php @@ -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 + */ + 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 + */ + 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. */ diff --git a/app/Services/Banking/BitpandaBalanceSyncService.php b/app/Services/Banking/BitpandaBalanceSyncService.php index 2eb9f7b0..b451f71b 100644 --- a/app/Services/Banking/BitpandaBalanceSyncService.php +++ b/app/Services/Banking/BitpandaBalanceSyncService.php @@ -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 $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; + } } diff --git a/app/Services/Banking/BitpandaClient.php b/app/Services/Banking/BitpandaClient.php index 5da7dd67..d364dcf9 100644 --- a/app/Services/Banking/BitpandaClient.php +++ b/app/Services/Banking/BitpandaClient.php @@ -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, 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 + */ + 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 diff --git a/app/Services/Banking/IndexaCapitalBalanceSyncService.php b/app/Services/Banking/IndexaCapitalBalanceSyncService.php index 5f580eed..2df9048a 100644 --- a/app/Services/Banking/IndexaCapitalBalanceSyncService.php +++ b/app/Services/Banking/IndexaCapitalBalanceSyncService.php @@ -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 $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; + } } diff --git a/composer.json b/composer.json index 9a917f11..127a61cf 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/database/factories/AccountBalanceFactory.php b/database/factories/AccountBalanceFactory.php index 6ce8d962..603b6200 100644 --- a/database/factories/AccountBalanceFactory.php +++ b/database/factories/AccountBalanceFactory.php @@ -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'] + ), + ]); + } } diff --git a/database/migrations/2026_02_22_180317_add_invested_amount_to_account_balances_table.php b/database/migrations/2026_02_22_180317_add_invested_amount_to_account_balances_table.php new file mode 100644 index 00000000..0a9f8818 --- /dev/null +++ b/database/migrations/2026_02_22_180317_add_invested_amount_to_account_balances_table.php @@ -0,0 +1,28 @@ +bigInteger('invested_amount')->nullable()->after('balance'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('account_balances', function (Blueprint $table) { + $table->dropColumn('invested_amount'); + }); + } +}; diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx index b98844b5..9b949fac 100644 --- a/resources/js/components/accounts/account-balance-chart.tsx +++ b/resources/js/components/accounts/account-balance-chart.tsx @@ -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; + }>; + 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 ( +
+
+ {payload.map((item) => ( +
+
+
+ + {item.dataKey === 'value' + ? __('Balance') + : __('Invested')} + + + {typeof item.value === 'number' + ? valueFormatter(item.value) + : item.value} + +
+
+ ))} + {gain !== null && ( +
+
+
+ + {__('Gain/loss')} + + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`} + > + {gain >= 0 ? '+' : ''} + {valueFormatter(gain)} + +
+
+ )} +
+
+ ); +} + 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({ /> + showInvestmentBenefits ? ( + + ) : ( + + ) } /> + {showInvestmentBenefits && + currentInvestedAmount !== null && ( + + )} + ) : showInvestmentBenefits && + currentInvestedAmount !== null ? ( + + + + } + /> + + + ) : ( -

+

{data.date}

-

- -

+ {invested !== null ? ( +
+ + {__('Balance')} + + + + + + {__('Invested')} + + + + + {gain !== null && ( + <> + + {__( + 'Gain/loss', + )} + + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`} + > + {gain >= 0 + ? '+' + : ''} + + + + )} +
+ ) : ( +

+ +

+ )}
); }} @@ -151,6 +217,17 @@ export function AccountListCard({ strokeWidth={2} dot={false} /> + {supportsInvestedAmount(account) && ( + + )}
diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx index 531cd935..3931252b 100644 --- a/resources/js/components/accounts/balances-modal.tsx +++ b/resources/js/components/accounts/balances-modal.tsx @@ -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( + 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 ( <> - + {__('Balance History')} @@ -242,6 +258,11 @@ export function BalancesModal({ {__('Balance')} + {showInvestedAmount && ( + + {__('Invested')} + + )} {__('Actions')} @@ -251,7 +272,9 @@ export function BalancesModal({ {isLoading ? ( {__('Loading...')} @@ -260,7 +283,9 @@ export function BalancesModal({ ) : balances.length === 0 ? ( {__( @@ -281,6 +306,17 @@ export function BalancesModal({ balance.balance / 100, )} + {showInvestedAmount && ( + + {balance.invested_amount !== + null + ? formatter.format( + balance.invested_amount / + 100, + ) + : '—'} + + )}
+ {showInvestedAmount && ( +
+ + + setEditInvestedAmount(value || null) + } + currencyCode={account.currency_code} + /> +
+ )} +