diff --git a/bookkeeping.md b/bookkeeping.md index ab2e103..7fa3004 100644 --- a/bookkeeping.md +++ b/bookkeeping.md @@ -2,82 +2,94 @@ ## Overview -| Category | Amount | -| --- | --- | -| Income | +$60.00 | -| Expenses | -$12.99 | -| Balance | +$47.01 | +| Income | Expenses | Balance | +| --- | --- | --- | +| +$60.00 | -$6.72 | +$53.28 | + +## Projection (Next 12 Months) + +| Month | Starting balance | Income | Expenses | Net Change | Ending Balance | +| --- | --- | --- | --- | --- | --- | +| 2025-03 | +$53.28 | $0.00 | -$0.28 | -$0.28 | +$53.00 | +| 2025-04 | +$53.00 | $0.00 | -$6.27 | -$6.27 | +$46.73 | +| 2025-05 | +$46.73 | $0.00 | -$6.27 | -$6.27 | +$40.46 | +| 2025-06 | +$40.46 | $0.00 | -$6.27 | -$6.27 | +$34.19 | +| 2025-07 | +$34.19 | $0.00 | -$6.27 | -$6.27 | +$27.92 | +| 2025-08 | +$27.92 | $0.00 | -$6.27 | -$6.27 | +$21.65 | +| 2025-09 | +$21.65 | $0.00 | -$6.27 | -$6.27 | +$15.38 | +| 2025-10 | +$15.38 | $0.00 | -$6.27 | -$6.27 | +$9.11 | +| 2025-11 | +$9.11 | $0.00 | -$6.27 | -$6.27 | +$2.84 | +| 2025-12 | +$2.84 | $0.00 | -$6.27 | -$6.27 | -$3.43 | +| 2026-01 | -$3.43 | $0.00 | -$6.27 | -$6.27 | -$9.70 | +| 2026-02 | -$9.70 | $0.00 | -$6.27 | -$6.27 | -$15.97 | ## By Provider -| By Provider | Total | +| Provider | Total | | --- | --- | -| Hetzner | +$54.01 | -| Cloudflare | +$0.00 | -| Backblaze | +$0.00 | -| AWS | -$7.00 | +| Hetzner | +$60.00 | +| AWS | -$6.72 | +| Cloudflare | $0.00 | +| Backblaze | $0.00 | ## By Year -| By Year | Total | +| year | total | | --- | --- | -| 2025 | +$53.45 | +| 2023 | -$2.80 | | 2024 | -$3.36 | -| 2023 | -$3.08 | +| 2025 | +$59.44 | ## Transactions + | Date | Description | Amount | Provider | | --- | --- | --- | --- | -| 2025-02-12 | Hetzner Object Storage Billing | -$5.99 | Hetzner | -| 2025-02-12 | Backblaze B2 Billing | +$0.00 | Backblaze | | 2025-02-10 | Hetzner Cloud Credits | +$60.00 | Hetzner | | 2025-02-01 | AWS S3 Billing | -$0.28 | AWS | -| 2025-02-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2025-02-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2025-01-01 | AWS S3 Billing | -$0.28 | AWS | -| 2025-01-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2025-01-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2024-12-01 | AWS S3 Billing | -$0.28 | AWS | -| 2024-12-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2024-12-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2024-11-01 | AWS S3 Billing | -$0.28 | AWS | -| 2024-11-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-09-30 | AWS S3 Billing | -$0.28 | AWS | -| 2024-09-30 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-08-31 | AWS S3 Billing | -$0.28 | AWS | -| 2024-08-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-07-31 | AWS S3 Billing | -$0.28 | AWS | -| 2024-07-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-06-30 | AWS S3 Billing | -$0.28 | AWS | -| 2024-06-30 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-05-31 | AWS S3 Billing | -$0.28 | AWS | -| 2024-05-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-04-30 | AWS S3 Billing | -$0.28 | AWS | -| 2024-04-30 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2024-03-31 | AWS S3 Billing | -$0.28 | AWS | -| 2024-03-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2024-11-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-10-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-10-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-09-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-09-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-08-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-08-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-07-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-07-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-06-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-06-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-05-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-05-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2024-04-01 | AWS S3 Billing | -$0.28 | AWS | +| 2024-04-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2024-03-01 | AWS S3 Billing | -$0.28 | AWS | -| 2024-03-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2024-03-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2024-02-01 | AWS S3 Billing | -$0.28 | AWS | -| 2024-02-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2024-02-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2024-01-01 | AWS S3 Billing | -$0.28 | AWS | -| 2024-01-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2024-01-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2023-12-01 | AWS S3 Billing | -$0.28 | AWS | -| 2023-12-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2023-12-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2023-11-01 | AWS S3 Billing | -$0.28 | AWS | -| 2023-11-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-09-30 | AWS S3 Billing | -$0.28 | AWS | -| 2023-09-30 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-08-31 | AWS S3 Billing | -$0.28 | AWS | -| 2023-08-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-07-31 | AWS S3 Billing | -$0.28 | AWS | -| 2023-07-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-06-30 | AWS S3 Billing | -$0.28 | AWS | -| 2023-06-30 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-05-31 | AWS S3 Billing | -$0.28 | AWS | -| 2023-05-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-04-30 | AWS S3 Billing | -$0.28 | AWS | -| 2023-04-30 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-03-31 | AWS S3 Billing | -$0.28 | AWS | -| 2023-03-31 | Cloudflare R2 Billing | +$0.00 | Cloudflare | +| 2023-11-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-10-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-10-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-09-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-09-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-08-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-08-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-07-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-07-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-06-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-06-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-05-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-05-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | +| 2023-04-01 | AWS S3 Billing | -$0.28 | AWS | +| 2023-04-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | | 2023-03-01 | AWS S3 Billing | -$0.28 | AWS | -| 2023-03-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | -| 2023-02-01 | AWS S3 Billing | -$0.28 | AWS | -| 2023-02-01 | Cloudflare R2 Billing | +$0.00 | Cloudflare | \ No newline at end of file +| 2023-03-01 | Cloudflare R2 Billing | $0.00 | Cloudflare | \ No newline at end of file diff --git a/changelog.md b/changelog.md index 8d92c62..6cd4164 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## v1.6.3 + +- Improved documentation. +- Added projections to bookkeeping. + +This is a maintenance release. + ## v1.6.2 - Improved documentation. diff --git a/etc/bookkeeping.js b/etc/bookkeeping.js index 5810a9a..90e1bd6 100644 --- a/etc/bookkeeping.js +++ b/etc/bookkeeping.js @@ -1,103 +1,233 @@ import fs from 'node:fs/promises' -const formatDate = date => date.toISOString().split('T')[0] - -const formatAmount = (amount, isExpense = false) => { - const value = Math.abs(amount).toFixed(2) - return isExpense ? `-$${value}` : `+$${value}` -} - -const generateTransactions = (records, type = 'expense') => { - const now = new Date() - const isExpense = type === 'expense' - - return records.flatMap(record => { - const transactions = [] - const { date, period, amount, desc, provider } = record - - if (period === 'once') { - return [{ - ...record, - amount: isExpense ? -amount : amount - }] +// finish the implementation +// the output should be a markdown document +// the document should be written like so await fs.writeFile('./bookkeeping.md', content, 'utf-8') + +// the document should include: +// - overview table (columns: income, expenses, balance) +// - projection table (columns: month, starting balance, income, expenses, net change, ending balance) +// - a breakdown by provider (a table with provider, total columns, all providers should be listed even if total is 0) +// - a breakdown by year (a table with year, total columns) +// - a table of transactions (columns: date, description, amount, provider) + +// details: +// - generate transactions first and use the list as the source of truth for the calculations +// - project for the next 12 months +// - projections can be negative (can start/end with negative balance) +// - bill all services on the 1st of every month +// - if a service is billed in the middle of the month, prorate the bill on the first of the next month +// - bill services for the usage of the previous month (e.g. if a service usage started on the 20th of March, bill those 11 days on the 1st of April) +// - Rendered transactions should only be up to the current month +// - Transactions hsould be sorted by date descending +// - All reports (apart from projections) should take into account transaction up until the current date +// - Projections should be from the next month + 11 months +// - starting balance of the projections (first projected month) should be the income - expenses up to this point +// - money spent should be visualized as negative amount, income should be visualized as a positive amount (e.g. there's an error in the breakdown by provider) +// - amounts should be rendered with a dollar (e.g. +$1.2, -$2.4, zero should without a prefix) + +// coding rules: +// - no comments, no semi-colons, use modern javascript +const generateBookkeeping = (income = [], expenses = []) => { + const now = new Date('2025-02-16') + const zeroPad = v => v.toString().padStart(2, '0') + const formatDate = d => `${d.getFullYear()}-${zeroPad(d.getMonth()+1)}-${zeroPad(d.getDate())}` + const daysInMonth = (y, m) => new Date(y, m+1, 0).getDate() + const toMoney = v => { + const sign = v > 0 ? '+' : v < 0 ? '-' : '' + return sign + ? `${sign}$${Math.abs(v).toFixed(2)}` + : `$${Math.abs(v).toFixed(2)}` + } + const getMonthStart = (y, m) => new Date(y, m, 1) + const addMonths = (d, n) => { + const nd = new Date(d.getTime()) + nd.setMonth(nd.getMonth()+n) + return nd + } + const nextMonthFirst = d => { + const nd = new Date(d.getFullYear(), d.getMonth(), 1) + nd.setMonth(nd.getMonth()+1) + return nd + } + + const transactions = [] + + income.forEach(i => { + if (i.period === 'once') { + if (i.date <= now) { + transactions.push({ + date: new Date(i.date), + desc: i.desc, + amount: i.amount, + provider: i.provider + }) + } } - - let currentDate = new Date(date) - while (currentDate <= now) { - transactions.push({ - date: new Date(currentDate), - amount: isExpense ? -amount : amount, - desc, - provider, - period - }) - currentDate.setMonth(currentDate.getMonth() + 1) + if (i.period === 'monthly') { + let usageStart = new Date(i.date) + let billDate = nextMonthFirst(usageStart) + while (billDate <= now) { + const y = usageStart.getFullYear() + const m = usageStart.getMonth() + const dim = daysInMonth(y, m) + const day = usageStart.getDate() + const fraction = day === 1 ? 1 : (dim - day + 1) / dim + const amt = i.amount * fraction + transactions.push({ + date: new Date(billDate), + desc: i.desc, + amount: +amt.toFixed(2), + provider: i.provider + }) + usageStart = getMonthStart(billDate.getFullYear(), billDate.getMonth()) + billDate = addMonths(billDate, 1) + } } - - return transactions }) -} -const generateTransactionTable = transactions => { - const header = '| Date | Description | Amount | Provider |\n| --- | --- | --- | --- |' - const rows = transactions - .sort((a, b) => b.date - a.date) - .map(({ desc, amount, date, provider }) => - `| ${formatDate(date)} | ${desc} | ${formatAmount(amount, amount < 0)} | ${provider} |` - ) - return [header, ...rows].join('\n') -} - -const groupBy = (array, key) => - array.reduce((acc, item) => { - const groupKey = typeof key === 'function' ? key(item) : item[key] - return { ...acc, [groupKey]: [...(acc[groupKey] || []), item] } - }, {}) - -const calculateTotal = transactions => - transactions.reduce((sum, { amount }) => sum + amount, 0) - -const generateBreakdown = (transactions, groupingKey, title) => { - const grouped = groupBy(transactions, groupingKey) - const breakdown = Object.entries(grouped) - .sort(([a], [b]) => b.localeCompare(a)) - .map(([key, items]) => ({ - key, - total: calculateTotal(items) - })) - - const header = `\n## ${title}\n\n| ${title} | Total |\n| --- | --- |` - const rows = breakdown.map(({ key, total }) => - `| ${key} | ${formatAmount(total, total < 0)} |` - ) - return [header, ...rows].join('\n') -} + expenses.forEach(e => { + if (e.period === 'once') { + if (e.date <= now) { + transactions.push({ + date: new Date(e.date), + desc: e.desc, + amount: -Math.abs(e.amount), + provider: e.provider + }) + } + } + if (e.period === 'monthly') { + let usageStart = new Date(e.date) + let billDate = nextMonthFirst(usageStart) + while (billDate <= now) { + const y = usageStart.getFullYear() + const m = usageStart.getMonth() + const dim = daysInMonth(y, m) + const day = usageStart.getDate() + const fraction = day === 1 ? 1 : (dim - day + 1) / dim + const amt = e.amount * fraction + transactions.push({ + date: new Date(billDate), + desc: e.desc, + amount: -Math.abs(+amt.toFixed(2)), + provider: e.provider + }) + usageStart = getMonthStart(billDate.getFullYear(), billDate.getMonth()) + billDate = addMonths(billDate, 1) + } + } + }) -const generateBookkeeping = async (income, expenses) => { - const allTransactions = [ - ...generateTransactions(income, 'income'), - ...generateTransactions(expenses, 'expense') - ] + transactions.sort((a, b) => b.date - a.date) - const totalIncome = calculateTotal(allTransactions.filter(t => t.amount > 0)) - const totalExpenses = calculateTotal(allTransactions.filter(t => t.amount < 0)) + const totalIncome = transactions + .filter(t => t.amount > 0) + .reduce((acc, t) => acc + t.amount, 0) + const totalExpenses = transactions + .filter(t => t.amount < 0) + .reduce((acc, t) => acc + t.amount, 0) const balance = totalIncome + totalExpenses - const content = [ - '# Bookkeeping', - '\n## Overview', - '\n| Category | Amount |', - '| --- | --- |', - `| Income | ${formatAmount(totalIncome)} |`, - `| Expenses | ${formatAmount(totalExpenses, true)} |`, - `| Balance | ${formatAmount(balance, balance < 0)} |`, - generateBreakdown(allTransactions, 'provider', 'By Provider'), - generateBreakdown(allTransactions, item => formatDate(item.date).slice(0, 4), 'By Year'), - '\n## Transactions', - generateTransactionTable(allTransactions) - ].join('\n') + const overviewTable = `| Income | Expenses | Balance | +| --- | --- | --- | +| ${toMoney(totalIncome)} | ${toMoney(totalExpenses)} | ${toMoney(balance)} |` - await fs.writeFile('./bookkeeping.md', content, 'utf-8') + const providers = [...new Set([...income.map(i => i.provider), ...expenses.map(e => e.provider)])] + const providerTotals = providers.map(p => { + const sum = transactions + .filter(t => t.provider === p) + .reduce((acc, t) => acc + t.amount, 0) + return { provider: p, total: sum } + }) + const breakdownByProvider = `| Provider | Total | +| --- | --- | +${providerTotals.map(pt => `| ${pt.provider} | ${pt.total === 0 ? `$0.00` : toMoney(pt.total)} |`).join('\n')}` + + const years = [...new Set(transactions.map(t => t.date.getFullYear()))].sort((a,b) => a-b) + const yearRows = years.map(y => { + const sum = transactions + .filter(t => t.date.getFullYear() === y) + .reduce((acc, t) => acc + t.amount, 0) + return { year: y, total: sum } + }) + const breakdownByYear = `| year | total | +| --- | --- | +${yearRows.map(r => `| ${r.year} | ${r.total === 0 ? `$0.00` : toMoney(r.total)} |`).join('\n')}` + + const transactionTable = `| Date | Description | Amount | Provider | +| --- | --- | --- | --- | +${transactions.map(t => `| ${formatDate(t.date)} | ${t.desc} | ${t.amount === 0 ? '$0.00' : toMoney(t.amount)} | ${t.provider} |`).join('\n')}` + + const currentBalance = balance + + const next12Months = [] + const startDate = new Date(now.getFullYear(), now.getMonth()+1, 1) + let runningBalance = currentBalance + for (let i=0; i<12; i++) { + const ymDate = addMonths(startDate, i) + const y = ymDate.getFullYear() + const m = ymDate.getMonth() + const monthlyIncomes = 0 + let monthlyExpenses = 0 + expenses.forEach(e => { + if (e.period === 'monthly') { + const start = new Date(e.date) + const usageYear = i === 0 ? now.getFullYear() : addMonths(startDate, i-1).getFullYear() + const usageMonth = i === 0 ? now.getMonth() : addMonths(startDate, i-1).getMonth() + if (start <= getMonthStart(usageYear, usageMonth)) { + const dim = daysInMonth(usageYear, usageMonth) + const d = start > getMonthStart(usageYear, usageMonth) ? start.getDate() : 1 + const fraction = d === 1 ? 1 : (dim - d + 1)/dim + if (i === 0) { + monthlyExpenses += (e.amount * fraction) + } else { + monthlyExpenses += e.amount + } + } + } + }) + const finalExpense = i === 0 ? +(monthlyExpenses.toFixed(2)) : monthlyExpenses + const inc = 0 + const exp = finalExpense === 0 ? 0 : -Math.abs(finalExpense) + const startBal = runningBalance + const netChange = inc + exp + const endBal = startBal + netChange + next12Months.push({ + month: `${y}-${zeroPad(m+1)}`, + startBal, + inc, + exp, + netChange, + endBal + }) + runningBalance = endBal + } + + const projectionTable = `| Month | Starting balance | Income | Expenses | Net Change | Ending Balance | +| --- | --- | --- | --- | --- | --- | +${next12Months.map(row => { + const sb = toMoney(row.startBal) + const i = row.inc === 0 ? '$0.00' : toMoney(row.inc) + const e = row.exp === 0 ? '$0.00' : toMoney(row.exp) + const n = row.netChange === 0 ? '$0.00' : toMoney(row.netChange) + const eb = toMoney(row.endBal) + return `| ${row.month} | ${sb} | ${i} | ${e} | ${n} | ${eb} |` +}).join('\n')}` + + return [ + '# Bookkeeping', + '## Overview', + overviewTable, + '## Projection (Next 12 Months)', + projectionTable, + '## By Provider', + breakdownByProvider, + '## By Year', + breakdownByYear, + '## Transactions', + transactionTable + ].join('\n\n') } const income = [ @@ -108,7 +238,10 @@ const expenses = [ { provider: 'AWS', desc: 'AWS S3 Billing', amount: 0.28, date: new Date('2023-02-01'), period: 'monthly' }, { provider: 'Cloudflare', desc: 'Cloudflare R2 Billing', amount: 0.0, date: new Date('2023-02-01'), period: 'monthly' }, { provider: 'Hetzner', desc: 'Hetzner Object Storage Billing', amount: 5.99, date: new Date('2025-02-12'), period: 'monthly' }, - { provider: 'Backblaze', desc: 'Backblaze B2 Billing', amount: 0.0, date: new Date('2025-02-12'), period: 'monthly' }, + { provider: 'Backblaze', desc: 'Backblaze B2 Billing', amount: 0.0, date: new Date('2025-02-12'), period: 'monthly' } ] -generateBookkeeping(income, expenses) +;(async () => { + const content = generateBookkeeping(income, expenses) + await fs.writeFile('./bookkeeping.md', content, 'utf-8') +})() diff --git a/readme.md b/readme.md index ea228d6..1524fc1 100644 --- a/readme.md +++ b/readme.md @@ -62,14 +62,14 @@ For more information: Here's a quick API overview: ```js -await storage.read(file) -await storage.write(file, fileContents) -await storage.remove(fileOrDir) +await storage.read(file, options) +await storage.write(file, stringOrBuffer, options) +await storage.remove(fileOrDir, options) +await storage.list(dir, options) +await storage.copy(fileOrDir, fileOrDir) +await storage.presign(file, options) await storage.exists(fileOrDir) await storage.stat(file) -await storage.copy(fileOrDir, fileOrDir) -await storage.list(dir) -await storage.presign(file) ``` See [StorageInterface](src/storage/docs/StorageInterface.md) for more information.