From 299b8a56d87f8217a9d5ce5a0916361a751d5a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 23 Feb 2026 13:59:10 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20investment=20benefits=20=E2=80=94=20sho?= =?UTF-8?q?w=20gains/losses=20on=20investment=20accounts=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 image image image image image --- .../Commands/ResetDemoAccountCommand.php | 19 +- .../Commands/SyncBankingConnections.php | 23 +- app/Enums/AccountType.php | 8 + .../Controllers/AccountBalanceController.php | 17 +- .../Api/DashboardAnalyticsController.php | 89 +++- .../Requests/StoreAccountBalanceRequest.php | 11 +- .../UpdateCurrentAccountBalanceRequest.php | 1 + app/Models/AccountBalance.php | 3 + app/Services/BalanceLookup.php | 172 +++++++ .../Banking/BinanceBalanceSyncService.php | 183 +++++++- app/Services/Banking/BinanceClient.php | 42 ++ .../Banking/BitpandaBalanceSyncService.php | 67 ++- app/Services/Banking/BitpandaClient.php | 54 +++ .../IndexaCapitalBalanceSyncService.php | 35 +- composer.json | 4 +- database/factories/AccountBalanceFactory.php | 13 + ...ested_amount_to_account_balances_table.php | 28 ++ .../accounts/account-balance-chart.tsx | 188 +++++++- .../components/accounts/account-list-card.tsx | 99 +++- .../js/components/accounts/balances-modal.tsx | 59 ++- .../accounts/import-balances-drawer.tsx | 47 +- .../import-balance-step-mapping.tsx | 69 ++- .../import-balance-step-preview.tsx | 22 +- .../accounts/update-balance-dialog.tsx | 44 +- .../dashboard/account-balance-card.tsx | 99 +++- resources/js/hooks/use-dashboard-data.ts | 14 +- resources/js/pages/Accounts/Index.tsx | 1 + resources/js/types/account.ts | 7 + resources/js/types/balance-import.ts | 2 + .../Feature/AccountBalanceControllerTest.php | 156 +++++++ tests/Feature/BalanceLookupTest.php | 229 ++++++++++ tests/Feature/DashboardAnalyticsTest.php | 216 +++++++++ .../OpenBanking/BinanceBalanceSyncTest.php | 422 ++++++++++++++++++ .../OpenBanking/BitpandaBalanceSyncTest.php | 283 ++++++++++++ .../IndexaCapitalBalanceSyncTest.php | 108 +++++ .../SyncBankingConnectionJobTest.php | 7 + tests/Unit/Enums/AccountTypeTest.php | 21 + 37 files changed, 2768 insertions(+), 94 deletions(-) create mode 100644 app/Services/BalanceLookup.php create mode 100644 database/migrations/2026_02_22_180317_add_invested_amount_to_account_balances_table.php create mode 100644 tests/Feature/BalanceLookupTest.php create mode 100644 tests/Unit/Enums/AccountTypeTest.php 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} + /> +
+ )} +