import { monthOf } from "../domain/months.ts"; import { occurrencesOf } from "../domain/occurrences.ts"; import type { Anchoring, Dataset, IsoDate, Purchase, Recurrence, RecurringCost, } from "../domain/types.ts"; import type { PurchaseDraft } from "./purchaseForm.ts"; /** * The costs a purchase dated `date` could be a charge for. * * An anchored (`lastCharge`) cost is always offerable: it has no grid of * slots, so any date can be the moment its cycle restarted — that IS the * refresh. A `calendar` cost is offerable only where its grid actually has a * slot in that purchase's month; tagging one outside its grid would record a * charge settling nothing. */ export function offerableCosts(dataset: Dataset, date: IsoDate): RecurringCost[] { const month = monthOf(date); return dataset.recurring .filter((cost) => !cost.archived) .filter((cost) => { if (cost.anchoring === "lastCharge") return true; return occurrencesOf(cost, dataset.purchases, month).some( (occurrence) => monthOf(occurrence.date) === month, ); }) .sort((a, b) => a.order - b.order); } /** * The `source` a purchase gets when tagged to a cost. * * An anchored cost has no slots, so the purchase's own date is the whole * story — the charge itself is what rebases the series. * * A calendar cost's charge settles a specific slot, and that slot is NOT * always `{ recurringId, occurrenceDate: monthOf(purchaseDate) }`: that * shape is only correct for a month-granular grid (`everyNMonths` under * `calendar`). `everyNDays` + `calendar` is a legal combination too, and its * slots are specific DAYS — deriving the slot from the month alone would * name a slot that does not exist on that grid. * * So the slot is resolved by LOOKUP instead of derivation: walk the cost's * occurrences through the purchase's month and take whichever one actually * falls there. That is correct for both a month-granular grid and a * day-granular calendar cost. If nothing falls in that month, the cost * should never have been offered — throw rather than invent a slot. */ export function sourceFor( dataset: Dataset, cost: RecurringCost, purchaseDate: IsoDate, ): Purchase["source"] { if (cost.anchoring === "lastCharge") return { recurringId: cost.id }; const month = monthOf(purchaseDate); const occurrence = occurrencesOf(cost, dataset.purchases, month).find( (candidate) => monthOf(candidate.date) === month, ); if (!occurrence) { throw new Error( `"${cost.name}" has no occurrence in ${month}, so a purchase dated ${purchaseDate} cannot ` + `be a charge for it.`, ); } return { recurringId: cost.id, occurrenceDate: occurrence.date }; } /** * A new cost seeded from the purchase being typed. * * A calendar monthly cost is month-granular; every other combination needs a * real day — an anchored monthly cost's next due date is computed * day-precisely from the charge, and the two day-granular recurrence kinds * hand their `startDate` straight to `addDays` regardless of anchoring. */ export function seedCostFromDraft( draft: PurchaseDraft, recurrence: Recurrence, anchoring: Anchoring, ): Omit { const monthGranular = recurrence.kind === "everyNMonths" && anchoring === "calendar"; return { name: draft.description, archived: false, amount: { amount: draft.amount, currency: draft.currency }, startDate: monthGranular ? monthOf(draft.date) : draft.date, recurrence, anchoring, splitMode: draft.splitMode, splits: draft.splits.map((s) => ({ ...s })), }; }