147 lines
4.5 KiB
PHP
147 lines
4.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Account;
|
|
use App\Models\AccountBalance;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Str;
|
|
|
|
class RealEstateBalanceGeneratorService
|
|
{
|
|
/**
|
|
* Upsert historical balances in batches of this size. An old purchase date
|
|
* (which has no lower bound in validation) can produce a very long monthly
|
|
* series; building and upserting it all at once exhausts the queue worker's
|
|
* memory in Arr::map/flatten (PHP-LARAVEL-49). Batching bounds peak memory.
|
|
*/
|
|
private const UPSERT_CHUNK_SIZE = 500;
|
|
|
|
/**
|
|
* Generate historical monthly balances from purchase date to today
|
|
* using linear interpolation between purchase price and current value.
|
|
*
|
|
* Balances are placed on:
|
|
* - The purchase date (with purchase price)
|
|
* - The 1st of each month from the month after purchase to the current month
|
|
* - Today (with current value)
|
|
*
|
|
* Use $from/$to to generate only a specific date range while still
|
|
* interpolating against the full purchase-to-today timeline.
|
|
*/
|
|
public function generateHistoricalBalances(
|
|
Account $account,
|
|
int $purchasePrice,
|
|
Carbon $purchaseDate,
|
|
int $currentValue,
|
|
?Carbon $from = null,
|
|
?Carbon $to = null,
|
|
): void {
|
|
$today = Carbon::today();
|
|
|
|
if ($purchaseDate->isAfter($today)) {
|
|
return;
|
|
}
|
|
|
|
$totalDays = (int) $purchaseDate->diffInDays($today);
|
|
|
|
// If purchase date is today, just ensure today's balance exists
|
|
if ($totalDays === 0) {
|
|
$account->balances()->updateOrCreate(
|
|
['balance_date' => $today->toDateString()],
|
|
['balance' => $currentValue],
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$rangeStart = $from ?? $purchaseDate;
|
|
$rangeEnd = $to ?? $today;
|
|
|
|
$dates = $this->buildDateList($purchaseDate, $today, $rangeStart, $rangeEnd);
|
|
|
|
if (empty($dates)) {
|
|
return;
|
|
}
|
|
|
|
$now = now();
|
|
$rows = [];
|
|
|
|
foreach ($dates as $date) {
|
|
$elapsedDays = $purchaseDate->diffInDays($date);
|
|
$balance = (int) round(
|
|
$purchasePrice + ($currentValue - $purchasePrice) * ($elapsedDays / $totalDays)
|
|
);
|
|
|
|
$rows[] = [
|
|
'id' => (string) Str::uuid(),
|
|
'account_id' => $account->id,
|
|
'balance_date' => $date->toDateString(),
|
|
'balance' => $balance,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
if (count($rows) >= self::UPSERT_CHUNK_SIZE) {
|
|
$this->upsertBalances($rows);
|
|
$rows = [];
|
|
}
|
|
}
|
|
|
|
$this->upsertBalances($rows);
|
|
}
|
|
|
|
/**
|
|
* Upsert a batch of balance rows, keyed by (account_id, balance_date).
|
|
*
|
|
* @param list<array<string, mixed>> $rows
|
|
*/
|
|
private function upsertBalances(array $rows): void
|
|
{
|
|
if ($rows === []) {
|
|
return;
|
|
}
|
|
|
|
AccountBalance::upsert($rows, ['account_id', 'balance_date'], ['balance', 'updated_at']);
|
|
}
|
|
|
|
/**
|
|
* Build the list of dates for balance generation:
|
|
* purchase date, 1st of each intermediate month, and today.
|
|
*
|
|
* Only dates within $rangeStart..$rangeEnd are included.
|
|
*
|
|
* @return Carbon[]
|
|
*/
|
|
private function buildDateList(Carbon $purchaseDate, Carbon $today, Carbon $rangeStart, Carbon $rangeEnd): array
|
|
{
|
|
$dates = [];
|
|
|
|
// Start with the purchase date if it falls within the range
|
|
if ($purchaseDate->gte($rangeStart) && $purchaseDate->lte($rangeEnd)) {
|
|
$dates[] = $purchaseDate->copy();
|
|
}
|
|
|
|
// Add the 1st of each month from the month after purchase to the current month
|
|
$firstOfNextMonth = $purchaseDate->copy()->addMonth()->startOfMonth();
|
|
|
|
while ($firstOfNextMonth->lte($today)) {
|
|
if ($firstOfNextMonth->gte($rangeStart) && $firstOfNextMonth->lte($rangeEnd)) {
|
|
// Avoid duplicate if today is the 1st and matches this date
|
|
if (! $firstOfNextMonth->isSameDay($today)) {
|
|
$dates[] = $firstOfNextMonth->copy();
|
|
}
|
|
}
|
|
|
|
$firstOfNextMonth->addMonth();
|
|
}
|
|
|
|
// End with today if it falls within the range (unless purchase date is today, handled above)
|
|
if (! $purchaseDate->isSameDay($today) && $today->gte($rangeStart) && $today->lte($rangeEnd)) {
|
|
$dates[] = $today->copy();
|
|
}
|
|
|
|
return $dates;
|
|
}
|
|
}
|