582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
import {
|
|
assets,
|
|
baseRates,
|
|
bills,
|
|
type Code,
|
|
liabilities,
|
|
primaryCurrency,
|
|
RENT,
|
|
SALARY,
|
|
SAVINGS,
|
|
trips,
|
|
} from "./catalog.ts";
|
|
import {
|
|
billNotes,
|
|
type Habit,
|
|
habits,
|
|
type Kind,
|
|
type Plan,
|
|
plans,
|
|
type SharedDinner,
|
|
sharedDinners,
|
|
transferNotes,
|
|
} from "./plan.ts";
|
|
|
|
export const HISTORY_MONTHS = 12;
|
|
const CURRENT = HISTORY_MONTHS - 1;
|
|
const SEED = 20_261_016;
|
|
const MODULUS = 2_147_483_647;
|
|
const MULTIPLIER = 48_271;
|
|
const CARD_MARGIN = 1.015;
|
|
const BUY_SPREAD = 1.012;
|
|
const SELL_SPREAD = 0.99;
|
|
const PAID_THIS_MONTH = 0.6;
|
|
const LATEST_RANDOM_DAY = 27;
|
|
|
|
export interface Split {
|
|
amount: number;
|
|
bill?: string;
|
|
bookDate?: string;
|
|
budget?: string;
|
|
category?: string;
|
|
description: string;
|
|
destination: string;
|
|
externalUrl?: string;
|
|
foreign?: { amount: number; currency: Code };
|
|
interestDate?: string;
|
|
notes?: string;
|
|
piggy?: string;
|
|
processDate?: string;
|
|
reconciled?: boolean;
|
|
source: string;
|
|
tags?: string[];
|
|
}
|
|
|
|
export interface Entry {
|
|
date: string;
|
|
key?: string;
|
|
month: number;
|
|
splits: Split[];
|
|
title?: string;
|
|
type: Kind;
|
|
}
|
|
|
|
type Extra = Omit<Split, "amount" | "destination" | "source"> & {
|
|
key?: string;
|
|
};
|
|
|
|
export const accountCurrency: Record<string, Code> = Object.fromEntries(
|
|
[...assets, ...liabilities].map((account) => [account.key, account.currency])
|
|
);
|
|
|
|
export const createRng = (seed: number) => {
|
|
let state = seed % MODULUS || 1;
|
|
const next = () => {
|
|
state = (state * MULTIPLIER) % MODULUS;
|
|
return (state - 1) / (MODULUS - 1);
|
|
};
|
|
const int = (min: number, max: number) =>
|
|
min + Math.floor(next() * (max - min + 1));
|
|
const amount = (min: number, max: number, step = 0.01) =>
|
|
Math.round((min + next() * (max - min)) / step) * step;
|
|
const pick = <T>(items: readonly T[]) =>
|
|
items[Math.floor(next() * items.length)] as T;
|
|
const chance = (probability: number) => next() < probability;
|
|
const weighted = <T>(items: readonly (readonly [T, number])[]) => {
|
|
const total = items.reduce((sum, [, weight]) => sum + weight, 0);
|
|
let roll = next() * total;
|
|
for (const [item, weight] of items) {
|
|
roll -= weight;
|
|
if (roll < 0) {
|
|
return item;
|
|
}
|
|
}
|
|
return items.at(-1)?.[0] as T;
|
|
};
|
|
return { amount, chance, int, pick, weighted };
|
|
};
|
|
|
|
export const round2 = (value: number) => Math.round(value * 100) / 100;
|
|
const pad = (value: number) => String(value).padStart(2, "0");
|
|
|
|
export const createCalendar = (today: Date) => {
|
|
const monthStart = (index: number) =>
|
|
new Date(today.getFullYear(), today.getMonth() - CURRENT + index, 1);
|
|
const daysIn = (index: number) => {
|
|
const start = monthStart(index);
|
|
return new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate();
|
|
};
|
|
const date = (index: number, day: number) => {
|
|
const start = monthStart(index);
|
|
const clamped = Math.min(Math.max(day, 1), daysIn(index));
|
|
return `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(clamped)}`;
|
|
};
|
|
const end = (index: number) => date(index, daysIn(index));
|
|
return {
|
|
date,
|
|
daysIn,
|
|
end,
|
|
lastDay: (index: number) =>
|
|
index === CURRENT ? today.getDate() : daysIn(index),
|
|
opening: () => end(-1),
|
|
today: date(CURRENT, today.getDate()),
|
|
year: (index: number) => monthStart(index).getFullYear(),
|
|
};
|
|
};
|
|
|
|
export type Calendar = ReturnType<typeof createCalendar>;
|
|
|
|
export const rate = (code: Code, month: number) => {
|
|
const drift: Record<Code, number> = {
|
|
EUR: 0.012 * Math.sin(month * 0.7) + 0.0021 * month,
|
|
PLN: 0.009 * Math.sin(month * 1.3) + 0.0028 * month,
|
|
UAH: 0,
|
|
USD: 0.004 * Math.sin(month) + 0.0017 * month,
|
|
};
|
|
return Math.round(baseRates[code] * (1 + drift[code]) * 10_000) / 10_000;
|
|
};
|
|
|
|
const convert = (amount: number, from: Code, to: Code, month: number) =>
|
|
(amount * rate(from, month)) / rate(to, month);
|
|
|
|
export const tripTag = (calendar: Calendar, key: string) => {
|
|
const trip = trips.find((item) => item.key === key);
|
|
return trip ? `travel/${trip.slug}-${calendar.year(trip.month)}` : key;
|
|
};
|
|
|
|
export const buildLedger = (calendar: Calendar) => {
|
|
const rng = createRng(SEED);
|
|
const entries: Entry[] = [];
|
|
const creditSpent: number[] = new Array(HISTORY_MONTHS).fill(0);
|
|
|
|
const at = (month: number, day: number, hour = rng.int(8, 21)) =>
|
|
`${calendar.date(month, day)}T${pad(hour)}:${pad(rng.int(0, 59))}:00`;
|
|
|
|
const chosen = <T>(odds?: readonly [T, number]) =>
|
|
odds && rng.chance(odds[1]) ? odds[0] : undefined;
|
|
|
|
const pickFrom = <T>(odds?: readonly [readonly T[], number]) => {
|
|
const list = chosen(odds);
|
|
return list ? rng.pick(list) : undefined;
|
|
};
|
|
|
|
const add = (entry: Entry) => {
|
|
entries.push(entry);
|
|
};
|
|
|
|
const spend = (
|
|
month: number,
|
|
source: string,
|
|
destination: string,
|
|
price: number,
|
|
priceCurrency: Code,
|
|
extra: Omit<Split, "amount" | "destination" | "source">
|
|
): Split => {
|
|
const currency = accountCurrency[source] ?? primaryCurrency;
|
|
const same = currency === priceCurrency;
|
|
const amount = round2(
|
|
same
|
|
? price
|
|
: convert(price, priceCurrency, currency, month) * CARD_MARGIN
|
|
);
|
|
if (source === "credit") {
|
|
creditSpent[month] = round2((creditSpent[month] ?? 0) + amount);
|
|
}
|
|
const foreign = same
|
|
? undefined
|
|
: { amount: round2(price), currency: priceCurrency };
|
|
return { amount, destination, foreign, source, ...extra };
|
|
};
|
|
|
|
const purchase = (
|
|
month: number,
|
|
day: number,
|
|
source: string,
|
|
destination: string,
|
|
price: number,
|
|
priceCurrency: Code,
|
|
{ key, ...extra }: Extra
|
|
) => {
|
|
const reconciled =
|
|
month === 0 && source === "mono" && rng.chance(0.3) ? true : undefined;
|
|
add({
|
|
date: at(month, day),
|
|
key,
|
|
month,
|
|
splits: [
|
|
spend(month, source, destination, price, priceCurrency, {
|
|
...extra,
|
|
reconciled,
|
|
}),
|
|
],
|
|
type: "withdrawal",
|
|
});
|
|
};
|
|
|
|
const income = (
|
|
month: number,
|
|
day: number,
|
|
source: string,
|
|
destination: string,
|
|
amount: number,
|
|
{ key, ...extra }: Extra
|
|
) => {
|
|
add({
|
|
date: at(month, day, rng.int(8, 12)),
|
|
key,
|
|
month,
|
|
splits: [{ amount: round2(amount), destination, source, ...extra }],
|
|
type: "deposit",
|
|
});
|
|
};
|
|
|
|
const transfer = (
|
|
month: number,
|
|
day: number,
|
|
source: string,
|
|
destination: string,
|
|
amount: number,
|
|
{ foreignAmount, key, ...extra }: Extra & { foreignAmount?: number }
|
|
) => {
|
|
const from = accountCurrency[source] ?? primaryCurrency;
|
|
const to = accountCurrency[destination] ?? primaryCurrency;
|
|
const foreign =
|
|
from === to
|
|
? undefined
|
|
: {
|
|
amount: round2(foreignAmount ?? convert(amount, from, to, month)),
|
|
currency: to,
|
|
};
|
|
add({
|
|
date: at(month, day),
|
|
key,
|
|
month,
|
|
splits: [
|
|
{ amount: round2(amount), destination, foreign, source, ...extra },
|
|
],
|
|
type: "transfer",
|
|
});
|
|
};
|
|
|
|
const payBills = (month: number, lastDay: number) => {
|
|
for (const bill of bills) {
|
|
const yearly = bill.month !== undefined;
|
|
const due = yearly
|
|
? bill.month === month
|
|
: month <= (bill.stopAfter ?? CURRENT);
|
|
const skipped =
|
|
!yearly && month === CURRENT && !rng.chance(PAID_THIS_MONTH);
|
|
if (due && bill.day <= lastDay && !skipped) {
|
|
const [min, max] = bill.amount;
|
|
purchase(
|
|
month,
|
|
bill.day,
|
|
bill.account,
|
|
bill.expense,
|
|
rng.amount(min, max),
|
|
bill.currency,
|
|
{
|
|
bill: bill.key,
|
|
category: bill.category,
|
|
description: bill.name,
|
|
notes: pickFrom([billNotes, 0.15]),
|
|
tags: bill.tags,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const habit = (month: number, lastDay: number, spec: Habit) => {
|
|
const scale = lastDay / calendar.daysIn(month);
|
|
const count = Math.round(rng.int(spec.count[0], spec.count[1]) * scale);
|
|
for (let index = 0; index < count; index += 1) {
|
|
const [place, , min, max, descriptions] = rng.weighted(
|
|
spec.places.map((item) => [item, item[1]] as const)
|
|
);
|
|
const source = rng.weighted(spec.sources);
|
|
const url = chosen(spec.url);
|
|
const tags = pickFrom(spec.tags);
|
|
purchase(
|
|
month,
|
|
rng.int(1, lastDay),
|
|
source,
|
|
place,
|
|
rng.amount(min, max),
|
|
spec.currency ?? "PLN",
|
|
{
|
|
budget: chosen(spec.budget),
|
|
category: chosen(spec.category),
|
|
description: rng.pick(descriptions),
|
|
externalUrl: url ? `${url}${rng.int(100_000, 999_999)}` : undefined,
|
|
notes: pickFrom(spec.notes),
|
|
tags: tags ? [...tags] : undefined,
|
|
}
|
|
);
|
|
}
|
|
};
|
|
|
|
const oneOf = <T>(value: T | readonly T[]) =>
|
|
Array.isArray(value) ? rng.pick(value as readonly T[]) : (value as T);
|
|
|
|
const runPlan = (month: number, lastDay: number, plan: Plan) => {
|
|
if (plan.months !== "all" && !plan.months.includes(month)) {
|
|
return;
|
|
}
|
|
const days = plan.days ?? [rng.int(1, LATEST_RANDOM_DAY)];
|
|
for (const day of days.filter((value) => value <= lastDay)) {
|
|
const amount =
|
|
typeof plan.amount === "number"
|
|
? plan.amount
|
|
: rng.amount(plan.amount[0], plan.amount[1], plan.step);
|
|
const destination = oneOf(plan.destination);
|
|
const extra: Extra = {
|
|
budget: plan.budget,
|
|
category: plan.category,
|
|
description: oneOf(plan.description),
|
|
key: plan.key?.[1] === month ? plan.key[0] : undefined,
|
|
notes: plan.notes,
|
|
piggy: plan.piggy,
|
|
tags: plan.trip
|
|
? [...(plan.tags ?? []), tripTag(calendar, plan.trip)]
|
|
: plan.tags && [...plan.tags],
|
|
};
|
|
const currency =
|
|
plan.currency ?? accountCurrency[plan.source] ?? primaryCurrency;
|
|
const handlers: Record<Kind, () => void> = {
|
|
deposit: () =>
|
|
income(month, day, plan.source, destination, amount, extra),
|
|
transfer: () =>
|
|
transfer(month, day, plan.source, destination, amount, extra),
|
|
withdrawal: () =>
|
|
purchase(
|
|
month,
|
|
day,
|
|
plan.source,
|
|
destination,
|
|
amount,
|
|
currency,
|
|
extra
|
|
),
|
|
};
|
|
handlers[plan.type]();
|
|
}
|
|
};
|
|
|
|
const salary = (month: number, lastDay: number) => {
|
|
if (lastDay < 10) {
|
|
return;
|
|
}
|
|
const early = month < 2;
|
|
income(month, 10, "northwind", "wise", SALARY, {
|
|
bookDate: early ? calendar.date(month, 11) : undefined,
|
|
description: "Salary — Northwind Labs",
|
|
notes: pickFrom([
|
|
[
|
|
`salary for ${calendar.date(month, 1).slice(0, 7)}`,
|
|
"paid a day late",
|
|
],
|
|
0.25,
|
|
]),
|
|
processDate: early ? calendar.date(month, 12) : undefined,
|
|
});
|
|
};
|
|
|
|
const invoices = (month: number, lastDay: number) => {
|
|
const label = calendar.date(month, 1).slice(0, 7);
|
|
const day = rng.int(5, 8);
|
|
if (day <= lastDay) {
|
|
income(month, day, "brightline", "wise", rng.amount(800, 1600, 50), {
|
|
description: `Invoice BL-${label} — Brightline Studio`,
|
|
externalUrl: `https://invoices.example.com/brightline/${label}`,
|
|
key: month === 0 ? "brightlineInvoice" : undefined,
|
|
notes: pickFrom([
|
|
[
|
|
"hours logged in Toggl",
|
|
"includes the hotfix weekend",
|
|
"paid via Wise, no fees",
|
|
],
|
|
0.3,
|
|
]),
|
|
tags: [
|
|
rng.chance(0.85) ? "programming" : "Programming",
|
|
"programming/client-a",
|
|
],
|
|
});
|
|
}
|
|
if (month % 2 === 0 && lastDay >= 18) {
|
|
const reimbursed = month === 4;
|
|
income(month, 18, "kraken", "pko", rng.amount(3000, 6000, 100), {
|
|
description: `Kraken Digital — ${rng.pick(["landing page", "API integration", "bug bash", "consulting"])}`,
|
|
key: reimbursed ? "krakenInvoice" : undefined,
|
|
notes: reimbursed
|
|
? "includes the USB-C dock they reimbursed"
|
|
: undefined,
|
|
tags: [rng.chance(0.8) ? "programming/client-b" : "progamming"],
|
|
});
|
|
}
|
|
};
|
|
|
|
const interest = (month: number, lastDay: number) => {
|
|
income(month, 1, "wiseInterest", "wise", rng.amount(3, 7), {
|
|
description: "Interest",
|
|
});
|
|
if (lastDay >= 2) {
|
|
income(month, 2, "cashback", "mono", rng.amount(120, 480), {
|
|
description: "Cashback",
|
|
});
|
|
}
|
|
if (lastDay >= 28) {
|
|
income(month, 28, "interest", "savings", rng.amount(900, 1300), {
|
|
description: "Jar interest",
|
|
interestDate: month >= 8 ? calendar.date(month, 27) : undefined,
|
|
});
|
|
}
|
|
};
|
|
|
|
const conversions = (month: number, lastDay: number) => {
|
|
for (const day of [11, 25].filter((value) => value <= lastDay)) {
|
|
const usd = rng.amount(1700, 2150, 50);
|
|
transfer(month, day, "wise", "mono", usd, {
|
|
description: "Wise → Mono",
|
|
foreignAmount: usd * rate("USD", month) * SELL_SPREAD,
|
|
notes: pickFrom([transferNotes, 0.4]),
|
|
});
|
|
}
|
|
for (const day of [12, 26].filter((value) => value <= lastDay)) {
|
|
const pln = rng.amount(2900, 3900, 100);
|
|
transfer(
|
|
month,
|
|
day,
|
|
"mono",
|
|
"pko",
|
|
pln * rate("PLN", month) * BUY_SPREAD,
|
|
{
|
|
description: "Mono → PKO",
|
|
foreignAmount: pln,
|
|
notes: pickFrom([transferNotes, 0.4]),
|
|
piggy: day === 26 && month % 6 === 0 ? "synth" : undefined,
|
|
}
|
|
);
|
|
}
|
|
const owed = creditSpent[month - 1] ?? 0;
|
|
if (lastDay >= 20 && owed > 0) {
|
|
transfer(month, 20, "pko", "credit", owed, {
|
|
description: "Credit card repayment",
|
|
});
|
|
}
|
|
};
|
|
|
|
const receipt = (month: number, lastDay: number) => {
|
|
const day = rng.int(1, 28);
|
|
if (day > lastDay) {
|
|
return;
|
|
}
|
|
const shop = rng.pick(["kaufland", "carrefour"] as const);
|
|
const source = rng.pick(["pko", "mono"]);
|
|
add({
|
|
date: at(month, day),
|
|
key: month === 0 ? "receipt" : undefined,
|
|
month,
|
|
splits: [
|
|
spend(month, source, shop, rng.amount(80, 220), "PLN", {
|
|
budget: "Groceries",
|
|
category: "Groceries",
|
|
description: "Groceries",
|
|
}),
|
|
spend(month, source, shop, rng.amount(25, 90), "PLN", {
|
|
budget: "Everything else",
|
|
category: "Household",
|
|
description: "Household — detergent, paper towels",
|
|
notes: pickFrom([
|
|
["receipt photo attached", "bought the big pack"],
|
|
0.4,
|
|
]),
|
|
}),
|
|
],
|
|
title: `${shop === "kaufland" ? "Kaufland" : "Carrefour"} — weekly shop`,
|
|
type: "withdrawal",
|
|
});
|
|
};
|
|
|
|
const sharedDinner = (month: number, lastDay: number, spec: SharedDinner) => {
|
|
const day = rng.int(1, 28);
|
|
if (!spec.months.includes(month) || day > lastDay) {
|
|
return;
|
|
}
|
|
const total = rng.amount(spec.total[0], spec.total[1]);
|
|
const share = round2(total * rng.amount(spec.ratio[0], spec.ratio[1]));
|
|
add({
|
|
date: at(month, day, 20),
|
|
month,
|
|
splits: [
|
|
spend(month, spec.source, spec.place, total - share, "PLN", {
|
|
budget: spec.budget,
|
|
category: "Eating out",
|
|
description: `${spec.title} — my part`,
|
|
}),
|
|
{
|
|
...spend(month, spec.source, spec.friend, share, "PLN", {
|
|
description: spec.share,
|
|
notes: `owes me ${share.toFixed(0)} zł`,
|
|
}),
|
|
foreign: undefined,
|
|
},
|
|
],
|
|
title: spec.title,
|
|
type: "withdrawal",
|
|
});
|
|
};
|
|
|
|
for (let month = 0; month < HISTORY_MONTHS; month += 1) {
|
|
const lastDay = calendar.lastDay(month);
|
|
salary(month, lastDay);
|
|
invoices(month, lastDay);
|
|
interest(month, lastDay);
|
|
conversions(month, lastDay);
|
|
payBills(month, lastDay);
|
|
for (const spec of habits) {
|
|
habit(month, lastDay, spec);
|
|
}
|
|
for (const plan of plans) {
|
|
runPlan(month, lastDay, plan);
|
|
}
|
|
receipt(month, lastDay);
|
|
for (const spec of sharedDinners) {
|
|
sharedDinner(month, lastDay, spec);
|
|
}
|
|
}
|
|
|
|
const next = HISTORY_MONTHS;
|
|
add({
|
|
date: `${calendar.date(next, 1)}T09:00:00`,
|
|
month: next,
|
|
splits: [
|
|
{
|
|
amount: RENT,
|
|
category: "Housing",
|
|
description: "Rent — scheduled",
|
|
destination: "landlord",
|
|
notes: "standing order, goes out on the 1st",
|
|
source: "pko",
|
|
},
|
|
],
|
|
type: "withdrawal",
|
|
});
|
|
add({
|
|
date: `${calendar.date(next, 1)}T09:05:00`,
|
|
month: next,
|
|
splits: [
|
|
{
|
|
amount: SAVINGS,
|
|
description: "Savings — scheduled",
|
|
destination: "savings",
|
|
source: "mono",
|
|
},
|
|
],
|
|
type: "transfer",
|
|
});
|
|
|
|
return entries.sort((left, right) => left.date.localeCompare(right.date));
|
|
};
|