Files

651 lines
18 KiB
TypeScript

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