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); };