feat(store): add plan slicing and dataset actions
Every mutation is a pure function over a draft dataset, so the write surface is unit-tested without React. Money amounts (income, purchase totals, split values, schedule slices, fixed/percent rule amounts) are rounded to their currency's minor unit before they land in the draft, per CLAUDE.md's round-before-persisting rule -- actions.ts is the single boundary every UI mutation passes through. FxRate.baseUnitsPerOne is deliberately exempt: it is stored at six decimal places, not money. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7edcc7f31e
commit
ccb97818d7
5 files changed
+612
No files matched your search
@@ -9,3 +9,4 @@ export * from "./charges.ts";
|
||||
export * from "./fold.ts";
|
||||
export * from "./views.ts";
|
||||
export * from "./seed.ts";
|
||||
export * from "./plans.ts";
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts";
|
||||
|
||||
test("equal slices divide the total across consecutive months", () => {
|
||||
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6);
|
||||
expect(slices).toHaveLength(6);
|
||||
expect(slices[0]).toEqual({ month: "2026-10", amount: { amount: 500, currency: "DKK" } });
|
||||
expect(slices[5]!.month).toBe("2027-03");
|
||||
});
|
||||
|
||||
test("uneven totals still sum exactly, the last slice absorbing the drift", () => {
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3);
|
||||
expect(slices.map((s) => s.amount.amount)).toEqual([33.33, 33.33, 33.34]);
|
||||
expect(sliceTotal(slices)).toBe(100);
|
||||
});
|
||||
|
||||
test("a single slice takes the whole total", () => {
|
||||
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1);
|
||||
expect(slices).toEqual([
|
||||
{ month: "2026-01", amount: { amount: 99.99, currency: "DKK" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a count below one is rejected", () => {
|
||||
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0)).toThrow(
|
||||
/at least one month/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("slicesBalance reports the shortfall the editor must show", () => {
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(0);
|
||||
|
||||
slices[0]!.amount.amount = 30;
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { distributeByWeight, roundMoney } from "./money.ts";
|
||||
import { addMonths } from "./months.ts";
|
||||
import type { Money, MonthId, ScheduleSlice } from "./types.ts";
|
||||
|
||||
/** Splits a total into `count` consecutive monthly slices that sum exactly. */
|
||||
export function equalSlices(
|
||||
total: Money,
|
||||
startMonth: MonthId,
|
||||
count: number,
|
||||
): ScheduleSlice[] {
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new Error("A finance plan needs at least one month");
|
||||
}
|
||||
const weights = new Array(count).fill(1);
|
||||
const amounts = distributeByWeight(
|
||||
total.amount,
|
||||
weights,
|
||||
count - 1,
|
||||
total.currency,
|
||||
);
|
||||
return amounts.map((amount, i) => ({
|
||||
month: addMonths(startMonth, i),
|
||||
amount: { amount, currency: total.currency },
|
||||
}));
|
||||
}
|
||||
|
||||
export function sliceTotal(slices: ScheduleSlice[]): number {
|
||||
return roundMoney(slices.reduce((sum, s) => sum + s.amount.amount, 0));
|
||||
}
|
||||
|
||||
/** total - sum(slices). Positive means the plan is short of the total. */
|
||||
export function slicesBalance(total: Money, slices: ScheduleSlice[]): number {
|
||||
return roundMoney(total.amount - sliceTotal(slices), total.currency);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import * as actions from "./actions.ts";
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
function draft(): Dataset {
|
||||
return createSeedDataset("2026-09");
|
||||
}
|
||||
|
||||
describe("months", () => {
|
||||
test("ensureMonth creates a missing month with zero income", () => {
|
||||
const data = draft();
|
||||
const month = actions.ensureMonth(data, "2026-10");
|
||||
expect(month.income.amount).toBe(0);
|
||||
expect(data.months.map((m) => m.id)).toEqual(["2026-09", "2026-10"]);
|
||||
});
|
||||
|
||||
test("ensureMonth returns the existing month rather than duplicating it", () => {
|
||||
const data = draft();
|
||||
actions.ensureMonth(data, "2026-09");
|
||||
expect(data.months).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("setIncome creates the month if needed", () => {
|
||||
const data = draft();
|
||||
actions.setIncome(data, "2026-11", { amount: 25000, currency: "DKK" });
|
||||
expect(data.months.find((m) => m.id === "2026-11")!.income.amount).toBe(25000);
|
||||
});
|
||||
|
||||
test("setIncome rounds the amount to the currency's minor unit", () => {
|
||||
const data = draft();
|
||||
actions.setIncome(data, "2026-11", { amount: 25000.005, currency: "DKK" });
|
||||
expect(data.months.find((m) => m.id === "2026-11")!.income.amount).toBe(25000.01);
|
||||
actions.setIncome(data, "2026-12", { amount: 12.345, currency: "DKK" });
|
||||
expect(data.months.find((m) => m.id === "2026-12")!.income.amount).toBe(12.35);
|
||||
});
|
||||
|
||||
test("setRuleOverride sets and clears an override", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleOverride(data, "2026-09", postId, {
|
||||
kind: "percentOfIncome",
|
||||
percent: 15,
|
||||
});
|
||||
expect(data.months[0]!.ruleOverrides[postId]).toEqual({
|
||||
kind: "percentOfIncome",
|
||||
percent: 15,
|
||||
});
|
||||
|
||||
actions.setRuleOverride(data, "2026-09", postId, null);
|
||||
expect(data.months[0]!.ruleOverrides[postId]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("setRuleOverride rounds a fixed rule's amount and a percent rule's percent", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setRuleOverride(data, "2026-09", postId, {
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.345, currency: "DKK" },
|
||||
});
|
||||
expect(data.months[0]!.ruleOverrides[postId]).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.35, currency: "DKK" },
|
||||
});
|
||||
|
||||
actions.setRuleOverride(data, "2026-09", postId, {
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.3456,
|
||||
});
|
||||
expect(data.months[0]!.ruleOverrides[postId]).toEqual({
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.35,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("posts", () => {
|
||||
test("addPost appends with the next order value", () => {
|
||||
const data = draft();
|
||||
const post = actions.addPost(data, "Travel", "EUR", {
|
||||
kind: "percentOfIncome",
|
||||
percent: 5,
|
||||
});
|
||||
expect(post.order).toBe(3);
|
||||
expect(post.archived).toBe(false);
|
||||
expect(data.posts).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("addPost rounds a fixed standing rule's amount and a percent's percent", () => {
|
||||
const data = draft();
|
||||
const fixedPost = actions.addPost(data, "Rent", "DKK", {
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.345, currency: "DKK" },
|
||||
});
|
||||
expect(fixedPost.standingRule).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.35, currency: "DKK" },
|
||||
});
|
||||
|
||||
const percentPost = actions.addPost(data, "Travel", "EUR", {
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.3456,
|
||||
});
|
||||
expect(percentPost.standingRule).toEqual({
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.35,
|
||||
});
|
||||
});
|
||||
|
||||
test("setPostArchived toggles without deleting", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.setPostArchived(data, postId, true);
|
||||
expect(data.posts.find((p) => p.id === postId)!.archived).toBe(true);
|
||||
expect(data.posts).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("movePost swaps order with its neighbour", () => {
|
||||
const data = draft();
|
||||
actions.movePost(data, data.posts[1]!.id, -1);
|
||||
expect([...data.posts].sort((a, b) => a.order - b.order).map((p) => p.name)).toEqual([
|
||||
"Food",
|
||||
"Video Games",
|
||||
"Events and Social",
|
||||
]);
|
||||
});
|
||||
|
||||
test("movePost at the boundary is a no-op", () => {
|
||||
const data = draft();
|
||||
actions.movePost(data, data.posts[0]!.id, -1);
|
||||
expect(data.posts[0]!.order).toBe(0);
|
||||
});
|
||||
|
||||
test("updatePost renames without touching other fields", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.updatePost(data, postId, { name: "Games" });
|
||||
const post = data.posts.find((p) => p.id === postId)!;
|
||||
expect(post.name).toBe("Games");
|
||||
expect(post.currency).toBe("DKK");
|
||||
});
|
||||
|
||||
test("updatePost rounds a fixed standing rule's amount and a percent's percent", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.updatePost(data, postId, {
|
||||
standingRule: { kind: "fixed", amount: { amount: 12.345, currency: "DKK" } },
|
||||
});
|
||||
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.35, currency: "DKK" },
|
||||
});
|
||||
|
||||
actions.updatePost(data, postId, {
|
||||
standingRule: { kind: "percentOfIncome", percent: 12.3456 },
|
||||
});
|
||||
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.35,
|
||||
});
|
||||
});
|
||||
|
||||
test("an unknown post id throws", () => {
|
||||
expect(() => actions.updatePost(draft(), "ghost", { name: "x" })).toThrow(/ghost/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchases", () => {
|
||||
const newPurchase = (postId: string) => ({
|
||||
date: "2026-09-14",
|
||||
description: "Groceries",
|
||||
total: { amount: 200, currency: "DKK" as const },
|
||||
splitMode: "percent" as const,
|
||||
splits: [{ postId, value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
});
|
||||
|
||||
test("addPurchase assigns an id and ensures the month exists", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
...newPurchase(data.posts[0]!.id),
|
||||
date: "2026-12-01",
|
||||
});
|
||||
expect(purchase.id).toBeTruthy();
|
||||
expect(data.months.map((m) => m.id)).toContain("2026-12");
|
||||
});
|
||||
|
||||
test("addPurchase rounds the total amount, split values, and schedule slice amounts", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
const purchase = actions.addPurchase(data, {
|
||||
date: "2026-09-14",
|
||||
description: "Sofa",
|
||||
total: { amount: 199.995, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId, value: 33.3333, absorbsRemainder: true }],
|
||||
schedule: {
|
||||
slices: [
|
||||
{ month: "2026-09", amount: { amount: 66.665, currency: "DKK" } },
|
||||
{ month: "2026-10", amount: { amount: 66.665, currency: "DKK" } },
|
||||
{ month: "2026-11", amount: { amount: 66.665, currency: "DKK" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(purchase.total.amount).toBe(200);
|
||||
expect(purchase.splits[0]!.value).toBe(33.33);
|
||||
expect(purchase.schedule!.slices.map((s) => s.amount.amount)).toEqual([
|
||||
66.67, 66.67, 66.67,
|
||||
]);
|
||||
});
|
||||
|
||||
test("updatePurchase replaces the given fields", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, newPurchase(data.posts[0]!.id));
|
||||
actions.updatePurchase(data, purchase.id, { description: "Dinner" });
|
||||
expect(data.purchases[0]!.description).toBe("Dinner");
|
||||
});
|
||||
|
||||
test("updatePurchase rounds the total amount, split values, and schedule slice amounts", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
const purchase = actions.addPurchase(data, newPurchase(postId));
|
||||
actions.updatePurchase(data, purchase.id, {
|
||||
total: { amount: 199.995, currency: "DKK" },
|
||||
splits: [{ postId, value: 33.3333, absorbsRemainder: true }],
|
||||
schedule: {
|
||||
slices: [
|
||||
{ month: "2026-09", amount: { amount: 66.665, currency: "DKK" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const updated = data.purchases.find((p) => p.id === purchase.id)!;
|
||||
expect(updated.total.amount).toBe(200);
|
||||
expect(updated.splits[0]!.value).toBe(33.33);
|
||||
expect(updated.schedule!.slices[0]!.amount.amount).toBe(66.67);
|
||||
});
|
||||
|
||||
test("deletePurchase removes it", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, newPurchase(data.posts[0]!.id));
|
||||
actions.deletePurchase(data, purchase.id);
|
||||
expect(data.purchases).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("cancelScheduleFrom records the cancellation month", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
...newPurchase(data.posts[0]!.id),
|
||||
schedule: {
|
||||
slices: [
|
||||
{ month: "2026-09", amount: { amount: 100, currency: "DKK" } },
|
||||
{ month: "2026-10", amount: { amount: 100, currency: "DKK" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
actions.cancelScheduleFrom(data, purchase.id, "2026-10");
|
||||
expect(data.purchases[0]!.schedule!.cancelledFromMonth).toBe("2026-10");
|
||||
});
|
||||
|
||||
test("cancelling an unscheduled purchase throws", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, newPurchase(data.posts[0]!.id));
|
||||
expect(() => actions.cancelScheduleFrom(data, purchase.id, "2026-10")).toThrow(
|
||||
/no finance plan/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
test("setFxRate inserts then updates in place", () => {
|
||||
const data = draft();
|
||||
actions.setFxRate(data, {
|
||||
currency: "EUR",
|
||||
baseUnitsPerOne: 7.46,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual",
|
||||
});
|
||||
expect(data.fxRates).toHaveLength(1);
|
||||
|
||||
actions.setFxRate(data, {
|
||||
currency: "EUR",
|
||||
baseUnitsPerOne: 7.5,
|
||||
updatedAt: "2026-09-02",
|
||||
source: "api",
|
||||
});
|
||||
expect(data.fxRates).toHaveLength(1);
|
||||
expect(data.fxRates[0]!.baseUnitsPerOne).toBe(7.5);
|
||||
});
|
||||
|
||||
test("setFxRate preserves a 6-decimal rate unrounded", () => {
|
||||
const data = draft();
|
||||
actions.setFxRate(data, {
|
||||
currency: "USD",
|
||||
baseUnitsPerOne: 7.123456,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "api",
|
||||
});
|
||||
expect(data.fxRates.find((r) => r.currency === "USD")!.baseUnitsPerOne).toBe(
|
||||
7.123456,
|
||||
);
|
||||
});
|
||||
|
||||
test("setBaseCurrency drops the new base's own rate row", () => {
|
||||
const data = draft();
|
||||
actions.setFxRate(data, {
|
||||
currency: "EUR",
|
||||
baseUnitsPerOne: 7.46,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual",
|
||||
});
|
||||
actions.setBaseCurrency(data, "EUR");
|
||||
expect(data.settings.baseCurrency).toBe("EUR");
|
||||
expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { newId } from "../domain/seed.ts";
|
||||
import { monthOf } from "../domain/months.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import type {
|
||||
Currency,
|
||||
Dataset,
|
||||
FxRate,
|
||||
Money,
|
||||
Month,
|
||||
MonthId,
|
||||
Post,
|
||||
PostId,
|
||||
Purchase,
|
||||
PurchaseId,
|
||||
Rule,
|
||||
Schedule,
|
||||
Split,
|
||||
} from "../domain/types.ts";
|
||||
|
||||
/**
|
||||
* Every function here mutates a draft Dataset in place. They are called from
|
||||
* store.mutate(), which clones before applying and writes through afterwards.
|
||||
*
|
||||
* Every MONEY amount that flows through here is rounded to its currency's
|
||||
* minor unit (via roundMoney) before it lands in the draft, per CLAUDE.md's
|
||||
* "round before persisting" rule. FxRate.baseUnitsPerOne is deliberately
|
||||
* exempted in setFxRate below — an exchange rate is not money, and rounding
|
||||
* it to cents would be wrong in kind (see the comment there).
|
||||
*/
|
||||
|
||||
function requirePost(draft: Dataset, postId: PostId): Post {
|
||||
const post = draft.posts.find((p) => p.id === postId);
|
||||
if (!post) throw new Error(`Unknown post: ${postId}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
function requirePurchase(draft: Dataset, purchaseId: PurchaseId): Purchase {
|
||||
const purchase = draft.purchases.find((p) => p.id === purchaseId);
|
||||
if (!purchase) throw new Error(`Unknown purchase: ${purchaseId}`);
|
||||
return purchase;
|
||||
}
|
||||
|
||||
/** Rounds a non-money percentage (e.g. Rule.percent, Split.value) to 2 decimals. */
|
||||
function round2(value: number): number {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function roundMoneyValue(money: Money): Money {
|
||||
return { amount: roundMoney(money.amount, money.currency), currency: money.currency };
|
||||
}
|
||||
|
||||
function roundRule(rule: Rule): Rule {
|
||||
if (rule.kind === "fixed") {
|
||||
return { kind: "fixed", amount: roundMoneyValue(rule.amount) };
|
||||
}
|
||||
return { kind: "percentOfIncome", percent: round2(rule.percent) };
|
||||
}
|
||||
|
||||
function roundSplits(splits: Split[]): Split[] {
|
||||
return splits.map((split) => ({ ...split, value: round2(split.value) }));
|
||||
}
|
||||
|
||||
function roundSchedule(schedule: Schedule | null): Schedule | null {
|
||||
if (!schedule) return null;
|
||||
return {
|
||||
...schedule,
|
||||
slices: schedule.slices.map((slice) => ({
|
||||
month: slice.month,
|
||||
amount: roundMoneyValue(slice.amount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureMonth(draft: Dataset, monthId: MonthId): Month {
|
||||
const existing = draft.months.find((m) => m.id === monthId);
|
||||
if (existing) return existing;
|
||||
const created: Month = {
|
||||
id: monthId,
|
||||
income: { amount: 0, currency: draft.settings.baseCurrency },
|
||||
ruleOverrides: {},
|
||||
};
|
||||
draft.months.push(created);
|
||||
draft.months.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return created;
|
||||
}
|
||||
|
||||
export function setIncome(draft: Dataset, monthId: MonthId, income: Money): void {
|
||||
ensureMonth(draft, monthId).income = roundMoneyValue(income);
|
||||
}
|
||||
|
||||
export function setRuleOverride(
|
||||
draft: Dataset,
|
||||
monthId: MonthId,
|
||||
postId: PostId,
|
||||
rule: Rule | null,
|
||||
): void {
|
||||
const month = ensureMonth(draft, monthId);
|
||||
if (rule === null) {
|
||||
delete month.ruleOverrides[postId];
|
||||
return;
|
||||
}
|
||||
month.ruleOverrides[postId] = roundRule(rule);
|
||||
}
|
||||
|
||||
export function addPost(
|
||||
draft: Dataset,
|
||||
name: string,
|
||||
currency: Currency,
|
||||
standingRule: Rule,
|
||||
): Post {
|
||||
const post: Post = {
|
||||
id: newId(),
|
||||
name,
|
||||
order: draft.posts.length,
|
||||
archived: false,
|
||||
currency,
|
||||
standingRule: roundRule(standingRule),
|
||||
};
|
||||
draft.posts.push(post);
|
||||
return post;
|
||||
}
|
||||
|
||||
export function updatePost(
|
||||
draft: Dataset,
|
||||
postId: PostId,
|
||||
changes: Partial<Omit<Post, "id">>,
|
||||
): void {
|
||||
const post = requirePost(draft, postId);
|
||||
const resolved: Partial<Omit<Post, "id">> = { ...changes };
|
||||
if (changes.standingRule) {
|
||||
resolved.standingRule = roundRule(changes.standingRule);
|
||||
}
|
||||
Object.assign(post, resolved);
|
||||
}
|
||||
|
||||
/** Archives rather than deletes, because purchases reference posts. */
|
||||
export function setPostArchived(
|
||||
draft: Dataset,
|
||||
postId: PostId,
|
||||
archived: boolean,
|
||||
): void {
|
||||
requirePost(draft, postId).archived = archived;
|
||||
}
|
||||
|
||||
export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): void {
|
||||
const ordered = [...draft.posts].sort((a, b) => a.order - b.order);
|
||||
const index = ordered.findIndex((p) => p.id === postId);
|
||||
const target = index + direction;
|
||||
if (index === -1 || target < 0 || target >= ordered.length) return;
|
||||
const a = ordered[index]!;
|
||||
const b = ordered[target]!;
|
||||
[a.order, b.order] = [b.order, a.order];
|
||||
}
|
||||
|
||||
export function addPurchase(
|
||||
draft: Dataset,
|
||||
purchase: Omit<Purchase, "id">,
|
||||
): Purchase {
|
||||
const created: Purchase = {
|
||||
...purchase,
|
||||
id: newId(),
|
||||
total: roundMoneyValue(purchase.total),
|
||||
splits: roundSplits(purchase.splits),
|
||||
schedule: roundSchedule(purchase.schedule),
|
||||
};
|
||||
draft.purchases.push(created);
|
||||
// Make sure every month the purchase touches exists, so income can be
|
||||
// entered against it later.
|
||||
const months = created.schedule
|
||||
? created.schedule.slices.map((s) => s.month)
|
||||
: [monthOf(created.date)];
|
||||
for (const monthId of months) ensureMonth(draft, monthId);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updatePurchase(
|
||||
draft: Dataset,
|
||||
purchaseId: PurchaseId,
|
||||
changes: Partial<Omit<Purchase, "id">>,
|
||||
): void {
|
||||
const purchase = requirePurchase(draft, purchaseId);
|
||||
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
|
||||
if (changes.total) {
|
||||
resolved.total = roundMoneyValue(changes.total);
|
||||
}
|
||||
if (changes.splits) {
|
||||
resolved.splits = roundSplits(changes.splits);
|
||||
}
|
||||
if (changes.schedule !== undefined) {
|
||||
resolved.schedule = roundSchedule(changes.schedule);
|
||||
}
|
||||
Object.assign(purchase, resolved);
|
||||
}
|
||||
|
||||
export function deletePurchase(draft: Dataset, purchaseId: PurchaseId): void {
|
||||
draft.purchases = draft.purchases.filter((p) => p.id !== purchaseId);
|
||||
}
|
||||
|
||||
export function cancelScheduleFrom(
|
||||
draft: Dataset,
|
||||
purchaseId: PurchaseId,
|
||||
monthId: MonthId,
|
||||
): void {
|
||||
const purchase = requirePurchase(draft, purchaseId);
|
||||
if (!purchase.schedule) {
|
||||
throw new Error(`Purchase ${purchaseId} has no finance plan to cancel`);
|
||||
}
|
||||
purchase.schedule.cancelledFromMonth = monthId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the rate exactly as passed. Exchange rates are not money: they are
|
||||
* kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding to a
|
||||
* currency's minor unit would quantize a rate below 0.01 straight to zero.
|
||||
*/
|
||||
export function setFxRate(draft: Dataset, rate: FxRate): void {
|
||||
const index = draft.fxRates.findIndex((r) => r.currency === rate.currency);
|
||||
if (index === -1) draft.fxRates.push(rate);
|
||||
else draft.fxRates[index] = rate;
|
||||
}
|
||||
|
||||
/** The base currency never has a rate row of its own. */
|
||||
export function setBaseCurrency(draft: Dataset, currency: Currency): void {
|
||||
draft.settings.baseCurrency = currency;
|
||||
draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency);
|
||||
}
|
||||
Reference in new issue
Block a user