feat(stand,seed): reproducible demo data on both stands, make seed, smoke and prodlike
This commit is contained in:
1 parent
772047c0a1
commit
8b3f7e78a3
8 files changed
+3037
-1
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
Reference in new issue
Block a user