diff --git a/app/Actions/EnsureDailyChangeIsSynced.php b/app/Actions/EnsureDailyChangeIsSynced.php index 9576d1b..8dbc529 100644 --- a/app/Actions/EnsureDailyChangeIsSynced.php +++ b/app/Actions/EnsureDailyChangeIsSynced.php @@ -7,6 +7,8 @@ namespace App\Actions; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; +use function Illuminate\Support\defer; + class EnsureDailyChangeIsSynced { public function __invoke(Model $model, callable $next) diff --git a/app/Jobs/BatchInsertNewCurrencyRatesJob.php b/app/Jobs/QueuedCurrencyRateInsertJob.php similarity index 56% rename from app/Jobs/BatchInsertNewCurrencyRatesJob.php rename to app/Jobs/QueuedCurrencyRateInsertJob.php index ca8d6bf..c485c08 100644 --- a/app/Jobs/BatchInsertNewCurrencyRatesJob.php +++ b/app/Jobs/QueuedCurrencyRateInsertJob.php @@ -8,7 +8,7 @@ use App\Models\CurrencyRate; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; -class BatchInsertNewCurrencyRatesJob implements ShouldQueue +class QueuedCurrencyRateInsertJob implements ShouldQueue { use Queueable; @@ -17,12 +17,10 @@ class BatchInsertNewCurrencyRatesJob implements ShouldQueue */ public $tries = 3; - public int $chunk_size = 100; - public function __construct( - protected array $updates + protected array $chunk ) { - $this->updates = $updates; + $this->chunk = $chunk; } /** @@ -31,11 +29,6 @@ class BatchInsertNewCurrencyRatesJob implements ShouldQueue public function handle(): void { - $chunks = array_chunk($this->updates, $this->chunk_size); - - foreach ($chunks as $chunk) { - CurrencyRate::insertOrIgnore($chunk); - } - + CurrencyRate::insertOrIgnore($this->chunk); } } diff --git a/app/Models/CurrencyRate.php b/app/Models/CurrencyRate.php index 9ad425e..c8df9c0 100644 --- a/app/Models/CurrencyRate.php +++ b/app/Models/CurrencyRate.php @@ -4,7 +4,8 @@ declare(strict_types=1); namespace App\Models; -use App\Jobs\BatchInsertNewCurrencyRatesJob; +use App\Jobs\QueuedCurrencyRateInsertJob; +use Carbon\CarbonInterface; use Carbon\CarbonPeriod; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; @@ -97,7 +98,7 @@ class CurrencyRate extends Model }); // persist - BatchInsertNewCurrencyRatesJob::dispatch($updates); + self::chunkInsert($updates); return new CurrencyRate(Arr::first($updates, fn ($update) => $update['currency'] == $currency) ?? ['rate' => 1]); }); @@ -148,38 +149,19 @@ class CurrencyRate extends Model $updates = []; foreach ($period as $date) { - $skip = false; + $lookupDate = self::getNearestPastDate($date, $rates); - $lookupDate = $date->toDateString(); - - // get rates or find closest valid rate (handles missing weekend rates) - while (! isset($rates[$lookupDate])) { - $lookupDate = Carbon::parse($lookupDate)->subDay(); - - // prevent runaway infinite loops - if ($lookupDate->lessThan($date->copy()->subWeek())) { - - $skip = true; - break; - } - - $lookupDate = $lookupDate->toDateString(); - } - - if ($skip) { + if (is_null($lookupDate)) { continue; } - // make date a string - $date = $date->toDateString(); - // loop through each rate - foreach ($rates[$lookupDate] as $curr => $rate) { + foreach ($rates[$lookupDate->toDateString()] as $curr => $rate) { // add to updates $updates[] = [ 'currency' => $curr, - 'date' => $date, + 'date' => $date->toDateString(), 'rate' => $rate, 'updated_at' => now()->toDateTimeString(), 'created_at' => now()->toDateTimeString(), @@ -188,7 +170,7 @@ class CurrencyRate extends Model } // persist - BatchInsertNewCurrencyRatesJob::dispatch($updates); + self::chunkInsert($updates); return collect($updates) ->whereBetween('date', [$start, $end ?? now()]) @@ -199,6 +181,35 @@ class CurrencyRate extends Model ->toArray(); } + private static function getNearestPastDate(CarbonInterface $date, array $rates): ?CarbonInterface + { + $datesWithRates = array_keys($rates); + sort($datesWithRates); + + // get rates or find closest valid rate (handles missing weekend rates) + while (! isset($rates[$date->toDateString()])) { + + // is this the start of a range that falls on a weekend? + if ($date->lessThan($first_date = Carbon::parse($datesWithRates[0]))) { + + $date = $first_date; + break; + } + + // try the day before then + $date = Carbon::parse($date)->subDay(); + + // prevent runaway infinite loops + if ($date->lessThan($date->copy()->subWeek())) { + + $date = null; + break; + } + } + + return $date; + } + public static function refreshCurrencyData($force = false): void { $currencies = Currency::all()->pluck('currency')->toArray(); @@ -234,6 +245,17 @@ class CurrencyRate extends Model } } + public static function chunkInsert(array $updates): void + { + + $chunks = array_chunk($updates, 250); + + foreach ($chunks as $chunk) { + + QueuedCurrencyRateInsertJob::dispatch($chunk); + } + } + protected static function getCurrencyAliasAdjustments($currency) { $adjustment = 1; diff --git a/tests/Api/TransactionsTest.php b/tests/Api/TransactionsTest.php index 3f22d17..c4b9e45 100644 --- a/tests/Api/TransactionsTest.php +++ b/tests/Api/TransactionsTest.php @@ -7,7 +7,9 @@ namespace Tests\Api; use App\Models\Portfolio; use App\Models\Transaction; use App\Models\User; +use Database\Seeders\CurrencySeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Artisan; use Tests\TestCase; class TransactionsTest extends TestCase @@ -69,6 +71,11 @@ class TransactionsTest extends TestCase public function test_can_create_transaction() { + Artisan::call('db:seed', [ + '--class' => CurrencySeeder::class, + '--force' => true, + ]); + $this->actingAs($this->user); $data = [ @@ -76,6 +83,7 @@ class TransactionsTest extends TestCase 'portfolio_id' => $this->portfolio->id, 'transaction_type' => 'BUY', 'quantity' => 10, + 'currency' => 'USD', 'date' => now()->toDateString(), 'cost_basis' => 150, ]; diff --git a/tests/DailyChangeTest.php b/tests/DailyChangeTest.php index d55381a..43cbc84 100644 --- a/tests/DailyChangeTest.php +++ b/tests/DailyChangeTest.php @@ -241,9 +241,11 @@ class DailyChangeTest extends TestCase '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(); + $daily_change_day_after = DailyChange::withDailyPerformance()->whereDate('daily_change.date', $third_transaction->date->nextWeekday())->first(); + $daily_change_day_before = DailyChange::withDailyPerformance()->whereDate('daily_change.date', $third_transaction->date->previousWeekday())->first(); + $this->assertNotNull($daily_change_day_after); + $this->assertNotNull($daily_change_day_before); $this->assertEquals(39.89, $daily_change_day_after->total_cost_basis - $daily_change_day_before->total_cost_basis); } } diff --git a/tests/MultiCurrencyTest.php b/tests/MultiCurrencyTest.php index 621d355..99b9d98 100644 --- a/tests/MultiCurrencyTest.php +++ b/tests/MultiCurrencyTest.php @@ -17,6 +17,7 @@ use App\Models\User; use Carbon\CarbonPeriod; use Database\Seeders\CurrencySeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Artisan; use Investbrain\Frankfurter\Frankfurter; use Mockery; @@ -210,10 +211,10 @@ class MultiCurrencyTest extends TestCase $start = now()->subWeeks(2); $end = now(); - $results = []; - $period = CarbonPeriod::create($start, $end); + // mock response from Frankfurter + $results = []; collect($period->copy()->filter('isWeekday'))->each(function ($date) use (&$results) { $date = $date->toDateString(); @@ -299,7 +300,6 @@ class MultiCurrencyTest extends TestCase public function test_can_handle_aliases_for_time_series_rates() { - $start = now()->subWeeks(2); $end = now(); $adjustment = 100; @@ -333,8 +333,8 @@ class MultiCurrencyTest extends TestCase $result = CurrencyRate::timeSeriesRates('ZZZ', $start, $end); $this->assertEquals( - $results[$end->toDateString()]['YYY'] * $adjustment, - $result[$end->toDateString()] + Arr::last($results)['YYY'] * $adjustment, + Arr::last($result) ); }