feat(domain): allocation rules become a dated series

Post.standingRule becomes Post.rules: RuleVersion[], resolved by ruleAt() as
the last version effective at or before a month. Per-month overrides still win
outright; a post with no applicable version allocates zero rather than
applying its earliest rule backwards.

allocationFor keeps its signature, so the fold, the views and the ODS export
are untouched. Settings gets a read-only rule summary for now; the history
editor follows.
This commit is contained in:
Lucas Winther committed 2026-09-02 02:15:34 +02:00
1 parent c74a66aea9
commit 68712e72a3
11 files changed
+252 -151

No files matched your search

+35 -3
View File
@@ -1,5 +1,6 @@
import { toBase } from "./fx.ts";
import { roundMoney } from "./money.ts";
import { compareMonths } from "./months.ts";
import type {
Currency,
Dataset,
@@ -9,6 +10,7 @@ import type {
Post,
PostId,
Rule,
RuleVersion,
} from "./types.ts";
/** Resolves a rule to a base-currency amount. Percentages may exceed 100. */
@@ -24,8 +26,36 @@ export function resolveRule(
return roundMoney((income * rule.percent) / 100, baseCurrency);
}
export function ruleForPost(month: Month | undefined, post: Post): Rule {
return month?.ruleOverrides[post.id] ?? post.standingRule;
/**
* The version in effect for `monthId`: the last one whose `from` is at or
* before it, or null when the post was not yet budgeted.
*
* Scans rather than assuming sorted order. The array is kept sorted by
* `setRuleFrom`, but a hand-edited import must still resolve correctly.
*/
export function ruleAt(post: Post, monthId: MonthId): RuleVersion | null {
let effective: RuleVersion | null = null;
for (const version of post.rules) {
if (compareMonths(version.from, monthId) > 0) continue;
if (effective === null || compareMonths(version.from, effective.from) > 0) {
effective = version;
}
}
return effective;
}
/**
* Precedence: a one-month override wins outright; otherwise the version in
* effect; otherwise nothing, which the caller renders as an allocation of zero.
*/
export function ruleForPost(
month: Month | undefined,
post: Post,
monthId: MonthId,
): Rule | null {
const override = month?.ruleOverrides[post.id];
if (override) return override;
return ruleAt(post, monthId)?.rule ?? null;
}
export function isOverridden(month: Month | undefined, postId: PostId): boolean {
@@ -46,8 +76,10 @@ export function allocationFor(
const post = dataset.posts.find((p) => p.id === postId);
if (!post) throw new Error(`Unknown post: ${postId}`);
const month = dataset.months.find((m) => m.id === monthId);
const rule = ruleForPost(month, post, monthId);
if (rule === null) return 0;
return resolveRule(
ruleForPost(month, post),
rule,
incomeFor(dataset, monthId),
dataset.settings.baseCurrency,
dataset.fxRates,