fix(domain): export wouldAdvancePast so a caller can check before confirming

occurrencesOf's "did not advance" throw is correct and permanent once a bad
purchase is written, so a caller planning to record a confirmation on a date
other than its slot needs to ask, up front, whether that pairing survives the
walk. Extract the rebase-from-a-confirmation rule (previously inlined once)
into `rebasedFrom`, reuse it from the walk itself, and build `wouldAdvancePast`
on top of it and `stepFrom` rather than re-deriving the rule a third time.

No behavioural change to occurrencesOf itself.
This commit is contained in:
Lucas Winther committed 2026-09-04 09:14:59 +02:00
1 parent b276573ffe
commit 17f9a44b65
1 file changed
+38 -8
+38 -8
View File
@@ -86,6 +86,43 @@ export function confirmationsFor(
return found;
}
/**
* The date `occurrencesOf`'s walk would step from after confirming `cost`'s
* slot `cursor` as having been paid on `confirmedDate` — the same rebase rule
* the walk applies inline. Extracted so a caller can ask "would this pairing
* advance the series" (see `wouldAdvancePast` below) without re-deriving it.
*/
function rebasedFrom(cost: RecurringCost, cursor: IsoDate, confirmedDate: IsoDate): IsoDate {
if (cost.anchoring !== "lastCharge") return cursor;
if (cost.recurrence.kind === "everyNMonths") {
return compareMonths(monthOf(confirmedDate), cursor) > 0 ? confirmedDate : cursor;
}
return confirmedDate;
}
/**
* Whether recording `cost`'s slot `slot` as paid on `recordedDate` would
* leave `occurrencesOf` able to step strictly past `slot` — i.e. whether that
* pairing is one the walk can survive at all, checked BEFORE a purchase
* carrying it is ever written.
*
* Calendar anchoring always advances, because it never rebases from the
* confirmation date; `everyNMonths` under `lastCharge` also always advances,
* because it only rebases forward within the slot's own month. The one case
* that can fail is a day/week-granular `lastCharge` cost recorded far enough
* before its slot that stepping from `recordedDate` still lands at or before
* `slot` — exactly the shape of `occurrencesOf`'s "did not advance" throw.
*
* Exported for `ExpectedBand.tsx`'s "Coming up" group, whose whole premise is
* recording a slot's confirmation on a date OTHER than the slot itself
* (today, because paying early). The walk's throw is correct — a step that
* lands backwards really is bad data — so the band, which chooses
* `recordedDate`, must not offer a row that would produce that pairing.
*/
export function wouldAdvancePast(cost: RecurringCost, slot: IsoDate, recordedDate: IsoDate): boolean {
return isBefore(slot, stepFrom(rebasedFrom(cost, slot, recordedDate), cost.recurrence));
}
/**
* Every occurrence of one cost from its start through `upToMonth`, in order.
*
@@ -133,14 +170,7 @@ export function occurrencesOf(
// later, the cursor or the confirmation's month; an early payment within
// the same month simply does not rebase. Day/week-granular kinds keep
// stepping from the confirmation's exact date, unchanged.
const from =
cost.anchoring === "lastCharge" && confirmation
? cost.recurrence.kind === "everyNMonths"
? compareMonths(monthOf(confirmation.date), cursor) > 0
? confirmation.date
: cursor
: confirmation.date
: cursor;
const from = confirmation ? rebasedFrom(cost, cursor, confirmation.date) : cursor;
const next = stepFrom(from, cost.recurrence);
// The loop terminates only if the step strictly increases. `n >= 1` is