From 1ef8dd9378717a75ca96362e2372fe4c3d602e44 Mon Sep 17 00:00:00 2001 From: hackerESQ Date: Thu, 10 Apr 2025 20:47:35 -0500 Subject: [PATCH] Feat: Adds multi currency to imports and exports (#89) * Also adds ability for user to export configurations --- app/Actions/EnsureDailyChangeIsSynced.php | 33 +++++ app/Exports/BackupExport.php | 2 + app/Exports/Sheets/ConfigSheet.php | 64 ++++++++++ app/Exports/Sheets/DailyChangesSheet.php | 2 +- app/Exports/Sheets/TransactionsSheet.php | 26 +++- app/Imports/BackupImport.php | 2 + app/Imports/Sheets/ConfigSheet.php | 79 ++++++++++++ app/Imports/Sheets/DailyChangesSheet.php | 25 ++-- app/Imports/Sheets/TransactionsSheet.php | 37 +++++- app/Imports/ValidatesPortfolioAccess.php | 8 +- app/Models/DailyChange.php | 2 +- app/Models/Transaction.php | 8 ++ app/Models/User.php | 10 ++ lang/en.json | 7 +- lang/es.json | 7 +- .../profile/import-portfolios-field.blade.php | 2 +- .../views/profile/localization-form.blade.php | 4 +- tests/0000_00_00_import_configs_test.xlsx | Bin 0 -> 11109 bytes tests/0000_00_00_import_multi_curr_test.xlsx | Bin 0 -> 11222 bytes tests/0000_00_00_import_test.xlsx | Bin 10371 -> 11203 bytes tests/DailyChangeTest.php | 48 ++++++++ tests/ImportExportTest.php | 113 +++++++++++------- tests/MultiCurrencyTest.php | 43 +++++++ 23 files changed, 445 insertions(+), 77 deletions(-) create mode 100644 app/Actions/EnsureDailyChangeIsSynced.php create mode 100644 app/Exports/Sheets/ConfigSheet.php create mode 100644 app/Imports/Sheets/ConfigSheet.php create mode 100644 tests/0000_00_00_import_configs_test.xlsx create mode 100644 tests/0000_00_00_import_multi_curr_test.xlsx diff --git a/app/Actions/EnsureDailyChangeIsSynced.php b/app/Actions/EnsureDailyChangeIsSynced.php new file mode 100644 index 0000000..9576d1b --- /dev/null +++ b/app/Actions/EnsureDailyChangeIsSynced.php @@ -0,0 +1,33 @@ +portfolio_id; + + if ( + ! Cache::has($cacheKey) + && $model->date->lessThan(now()) + && ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now()) + || $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->min('date') ?? now()) + ) + ) { + defer(fn () => $model->portfolio->syncDailyChanges()); + + Cache::put($cacheKey, now(), now()->addMinutes(5)); + } + } + + return $next($model); + } +} diff --git a/app/Exports/BackupExport.php b/app/Exports/BackupExport.php index c31c10f..001030b 100644 --- a/app/Exports/BackupExport.php +++ b/app/Exports/BackupExport.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Exports; +use App\Exports\Sheets\ConfigSheet; use App\Exports\Sheets\DailyChangesSheet; use App\Exports\Sheets\PortfoliosSheet; use App\Exports\Sheets\TransactionsSheet; @@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets new PortfoliosSheet($this->empty), new TransactionsSheet($this->empty), new DailyChangesSheet($this->empty), + new ConfigSheet($this->empty), ]; } } diff --git a/app/Exports/Sheets/ConfigSheet.php b/app/Exports/Sheets/ConfigSheet.php new file mode 100644 index 0000000..f01ac32 --- /dev/null +++ b/app/Exports/Sheets/ConfigSheet.php @@ -0,0 +1,64 @@ +empty) { + return $configs; + } + + // collect user settings + $configs->push([ + 'key' => 'name', + 'value' => auth()->user()->name, + ], [ + 'key' => 'locale', + 'value' => auth()->user()->getLocale(), + ], [ + 'key' => 'display_currency', + 'value' => auth()->user()->getCurrency(), + ]); + + // reinvested holdings + Holding::myHoldings()->where('reinvest_dividends', true)->get()->each(function ($holding) use (&$configs) { + $configs->push([ + 'key' => 'reinvested_dividends', + 'value' => $holding->id, + ]); + }); + + return $configs; + } + + public function title(): string + { + return 'Config'; + } +} diff --git a/app/Exports/Sheets/DailyChangesSheet.php b/app/Exports/Sheets/DailyChangesSheet.php index 442f133..a22786d 100644 --- a/app/Exports/Sheets/DailyChangesSheet.php +++ b/app/Exports/Sheets/DailyChangesSheet.php @@ -34,7 +34,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle */ public function collection() { - return $this->empty ? collect() : DailyChange::myDailyChanges()->get(); + return $this->empty ? collect() : DailyChange::myDailyChanges()->withDailyPerformance()->get(); } public function title(): string diff --git a/app/Exports/Sheets/TransactionsSheet.php b/app/Exports/Sheets/TransactionsSheet.php index 83fae17..f8426ba 100644 --- a/app/Exports/Sheets/TransactionsSheet.php +++ b/app/Exports/Sheets/TransactionsSheet.php @@ -25,6 +25,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle 'Quantity', 'Cost Basis', 'Sale Price', + 'Currency', 'Split', 'Reinvested Dividend', 'Date', @@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle */ public function collection() { - return $this->empty ? collect() : Transaction::myTransactions()->get(); + if ($this->empty) { + return collect(); + } + + return Transaction::myTransactions() + ->withMarketData() + ->get() + ->map(function ($transaction) { + return [ + 'id' => $transaction->id, + 'symbol' => $transaction->symbol, + 'portfolio_id' => $transaction->portfolio_id, + 'transaction_type' => $transaction->transaction_type, + 'quantity' => $transaction->quantity, + 'cost_basis' => $transaction->cost_basis, + 'sale_price' => $transaction->sale_price, + 'currency' => $transaction->market_data_currency, + 'split' => $transaction->split, + 'reinvested_dividend' => $transaction->reinvested_dividend, + 'date' => $transaction->date, + 'created_at' => $transaction->created_at, + 'updated_at' => $transaction->updated_at, + ]; + }); } public function title(): string diff --git a/app/Imports/BackupImport.php b/app/Imports/BackupImport.php index 7416f9a..8c07cc2 100644 --- a/app/Imports/BackupImport.php +++ b/app/Imports/BackupImport.php @@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData; use App\Console\Commands\RefreshMarketData; use App\Console\Commands\SyncDailyChange; use App\Console\Commands\SyncHoldingData; +use App\Imports\Sheets\ConfigSheet; use App\Imports\Sheets\DailyChangesSheet; use App\Imports\Sheets\PortfoliosSheet; use App\Imports\Sheets\TransactionsSheet; @@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets 'Portfolios' => new PortfoliosSheet($this->backupImportModel), 'Transactions' => new TransactionsSheet($this->backupImportModel), 'Daily Changes' => new DailyChangesSheet($this->backupImportModel), + 'Config' => new ConfigSheet($this->backupImportModel), ]; } } diff --git a/app/Imports/Sheets/ConfigSheet.php b/app/Imports/Sheets/ConfigSheet.php new file mode 100644 index 0000000..8390464 --- /dev/null +++ b/app/Imports/Sheets/ConfigSheet.php @@ -0,0 +1,79 @@ + function (BeforeSheet $event) { + DB::commit(); + $this->backupImport->update([ + 'message' => __('Importing configurations...'), + ]); + DB::beginTransaction(); + }, + ]; + } + + public function collection(Collection $configs) + { + $user = auth()->user(); + + foreach ($configs as $config) { + + switch ($config['key']) { + case 'name': + $user->name = $config['value']; + $user->save(); + break; + + case 'locale': + $user->setOption('locale', $config['value']); + $user->save(); + break; + + case 'display_currency': + $user->setOption('display_currency', $config['value']); + $user->save(); + break; + + case 'reinvest_dividends': + + Holding::myHoldings()->where('id', $config['value'])->update([ + 'reinvest_dividends' => true, + ]); + break; + + default: + break; + } + } + } + + public function rules(): array + { + return [ + 'key' => ['required', 'string'], + 'value' => ['required', 'string'], + ]; + } +} diff --git a/app/Imports/Sheets/DailyChangesSheet.php b/app/Imports/Sheets/DailyChangesSheet.php index 7cdb405..c0e4db9 100644 --- a/app/Imports/Sheets/DailyChangesSheet.php +++ b/app/Imports/Sheets/DailyChangesSheet.php @@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit BeforeSheet::class => function (BeforeSheet $event) { DB::commit(); $this->backupImport->update([ - 'message' => __('Importing daily changes...'), + 'message' => __('Preparing to import daily changes...'), ]); DB::beginTransaction(); }, @@ -40,19 +40,20 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit public function collection(Collection $dailyChanges) { - $dailyChanges->chunk($this->batchSize())->each(function ($chunk) { + $totalBatches = count($dailyChanges) / $this->batchSize(); + + $dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) { $this->validatePortfolioAccess($chunk); + $this->backupImport->update([ + 'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]), + ]); + // have to cast to native values $chunk = $chunk->map(function ($dailyChange) { return [ - 'total_market_value' => $dailyChange['total_market_value'], - 'total_cost_basis' => $dailyChange['total_cost_basis'], - 'total_gain' => $dailyChange['total_gain'], - 'total_dividends_earned' => $dailyChange['total_dividends_earned'], - 'realized_gains' => $dailyChange['realized_gains'], 'annotation' => $dailyChange['annotation'], 'portfolio_id' => $dailyChange['portfolio_id'], 'date' => Carbon::parse($dailyChange['date'])->toDateString(), @@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit $chunk->toArray(), ['portfolio_id', 'date'], [ - 'total_market_value', - 'total_cost_basis', - 'total_gain', - 'total_dividends_earned', - 'realized_gains', 'annotation', 'portfolio_id', 'date', @@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit return [ 'portfolio_id' => ['required', 'uuid'], 'date' => ['required', 'date'], - 'total_market_value' => ['sometimes', 'nullable', 'numeric'], - 'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], - 'total_gain' => ['sometimes', 'nullable', 'numeric'], - 'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'], - 'realized_gains' => ['sometimes', 'nullable', 'numeric'], 'annotation' => ['sometimes', 'nullable', 'string'], ]; } diff --git a/app/Imports/Sheets/TransactionsSheet.php b/app/Imports/Sheets/TransactionsSheet.php index 2682423..700a739 100644 --- a/app/Imports/Sheets/TransactionsSheet.php +++ b/app/Imports/Sheets/TransactionsSheet.php @@ -6,6 +6,8 @@ namespace App\Imports\Sheets; use App\Imports\ValidatesPortfolioAccess; use App\Models\BackupImport; +use App\Models\Currency; +use App\Models\CurrencyRate; use App\Models\Holding; use App\Models\Transaction; use Illuminate\Support\Carbon; @@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit BeforeSheet::class => function (BeforeSheet $event) { DB::commit(); $this->backupImport->update([ - 'message' => __('Importing transactions...'), + 'message' => __('Preparing to import transactions...'), ]); DB::beginTransaction(); }, @@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit public function collection(Collection $transactions) { - $transactions->chunk($this->batchSize())->each(function ($chunk) { + // if has any transactions not in base currency, need to sync timeseries conversion rates + if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) { + + CurrencyRate::timeSeriesRates('', $transactions->min('date')); + } + + $totalBatches = count($transactions) / $this->batchSize(); + + // chunk transactions + $transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) { + + $this->backupImport->update([ + 'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]), + ]); $this->validatePortfolioAccess($chunk); // have to cast to native values $chunk = $chunk->map(function ($transaction) { + $date = Carbon::parse($transaction['date'])->toDateString(); + + // if transaction not in base currency, need to convert + if ($transaction['currency'] == config('investbrain.base_currency')) { + $cost_basis_base = $transaction['cost_basis'] ?? 0; + $sale_price_base = $transaction['sale_price']; + } else { + $cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date); + $sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date); + } + return [ 'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(), 'symbol' => strtoupper($transaction['symbol']), @@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 'quantity' => $transaction['quantity'], 'cost_basis' => $transaction['cost_basis'] ?? 0, 'sale_price' => $transaction['sale_price'], + 'cost_basis_base' => $cost_basis_base, + 'sale_price_base' => $sale_price_base, 'split' => boolval($transaction['split']) ? 1 : 0, 'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0, - 'date' => Carbon::parse($transaction['date'])->toDateString(), + 'date' => $date, ]; }); @@ -81,7 +109,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit ] ); - // stub out related holdings + // get unique symbol/portfolio id combination and stub out related holdings $chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id']) ->each(function ($holding) { @@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit 'transaction_type' => ['required', 'in:BUY,SELL'], 'date' => ['required', 'date'], 'quantity' => ['required', 'min:0', 'numeric'], + 'currency' => ['required', 'string'], 'split' => ['sometimes', 'nullable', 'boolean'], 'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'], 'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], diff --git a/app/Imports/ValidatesPortfolioAccess.php b/app/Imports/ValidatesPortfolioAccess.php index 0336a27..a47c8cf 100644 --- a/app/Imports/ValidatesPortfolioAccess.php +++ b/app/Imports/ValidatesPortfolioAccess.php @@ -11,13 +11,13 @@ trait ValidatesPortfolioAccess public function validatePortfolioAccess($collection) { - $uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id'); - $countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id) - ->whereIn('id', $uniquePortfolios) + $importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id'); + $portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id) + ->whereIn('id', $importingPortfolios) ->count(); if ( - $countPortfoliosWithAccess < $uniquePortfolios->count() + $importingPortfolios->count() > $portfoliosWithAccess ) { throw new \Exception(__('You do not have access to that portfolio.')); } diff --git a/app/Models/DailyChange.php b/app/Models/DailyChange.php index 01127de..8adf726 100644 --- a/app/Models/DailyChange.php +++ b/app/Models/DailyChange.php @@ -149,7 +149,7 @@ class DailyChange extends Model ]); return $query - ->select(['daily_change.portfolio_id', 'daily_change.date']) + ->select(['daily_change.date', 'daily_change.portfolio_id']) ->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) { $join->on('daily_change.date', '>=', 'cost_basis_display.date') ->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id'); diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 4a7ce35..4450139 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Actions\ConvertToMarketDataCurrency; use App\Actions\CopyToBaseCurrency; use App\Actions\EnsureCostBasisAddedToSale; +use App\Actions\EnsureDailyChangeIsSynced; use App\Casts\BaseCurrency; use App\Traits\HasMarketData; use Illuminate\Contracts\Database\Eloquent\Builder; @@ -69,6 +70,12 @@ class Transaction extends Model $transaction->syncToHolding(); + $transaction = Pipeline::send($transaction) + ->through([ + EnsureDailyChangeIsSynced::class, + ]) + ->then(fn (Transaction $transaction) => $transaction); + cache()->forget('portfolio-metrics-'.$transaction->portfolio_id); }); @@ -104,6 +111,7 @@ class Transaction extends Model { return $query->withAggregate('market_data', 'name') ->withAggregate('market_data', 'market_value') + ->withAggregate('market_data', 'currency') ->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'updated_at') diff --git a/app/Models/User.php b/app/Models/User.php index 03d76a4..7ab7cce 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -98,4 +98,14 @@ class User extends Authenticatable implements MustVerifyEmail return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale'); } + + public function setOption(mixed $key, string $value): self + { + + $options = is_array($key) ? $key : [$key => $value]; + + $this->user->options = array_merge($this->user->options ?? [], $options); + + return $this; + } } diff --git a/lang/en.json b/lang/en.json index b87ee63..a73c43b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -367,8 +367,11 @@ "Import starting...": "Import starting...", "Import is in progress...": "Import is in progress...", "Importing portfolios...": "Importing portfolios...", - "Importing transactions...": "Importing transactions...", - "Importing daily changes...": "Importing daily changes...", + "Preparing to import transactions...": "Preparing to import transactions...", + "Importing transactions (Batch :currentBatch of :totalBatches)...": "Importing transactions (Batch :currentBatch of :totalBatches)...", + "Preparing to import daily changes...": "Preparing to import daily changes...", + "Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importing daily changes (Batch :currentBatch of :totalBatches)...", + "Importing configurations...": "Importing configurations...", "Import completed successfully!": "Import completed successfully!", "Your import will continue in the background": "Your import will continue in the background", diff --git a/lang/es.json b/lang/es.json index aec5aaa..3236b6e 100644 --- a/lang/es.json +++ b/lang/es.json @@ -367,8 +367,11 @@ "Import starting...": "Iniciando la importación...", "Import is in progress...": "La importación está en progreso...", "Importing portfolios...": "Importando portafolios...", - "Importing transactions...": "Importando transacciones...", - "Importing daily changes...": "Importando cambios diarios...", + "Preparing to import transactions...": "Preparándose para importar transacciones...", + "Importing transactions (Batch :currentBatch of :totalBatches)...": "Importando transacciones (Lote :currentBatch de :totalBatches)...", + "Preparing to import daily changes...": "Preparing to import cambios diarios...", + "Importing daily changes (Batch :currentBatch of :totalBatches)...": "Importando cambios diarios (Lote :currentBatch de :totalBatches)...", + "Importing configurations...": "Importando configuraciones...", "Import completed successfully!": "¡La importación se completó con éxito!", "Your import will continue in the background": "La importación continuará en segundo plano", diff --git a/resources/views/profile/import-portfolios-field.blade.php b/resources/views/profile/import-portfolios-field.blade.php index 6986862..da60734 100644 --- a/resources/views/profile/import-portfolios-field.blade.php +++ b/resources/views/profile/import-portfolios-field.blade.php @@ -89,13 +89,13 @@ new class extends Component {{ __('Upload or recover your Investbrain portfolio and holdings.') }} + {{ __('Download import template.') }} diff --git a/resources/views/profile/localization-form.blade.php b/resources/views/profile/localization-form.blade.php index 1af652a..2e3bffd 100644 --- a/resources/views/profile/localization-form.blade.php +++ b/resources/views/profile/localization-form.blade.php @@ -40,7 +40,7 @@ new class extends Component $this->validate(); - $this->user->options = array_merge($this->user->options ?? [], [ + $this->user->setOption([ 'locale' => $this->locale, 'display_currency' => $this->display_currency, ]); @@ -51,7 +51,7 @@ new class extends Component $this->dispatch('saved'); - //$this->js('window.location.reload();'); + // $this->js('window.location.reload();'); } }; ?> diff --git a/tests/0000_00_00_import_configs_test.xlsx b/tests/0000_00_00_import_configs_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..325393660526ac2c02795be735713e11b3505262 GIT binary patch literal 11109 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 literal 0 HcmV?d00001 diff --git a/tests/0000_00_00_import_multi_curr_test.xlsx b/tests/0000_00_00_import_multi_curr_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..651cc5a9d1d74116d95e236e2aec44aad50f0567 GIT binary patch literal 11222 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 AX#fBK literal 0 HcmV?d00001 diff --git a/tests/0000_00_00_import_test.xlsx b/tests/0000_00_00_import_test.xlsx index a58b90ce7dd0aa2ae1f9547eb7ba7a063012e543..36b9a1e919d6f5bf716432eeb34b40acf6988203 100644 GIT binary patch delta 5132 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 XKqMG#CIF%!2ap2{Zi40le?|WfWASFR delta 4378 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 diff --git a/tests/DailyChangeTest.php b/tests/DailyChangeTest.php index 81bc9ef..d55381a 100644 --- a/tests/DailyChangeTest.php +++ b/tests/DailyChangeTest.php @@ -198,4 +198,52 @@ class DailyChangeTest extends TestCase $this->assertEqualsWithDelta($total_dividends, $last_dividend_change->total_dividends_earned, 0.01); } + + public function test_daily_changes_synced_into_past_for_older_transactions(): void + { + $this->actingAs($user = User::factory()->create()); + + $portfolio = Portfolio::factory()->create(); + + // 1. test daily change will fill to the date of first transaction + $first_transaction = Transaction::factory(5)->buy()->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->create(); + + $portfolio->syncDailyChanges(); + + $first_date = DailyChange::min('date'); + + $this->assertEquals($first_transaction->min('date')->toDateString(), $first_date); + + // 2. test daily change will fill when new transaction pre-dates earliest daily change + config()->set('app.env', 'local'); + $this->withoutDefer(); + + $second_transaction = Transaction::create([ + 'symbol' => 'AAPL', + 'portfolio_id' => $portfolio->id, + 'date' => now()->subYears(3), + 'quantity' => 1, + 'cost_basis' => 39.89, + 'transaction_type' => 'BUY', + ]); + + $second_date = DailyChange::min('date'); + + $this->assertEquals($second_transaction->date->toDateString(), $second_date); + + // 3. test daily change will fill when new transaction is between earliest daily change and earliest transaction + $third_transaction = Transaction::create([ + 'symbol' => 'AAPL', + 'portfolio_id' => $portfolio->id, + 'date' => now()->subYears(1), + 'quantity' => 1, + 'cost_basis' => 39.89, + 'transaction_type' => 'BUY', + ]); + + $daily_change_day_after = DailyChange::withDailyPerformance()->whereDate('daily_change.date', $third_transaction->date->addDay())->first(); + $daily_change_day_before = DailyChange::withDailyPerformance()->whereDate('daily_change.date', $third_transaction->date->subDay())->first(); + + $this->assertEquals(39.89, $daily_change_day_after->total_cost_basis - $daily_change_day_before->total_cost_basis); + } } diff --git a/tests/ImportExportTest.php b/tests/ImportExportTest.php index 1109d4e..9c87469 100644 --- a/tests/ImportExportTest.php +++ b/tests/ImportExportTest.php @@ -6,6 +6,8 @@ namespace Tests; use App\Exports\BackupExport; use App\Models\BackupImport as BackupImportModel; +use App\Models\Holding; +use App\Models\Portfolio; use App\Models\Transaction; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -15,62 +17,89 @@ class ImportExportTest extends TestCase { use RefreshDatabase; - // todo: need to fix import export + public function test_can_create_exports(): void + { + Excel::fake(); - // public function test_can_create_exports(): void - // { - // Excel::fake(); + $this->actingAs($user = User::factory()->create()); - // $this->actingAs($user = User::factory()->create()); + Transaction::factory(5)->buy()->lastYear()->symbol('AAPL')->create(); - // Transaction::factory(5)->buy()->lastYear()->symbol('AAPL')->create(); + Excel::download(new BackupExport, now()->format('Y_m_d').'_investbrain_backup.xlsx'); - // Excel::download(new BackupExport, now()->format('Y_m_d').'_investbrain_backup.xlsx'); + Excel::assertDownloaded(now()->format('Y_m_d').'_investbrain_backup.xlsx', function (BackupExport $export) { + return true; + }); + } - // Excel::assertDownloaded(now()->format('Y_m_d').'_investbrain_backup.xlsx', function (BackupExport $export) { - // return true; - // }); - // } + public function test_import_job_completes(): void + { + $this->actingAs($user = User::factory()->create()); - // public function test_backup_job_completes(): void - // { - // $this->actingAs($user = User::factory()->create()); + $import_job = BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_test.xlsx', + ]); - // $backup_job = BackupImportModel::create([ - // 'user_id' => auth()->user()->id, - // 'path' => __DIR__.'/0000_00_00_import_test.xlsx', - // ]); + $import_job->refresh(); - // $backup_job->refresh(); + $this->assertEquals('success', $import_job->status); + } - // $this->assertEquals('success', $backup_job->status); - // } + public function test_import_job_inserts_rows(): void + { + $this->actingAs($user = User::factory()->create()); - // public function test_backup_job_inserts_rows(): void - // { - // $this->actingAs($user = User::factory()->create()); + BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_test.xlsx', + ]); - // BackupImportModel::create([ - // 'user_id' => auth()->user()->id, - // 'path' => __DIR__.'/0000_00_00_import_test.xlsx', - // ]); + $this->assertEquals(3, $user->transactions->count()); + } - // $this->assertEquals(3, $user->transactions->count()); - // } + public function test_import_job_calculates_correct_holding_data(): void + { + $this->actingAs($user = User::factory()->create()); - // public function test_backup_job_calculates_correct_holding_data(): void - // { - // $this->actingAs($user = User::factory()->create()); + BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_test.xlsx', + ]); - // BackupImportModel::create([ - // 'user_id' => auth()->user()->id, - // 'path' => __DIR__.'/0000_00_00_import_test.xlsx', - // ]); + $holding = $user->holdings->first(); - // $holding = $user->holdings->first(); + $this->assertEquals('AAPL', $holding->symbol); + $this->assertEquals(6, $holding->quantity); + $this->assertEqualsWithDelta(233.33, $holding->average_cost_basis, 0.01); + } - // $this->assertEquals('AAPL', $holding->symbol); - // $this->assertEquals(6, $holding->quantity); - // $this->assertEqualsWithDelta(233.33, $holding->average_cost_basis, 0.01); - // } + public function test_configurations_are_set_on_import(): void + { + $this->actingAs($user = User::factory()->create()); + + Portfolio::create([ + 'id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337', + 'title' => 'Test Portfolio', + ]); + + $holding = Holding::create([ + 'id' => '9cf8a662-7347-49fb-b9de-0cc1430a8d1f', + 'portfolio_id' => '9e792bb8-94e7-4ed3-b8cc-43b50d34c337', + 'symbol' => 'ACME', + 'quantity' => 0, + 'reinvest_dividends' => false, + ]); + + $this->assertEquals(false, $holding->reinvest_dividends); + + BackupImportModel::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_configs_test.xlsx', + ]); + + $holding->refresh(); + + $this->assertEquals(true, $holding->reinvest_dividends); + } } diff --git a/tests/MultiCurrencyTest.php b/tests/MultiCurrencyTest.php index 33e9d7d..621d355 100644 --- a/tests/MultiCurrencyTest.php +++ b/tests/MultiCurrencyTest.php @@ -6,6 +6,7 @@ namespace Tests; use App\Interfaces\MarketData\FakeMarketData; use App\Interfaces\MarketData\Types\Quote; +use App\Models\BackupImport; use App\Models\Currency; use App\Models\CurrencyRate; use App\Models\DailyChange; @@ -549,4 +550,46 @@ class MultiCurrencyTest extends TestCase $this->assertEqualsWithDelta($metrics->get('realized_gain_dollars'), $dailyChange->last()->realized_gain_dollars, 0.01); $this->assertEqualsWithDelta($metrics->get('total_market_value') - $metrics->get('total_cost_basis'), $dailyChange->last()->total_gain, 0.01); } + + public function test_multi_currency_import_calculates_correct_holding_data(): void + { + $this->actingAs($user = User::factory()->create()); + + Frankfurter::expects('setSymbols') + ->zeroOrMoreTimes() + ->andReturnSelf(); + Frankfurter::expects('timeSeries') + ->zeroOrMoreTimes() + ->andReturn(['rates' => [ + now()->subDays(3)->toDateString() => [ + 'ZZZ' => .01, + ], + now()->subDays(2)->toDateString() => [ + 'ZZZ' => .01, + ], + now()->subDays(1)->toDateString() => [ + 'ZZZ' => .01, + ], + now()->toDateString() => [ + 'ZZZ' => .01, + ], + ]]); + Frankfurter::expects('historical') + ->zeroOrMoreTimes() + ->andReturn([ + 'rates' => [ + 'GBP' => .89, + ], + ]); + + BackupImport::create([ + 'user_id' => auth()->user()->id, + 'path' => __DIR__.'/0000_00_00_import_multi_curr_test.xlsx', + ]); + + $this->assertContains('AAPL', $user->holdings->pluck('symbol')); + $this->assertContains('BP.L', $user->holdings->pluck('symbol')); + $this->assertEquals(17, $user->holdings->sum('quantity')); + $this->assertEqualsWithDelta(371.42, $user->holdings->sum('average_cost_basis'), 0.01); + } }