. */ declare(strict_types=1); namespace FireflyIII\Support\Models; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; class BillDateCalculator { // #8401 we start keeping track of the diff in periods, because if it can't jump over a period (happens often in February) // we can force the process along. private int $diffInMonths = 0; /** * Returns the dates a bill needs to be paid. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function getPayDates(Carbon $earliest, Carbon $latest, Carbon $billStart, string $period, int $skip, ?Carbon $lastPaid): array { $this->diffInMonths = 0; $earliest->startOfDay(); $latest->endOfDay(); $billStart->startOfDay(); Log::debug('Now in BillDateCalculator::getPayDates()'); Log::debug(sprintf('Dates must be between %s and %s.', $earliest->format('Y-m-d'), $latest->format('Y-m-d'))); Log::debug(sprintf('Bill started on %s, period is "%s", skip is %d, last paid = "%s".', $billStart->format('Y-m-d'), $period, $skip, $lastPaid?->format('Y-m-d'))); $daysUntilEOM = app('navigation')->daysUntilEndOfMonth($billStart); Log::debug(sprintf('For bill start, days until end of month is %d', $daysUntilEOM)); $set = new Collection(); $currentStart = clone $earliest; // 2023-06-23 subDay to fix 7655 $currentStart->subDay(); $loop = 0; Log::debug('Start of loop'); while ($currentStart <= $latest) { Log::debug(sprintf('Current start is %s', $currentStart->format('Y-m-d'))); $nextExpectedMatch = $this->nextDateMatch(clone $currentStart, clone $billStart, $period, $skip); Log::debug(sprintf('Next expected match is %s', $nextExpectedMatch->format('Y-m-d'))); // If nextExpectedMatch is after end, we stop looking: if ($nextExpectedMatch->gt($latest)) { Log::debug('Next expected match is after $latest.'); if ($set->count() > 0) { Log::debug(sprintf('Already have %d date(s), so we can safely break.', $set->count())); break; } Log::debug('Add date to set anyway, since we had no dates yet.'); $set->push(clone $nextExpectedMatch); continue; } // add to set, if the date is ON or after the start parameter // AND date is after last paid date if ( $nextExpectedMatch->gte($earliest) // date is after "earliest possible date" && (null === $lastPaid || $nextExpectedMatch->gt($lastPaid)) // date is after last paid date, if that date is not NULL ) { Log::debug('Add date to set, because it is after earliest possible date and after last paid date.'); $set->push(clone $nextExpectedMatch); } // #8401 // a little check for when the day of the bill (ie 30th of the month) is not possible in // the next expected month because that month has only 28 days (i.e. february). // this applies to leap years as well. if ($daysUntilEOM < 4) { $nextUntilEOM = app('navigation')->daysUntilEndOfMonth($nextExpectedMatch); $diffEOM = $daysUntilEOM - $nextUntilEOM; if ($diffEOM > 0) { Log::debug(sprintf('Bill start is %d days from the end of the month. nextExceptedMatch is %d days from the end of the month.', $daysUntilEOM, $nextUntilEOM)); $nextExpectedMatch->subDays(1); Log::debug(sprintf('Subtract %d days from next expected match, which is now %s', $diffEOM, $nextExpectedMatch->format('Y-m-d'))); } } // 2023-10 // for the next loop, go to end of period, THEN add day. Log::debug('Add one day to nextExpectedMatch/currentStart.'); $nextExpectedMatch->addDay(); $currentStart = clone $nextExpectedMatch; ++$loop; if ($loop > 12) { Log::debug('Loop is more than 12, so we break.'); break; } } Log::debug('end of loop'); $simple = $set->map( static function (Carbon $date) { return $date->format('Y-m-d'); } ); Log::debug(sprintf('Found %d pay dates', $set->count()), $simple->toArray()); return $simple->toArray(); } /** * Given a bill and a date, this method will tell you at which moment this bill expects its next * transaction given the earliest date this could happen. * * That date must be AFTER $billStartDate, as a sanity check. */ protected function nextDateMatch(Carbon $earliest, Carbon $billStartDate, string $period, int $skip): Carbon { Log::debug(sprintf('Bill start date is %s', $billStartDate->format('Y-m-d'))); if ($earliest->lt($billStartDate)) { Log::debug('Earliest possible date is after bill start, so just return bill start date.'); return $billStartDate; } $steps = app('navigation')->diffInPeriods($period, $skip, $earliest, $billStartDate); if ($steps === $this->diffInMonths) { Log::debug(sprintf('Steps is %d, which is the same as diffInMonths (%d), so we add another 1.', $steps, $this->diffInMonths)); ++$steps; } $this->diffInMonths = $steps; $result = clone $billStartDate; if ($steps > 0) { --$steps; Log::debug(sprintf('Steps is %d, because addPeriod already adds 1.', $steps)); $result = app('navigation')->addPeriod($billStartDate, $period, $steps); } Log::debug(sprintf('Number of steps is %d, added to %s, result is %s', $steps, $billStartDate->format('Y-m-d'), $result->format('Y-m-d'))); return $result; } }