Files
budget2.0/src/ui/recurringForm.ts
T
Lucas WintherandClaude Opus 5 87f309a32c feat(purchase): tag a purchase to a recurring cost, or create one from it
Two requests in one control. Tagging an existing cost is the refresh: the data
runs out, the owner enters the purchase, picks the plan, and the money is
booked while the cycle restarts from that date — because the anchor is the
latest charge. The data cap stops needing a flow of its own.

"+ New recurring cost" seeds a cost from the purchase being typed, and that
purchase becomes its first charge, so an anchored cost created this way is
anchored from the moment it exists. Both land in one mutate call, so a failure
cannot leave a cost with no first charge.

A calendar cost is offered only where its grid has a slot in the purchase's
month. sourceFor resolves that slot by LOOKUP (walking occurrencesOf and
matching the purchase's month) rather than by deriving it from monthOf alone —
everyNDays + calendar is a legal combination whose slots are specific days, so
a derived slot could name one that does not exist on the grid.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-05 23:32:38 +02:00

99 lines
3.6 KiB
TypeScript

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<RecurringCost, "id" | "order"> {
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 })),
};
}