From 8b3f7e78a3adf271c5f7ed73bd3b09d9ff0b7204 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 03:34:20 +0000 Subject: [PATCH] feat(stand,seed): reproducible demo data on both stands, make seed, smoke and prodlike --- Makefile | 31 +- scripts/seed/api.ts | 119 ++++++ scripts/seed/catalog.ts | 803 ++++++++++++++++++++++++++++++++++++++++ scripts/seed/index.ts | 74 ++++ scripts/seed/ledger.ts | 581 +++++++++++++++++++++++++++++ scripts/seed/plan.ts | 742 +++++++++++++++++++++++++++++++++++++ scripts/seed/seed.ts | 650 ++++++++++++++++++++++++++++++++ scripts/seed/wipe.ts | 38 ++ 8 files changed, 3037 insertions(+), 1 deletion(-) create mode 100644 scripts/seed/api.ts create mode 100644 scripts/seed/catalog.ts create mode 100644 scripts/seed/index.ts create mode 100644 scripts/seed/ledger.ts create mode 100644 scripts/seed/plan.ts create mode 100644 scripts/seed/seed.ts create mode 100644 scripts/seed/wipe.ts diff --git a/Makefile b/Makefile index 73753c8..e94a035 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,11 @@ COMPOSE := docker compose +STAND := $(COMPOSE) -f stand/docker-compose.yml +FFUI_V1_URL ?= http://127.0.0.1:30072 +FFUI_V3_URL ?= http://127.0.0.1:40869 +FFUI_TOKENS ?= $(HOME)/.config/ffui +SEED := bun scripts/seed/index.ts -.PHONY: env recreate rebuild restart logs down fmt check build dev +.PHONY: env recreate rebuild restart logs down fmt check build dev stand stand-down seed seed-v1 seed-v3 unseed smoke prodlike fmt: bun --bun run fix @@ -40,3 +45,27 @@ logs: down: $(COMPOSE) down + +stand: + $(STAND) up -d + +stand-down: + $(STAND) stop + +seed: + $(MAKE) -j2 --output-sync=target seed-v1 seed-v3 + +seed-v1: + $(SEED) --base $(FFUI_V1_URL) --token-file $(FFUI_TOKENS)/stand-v1.token $(SEED_FLAGS) + +seed-v3: + $(SEED) --base $(FFUI_V3_URL) --token-file $(FFUI_TOKENS)/stand-v3.token $(SEED_FLAGS) + +unseed: + $(MAKE) -j2 --output-sync=target seed-v1 seed-v3 SEED_FLAGS=--wipe + +smoke: + FFUI_URL=$(FFUI_V3_URL) FFUI_TOKEN_FILE=$(FFUI_TOKENS)/stand-v3.token bun scripts/smoke.ts + +prodlike: + $(STAND) --profile prodlike up -d --build frontend caddy diff --git a/scripts/seed/api.ts b/scripts/seed/api.ts new file mode 100644 index 0000000..d21fa91 --- /dev/null +++ b/scripts/seed/api.ts @@ -0,0 +1,119 @@ +export interface Resource { + attributes: Record; + 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 ( + method: string, + path: string, + body?: string | Uint8Array, + contentType = "application/json" + ): Promise => { + 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 = (method: string, path: string, body?: unknown) => + send( + 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 => { + const glue = path.includes("?") ? "&" : "?"; + const response = await json( + "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("GET", `${path}${glue}limit=1`); + return response.meta.pagination.total; + }; + + const upload = (path: string, body: Uint8Array) => + send("POST", path, body, "application/octet-stream"); + + return { create, json, list, total, upload }; +}; + +export type Api = ReturnType; + +const PROGRESS_STEP = 25; +const MS_PER_SECOND = 1000; + +export const pool = async ( + label: string, + items: readonly T[], + limit: number, + task: (item: T) => Promise +): Promise => { + 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 => { + 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; +}; diff --git a/scripts/seed/catalog.ts b/scripts/seed/catalog.ts new file mode 100644 index 0000000..7a67892 --- /dev/null +++ b/scripts/seed/catalog.ts @@ -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 = { + 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, + }, +]; diff --git a/scripts/seed/index.ts b/scripts/seed/index.ts new file mode 100644 index 0000000..8dea2ec --- /dev/null +++ b/scripts/seed/index.ts @@ -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 --token-file [--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(); diff --git a/scripts/seed/ledger.ts b/scripts/seed/ledger.ts new file mode 100644 index 0000000..9531b90 --- /dev/null +++ b/scripts/seed/ledger.ts @@ -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 & { + key?: string; +}; + +export const accountCurrency: Record = Object.fromEntries( + [...assets, ...liabilities].map((account) => [account.key, account.currency]) +); + +export const createRng = (seed: number) => { + let state = seed % MODULUS || 1; + const next = () => { + state = (state * MULTIPLIER) % MODULUS; + return (state - 1) / (MODULUS - 1); + }; + const int = (min: number, max: number) => + min + Math.floor(next() * (max - min + 1)); + const amount = (min: number, max: number, step = 0.01) => + Math.round((min + next() * (max - min)) / step) * step; + const pick = (items: readonly T[]) => + items[Math.floor(next() * items.length)] as T; + const chance = (probability: number) => next() < probability; + const weighted = (items: readonly (readonly [T, number])[]) => { + const total = items.reduce((sum, [, weight]) => sum + weight, 0); + let roll = next() * total; + for (const [item, weight] of items) { + roll -= weight; + if (roll < 0) { + return item; + } + } + return items.at(-1)?.[0] as T; + }; + return { amount, chance, int, pick, weighted }; +}; + +export const round2 = (value: number) => Math.round(value * 100) / 100; +const pad = (value: number) => String(value).padStart(2, "0"); + +export const createCalendar = (today: Date) => { + const monthStart = (index: number) => + new Date(today.getFullYear(), today.getMonth() - CURRENT + index, 1); + const daysIn = (index: number) => { + const start = monthStart(index); + return new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate(); + }; + const date = (index: number, day: number) => { + const start = monthStart(index); + const clamped = Math.min(Math.max(day, 1), daysIn(index)); + return `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(clamped)}`; + }; + const end = (index: number) => date(index, daysIn(index)); + return { + date, + daysIn, + end, + lastDay: (index: number) => + index === CURRENT ? today.getDate() : daysIn(index), + opening: () => end(-1), + today: date(CURRENT, today.getDate()), + year: (index: number) => monthStart(index).getFullYear(), + }; +}; + +export type Calendar = ReturnType; + +export const rate = (code: Code, month: number) => { + const drift: Record = { + EUR: 0.012 * Math.sin(month * 0.7) + 0.0021 * month, + PLN: 0.009 * Math.sin(month * 1.3) + 0.0028 * month, + UAH: 0, + USD: 0.004 * Math.sin(month) + 0.0017 * month, + }; + return Math.round(baseRates[code] * (1 + drift[code]) * 10_000) / 10_000; +}; + +const convert = (amount: number, from: Code, to: Code, month: number) => + (amount * rate(from, month)) / rate(to, month); + +export const tripTag = (calendar: Calendar, key: string) => { + const trip = trips.find((item) => item.key === key); + return trip ? `travel/${trip.slug}-${calendar.year(trip.month)}` : key; +}; + +export const buildLedger = (calendar: Calendar) => { + const rng = createRng(SEED); + const entries: Entry[] = []; + const creditSpent: number[] = new Array(HISTORY_MONTHS).fill(0); + + const at = (month: number, day: number, hour = rng.int(8, 21)) => + `${calendar.date(month, day)}T${pad(hour)}:${pad(rng.int(0, 59))}:00`; + + const chosen = (odds?: readonly [T, number]) => + odds && rng.chance(odds[1]) ? odds[0] : undefined; + + const pickFrom = (odds?: readonly [readonly T[], number]) => { + const list = chosen(odds); + return list ? rng.pick(list) : undefined; + }; + + const add = (entry: Entry) => { + entries.push(entry); + }; + + const spend = ( + month: number, + source: string, + destination: string, + price: number, + priceCurrency: Code, + extra: Omit + ): Split => { + const currency = accountCurrency[source] ?? primaryCurrency; + const same = currency === priceCurrency; + const amount = round2( + same + ? price + : convert(price, priceCurrency, currency, month) * CARD_MARGIN + ); + if (source === "credit") { + creditSpent[month] = round2((creditSpent[month] ?? 0) + amount); + } + const foreign = same + ? undefined + : { amount: round2(price), currency: priceCurrency }; + return { amount, destination, foreign, source, ...extra }; + }; + + const purchase = ( + month: number, + day: number, + source: string, + destination: string, + price: number, + priceCurrency: Code, + { key, ...extra }: Extra + ) => { + const reconciled = + month === 0 && source === "mono" && rng.chance(0.3) ? true : undefined; + add({ + date: at(month, day), + key, + month, + splits: [ + spend(month, source, destination, price, priceCurrency, { + ...extra, + reconciled, + }), + ], + type: "withdrawal", + }); + }; + + const income = ( + month: number, + day: number, + source: string, + destination: string, + amount: number, + { key, ...extra }: Extra + ) => { + add({ + date: at(month, day, rng.int(8, 12)), + key, + month, + splits: [{ amount: round2(amount), destination, source, ...extra }], + type: "deposit", + }); + }; + + const transfer = ( + month: number, + day: number, + source: string, + destination: string, + amount: number, + { foreignAmount, key, ...extra }: Extra & { foreignAmount?: number } + ) => { + const from = accountCurrency[source] ?? primaryCurrency; + const to = accountCurrency[destination] ?? primaryCurrency; + const foreign = + from === to + ? undefined + : { + amount: round2(foreignAmount ?? convert(amount, from, to, month)), + currency: to, + }; + add({ + date: at(month, day), + key, + month, + splits: [ + { amount: round2(amount), destination, foreign, source, ...extra }, + ], + type: "transfer", + }); + }; + + const payBills = (month: number, lastDay: number) => { + for (const bill of bills) { + const yearly = bill.month !== undefined; + const due = yearly + ? bill.month === month + : month <= (bill.stopAfter ?? CURRENT); + const skipped = + !yearly && month === CURRENT && !rng.chance(PAID_THIS_MONTH); + if (due && bill.day <= lastDay && !skipped) { + const [min, max] = bill.amount; + purchase( + month, + bill.day, + bill.account, + bill.expense, + rng.amount(min, max), + bill.currency, + { + bill: bill.key, + category: bill.category, + description: bill.name, + notes: pickFrom([billNotes, 0.15]), + tags: bill.tags, + } + ); + } + } + }; + + const habit = (month: number, lastDay: number, spec: Habit) => { + const scale = lastDay / calendar.daysIn(month); + const count = Math.round(rng.int(spec.count[0], spec.count[1]) * scale); + for (let index = 0; index < count; index += 1) { + const [place, , min, max, descriptions] = rng.weighted( + spec.places.map((item) => [item, item[1]] as const) + ); + const source = rng.weighted(spec.sources); + const url = chosen(spec.url); + const tags = pickFrom(spec.tags); + purchase( + month, + rng.int(1, lastDay), + source, + place, + rng.amount(min, max), + spec.currency ?? "PLN", + { + budget: chosen(spec.budget), + category: chosen(spec.category), + description: rng.pick(descriptions), + externalUrl: url ? `${url}${rng.int(100_000, 999_999)}` : undefined, + notes: pickFrom(spec.notes), + tags: tags ? [...tags] : undefined, + } + ); + } + }; + + const oneOf = (value: T | readonly T[]) => + Array.isArray(value) ? rng.pick(value as readonly T[]) : (value as T); + + const runPlan = (month: number, lastDay: number, plan: Plan) => { + if (plan.months !== "all" && !plan.months.includes(month)) { + return; + } + const days = plan.days ?? [rng.int(1, LATEST_RANDOM_DAY)]; + for (const day of days.filter((value) => value <= lastDay)) { + const amount = + typeof plan.amount === "number" + ? plan.amount + : rng.amount(plan.amount[0], plan.amount[1], plan.step); + const destination = oneOf(plan.destination); + const extra: Extra = { + budget: plan.budget, + category: plan.category, + description: oneOf(plan.description), + key: plan.key?.[1] === month ? plan.key[0] : undefined, + notes: plan.notes, + piggy: plan.piggy, + tags: plan.trip + ? [...(plan.tags ?? []), tripTag(calendar, plan.trip)] + : plan.tags && [...plan.tags], + }; + const currency = + plan.currency ?? accountCurrency[plan.source] ?? primaryCurrency; + const handlers: Record void> = { + deposit: () => + income(month, day, plan.source, destination, amount, extra), + transfer: () => + transfer(month, day, plan.source, destination, amount, extra), + withdrawal: () => + purchase( + month, + day, + plan.source, + destination, + amount, + currency, + extra + ), + }; + handlers[plan.type](); + } + }; + + const salary = (month: number, lastDay: number) => { + if (lastDay < 10) { + return; + } + const early = month < 2; + income(month, 10, "northwind", "wise", SALARY, { + bookDate: early ? calendar.date(month, 11) : undefined, + description: "Salary — Northwind Labs", + notes: pickFrom([ + [ + `salary for ${calendar.date(month, 1).slice(0, 7)}`, + "paid a day late", + ], + 0.25, + ]), + processDate: early ? calendar.date(month, 12) : undefined, + }); + }; + + const invoices = (month: number, lastDay: number) => { + const label = calendar.date(month, 1).slice(0, 7); + const day = rng.int(5, 8); + if (day <= lastDay) { + income(month, day, "brightline", "wise", rng.amount(800, 1600, 50), { + description: `Invoice BL-${label} — Brightline Studio`, + externalUrl: `https://invoices.example.com/brightline/${label}`, + key: month === 0 ? "brightlineInvoice" : undefined, + notes: pickFrom([ + [ + "hours logged in Toggl", + "includes the hotfix weekend", + "paid via Wise, no fees", + ], + 0.3, + ]), + tags: [ + rng.chance(0.85) ? "programming" : "Programming", + "programming/client-a", + ], + }); + } + if (month % 2 === 0 && lastDay >= 18) { + const reimbursed = month === 4; + income(month, 18, "kraken", "pko", rng.amount(3000, 6000, 100), { + description: `Kraken Digital — ${rng.pick(["landing page", "API integration", "bug bash", "consulting"])}`, + key: reimbursed ? "krakenInvoice" : undefined, + notes: reimbursed + ? "includes the USB-C dock they reimbursed" + : undefined, + tags: [rng.chance(0.8) ? "programming/client-b" : "progamming"], + }); + } + }; + + const interest = (month: number, lastDay: number) => { + income(month, 1, "wiseInterest", "wise", rng.amount(3, 7), { + description: "Interest", + }); + if (lastDay >= 2) { + income(month, 2, "cashback", "mono", rng.amount(120, 480), { + description: "Cashback", + }); + } + if (lastDay >= 28) { + income(month, 28, "interest", "savings", rng.amount(900, 1300), { + description: "Jar interest", + interestDate: month >= 8 ? calendar.date(month, 27) : undefined, + }); + } + }; + + const conversions = (month: number, lastDay: number) => { + for (const day of [11, 25].filter((value) => value <= lastDay)) { + const usd = rng.amount(1700, 2150, 50); + transfer(month, day, "wise", "mono", usd, { + description: "Wise → Mono", + foreignAmount: usd * rate("USD", month) * SELL_SPREAD, + notes: pickFrom([transferNotes, 0.4]), + }); + } + for (const day of [12, 26].filter((value) => value <= lastDay)) { + const pln = rng.amount(2900, 3900, 100); + transfer( + month, + day, + "mono", + "pko", + pln * rate("PLN", month) * BUY_SPREAD, + { + description: "Mono → PKO", + foreignAmount: pln, + notes: pickFrom([transferNotes, 0.4]), + piggy: day === 26 && month % 6 === 0 ? "synth" : undefined, + } + ); + } + const owed = creditSpent[month - 1] ?? 0; + if (lastDay >= 20 && owed > 0) { + transfer(month, 20, "pko", "credit", owed, { + description: "Credit card repayment", + }); + } + }; + + const receipt = (month: number, lastDay: number) => { + const day = rng.int(1, 28); + if (day > lastDay) { + return; + } + const shop = rng.pick(["kaufland", "carrefour"] as const); + const source = rng.pick(["pko", "mono"]); + add({ + date: at(month, day), + key: month === 0 ? "receipt" : undefined, + month, + splits: [ + spend(month, source, shop, rng.amount(80, 220), "PLN", { + budget: "Groceries", + category: "Groceries", + description: "Groceries", + }), + spend(month, source, shop, rng.amount(25, 90), "PLN", { + budget: "Everything else", + category: "Household", + description: "Household — detergent, paper towels", + notes: pickFrom([ + ["receipt photo attached", "bought the big pack"], + 0.4, + ]), + }), + ], + title: `${shop === "kaufland" ? "Kaufland" : "Carrefour"} — weekly shop`, + type: "withdrawal", + }); + }; + + const sharedDinner = (month: number, lastDay: number, spec: SharedDinner) => { + const day = rng.int(1, 28); + if (!spec.months.includes(month) || day > lastDay) { + return; + } + const total = rng.amount(spec.total[0], spec.total[1]); + const share = round2(total * rng.amount(spec.ratio[0], spec.ratio[1])); + add({ + date: at(month, day, 20), + month, + splits: [ + spend(month, spec.source, spec.place, total - share, "PLN", { + budget: spec.budget, + category: "Eating out", + description: `${spec.title} — my part`, + }), + { + ...spend(month, spec.source, spec.friend, share, "PLN", { + description: spec.share, + notes: `owes me ${share.toFixed(0)} zł`, + }), + foreign: undefined, + }, + ], + title: spec.title, + type: "withdrawal", + }); + }; + + for (let month = 0; month < HISTORY_MONTHS; month += 1) { + const lastDay = calendar.lastDay(month); + salary(month, lastDay); + invoices(month, lastDay); + interest(month, lastDay); + conversions(month, lastDay); + payBills(month, lastDay); + for (const spec of habits) { + habit(month, lastDay, spec); + } + for (const plan of plans) { + runPlan(month, lastDay, plan); + } + receipt(month, lastDay); + for (const spec of sharedDinners) { + sharedDinner(month, lastDay, spec); + } + } + + const next = HISTORY_MONTHS; + add({ + date: `${calendar.date(next, 1)}T09:00:00`, + month: next, + splits: [ + { + amount: RENT, + category: "Housing", + description: "Rent — scheduled", + destination: "landlord", + notes: "standing order, goes out on the 1st", + source: "pko", + }, + ], + type: "withdrawal", + }); + add({ + date: `${calendar.date(next, 1)}T09:05:00`, + month: next, + splits: [ + { + amount: SAVINGS, + description: "Savings — scheduled", + destination: "savings", + source: "mono", + }, + ], + type: "transfer", + }); + + return entries.sort((left, right) => left.date.localeCompare(right.date)); +}; diff --git a/scripts/seed/plan.ts b/scripts/seed/plan.ts new file mode 100644 index 0000000..0802974 --- /dev/null +++ b/scripts/seed/plan.ts @@ -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 = readonly [T, number]; + +export interface Habit { + budget?: Odds; + category: Odds; + count: readonly [number, number]; + currency?: Code; + notes?: Odds; + places: readonly (readonly [ + Place, + number, + number, + number, + readonly string[], + ])[]; + sources: readonly Odds[]; + tags?: Odds; + url?: Odds; +} + +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], + }, +]; diff --git a/scripts/seed/seed.ts b/scripts/seed/seed.ts new file mode 100644 index 0000000..efa556d --- /dev/null +++ b/scripts/seed/seed.ts @@ -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; + +interface Registry { + accounts: Ids; + bills: Ids; + budgets: Ids; + categories: Ids; + journals: Ids; + piggies: Ids; +} + +const money = (value: number) => value.toFixed(2); + +const sequential = async ( + label: string, + items: readonly T[], + task: (item: T) => Promise +) => { + 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 +) => { + 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][] = [ + ...assets.map((account): [string, Record] => [ + 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] => [ + 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] => [ + key, + { name, type: "expense" }, + ] + ), + ...Object.entries(revenues).map( + ([key, name]): [string, Record] => [ + 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[], + 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(); + 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; + 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); +}; diff --git a/scripts/seed/wipe.ts b/scripts/seed/wipe.ts new file mode 100644 index 0000000..1404d9c --- /dev/null +++ b/scripts/seed/wipe.ts @@ -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"); +};