feat(stand,seed): reproducible demo data on both stands, make seed, smoke and prodlike

This commit is contained in:
hh committed 2026-10-16 03:34:20 +00:00
1 parent 772047c0a1
commit 8b3f7e78a3
8 files changed
+3037 -1

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
export interface Resource {
attributes: Record<string, unknown>;
id: string;
}
interface ListResponse {
data: Resource[];
meta: { pagination: { total: number; total_pages: number } };
}
const PAGE_LIMIT = 500;
const TRAILING_SLASHES = /\/+$/;
const BODY_PREVIEW = 1500;
const preview = (body: unknown) =>
typeof body === "string" ? body.slice(0, BODY_PREVIEW) : "";
export const createApi = (base: string, token: string) => {
const root = `${base.replace(TRAILING_SLASHES, "")}/api/v1`;
const send = async <T>(
method: string,
path: string,
body?: string | Uint8Array<ArrayBuffer>,
contentType = "application/json"
): Promise<T> => {
const response = await fetch(`${root}${path}`, {
body,
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${token}`,
"Content-Type": contentType,
},
method,
});
const text = await response.text();
if (!response.ok) {
throw new Error(
`${method} ${path} -> ${response.status}\n${text.slice(0, BODY_PREVIEW)}\nrequest: ${preview(body)}`
);
}
return (text ? JSON.parse(text) : undefined) as T;
};
const json = <T>(method: string, path: string, body?: unknown) =>
send<T>(
method,
path,
body === undefined ? undefined : JSON.stringify(body)
);
const create = async (path: string, body: unknown) =>
(await json<{ data: Resource }>("POST", path, body)).data;
const list = async (path: string, page = 1): Promise<Resource[]> => {
const glue = path.includes("?") ? "&" : "?";
const response = await json<ListResponse>(
"GET",
`${path}${glue}limit=${PAGE_LIMIT}&page=${page}`
);
if (page >= response.meta.pagination.total_pages) {
return response.data;
}
return [...response.data, ...(await list(path, page + 1))];
};
const total = async (path: string) => {
const glue = path.includes("?") ? "&" : "?";
const response = await json<ListResponse>("GET", `${path}${glue}limit=1`);
return response.meta.pagination.total;
};
const upload = (path: string, body: Uint8Array<ArrayBuffer>) =>
send<undefined>("POST", path, body, "application/octet-stream");
return { create, json, list, total, upload };
};
export type Api = ReturnType<typeof createApi>;
const PROGRESS_STEP = 25;
const MS_PER_SECOND = 1000;
export const pool = async <T, R>(
label: string,
items: readonly T[],
limit: number,
task: (item: T) => Promise<R>
): Promise<R[]> => {
const started = performance.now();
const results: R[] = new Array(items.length);
let cursor = 0;
let done = 0;
const report = () => {
const step = process.stdout.isTTY ? PROGRESS_STEP : items.length;
if (done % step === 0 || done === items.length) {
process.stdout.write(`\r ${label} ${done}/${items.length}`);
}
};
const worker = async (): Promise<void> => {
if (cursor >= items.length) {
return;
}
const index = cursor;
cursor += 1;
results[index] = await task(items[index] as T);
done += 1;
report();
await worker();
};
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker)
);
if (items.length > 0) {
const seconds = (performance.now() - started) / MS_PER_SECOND;
process.stdout.write(` (${seconds.toFixed(1)}s)\n`);
}
return results;
};
+803
View File
@@ -0,0 +1,803 @@
export type Code = "UAH" | "USD" | "PLN" | "EUR";
export const currencies: Code[] = ["UAH", "USD", "PLN", "EUR"];
export const primaryCurrency: Code = "UAH";
export const SALARY = 4200;
export const RENT = 3400;
export const SAVINGS = 6000;
export const baseRates: Record<Code, number> = {
EUR: 47.6,
PLN: 10.95,
UAH: 1,
USD: 41.3,
};
export interface AssetSpec {
creditLimit?: number;
currency: Code;
frontpage?: boolean;
key: string;
name: string;
notes?: string;
number?: string;
opening: number;
role: "defaultAsset" | "savingAsset" | "cashWalletAsset" | "ccAsset";
}
export const assets: AssetSpec[] = [
{
currency: "UAH",
frontpage: true,
key: "mono",
name: "Monobank Black",
notes:
"Main card. Most daily spending goes here, PLN is converted by the bank.",
number: "5375 4141 0000 4417",
opening: 48_250,
role: "defaultAsset",
},
{
currency: "PLN",
frontpage: true,
key: "pko",
name: "PKO BP Konto",
number: "PL61 1020 1026 0000 0422 7020 1111",
opening: 8000,
role: "defaultAsset",
},
{
currency: "USD",
frontpage: true,
key: "wise",
name: "Wise USD",
notes: "Salary lands here.",
opening: 2400,
role: "defaultAsset",
},
{
currency: "EUR",
key: "wiseEur",
name: "Wise EUR",
opening: 310,
role: "defaultAsset",
},
{
currency: "UAH",
frontpage: true,
key: "savings",
name: "Monobank Jar — Savings",
opening: 120_000,
role: "savingAsset",
},
{
currency: "PLN",
frontpage: true,
key: "cash",
name: "Cash wallet",
opening: 650,
role: "cashWalletAsset",
},
{
creditLimit: 8000,
currency: "PLN",
frontpage: true,
key: "credit",
name: "Millennium Visa Credit",
notes: "Repaid in full every month from PKO.",
opening: 0,
role: "ccAsset",
},
{
currency: "USD",
key: "usdt",
name: "Binance USDT",
opening: 1800,
role: "defaultAsset",
},
];
export interface LiabilitySpec {
currency: Code;
direction: "credit" | "debit";
interest: number;
key: string;
name: string;
notes?: string;
opening: number;
type: "loan" | "debt" | "mortgage";
}
export const liabilities: LiabilitySpec[] = [
{
currency: "UAH",
direction: "debit",
interest: 11.5,
key: "mortgage",
name: "Kyiv flat mortgage — Oschadbank",
opening: 640_000,
type: "mortgage",
},
{
currency: "PLN",
direction: "debit",
interest: 7.5,
key: "loan",
name: "MacBook installments — Santander",
opening: 14_400,
type: "loan",
},
{
currency: "UAH",
direction: "credit",
interest: 0,
key: "ivan",
name: "Ivan Kovalenko",
notes: "Borrowed for the car repair, pays back in chunks.",
opening: 6000,
type: "debt",
},
{
currency: "PLN",
direction: "debit",
interest: 0,
key: "artem",
name: "Artem Shevchuk",
notes: "Usually pays for dinners, I settle up later.",
opening: 400,
type: "debt",
},
{
currency: "UAH",
direction: "credit",
interest: 0,
key: "oksana",
name: "Oksana Melnyk",
opening: 2500,
type: "debt",
},
{
currency: "USD",
direction: "credit",
interest: 0,
key: "dmytro",
name: "Dmytro Bondar",
opening: 300,
type: "debt",
},
{
currency: "PLN",
direction: "credit",
interest: 0,
key: "marta",
name: "Marta Nowak",
opening: 120,
type: "debt",
},
{
currency: "UAH",
direction: "debit",
interest: 0,
key: "taras",
name: "Taras Lysenko",
notes: "Lent me money for the deposit on the flat.",
opening: 10_000,
type: "debt",
},
];
export const expenses = {
airbnb: "Airbnb",
allegro: "Allegro",
anthropic: "Anthropic",
apple: "Apple",
bar: "Bar Mleczny Prasowy",
biedronka: "Biedronka",
bistro: "Pierogarnia Zapiecek",
bolt: "Bolt",
booking: "Booking.com",
carrefour: "Carrefour Express",
cinema: "Cinema City",
cloudflare: "Cloudflare",
comeBackAlive: "Come Back Alive",
costa: "Costa Coffee",
cursor: "Cursor",
dentist: "Dental Studio Smile",
digitalocean: "DigitalOcean",
empik: "Empik",
flowers: "Kwiaciarnia Bukiet",
gemini: "Apteka Gemini",
github: "GitHub",
glovo: "Glovo",
google: "Google",
hamsa: "Hamsa",
hetzner: "Hetzner Online",
ikea: "IKEA",
jetbrains: "JetBrains",
kaufland: "Kaufland",
kyivstar: "Kyivstar",
landlord: "Landlord — Pan Zbigniew",
lidl: "Lidl",
mcdonalds: "McDonald's",
medicover: "Medicover",
midjourney: "Midjourney",
namecheap: "Namecheap",
netflix: "Netflix",
notion: "Notion",
onepassword: "1Password",
openai: "OpenAI",
orange: "Orange Polska",
perplexity: "Perplexity",
pge: "PGE Obrót",
pkp: "PKP Intercity",
play: "Play",
porkbun: "Porkbun",
prytula: "Serhiy Prytula Foundation",
puzata: "Puzata Hata",
raycast: "Raycast",
rossmann: "Rossmann",
rozetka: "Rozetka",
ryanair: "Ryanair",
silpo: "Silpo",
splice: "Splice",
spotify: "Spotify",
starbucks: "Starbucks",
steam: "Steam",
sushi: "Sushi Kushi",
telegram: "Telegram",
thomann: "Thomann",
tidal: "Tidal",
uber: "Uber",
vet: "Vetica Clinic",
wikimedia: "Wikimedia Foundation",
wolt: "Wolt",
xkom: "x-kom",
zabka: "Żabka",
zooplus: "Zooplus",
ztm: "ZTM Warszawa",
} as const;
export const revenues = {
brightline: "Brightline Studio",
cashback: "Monobank cashback",
interest: "Monobank Jar interest",
kraken: "Kraken Digital",
northwind: "Northwind Labs LLC",
refunds: "Allegro refunds",
taxOffice: "Urząd Skarbowy",
wiseInterest: "Wise interest",
} as const;
export const categories = [
"Groceries",
"Eating out",
"Software",
"Hardware",
"Transport",
"Health",
"Housing",
"Household",
"Entertainment",
"Gifts",
"Travel",
"Donations",
];
export interface BudgetSpec {
autoAmount?: number;
name: string;
}
export const budgets: BudgetSpec[] = [
{ name: "Groceries" },
{ name: "Eating out" },
{ name: "Transport" },
{ name: "Fun" },
{ autoAmount: 6000, name: "Everything else" },
];
export interface TripSpec {
description: string;
key: "krakow" | "lisbon";
month: number;
slug: string;
}
export const trips: TripSpec[] = [
{
description: "Long weekend in Kraków",
key: "krakow",
month: 6,
slug: "krakow",
},
{
description: "Lisbon with Oksana",
key: "lisbon",
month: 9,
slug: "lisbon",
},
];
export const plainTags = [
{ description: "Anything code-related", tag: "programming" },
{ description: "Brightline Studio contract", tag: "programming/client-a" },
{ description: "Kraken Digital, ad-hoc work", tag: "programming/client-b" },
{
description: "ffui and other pet projects",
tag: "programming/side-project",
},
{ tag: "Programming" },
{ tag: "progamming" },
{ tag: "music" },
{ description: "Synths, cables, plugins", tag: "music/gear" },
{ description: "Moose the cat", tag: "moose" },
{ tag: "reimbursable" },
{ tag: "dentist" },
];
export interface BillSpec {
account: string;
amount: [number, number];
category: string;
currency: Code;
day: number;
expense: keyof typeof expenses;
group?: string;
key: string;
month?: number;
name: string;
stopAfter?: number;
tags?: string[];
}
export const bills: BillSpec[] = [
{
account: "wise",
amount: [20, 20],
category: "Software",
currency: "USD",
day: 3,
expense: "openai",
group: "1. AI",
key: "chatgpt",
name: "ChatGPT Plus",
},
{
account: "mono",
amount: [100, 100],
category: "Software",
currency: "USD",
day: 7,
expense: "anthropic",
group: "1. AI",
key: "claude",
name: "Claude Max",
},
{
account: "wise",
amount: [20, 20],
category: "Software",
currency: "USD",
day: 14,
expense: "cursor",
group: "1. AI",
key: "cursor",
name: "Cursor Pro",
tags: ["programming"],
},
{
account: "wise",
amount: [10, 10],
category: "Software",
currency: "USD",
day: 21,
expense: "github",
group: "1. AI",
key: "copilot",
name: "GitHub Copilot",
tags: ["programming"],
},
{
account: "mono",
amount: [10, 40],
category: "Software",
currency: "USD",
day: 27,
expense: "openai",
group: "1. AI",
key: "openaiApi",
name: "OpenAI API credits",
tags: ["programming/side-project"],
},
{
account: "wise",
amount: [10, 10],
category: "Software",
currency: "USD",
day: 9,
expense: "notion",
group: "2. Apps",
key: "notion",
name: "Notion Plus",
},
{
account: "wise",
amount: [8, 8],
category: "Software",
currency: "USD",
day: 16,
expense: "raycast",
group: "2. Apps",
key: "raycast",
name: "Raycast Pro",
},
{
account: "mono",
amount: [4.99, 4.99],
category: "Software",
currency: "USD",
day: 19,
expense: "telegram",
group: "2. Apps",
key: "telegram",
name: "Telegram Premium",
},
{
account: "mono",
amount: [49.99, 49.99],
category: "Software",
currency: "PLN",
day: 24,
expense: "apple",
group: "2. Apps",
key: "icloud",
name: "iCloud+ 2 TB",
},
{
account: "mono",
amount: [29.99, 29.99],
category: "Entertainment",
currency: "PLN",
day: 5,
expense: "spotify",
group: "3. Music",
key: "spotify",
name: "Spotify Premium Duo",
tags: ["music"],
},
{
account: "wise",
amount: [12.99, 12.99],
category: "Software",
currency: "USD",
day: 12,
expense: "splice",
group: "3. Music",
key: "splice",
name: "Splice Sounds+",
tags: ["music"],
},
{
account: "mono",
amount: [25.99, 25.99],
category: "Entertainment",
currency: "PLN",
day: 18,
expense: "google",
group: "3. Music",
key: "youtube",
name: "YouTube Premium",
},
{
account: "mono",
amount: [8.5, 14.2],
category: "Software",
currency: "EUR",
day: 2,
expense: "hetzner",
group: "4. Infra",
key: "hetzner",
name: "Hetzner Cloud",
tags: ["programming/side-project"],
},
{
account: "wise",
amount: [5, 5],
category: "Software",
currency: "USD",
day: 6,
expense: "cloudflare",
group: "4. Infra",
key: "cloudflare",
name: "Cloudflare Workers Paid",
},
{
account: "wise",
amount: [6, 6],
category: "Software",
currency: "USD",
day: 1,
expense: "digitalocean",
group: "4. Infra",
key: "droplet",
name: "DigitalOcean droplet",
},
{
account: "wise",
amount: [14.99, 14.99],
category: "Software",
currency: "USD",
day: 11,
expense: "porkbun",
group: "5. Domains",
key: "moosedev",
month: 3,
name: "moose.dev",
},
{
account: "wise",
amount: [16.98, 16.98],
category: "Software",
currency: "USD",
day: 4,
expense: "namecheap",
group: "5. Domains",
key: "ffuiapp",
month: 0,
name: "ffui.app",
},
{
account: "wise",
amount: [11.48, 11.48],
category: "Software",
currency: "USD",
day: 20,
expense: "namecheap",
group: "5. Domains",
key: "sidexyz",
month: 8,
name: "side-project.xyz",
},
{
account: "wise",
amount: [28, 28],
category: "Software",
currency: "USD",
day: 28,
expense: "porkbun",
group: "5. Domains",
key: "olehcodes",
month: 11,
name: "oleh.codes",
},
{
account: "mono",
amount: [500, 500],
category: "Donations",
currency: "UAH",
day: 1,
expense: "comeBackAlive",
group: "6. Donations",
key: "cba",
name: "Come Back Alive",
},
{
account: "mono",
amount: [300, 300],
category: "Donations",
currency: "UAH",
day: 15,
expense: "prytula",
group: "6. Donations",
key: "prytula",
name: "Prytula Foundation",
},
{
account: "pko",
amount: [20, 20],
category: "Donations",
currency: "PLN",
day: 20,
expense: "wikimedia",
group: "6. Donations",
key: "wikimedia",
name: "Wikimedia",
},
{
account: "wise",
amount: [289, 289],
category: "Software",
currency: "USD",
day: 17,
expense: "jetbrains",
group: "Yearly",
key: "jetbrains",
month: 6,
name: "JetBrains All Products Pack",
tags: ["programming"],
},
{
account: "wise",
amount: [59.88, 59.88],
category: "Software",
currency: "USD",
day: 8,
expense: "onepassword",
group: "Yearly",
key: "onepassword",
month: 1,
name: "1Password Families",
},
{
account: "wise",
amount: [99, 99],
category: "Software",
currency: "USD",
day: 11,
expense: "apple",
group: "Yearly",
key: "appledev",
month: 1,
name: "Apple Developer Program",
},
{
account: "mono",
amount: [49, 49],
category: "Entertainment",
currency: "PLN",
day: 13,
expense: "netflix",
group: "Inactive",
key: "netflix",
name: "Netflix Standard",
stopAfter: 3,
},
{
account: "wise",
amount: [10, 10],
category: "Software",
currency: "USD",
day: 23,
expense: "midjourney",
group: "Inactive",
key: "midjourney",
name: "Midjourney Basic",
stopAfter: 5,
},
{
account: "mono",
amount: [29.99, 29.99],
category: "Entertainment",
currency: "PLN",
day: 10,
expense: "tidal",
group: "Inactive",
key: "tidal",
name: "Tidal HiFi",
stopAfter: 1,
tags: ["music"],
},
{
account: "wise",
amount: [20, 20],
category: "Software",
currency: "USD",
day: 26,
expense: "perplexity",
group: "Inactive",
key: "perplexity",
name: "Perplexity Pro",
stopAfter: 7,
},
{
account: "pko",
amount: [3400, 3400],
category: "Housing",
currency: "PLN",
day: 1,
expense: "landlord",
key: "rent",
name: "Rent",
},
{
account: "pko",
amount: [140, 290],
category: "Housing",
currency: "PLN",
day: 18,
expense: "pge",
key: "electricity",
name: "Electricity — PGE",
},
{
account: "pko",
amount: [69, 69],
category: "Housing",
currency: "PLN",
day: 8,
expense: "orange",
key: "internet",
name: "Internet — Orange",
},
{
account: "pko",
amount: [45, 45],
category: "Housing",
currency: "PLN",
day: 22,
expense: "play",
key: "mobile",
name: "Play mobile",
},
{
account: "mono",
amount: [150, 150],
category: "Housing",
currency: "UAH",
day: 3,
expense: "kyivstar",
key: "kyivstar",
name: "Kyivstar",
},
{
account: "pko",
amount: [189, 189],
category: "Health",
currency: "PLN",
day: 1,
expense: "medicover",
key: "medicover",
name: "Medicover",
},
];
export interface PiggySpec {
account: string;
current: number;
group: string;
key: string;
name: string;
notes?: string;
target: number;
targetMonths?: number;
}
export const piggyBanks: PiggySpec[] = [
{
account: "savings",
current: 40_000,
group: "Safety",
key: "emergency",
name: "Emergency fund",
notes: "Six months of expenses.",
target: 300_000,
targetMonths: 14,
},
{
account: "savings",
current: 0,
group: "Gear",
key: "macbook",
name: "MacBook Pro M5",
target: 110_000,
targetMonths: 4,
},
{
account: "pko",
current: 500,
group: "Gear",
key: "synth",
name: "Moog Matriarch",
notes: "Saving a bit of every PLN top-up.",
target: 9000,
},
{
account: "usdt",
current: 300,
group: "Travel",
key: "japan",
name: "Japan 2027",
target: 5000,
targetMonths: 6,
},
];
+74
View File
@@ -0,0 +1,74 @@
import { parseArgs } from "node:util";
import { type Api, createApi } from "./api.ts";
import { createCalendar, HISTORY_MONTHS } from "./ledger.ts";
import { seed } from "./seed.ts";
import { wipe } from "./wipe.ts";
const MS_PER_SECOND = 1000;
const usage =
"usage: bun scripts/seed/index.ts --base <url> --token-file <path> [--wipe]";
const summarize = async (api: Api) => {
const calendar = createCalendar(new Date());
const range = `start=${calendar.opening()}&end=${calendar.end(HISTORY_MONTHS)}`;
const rows = [
["asset accounts", "/accounts?type=asset"],
["liabilities", "/accounts?type=liabilities"],
["expense accounts", "/accounts?type=expense"],
["revenue accounts", "/accounts?type=revenue"],
["withdrawals", `/transactions?type=withdrawal&${range}`],
["deposits", `/transactions?type=deposit&${range}`],
["transfers", `/transactions?type=transfer&${range}`],
["categories", "/categories"],
["budgets", "/budgets"],
["budget limits", `/budget-limits?${range}`],
["available budgets", "/available-budgets"],
["bills", "/bills"],
["piggy banks", "/piggy-banks"],
["object groups", "/object-groups"],
["tags", "/tags"],
["rule groups", "/rule-groups"],
["rules", "/rules"],
["recurrences", "/recurrences"],
["webhooks", "/webhooks"],
["attachments", "/attachments"],
["transaction links", "/transaction-links"],
["exchange rates", "/exchange-rates"],
] as const;
const totals = await Promise.all(rows.map(([, path]) => api.total(path)));
console.log("summary:");
for (const [index, [label]] of rows.entries()) {
console.log(` ${label.padEnd(18)} ${totals[index]}`);
}
};
const main = async () => {
const { values } = parseArgs({
options: {
base: { type: "string" },
"token-file": { type: "string" },
wipe: { default: false, type: "boolean" },
},
});
const tokenFile = values["token-file"];
if (!(values.base && tokenFile)) {
throw new Error(usage);
}
const token = (await Bun.file(tokenFile).text()).trim();
const api = createApi(values.base, token);
const about = await api.json<{ data: { version: string } }>("GET", "/about");
console.log(`Firefly III ${about.data.version} at ${values.base}`);
const started = performance.now();
console.log("wipe:");
await wipe(api);
if (!values.wipe) {
console.log("seed:");
await seed(api, new Date());
await summarize(api);
}
const seconds = (performance.now() - started) / MS_PER_SECOND;
console.log(`done in ${seconds.toFixed(1)}s`);
};
await main();
+581
View File
@@ -0,0 +1,581 @@
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));
};
+742
View File
@@ -0,0 +1,742 @@
import { type Code, type expenses, SAVINGS } from "./catalog.ts";
export type Kind = "withdrawal" | "deposit" | "transfer";
type Place = keyof typeof expenses;
type Odds<T> = readonly [T, number];
export interface Habit {
budget?: Odds<string>;
category: Odds<string>;
count: readonly [number, number];
currency?: Code;
notes?: Odds<readonly string[]>;
places: readonly (readonly [
Place,
number,
number,
number,
readonly string[],
])[];
sources: readonly Odds<string>[];
tags?: Odds<readonly (readonly string[])[]>;
url?: Odds<string>;
}
export interface Plan {
amount: number | readonly [number, number];
budget?: string;
category?: string;
currency?: Code;
days?: readonly number[];
description: string | readonly string[];
destination: string | readonly string[];
key?: readonly [string, number];
months: readonly number[] | "all";
notes?: string;
piggy?: string;
source: string;
step?: number;
tags?: readonly string[];
trip?: "krakow" | "lisbon";
type: Kind;
}
export const groceryNotes = [
"weekly shop",
"split with Ivan, he owes 43 zł",
"forgot bags again",
"stuff for the Friday party",
"Moose's food included",
"promo on coffee, took 3",
"self-checkout was broken, queued 15 min",
];
export const transferNotes = [
"for rent",
"topping up for groceries",
"rate was ok today",
"moving money before the weekend",
];
export const billNotes = [
"price went up again",
"charged in USD, bank fee ~1.5%",
"switch to yearly?",
"invoice in email",
];
export const habits: Habit[] = [
{
budget: ["Groceries", 0.85],
category: ["Groceries", 0.9],
count: [26, 32],
notes: [groceryNotes, 0.28],
places: [
[
"biedronka",
6,
25,
220,
["Groceries — Biedronka", "Weekly shop — Biedronka"],
],
["lidl", 5, 30, 240, ["Groceries — Lidl", "Food — Lidl"]],
["zabka", 4, 8, 45, ["Żabka — snacks", "Żabka — hot dog and coffee"]],
["carrefour", 2, 15, 120, ["Carrefour Express"]],
["kaufland", 2, 60, 320, ["Groceries — Kaufland"]],
],
sources: [
["mono", 55],
["pko", 25],
["cash", 14],
["credit", 6],
],
tags: [[["moose"]], 0.12],
},
{
budget: ["Eating out", 0.7],
category: ["Eating out", 0.9],
count: [24, 30],
notes: [
[
"lunch with Marta",
"team lunch, will expense",
"dinner with Artem, he paid tips",
"coffee before standup",
"date night",
"worked from here half the day",
"ordered too much again",
],
0.32,
],
places: [
["starbucks", 4, 14, 32, ["Coffee — Starbucks"]],
["costa", 3, 12, 28, ["Coffee — Costa"]],
["mcdonalds", 2, 22, 55, ["Quick bite — McDonald's"]],
["bar", 2, 18, 40, ["Lunch — Bar Mleczny"]],
["bistro", 2, 55, 160, ["Dinner — pierogi"]],
["hamsa", 1, 70, 240, ["Dinner — Hamsa"]],
["sushi", 1, 80, 260, ["Sushi"]],
["wolt", 3, 45, 120, ["Delivery — Wolt"]],
["glovo", 2, 40, 110, ["Delivery — Glovo"]],
],
sources: [
["mono", 50],
["credit", 25],
["pko", 15],
["cash", 10],
],
tags: [[["reimbursable"], ["programming/side-project"]], 0.16],
},
{
budget: ["Transport", 0.8],
category: ["Transport", 0.92],
count: [12, 16],
notes: [
[
"airport",
"late night ride home",
"rain, no bus",
"to the coworking",
"surge pricing, ugh",
],
0.3,
],
places: [
["bolt", 3, 14, 65, ["Bolt ride"]],
["uber", 1, 14, 65, ["Uber trip"]],
],
sources: [
["mono", 60],
["credit", 40],
],
},
{
budget: ["Everything else", 0.5],
category: ["Health", 0.9],
count: [1, 3],
places: [
[
"gemini",
1,
18,
140,
["Pharmacy", "Vitamins", "Ibuprofen and plasters", "Allergy pills"],
],
],
sources: [
["mono", 1],
["pko", 1],
],
},
{
budget: ["Everything else", 0.5],
category: ["Household", 0.85],
count: [2, 3],
notes: [["shampoo + toothpaste", "cleaning stuff", "gift wrap"], 0.15],
places: [["rossmann", 1, 20, 150, ["Rossmann — toiletries"]]],
sources: [
["mono", 1],
["pko", 1],
["cash", 1],
],
},
{
budget: ["Fun", 0.7],
category: ["Entertainment", 0.9],
count: [1, 2],
places: [
[
"cinema",
1,
32,
70,
["Cinema — two tickets", "Cinema + popcorn", "IMAX"],
],
],
sources: [["mono", 1]],
},
{
budget: ["Fun", 0.7],
category: ["Entertainment", 1],
count: [0, 1],
currency: "USD",
places: [
[
"steam",
1,
5,
40,
[
"Steam — Factorio DLC",
"Steam sale",
"Steam — Balatro",
"Steam — gift for Dmytro",
],
],
],
sources: [["wise", 1]],
},
{
budget: ["Fun", 0.7],
category: ["Gifts", 1],
count: [0, 1],
notes: [
["for Marta's birthday", "mom's name day", "housewarming at Artem's"],
0.4,
],
places: [
["flowers", 1, 60, 180, ["Flowers"]],
["empik", 1, 50, 200, ["Book as a gift"]],
],
sources: [
["mono", 1],
["credit", 1],
],
},
{
category: ["Hardware", 1],
count: [0, 1],
places: [
[
"xkom",
1,
80,
2500,
[
"Mechanical keyboard switches",
"USB-C hub",
"SSD 2 TB",
"Monitor arm",
"Webcam",
],
],
],
sources: [
["credit", 1],
["pko", 1],
],
tags: [[["programming"], ["Programming"]], 0.3],
},
{
category: ["Household", 0.6],
count: [0, 1],
places: [
[
"allegro",
1,
30,
400,
["Phone case", "Kitchen scale", "Desk lamp", "LED strip", "Plant pots"],
],
],
sources: [
["credit", 1],
["pko", 1],
],
url: ["https://allegro.pl/moje-allegro/zakupy/kupione/", 0.6],
},
];
const EVEN = [0, 2, 4, 6, 8, 10];
const ODD = [1, 3, 5, 7, 9, 11];
export const plans: Plan[] = [
{
amount: 14_500,
category: "Housing",
days: [15],
description: "Mortgage payment",
destination: "mortgage",
months: "all",
source: "mono",
type: "withdrawal",
},
{
amount: 800,
days: [5],
description: "MacBook installment",
destination: "loan",
months: "all",
source: "pko",
type: "withdrawal",
},
{
amount: SAVINGS,
days: [11],
description: "Savings — emergency fund",
destination: "savings",
months: "all",
piggy: "emergency",
source: "mono",
type: "transfer",
},
{
amount: 4000,
days: [26],
description: "Savings — MacBook",
destination: "savings",
months: "all",
piggy: "macbook",
source: "mono",
type: "transfer",
},
{
amount: [150, 250],
days: [15],
description: "Wise → Binance",
destination: "usdt",
months: "all",
piggy: "japan",
source: "wise",
step: 10,
type: "transfer",
},
{
amount: [750, 950],
days: [7],
description: "ATM withdrawal",
destination: "cash",
months: "all",
source: "pko",
step: 50,
type: "transfer",
},
{
amount: 110,
budget: "Transport",
category: "Transport",
days: [2],
description: "ZTM 30-day ticket",
destination: "ztm",
months: "all",
source: "pko",
type: "withdrawal",
},
{
amount: [120, 260],
category: "Household",
description: "Zooplus — cat food and litter",
destination: "zooplus",
months: "all",
source: "pko",
tags: ["moose"],
type: "withdrawal",
},
{
amount: [150, 450],
category: "Health",
days: [12],
description: "Vet — Moose check-up",
destination: "vet",
months: [2, 6, 10],
notes: "vaccines next time",
source: "pko",
tags: ["moose"],
type: "withdrawal",
},
{
amount: [250, 900],
category: "Health",
description: [
"Dentist — filling",
"Dentist — cleaning",
"Dentist — check-up",
],
destination: "dentist",
months: [1, 5, 10],
source: "pko",
step: 10,
tags: ["dentist"],
type: "withdrawal",
},
{
amount: [150, 1200],
budget: "Everything else",
category: "Household",
days: [7],
description: [
"IKEA — shelves and boxes",
"IKEA — kitchen stuff",
"IKEA — lamp and plants",
],
destination: "ikea",
key: ["ikea", 4],
months: [1, 4, 7, 10],
source: "credit",
type: "withdrawal",
},
{
amount: 5000,
days: [9],
description: "Lent Ivan for the car repair",
destination: "ivan",
key: ["lendIvan", 1],
months: [1],
notes: "he'll return 2k every other month",
source: "mono",
type: "withdrawal",
},
{
amount: 2000,
days: [4],
description: "Ivan paid back",
destination: "mono",
key: ["ivanPaid", 3],
months: [3, 5, 7, 9, 11],
source: "ivan",
type: "deposit",
},
{
amount: [85, 160],
category: "Eating out",
description: "Artem paid for dinner",
destination: ["hamsa", "sushi"],
key: ["artemDinner", 2],
months: EVEN,
notes: "I owe him, settle up next month",
source: "artem",
type: "withdrawal",
},
{
amount: 200,
description: "Paid back Artem",
destination: "artem",
key: ["payArtem", 3],
months: [1, 3, 5, 7, 9],
notes: "BLIK",
source: "pko",
type: "withdrawal",
},
{
amount: 1500,
days: [6],
description: "Paid back Taras",
destination: "taras",
months: EVEN,
source: "mono",
type: "withdrawal",
},
{
amount: 1500,
description: "Oksana paid back",
destination: "mono",
months: [4, 8, 10],
notes: "for the dinners",
source: "oksana",
type: "deposit",
},
{
amount: 200,
days: [17],
description: "Lent Dmytro",
destination: "dmytro",
months: [2],
notes: "until his salary",
source: "wise",
type: "withdrawal",
},
{
amount: 500,
days: [3],
description: "Dmytro paid back",
destination: "wise",
months: [6],
notes: "all settled with Dmytro",
source: "dmytro",
type: "deposit",
},
{
amount: 240,
days: [12],
description: "Marta — BLIK for concert tickets",
destination: "pko",
months: [7],
source: "marta",
type: "deposit",
},
{
amount: [450, 1600],
budget: "Groceries",
category: "Groceries",
days: [21, 24, 28],
description: "Silpo — groceries in Kyiv",
destination: "silpo",
months: [1],
source: "mono",
type: "withdrawal",
},
{
amount: [250, 600],
category: "Eating out",
days: [22, 27],
description: "Puzata Hata",
destination: "puzata",
months: [1],
source: "mono",
type: "withdrawal",
},
{
amount: [1500, 3500],
category: "Gifts",
days: [23],
description: "Rozetka — gift for parents",
destination: "rozetka",
months: [1],
notes: "air purifier for mom",
source: "mono",
type: "withdrawal",
},
{
amount: 389,
category: "Hardware",
days: [14],
description: "USB-C dock for client work",
destination: "xkom",
key: ["dock", 3],
months: [3],
source: "credit",
tags: ["reimbursable", "programming/client-b"],
type: "withdrawal",
},
{
amount: 89.99,
category: "Hardware",
days: [9],
description: "Bluetooth headphones (returned)",
destination: "allegro",
key: ["allegroPurchase", 4],
months: [4],
source: "pko",
type: "withdrawal",
},
{
amount: 89.99,
days: [20],
description: "Allegro refund — headphones",
destination: "pko",
key: ["allegroRefund", 4],
months: [4],
source: "refunds",
type: "deposit",
},
{
amount: 1840,
days: [22],
description: "PIT refund",
destination: "pko",
months: [6],
notes: "tax return for last year",
source: "taxOffice",
type: "deposit",
},
{
amount: [120, 250],
category: "Hardware",
days: [19],
description: "Thomann — MIDI controller",
destination: "thomann",
months: [2],
source: "wiseEur",
tags: ["music", "music/gear"],
type: "withdrawal",
},
{
amount: [90, 200],
category: "Hardware",
days: [3],
description: "Thomann — cables and stand",
destination: "thomann",
months: [10],
source: "wiseEur",
tags: ["music/gear"],
type: "withdrawal",
},
{
amount: [89, 149],
category: "Travel",
days: [14],
description: "PKP Intercity Warszawa–Kraków",
destination: "pkp",
months: [6],
source: "pko",
trip: "krakow",
type: "withdrawal",
},
{
amount: 640,
category: "Travel",
days: [14],
description: "Hotel Stary — 2 nights",
destination: "booking",
months: [6],
notes: "booked via Booking.com, free cancellation",
source: "credit",
trip: "krakow",
type: "withdrawal",
},
{
amount: [89, 149],
category: "Travel",
days: [16],
description: "PKP Intercity Kraków–Warszawa",
destination: "pkp",
months: [6],
notes: "train delayed 40 min",
source: "pko",
trip: "krakow",
type: "withdrawal",
},
{
amount: [25, 140],
category: "Eating out",
days: [14, 15, 15, 16],
description: [
"Pierogi on Kazimierz",
"Lunch near Wawel",
"Coffee on Rynek",
"Zapiekanka at Plac Nowy",
],
destination: ["bistro", "bar", "starbucks"],
months: [6],
source: "mono",
trip: "krakow",
type: "withdrawal",
},
{
amount: 189,
category: "Travel",
currency: "EUR",
days: [9],
description: "Ryanair WAW–LIS",
destination: "ryanair",
months: [7],
source: "credit",
trip: "lisbon",
type: "withdrawal",
},
{
amount: 900,
days: [2],
description: "Wise USD → EUR",
destination: "wiseEur",
months: [9],
notes: "for Lisbon",
source: "wise",
type: "transfer",
},
{
amount: 420,
category: "Travel",
days: [3],
description: "Airbnb Alfama — 5 nights",
destination: "airbnb",
months: [9],
source: "wiseEur",
trip: "lisbon",
type: "withdrawal",
},
{
amount: [18, 65],
category: "Eating out",
days: [8, 9, 10, 11, 12],
description: [
"Pastéis de nata",
"Dinner in Bairro Alto",
"Seafood at Time Out Market",
"Coffee in Alfama",
"Bifana",
],
destination: ["bistro", "starbucks", "sushi"],
months: [9],
source: "wiseEur",
trip: "lisbon",
type: "withdrawal",
},
{
amount: [8, 20],
category: "Travel",
days: [8, 13],
description: "Uber in Lisbon",
destination: "uber",
months: [9],
source: "wiseEur",
trip: "lisbon",
type: "withdrawal",
},
];
export interface SharedDinner {
budget?: string;
friend: string;
months: readonly number[];
place: Place;
ratio: readonly [number, number];
share: string;
source: string;
title: string;
total: readonly [number, number];
}
export const sharedDinners: SharedDinner[] = [
{
budget: "Eating out",
friend: "oksana",
months: ODD,
place: "hamsa",
ratio: [0.4, 0.5],
share: "Oksana's share",
source: "mono",
title: "Dinner at Hamsa",
total: [160, 320],
},
{
friend: "marta",
months: [2, 6, 10],
place: "sushi",
ratio: [0.5, 0.5],
share: "Marta's half",
source: "pko",
title: "Sushi with Marta",
total: [120, 240],
},
];
+650
View File
@@ -0,0 +1,650 @@
import { type Api, pool } from "./api.ts";
import {
assets,
bills,
budgets,
categories,
currencies,
expenses,
liabilities,
piggyBanks,
plainTags,
primaryCurrency,
RENT,
revenues,
SALARY,
SAVINGS,
trips,
} from "./catalog.ts";
import {
accountCurrency,
buildLedger,
type Calendar,
createCalendar,
createRng,
type Entry,
HISTORY_MONTHS,
rate,
tripTag,
} from "./ledger.ts";
const LIMIT_SEED = 7;
const BUDGET_ROUNDING = 100;
const MIN_LIMIT = 500;
type Ids = Record<string, string>;
interface Registry {
accounts: Ids;
bills: Ids;
budgets: Ids;
categories: Ids;
journals: Ids;
piggies: Ids;
}
const money = (value: number) => value.toFixed(2);
const sequential = async <T>(
label: string,
items: readonly T[],
task: (item: T) => Promise<unknown>
) => {
await pool(label, items, 1, task);
};
const setupCurrencies = async (api: Api) => {
await sequential("currencies", currencies, (code) =>
api.json("POST", `/currencies/${code}/enable`)
);
await api.json("POST", `/currencies/${primaryCurrency}/primary`);
};
const createExchangeRates = async (api: Api, calendar: Calendar) => {
const points = Array.from({ length: HISTORY_MONTHS }, (_, month) => ({
date: calendar.date(month, 1),
month,
}));
points.push({ date: calendar.today, month: HISTORY_MONTHS - 1 });
const rates = points.flatMap(({ date, month }) =>
currencies
.filter((code) => code !== primaryCurrency)
.map((from) => ({
date,
from,
rate: String(rate(from, month)),
to: primaryCurrency,
}))
);
await sequential("exchange rates", rates, (body) =>
api.create("/exchange-rates", body)
);
};
const createOrReuseAccount = async (
api: Api,
body: Record<string, unknown>
) => {
try {
return (await api.create("/accounts", body)).id;
} catch (error) {
const shared = body.type === "expense" || body.type === "revenue";
if (!(shared && String(error).includes("already in use"))) {
throw error;
}
const existing = await api.list(`/accounts?type=${body.type}`);
const match = existing.find((item) => item.attributes.name === body.name);
if (!match) {
throw error;
}
console.log(`\n reused ${body.type} account "${body.name}"`);
return match.id;
}
};
const createAccounts = async (api: Api, calendar: Calendar) => {
const opening = calendar.opening();
const payloads: [string, Record<string, unknown>][] = [
...assets.map((account): [string, Record<string, unknown>] => [
account.key,
{
account_number: account.number,
account_role: account.role,
credit_card_type:
account.role === "ccAsset" ? "monthlyFull" : undefined,
currency_code: account.currency,
monthly_payment_date:
account.role === "ccAsset" ? calendar.date(0, 25) : undefined,
name: account.name,
notes: account.notes,
opening_balance: account.opening ? money(account.opening) : undefined,
opening_balance_date: account.opening ? opening : undefined,
type: "asset",
virtual_balance: account.creditLimit
? money(account.creditLimit)
: undefined,
},
]),
...liabilities.map((account): [string, Record<string, unknown>] => [
account.key,
{
currency_code: account.currency,
interest: String(account.interest),
interest_period: "monthly",
liability_direction: account.direction,
liability_type: account.type,
name: account.name,
notes: account.notes,
opening_balance: money(account.opening),
opening_balance_date: opening,
type: "liabilities",
},
]),
...Object.entries(expenses).map(
([key, name]): [string, Record<string, unknown>] => [
key,
{ name, type: "expense" },
]
),
...Object.entries(revenues).map(
([key, name]): [string, Record<string, unknown>] => [
key,
{ name, type: "revenue" },
]
),
];
const ids: Ids = {};
await sequential("accounts", payloads, async ([key, body]) => {
ids[key] = await createOrReuseAccount(api, body);
});
return ids;
};
const createNamed = async (
api: Api,
label: string,
path: string,
items: readonly Record<string, unknown>[],
nameField: string
) => {
const ids: Ids = {};
await sequential(label, items, async (body) => {
ids[String(body[nameField])] = (await api.create(path, body)).id;
});
return ids;
};
const createTags = (api: Api, calendar: Calendar) =>
createNamed(
api,
"tags",
"/tags",
[
...plainTags,
...trips.map((trip) => ({
date: calendar.date(trip.month, 14),
description: trip.description,
tag: tripTag(calendar, trip.key),
})),
],
"tag"
);
const createBudgets = (api: Api) =>
createNamed(
api,
"budgets",
"/budgets",
budgets.map((budget) =>
budget.autoAmount
? {
active: true,
auto_budget_amount: money(budget.autoAmount),
auto_budget_currency_code: primaryCurrency,
auto_budget_period: "monthly",
auto_budget_type: "rollover",
name: budget.name,
}
: { active: true, name: budget.name }
),
"name"
);
const createBills = async (api: Api, calendar: Calendar) => {
const ids: Ids = {};
await sequential("bills", bills, async (bill) => {
const yearly = bill.month !== undefined;
const [min, max] = bill.amount;
ids[bill.key] = (
await api.create("/bills", {
active: bill.stopAfter === undefined,
amount_max: money(max),
amount_min: money(min),
currency_code: bill.currency,
date: calendar.date(bill.month ?? 0, bill.day),
name: bill.name,
notes: bill.stopAfter === undefined ? undefined : "Cancelled.",
object_group_title: bill.group,
repeat_freq: yearly ? "yearly" : "monthly",
skip: 0,
})
).id;
});
return ids;
};
const createPiggyBanks = async (
api: Api,
calendar: Calendar,
accounts: Ids
) => {
const ids: Ids = {};
await sequential("piggy banks", piggyBanks, async (piggy) => {
ids[piggy.key] = (
await api.create("/piggy-banks", {
accounts: [
{
account_id: accounts[piggy.account],
current_amount: money(piggy.current),
},
],
name: piggy.name,
notes: piggy.notes,
object_group_title: piggy.group,
start_date: calendar.date(0, 1),
target_amount: money(piggy.target),
target_date: piggy.targetMonths
? calendar.date(HISTORY_MONTHS - 1 + piggy.targetMonths, 1)
: undefined,
transaction_currency_code: accountCurrency[piggy.account],
})
).id;
});
return ids;
};
const entryCurrency = (entry: Entry) => {
const [split] = entry.splits;
const owner = entry.type === "deposit" ? split?.destination : split?.source;
return accountCurrency[owner ?? ""] ?? primaryCurrency;
};
const transactionPayload = (entry: Entry, registry: Registry) => ({
apply_rules: false,
error_if_duplicate_hash: false,
fire_webhooks: false,
group_title: entry.title,
transactions: entry.splits.map((split) => ({
amount: money(split.amount),
bill_id: split.bill ? registry.bills[split.bill] : undefined,
book_date: split.bookDate,
budget_id: split.budget ? registry.budgets[split.budget] : undefined,
category_id: split.category
? registry.categories[split.category]
: undefined,
currency_code: entryCurrency(entry),
date: entry.date,
description: split.description,
destination_id: registry.accounts[split.destination],
external_url: split.externalUrl,
foreign_amount: split.foreign ? money(split.foreign.amount) : undefined,
foreign_currency_code: split.foreign?.currency,
interest_date: split.interestDate,
notes: split.notes,
piggy_bank_id: split.piggy ? registry.piggies[split.piggy] : undefined,
process_date: split.processDate,
reconciled: split.reconciled,
source_id: registry.accounts[split.source],
tags: split.tags,
type: entry.type,
})),
});
const createTransactions = async (
api: Api,
entries: Entry[],
registry: Registry
) => {
await sequential("transactions", entries, async (entry) => {
const group = await api.create(
"/transactions",
transactionPayload(entry, registry)
);
const [journal] = group.attributes.transactions as {
transaction_journal_id: string;
}[];
if (entry.key && journal) {
registry.journals[entry.key] = journal.transaction_journal_id;
}
});
};
const createBudgetLimits = async (
api: Api,
calendar: Calendar,
entries: Entry[],
ids: Ids
) => {
const rng = createRng(LIMIT_SEED);
const spent = new Map<string, number>();
for (const entry of entries) {
if (entryCurrency(entry) !== primaryCurrency) {
continue;
}
for (const split of entry.splits) {
if (split.budget) {
const key = `${split.budget}:${entry.month}`;
spent.set(key, (spent.get(key) ?? 0) + split.amount);
}
}
}
const current = HISTORY_MONTHS - 1;
const limits = budgets.flatMap((budget) =>
Array.from({ length: HISTORY_MONTHS }, (_, month) => month)
.filter((month) => !(budget.autoAmount && month === current))
.map((month) => {
const projection =
month === current
? calendar.daysIn(month) / calendar.lastDay(month)
: 1;
const base = (spent.get(`${budget.name}:${month}`) ?? 0) * projection;
const amount = Math.max(
MIN_LIMIT,
Math.round((base * rng.amount(0.8, 1.3)) / BUDGET_ROUNDING) *
BUDGET_ROUNDING
);
return { amount, budget: budget.name, month };
})
);
await sequential("budget limits", limits, (limit) =>
api.create(`/budgets/${ids[limit.budget]}/limits`, {
amount: money(limit.amount),
currency_code: primaryCurrency,
end: calendar.end(limit.month),
fire_webhooks: false,
start: calendar.date(limit.month, 1),
})
);
};
const createLinks = (api: Api, journals: Ids) =>
sequential(
"transaction links",
[
["Refund", "allegroPurchase", "allegroRefund", "Returned within 14 days"],
["Paid", "artemDinner", "payArtem", undefined],
["Paid", "lendIvan", "ivanPaid", "First instalment"],
["Reimbursement", "dock", "krakenInvoice", "Kraken paid for the dock"],
] as const,
([type, inward, outward, notes]) =>
api.create("/transaction-links", {
inward_id: journals[inward],
link_type_name: type,
notes,
outward_id: journals[outward],
})
);
const PNG_PIXEL =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const createAttachments = (api: Api, journals: Ids) =>
sequential(
"attachments",
[
{
content: new TextEncoder().encode(
"KAUFLAND\nGroceries .......... see split 1\nHousehold ......... see split 2\nPaid by card\n"
),
filename: "kaufland-receipt.txt",
journal: "receipt",
title: "Receipt",
},
{
content: new TextEncoder().encode(
"INVOICE BL-001\nBrightline Studio\nSoftware development services\nNet 14 days\n"
),
filename: "brightline-invoice.txt",
journal: "brightlineInvoice",
title: "Invoice",
},
{
content: Uint8Array.from(atob(PNG_PIXEL), (char) => char.charCodeAt(0)),
filename: "ikea-receipt.png",
journal: "ikea",
title: "IKEA receipt photo",
},
],
async (file) => {
const attachment = await api.create("/attachments", {
attachable_id: journals[file.journal],
attachable_type: "TransactionJournal",
filename: file.filename,
title: file.title,
});
await api.upload(`/attachments/${attachment.id}/upload`, file.content);
}
);
const nextYearly = (calendar: Calendar, key: string) => {
const bill = bills.find((item) => item.key === key);
return calendar.date((bill?.month ?? 0) + HISTORY_MONTHS, bill?.day ?? 1);
};
interface RecurrenceSpec {
active?: boolean;
end: { repeat_until: string } | { nr_of_repetitions: number };
first: string;
repetition: { moment: string; type: "monthly" | "yearly" };
title: string;
transaction: Record<string, unknown>;
type: "withdrawal" | "deposit" | "transfer";
}
const createRecurrences = (
api: Api,
calendar: Calendar,
registry: Registry
) => {
const next = HISTORY_MONTHS;
const domainDate = nextYearly(calendar, "moosedev");
const specs: RecurrenceSpec[] = [
{
end: { repeat_until: calendar.date(next + 12, 10) },
first: calendar.date(next, 10),
repetition: { moment: "10", type: "monthly" },
title: "Salary — Northwind Labs",
transaction: {
amount: money(SALARY),
currency_code: "USD",
destination_id: registry.accounts.wise,
source_id: registry.accounts.northwind,
},
type: "deposit",
},
{
end: { repeat_until: calendar.date(next + 9, 1) },
first: calendar.date(next, 1),
repetition: { moment: "1", type: "monthly" },
title: "Rent",
transaction: {
amount: money(RENT),
category_id: registry.categories.Housing,
currency_code: "PLN",
destination_id: registry.accounts.landlord,
source_id: registry.accounts.pko,
},
type: "withdrawal",
},
{
end: { nr_of_repetitions: 5 },
first: domainDate,
repetition: { moment: domainDate, type: "yearly" },
title: "moose.dev renewal",
transaction: {
amount: "14.99",
currency_code: "USD",
destination_id: registry.accounts.porkbun,
source_id: registry.accounts.wise,
},
type: "withdrawal",
},
{
active: false,
end: { nr_of_repetitions: 12 },
first: calendar.date(next, 11),
repetition: { moment: "11", type: "monthly" },
title: "Savings top-up",
transaction: {
amount: money(SAVINGS),
currency_code: "UAH",
destination_id: registry.accounts.savings,
source_id: registry.accounts.mono,
},
type: "transfer",
},
];
return sequential("recurrences", specs, (spec) =>
api.create("/recurrences", {
active: spec.active ?? true,
apply_rules: false,
first_date: spec.first,
repetitions: [{ ...spec.repetition, skip: 0, weekend: 1 }],
title: spec.title,
transactions: [{ ...spec.transaction, description: spec.title }],
type: spec.type,
...spec.end,
})
);
};
const rule = (
title: string,
group: string,
match: string,
actions: [string, string][],
active = true
) => ({
actions: actions.map(([type, value]) => ({
active: true,
stop_processing: false,
type,
value,
})),
active,
rule_group_id: group,
stop_processing: false,
strict: false,
title,
trigger: "store-journal",
triggers: [
{
active: true,
stop_processing: false,
type: "description_contains",
value: match,
},
],
});
const createRules = async (api: Api) => {
const auto = await api.create("/rule-groups", {
active: true,
description: "Categorise card payments by description",
title: "Auto-categorise",
});
const legacy = await api.create("/rule-groups", {
active: false,
description: "Left over from the bank CSV import days",
title: "Old import rules",
});
await sequential(
"rules",
[
rule("Bolt rides are transport", auto.id, "Bolt", [
["set_category", "Transport"],
]),
rule("Biedronka is groceries", auto.id, "Biedronka", [
["set_category", "Groceries"],
["set_budget", "Groceries"],
]),
rule("Tag Brightline invoices", auto.id, "Brightline", [
["add_tag", "programming/client-a"],
]),
rule(
"Netflix to bill",
legacy.id,
"Netflix",
[["link_to_bill", "Netflix Standard"]],
false
),
],
(body) => api.create("/rules", body)
);
};
const createWebhook = async (api: Api) => {
await api.json("PUT", "/configuration/configuration.allow_webhooks", {
value: true,
});
await api.create("/webhooks", {
active: false,
deliveries: ["JSON"],
responses: ["TRANSACTIONS"],
title: "Stand webhook (inactive)",
triggers: ["STORE_TRANSACTION"],
url: "http://127.0.0.1:9/ffui-stand-hook",
});
};
const setPreferences = (api: Api, accounts: Ids) =>
sequential(
"preferences",
[
["viewRange", "1M"],
["listPageSize", 50],
["locale", "en_GB"],
["language", "en_US"],
["darkMode", "browser"],
[
"frontpageAccounts",
assets
.filter((account) => account.frontpage)
.map((account) => Number(accounts[account.key])),
],
] as const,
([name, data]) => api.json("POST", "/preferences", { data, name })
);
export const seed = async (api: Api, today: Date) => {
const calendar = createCalendar(today);
const entries = buildLedger(calendar);
await setupCurrencies(api);
await createExchangeRates(api, calendar);
const registry: Registry = {
accounts: await createAccounts(api, calendar),
bills: {},
budgets: {},
categories: await createNamed(
api,
"categories",
"/categories",
categories.map((name) => ({ name })),
"name"
),
journals: {},
piggies: {},
};
await createTags(api, calendar);
registry.budgets = await createBudgets(api);
registry.bills = await createBills(api, calendar);
registry.piggies = await createPiggyBanks(api, calendar, registry.accounts);
await createTransactions(api, entries, registry);
await createBudgetLimits(api, calendar, entries, registry.budgets);
await createLinks(api, registry.journals);
await createAttachments(api, registry.journals);
await createRecurrences(api, calendar, registry);
await createRules(api);
await createWebhook(api);
await setPreferences(api, registry.accounts);
};
+38
View File
@@ -0,0 +1,38 @@
import { type Api, pool } from "./api.ts";
const DELETE_CONCURRENCY = 1;
const destroyable = [
"accounts",
"revenue_accounts",
"transactions",
"budgets",
"bills",
"piggy_banks",
"rules",
"recurring",
"categories",
"tags",
"object_groups",
];
const deleteAll = async (api: Api, label: string, path: string) => {
const items = await api.list(path);
await pool(label, items, DELETE_CONCURRENCY, (item) =>
api.json("DELETE", `${path}/${item.id}`)
);
};
export const wipe = async (api: Api) => {
await api.json("PUT", "/configuration/configuration.allow_webhooks", {
value: true,
});
await deleteAll(api, "webhooks", "/webhooks");
await deleteAll(api, "attachments", "/attachments");
await deleteAll(api, "transaction links", "/transaction-links");
await deleteAll(api, "exchange rates", "/exchange-rates");
await pool("destroy", destroyable, 1, (objects) =>
api.json("DELETE", `/data/destroy?objects=${objects}`)
);
await api.json("DELETE", "/data/purge");
};