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 & { key?: string; }; export const accountCurrency: Record = 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 = (items: readonly T[]) => items[Math.floor(next() * items.length)] as T; const chance = (probability: number) => next() < probability; const weighted = (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; export const rate = (code: Code, month: number) => { const drift: Record = { 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 = (odds?: readonly [T, number]) => odds && rng.chance(odds[1]) ? odds[0] : undefined; const pickFrom = (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 => { 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 = (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 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)); };