feat(stand,seed): reproducible demo data on both stands, make seed, smoke and prodlike
This commit is contained in:
1 parent
772047c0a1
commit
8b3f7e78a3
8 files changed
+3037
-1
No files matched your search
@@ -0,0 +1,650 @@
|
||||
import { type Api, pool } from "./api.ts";
|
||||
import {
|
||||
assets,
|
||||
bills,
|
||||
budgets,
|
||||
categories,
|
||||
currencies,
|
||||
expenses,
|
||||
liabilities,
|
||||
piggyBanks,
|
||||
plainTags,
|
||||
primaryCurrency,
|
||||
RENT,
|
||||
revenues,
|
||||
SALARY,
|
||||
SAVINGS,
|
||||
trips,
|
||||
} from "./catalog.ts";
|
||||
import {
|
||||
accountCurrency,
|
||||
buildLedger,
|
||||
type Calendar,
|
||||
createCalendar,
|
||||
createRng,
|
||||
type Entry,
|
||||
HISTORY_MONTHS,
|
||||
rate,
|
||||
tripTag,
|
||||
} from "./ledger.ts";
|
||||
|
||||
const LIMIT_SEED = 7;
|
||||
const BUDGET_ROUNDING = 100;
|
||||
const MIN_LIMIT = 500;
|
||||
|
||||
type Ids = Record<string, string>;
|
||||
|
||||
interface Registry {
|
||||
accounts: Ids;
|
||||
bills: Ids;
|
||||
budgets: Ids;
|
||||
categories: Ids;
|
||||
journals: Ids;
|
||||
piggies: Ids;
|
||||
}
|
||||
|
||||
const money = (value: number) => value.toFixed(2);
|
||||
|
||||
const sequential = async <T>(
|
||||
label: string,
|
||||
items: readonly T[],
|
||||
task: (item: T) => Promise<unknown>
|
||||
) => {
|
||||
await pool(label, items, 1, task);
|
||||
};
|
||||
|
||||
const setupCurrencies = async (api: Api) => {
|
||||
await sequential("currencies", currencies, (code) =>
|
||||
api.json("POST", `/currencies/${code}/enable`)
|
||||
);
|
||||
await api.json("POST", `/currencies/${primaryCurrency}/primary`);
|
||||
};
|
||||
|
||||
const createExchangeRates = async (api: Api, calendar: Calendar) => {
|
||||
const points = Array.from({ length: HISTORY_MONTHS }, (_, month) => ({
|
||||
date: calendar.date(month, 1),
|
||||
month,
|
||||
}));
|
||||
points.push({ date: calendar.today, month: HISTORY_MONTHS - 1 });
|
||||
const rates = points.flatMap(({ date, month }) =>
|
||||
currencies
|
||||
.filter((code) => code !== primaryCurrency)
|
||||
.map((from) => ({
|
||||
date,
|
||||
from,
|
||||
rate: String(rate(from, month)),
|
||||
to: primaryCurrency,
|
||||
}))
|
||||
);
|
||||
await sequential("exchange rates", rates, (body) =>
|
||||
api.create("/exchange-rates", body)
|
||||
);
|
||||
};
|
||||
|
||||
const createOrReuseAccount = async (
|
||||
api: Api,
|
||||
body: Record<string, unknown>
|
||||
) => {
|
||||
try {
|
||||
return (await api.create("/accounts", body)).id;
|
||||
} catch (error) {
|
||||
const shared = body.type === "expense" || body.type === "revenue";
|
||||
if (!(shared && String(error).includes("already in use"))) {
|
||||
throw error;
|
||||
}
|
||||
const existing = await api.list(`/accounts?type=${body.type}`);
|
||||
const match = existing.find((item) => item.attributes.name === body.name);
|
||||
if (!match) {
|
||||
throw error;
|
||||
}
|
||||
console.log(`\n reused ${body.type} account "${body.name}"`);
|
||||
return match.id;
|
||||
}
|
||||
};
|
||||
|
||||
const createAccounts = async (api: Api, calendar: Calendar) => {
|
||||
const opening = calendar.opening();
|
||||
const payloads: [string, Record<string, unknown>][] = [
|
||||
...assets.map((account): [string, Record<string, unknown>] => [
|
||||
account.key,
|
||||
{
|
||||
account_number: account.number,
|
||||
account_role: account.role,
|
||||
credit_card_type:
|
||||
account.role === "ccAsset" ? "monthlyFull" : undefined,
|
||||
currency_code: account.currency,
|
||||
monthly_payment_date:
|
||||
account.role === "ccAsset" ? calendar.date(0, 25) : undefined,
|
||||
name: account.name,
|
||||
notes: account.notes,
|
||||
opening_balance: account.opening ? money(account.opening) : undefined,
|
||||
opening_balance_date: account.opening ? opening : undefined,
|
||||
type: "asset",
|
||||
virtual_balance: account.creditLimit
|
||||
? money(account.creditLimit)
|
||||
: undefined,
|
||||
},
|
||||
]),
|
||||
...liabilities.map((account): [string, Record<string, unknown>] => [
|
||||
account.key,
|
||||
{
|
||||
currency_code: account.currency,
|
||||
interest: String(account.interest),
|
||||
interest_period: "monthly",
|
||||
liability_direction: account.direction,
|
||||
liability_type: account.type,
|
||||
name: account.name,
|
||||
notes: account.notes,
|
||||
opening_balance: money(account.opening),
|
||||
opening_balance_date: opening,
|
||||
type: "liabilities",
|
||||
},
|
||||
]),
|
||||
...Object.entries(expenses).map(
|
||||
([key, name]): [string, Record<string, unknown>] => [
|
||||
key,
|
||||
{ name, type: "expense" },
|
||||
]
|
||||
),
|
||||
...Object.entries(revenues).map(
|
||||
([key, name]): [string, Record<string, unknown>] => [
|
||||
key,
|
||||
{ name, type: "revenue" },
|
||||
]
|
||||
),
|
||||
];
|
||||
const ids: Ids = {};
|
||||
await sequential("accounts", payloads, async ([key, body]) => {
|
||||
ids[key] = await createOrReuseAccount(api, body);
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const createNamed = async (
|
||||
api: Api,
|
||||
label: string,
|
||||
path: string,
|
||||
items: readonly Record<string, unknown>[],
|
||||
nameField: string
|
||||
) => {
|
||||
const ids: Ids = {};
|
||||
await sequential(label, items, async (body) => {
|
||||
ids[String(body[nameField])] = (await api.create(path, body)).id;
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const createTags = (api: Api, calendar: Calendar) =>
|
||||
createNamed(
|
||||
api,
|
||||
"tags",
|
||||
"/tags",
|
||||
[
|
||||
...plainTags,
|
||||
...trips.map((trip) => ({
|
||||
date: calendar.date(trip.month, 14),
|
||||
description: trip.description,
|
||||
tag: tripTag(calendar, trip.key),
|
||||
})),
|
||||
],
|
||||
"tag"
|
||||
);
|
||||
|
||||
const createBudgets = (api: Api) =>
|
||||
createNamed(
|
||||
api,
|
||||
"budgets",
|
||||
"/budgets",
|
||||
budgets.map((budget) =>
|
||||
budget.autoAmount
|
||||
? {
|
||||
active: true,
|
||||
auto_budget_amount: money(budget.autoAmount),
|
||||
auto_budget_currency_code: primaryCurrency,
|
||||
auto_budget_period: "monthly",
|
||||
auto_budget_type: "rollover",
|
||||
name: budget.name,
|
||||
}
|
||||
: { active: true, name: budget.name }
|
||||
),
|
||||
"name"
|
||||
);
|
||||
|
||||
const createBills = async (api: Api, calendar: Calendar) => {
|
||||
const ids: Ids = {};
|
||||
await sequential("bills", bills, async (bill) => {
|
||||
const yearly = bill.month !== undefined;
|
||||
const [min, max] = bill.amount;
|
||||
ids[bill.key] = (
|
||||
await api.create("/bills", {
|
||||
active: bill.stopAfter === undefined,
|
||||
amount_max: money(max),
|
||||
amount_min: money(min),
|
||||
currency_code: bill.currency,
|
||||
date: calendar.date(bill.month ?? 0, bill.day),
|
||||
name: bill.name,
|
||||
notes: bill.stopAfter === undefined ? undefined : "Cancelled.",
|
||||
object_group_title: bill.group,
|
||||
repeat_freq: yearly ? "yearly" : "monthly",
|
||||
skip: 0,
|
||||
})
|
||||
).id;
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const createPiggyBanks = async (
|
||||
api: Api,
|
||||
calendar: Calendar,
|
||||
accounts: Ids
|
||||
) => {
|
||||
const ids: Ids = {};
|
||||
await sequential("piggy banks", piggyBanks, async (piggy) => {
|
||||
ids[piggy.key] = (
|
||||
await api.create("/piggy-banks", {
|
||||
accounts: [
|
||||
{
|
||||
account_id: accounts[piggy.account],
|
||||
current_amount: money(piggy.current),
|
||||
},
|
||||
],
|
||||
name: piggy.name,
|
||||
notes: piggy.notes,
|
||||
object_group_title: piggy.group,
|
||||
start_date: calendar.date(0, 1),
|
||||
target_amount: money(piggy.target),
|
||||
target_date: piggy.targetMonths
|
||||
? calendar.date(HISTORY_MONTHS - 1 + piggy.targetMonths, 1)
|
||||
: undefined,
|
||||
transaction_currency_code: accountCurrency[piggy.account],
|
||||
})
|
||||
).id;
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const entryCurrency = (entry: Entry) => {
|
||||
const [split] = entry.splits;
|
||||
const owner = entry.type === "deposit" ? split?.destination : split?.source;
|
||||
return accountCurrency[owner ?? ""] ?? primaryCurrency;
|
||||
};
|
||||
|
||||
const transactionPayload = (entry: Entry, registry: Registry) => ({
|
||||
apply_rules: false,
|
||||
error_if_duplicate_hash: false,
|
||||
fire_webhooks: false,
|
||||
group_title: entry.title,
|
||||
transactions: entry.splits.map((split) => ({
|
||||
amount: money(split.amount),
|
||||
bill_id: split.bill ? registry.bills[split.bill] : undefined,
|
||||
book_date: split.bookDate,
|
||||
budget_id: split.budget ? registry.budgets[split.budget] : undefined,
|
||||
category_id: split.category
|
||||
? registry.categories[split.category]
|
||||
: undefined,
|
||||
currency_code: entryCurrency(entry),
|
||||
date: entry.date,
|
||||
description: split.description,
|
||||
destination_id: registry.accounts[split.destination],
|
||||
external_url: split.externalUrl,
|
||||
foreign_amount: split.foreign ? money(split.foreign.amount) : undefined,
|
||||
foreign_currency_code: split.foreign?.currency,
|
||||
interest_date: split.interestDate,
|
||||
notes: split.notes,
|
||||
piggy_bank_id: split.piggy ? registry.piggies[split.piggy] : undefined,
|
||||
process_date: split.processDate,
|
||||
reconciled: split.reconciled,
|
||||
source_id: registry.accounts[split.source],
|
||||
tags: split.tags,
|
||||
type: entry.type,
|
||||
})),
|
||||
});
|
||||
|
||||
const createTransactions = async (
|
||||
api: Api,
|
||||
entries: Entry[],
|
||||
registry: Registry
|
||||
) => {
|
||||
await sequential("transactions", entries, async (entry) => {
|
||||
const group = await api.create(
|
||||
"/transactions",
|
||||
transactionPayload(entry, registry)
|
||||
);
|
||||
const [journal] = group.attributes.transactions as {
|
||||
transaction_journal_id: string;
|
||||
}[];
|
||||
if (entry.key && journal) {
|
||||
registry.journals[entry.key] = journal.transaction_journal_id;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createBudgetLimits = async (
|
||||
api: Api,
|
||||
calendar: Calendar,
|
||||
entries: Entry[],
|
||||
ids: Ids
|
||||
) => {
|
||||
const rng = createRng(LIMIT_SEED);
|
||||
const spent = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
if (entryCurrency(entry) !== primaryCurrency) {
|
||||
continue;
|
||||
}
|
||||
for (const split of entry.splits) {
|
||||
if (split.budget) {
|
||||
const key = `${split.budget}:${entry.month}`;
|
||||
spent.set(key, (spent.get(key) ?? 0) + split.amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = HISTORY_MONTHS - 1;
|
||||
const limits = budgets.flatMap((budget) =>
|
||||
Array.from({ length: HISTORY_MONTHS }, (_, month) => month)
|
||||
.filter((month) => !(budget.autoAmount && month === current))
|
||||
.map((month) => {
|
||||
const projection =
|
||||
month === current
|
||||
? calendar.daysIn(month) / calendar.lastDay(month)
|
||||
: 1;
|
||||
const base = (spent.get(`${budget.name}:${month}`) ?? 0) * projection;
|
||||
const amount = Math.max(
|
||||
MIN_LIMIT,
|
||||
Math.round((base * rng.amount(0.8, 1.3)) / BUDGET_ROUNDING) *
|
||||
BUDGET_ROUNDING
|
||||
);
|
||||
return { amount, budget: budget.name, month };
|
||||
})
|
||||
);
|
||||
await sequential("budget limits", limits, (limit) =>
|
||||
api.create(`/budgets/${ids[limit.budget]}/limits`, {
|
||||
amount: money(limit.amount),
|
||||
currency_code: primaryCurrency,
|
||||
end: calendar.end(limit.month),
|
||||
fire_webhooks: false,
|
||||
start: calendar.date(limit.month, 1),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const createLinks = (api: Api, journals: Ids) =>
|
||||
sequential(
|
||||
"transaction links",
|
||||
[
|
||||
["Refund", "allegroPurchase", "allegroRefund", "Returned within 14 days"],
|
||||
["Paid", "artemDinner", "payArtem", undefined],
|
||||
["Paid", "lendIvan", "ivanPaid", "First instalment"],
|
||||
["Reimbursement", "dock", "krakenInvoice", "Kraken paid for the dock"],
|
||||
] as const,
|
||||
([type, inward, outward, notes]) =>
|
||||
api.create("/transaction-links", {
|
||||
inward_id: journals[inward],
|
||||
link_type_name: type,
|
||||
notes,
|
||||
outward_id: journals[outward],
|
||||
})
|
||||
);
|
||||
|
||||
const PNG_PIXEL =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
const createAttachments = (api: Api, journals: Ids) =>
|
||||
sequential(
|
||||
"attachments",
|
||||
[
|
||||
{
|
||||
content: new TextEncoder().encode(
|
||||
"KAUFLAND\nGroceries .......... see split 1\nHousehold ......... see split 2\nPaid by card\n"
|
||||
),
|
||||
filename: "kaufland-receipt.txt",
|
||||
journal: "receipt",
|
||||
title: "Receipt",
|
||||
},
|
||||
{
|
||||
content: new TextEncoder().encode(
|
||||
"INVOICE BL-001\nBrightline Studio\nSoftware development services\nNet 14 days\n"
|
||||
),
|
||||
filename: "brightline-invoice.txt",
|
||||
journal: "brightlineInvoice",
|
||||
title: "Invoice",
|
||||
},
|
||||
{
|
||||
content: Uint8Array.from(atob(PNG_PIXEL), (char) => char.charCodeAt(0)),
|
||||
filename: "ikea-receipt.png",
|
||||
journal: "ikea",
|
||||
title: "IKEA receipt photo",
|
||||
},
|
||||
],
|
||||
async (file) => {
|
||||
const attachment = await api.create("/attachments", {
|
||||
attachable_id: journals[file.journal],
|
||||
attachable_type: "TransactionJournal",
|
||||
filename: file.filename,
|
||||
title: file.title,
|
||||
});
|
||||
await api.upload(`/attachments/${attachment.id}/upload`, file.content);
|
||||
}
|
||||
);
|
||||
|
||||
const nextYearly = (calendar: Calendar, key: string) => {
|
||||
const bill = bills.find((item) => item.key === key);
|
||||
return calendar.date((bill?.month ?? 0) + HISTORY_MONTHS, bill?.day ?? 1);
|
||||
};
|
||||
|
||||
interface RecurrenceSpec {
|
||||
active?: boolean;
|
||||
end: { repeat_until: string } | { nr_of_repetitions: number };
|
||||
first: string;
|
||||
repetition: { moment: string; type: "monthly" | "yearly" };
|
||||
title: string;
|
||||
transaction: Record<string, unknown>;
|
||||
type: "withdrawal" | "deposit" | "transfer";
|
||||
}
|
||||
|
||||
const createRecurrences = (
|
||||
api: Api,
|
||||
calendar: Calendar,
|
||||
registry: Registry
|
||||
) => {
|
||||
const next = HISTORY_MONTHS;
|
||||
const domainDate = nextYearly(calendar, "moosedev");
|
||||
const specs: RecurrenceSpec[] = [
|
||||
{
|
||||
end: { repeat_until: calendar.date(next + 12, 10) },
|
||||
first: calendar.date(next, 10),
|
||||
repetition: { moment: "10", type: "monthly" },
|
||||
title: "Salary — Northwind Labs",
|
||||
transaction: {
|
||||
amount: money(SALARY),
|
||||
currency_code: "USD",
|
||||
destination_id: registry.accounts.wise,
|
||||
source_id: registry.accounts.northwind,
|
||||
},
|
||||
type: "deposit",
|
||||
},
|
||||
{
|
||||
end: { repeat_until: calendar.date(next + 9, 1) },
|
||||
first: calendar.date(next, 1),
|
||||
repetition: { moment: "1", type: "monthly" },
|
||||
title: "Rent",
|
||||
transaction: {
|
||||
amount: money(RENT),
|
||||
category_id: registry.categories.Housing,
|
||||
currency_code: "PLN",
|
||||
destination_id: registry.accounts.landlord,
|
||||
source_id: registry.accounts.pko,
|
||||
},
|
||||
type: "withdrawal",
|
||||
},
|
||||
{
|
||||
end: { nr_of_repetitions: 5 },
|
||||
first: domainDate,
|
||||
repetition: { moment: domainDate, type: "yearly" },
|
||||
title: "moose.dev renewal",
|
||||
transaction: {
|
||||
amount: "14.99",
|
||||
currency_code: "USD",
|
||||
destination_id: registry.accounts.porkbun,
|
||||
source_id: registry.accounts.wise,
|
||||
},
|
||||
type: "withdrawal",
|
||||
},
|
||||
{
|
||||
active: false,
|
||||
end: { nr_of_repetitions: 12 },
|
||||
first: calendar.date(next, 11),
|
||||
repetition: { moment: "11", type: "monthly" },
|
||||
title: "Savings top-up",
|
||||
transaction: {
|
||||
amount: money(SAVINGS),
|
||||
currency_code: "UAH",
|
||||
destination_id: registry.accounts.savings,
|
||||
source_id: registry.accounts.mono,
|
||||
},
|
||||
type: "transfer",
|
||||
},
|
||||
];
|
||||
return sequential("recurrences", specs, (spec) =>
|
||||
api.create("/recurrences", {
|
||||
active: spec.active ?? true,
|
||||
apply_rules: false,
|
||||
first_date: spec.first,
|
||||
repetitions: [{ ...spec.repetition, skip: 0, weekend: 1 }],
|
||||
title: spec.title,
|
||||
transactions: [{ ...spec.transaction, description: spec.title }],
|
||||
type: spec.type,
|
||||
...spec.end,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const rule = (
|
||||
title: string,
|
||||
group: string,
|
||||
match: string,
|
||||
actions: [string, string][],
|
||||
active = true
|
||||
) => ({
|
||||
actions: actions.map(([type, value]) => ({
|
||||
active: true,
|
||||
stop_processing: false,
|
||||
type,
|
||||
value,
|
||||
})),
|
||||
active,
|
||||
rule_group_id: group,
|
||||
stop_processing: false,
|
||||
strict: false,
|
||||
title,
|
||||
trigger: "store-journal",
|
||||
triggers: [
|
||||
{
|
||||
active: true,
|
||||
stop_processing: false,
|
||||
type: "description_contains",
|
||||
value: match,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createRules = async (api: Api) => {
|
||||
const auto = await api.create("/rule-groups", {
|
||||
active: true,
|
||||
description: "Categorise card payments by description",
|
||||
title: "Auto-categorise",
|
||||
});
|
||||
const legacy = await api.create("/rule-groups", {
|
||||
active: false,
|
||||
description: "Left over from the bank CSV import days",
|
||||
title: "Old import rules",
|
||||
});
|
||||
await sequential(
|
||||
"rules",
|
||||
[
|
||||
rule("Bolt rides are transport", auto.id, "Bolt", [
|
||||
["set_category", "Transport"],
|
||||
]),
|
||||
rule("Biedronka is groceries", auto.id, "Biedronka", [
|
||||
["set_category", "Groceries"],
|
||||
["set_budget", "Groceries"],
|
||||
]),
|
||||
rule("Tag Brightline invoices", auto.id, "Brightline", [
|
||||
["add_tag", "programming/client-a"],
|
||||
]),
|
||||
rule(
|
||||
"Netflix to bill",
|
||||
legacy.id,
|
||||
"Netflix",
|
||||
[["link_to_bill", "Netflix Standard"]],
|
||||
false
|
||||
),
|
||||
],
|
||||
(body) => api.create("/rules", body)
|
||||
);
|
||||
};
|
||||
|
||||
const createWebhook = async (api: Api) => {
|
||||
await api.json("PUT", "/configuration/configuration.allow_webhooks", {
|
||||
value: true,
|
||||
});
|
||||
await api.create("/webhooks", {
|
||||
active: false,
|
||||
deliveries: ["JSON"],
|
||||
responses: ["TRANSACTIONS"],
|
||||
title: "Stand webhook (inactive)",
|
||||
triggers: ["STORE_TRANSACTION"],
|
||||
url: "http://127.0.0.1:9/ffui-stand-hook",
|
||||
});
|
||||
};
|
||||
|
||||
const setPreferences = (api: Api, accounts: Ids) =>
|
||||
sequential(
|
||||
"preferences",
|
||||
[
|
||||
["viewRange", "1M"],
|
||||
["listPageSize", 50],
|
||||
["locale", "en_GB"],
|
||||
["language", "en_US"],
|
||||
["darkMode", "browser"],
|
||||
[
|
||||
"frontpageAccounts",
|
||||
assets
|
||||
.filter((account) => account.frontpage)
|
||||
.map((account) => Number(accounts[account.key])),
|
||||
],
|
||||
] as const,
|
||||
([name, data]) => api.json("POST", "/preferences", { data, name })
|
||||
);
|
||||
|
||||
export const seed = async (api: Api, today: Date) => {
|
||||
const calendar = createCalendar(today);
|
||||
const entries = buildLedger(calendar);
|
||||
await setupCurrencies(api);
|
||||
await createExchangeRates(api, calendar);
|
||||
const registry: Registry = {
|
||||
accounts: await createAccounts(api, calendar),
|
||||
bills: {},
|
||||
budgets: {},
|
||||
categories: await createNamed(
|
||||
api,
|
||||
"categories",
|
||||
"/categories",
|
||||
categories.map((name) => ({ name })),
|
||||
"name"
|
||||
),
|
||||
journals: {},
|
||||
piggies: {},
|
||||
};
|
||||
await createTags(api, calendar);
|
||||
registry.budgets = await createBudgets(api);
|
||||
registry.bills = await createBills(api, calendar);
|
||||
registry.piggies = await createPiggyBanks(api, calendar, registry.accounts);
|
||||
await createTransactions(api, entries, registry);
|
||||
await createBudgetLimits(api, calendar, entries, registry.budgets);
|
||||
await createLinks(api, registry.journals);
|
||||
await createAttachments(api, registry.journals);
|
||||
await createRecurrences(api, calendar, registry);
|
||||
await createRules(api);
|
||||
await createWebhook(api);
|
||||
await setPreferences(api, registry.accounts);
|
||||
};
|
||||
Reference in new issue
Block a user