fix(explore): bound how far an oversize cluster member may overshoot (CG-30)

shrinkCluster keeps an oversize cluster's highest-importance member WHOLE on
purpose — an empty file section sends the agent to Read, the outcome explore
exists to prevent. What it lacked was a bound, and "never empty" quietly meant
"never bounded": on the reporting repo one file emitted 22,376 chars against a
9,181-char reservation (2.44x), past both the per-file budget and the spine
ceiling. That overshoot is what collapses `headroom` for every file below it.

The same rule has a second face. When the top member is bigger than the whole
response ceiling, the file does not overshoot — it is dropped entirely at the
renderCeiling check, so the agent gets nothing for a file it named.

renderCluster now takes a ceiling (1.5x what the file may spend — the same
multiple SPINE_CEILING already draws, and never below the cap, so a cluster
that fits is untouched). Past it the member is WINDOWED on whole lines rather
than emitted whole or dropped: leading window plus, on a flow cluster, a window
on the spine's call site. A partial window shorter than 12 lines is dropped
instead — a sliver in the session record forces the next call's dedup to shred
the block around it or re-send it — unless nothing else was emitted, where the
never-empty floor wins.

Measured on the new fixture, pre-fix vs post-fix:
  monthly.ts    12,391 chars on a 3,334 budget (3.7x)  →  4,941 (1.48x)
  quarterly.ts  dropped, no headroom left               →  4,004 delivered

Also: the diagnostic now reports `spendable` (reservation + inherited slack)
alongside `reserved`. Every render bound reads the former, so reporting only
the latter makes an ordinary carry-forward read as a file spending over budget
— and it made the overshoot this issue is about unmeasurable. A windowed file
is now flagged `clipped` too, instead of presenting a window as the whole file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-06 01:44:45 -05:00
co-authored by Claude Opus 5
parent d6d17288be
commit 765c06aa40
11 changed files with 1560 additions and 10 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* Regression fixture for CG-30 — a cluster's top member may not overshoot the
* file's budget without bound.
*
* `shrinkCluster` keeps the highest-importance member of an oversize cluster
* WHOLE, deliberately: an empty file section sends the agent to Read, which is
* the outcome explore exists to prevent. What it lacked was a bound. On the
* originating repo one file emitted 22,376 chars against a 9,181-char
* reservation — 2.44x — past both the per-file budget and the spine ceiling,
* because its top member alone was that big. The overshoot is what collapses
* `headroom` for every file ranked below it (CG-31), and it has a second face:
* a member too big for the whole response ceiling makes the file drop out
* entirely rather than render short.
*
* `__tests__/fixtures/oversize-member-ts/` reproduces both permanently. Three
* report builders compete for one envelope, each a single long function far
* bigger than any reservation it can earn beside its siblings. Measured against
* the pre-fix build, this fixture produced:
*
* monthly.ts 12,391 chars emitted on a 3,334 budget (3.7x)
* quarterly.ts dropped entirely — no headroom left (the CG-31 half)
*
* The gate below is that both are now bounded AND delivered: the bound cuts the
* overshoot, and cutting the overshoot is what buys back the starved file.
*
* Measured against `spendable`, not `reserved`: the render paths bound
* themselves by the reservation PLUS whatever slack the files above left on the
* table, so a file legitimately spending inherited slack is not an overshoot.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'oversize-member-ts');
/** A symbol bag spanning the three builders — the sibling files compete. */
const QUERY = 'buildMonthlyReport buildWeeklyReport buildQuarterlyReport formatReportRow persistReport';
/** The giant: one ~24K function, far past the whole-response ceiling. */
const GIANT = 'src/report/monthly.ts';
/** Mid-size: one ~11K function — the file the giant's overshoot used to starve. */
const STARVED = 'src/report/quarterly.ts';
/** The bound: 1.5x, the same multiple the spine ceiling already draws. */
const OVERSHOOT_FACTOR = 1.5;
describe('CG-30 — an oversize cluster member is bounded, not unbounded', () => {
let testDir: string;
let cg: CodeGraph;
let response: string;
let report: ExploreDiagnosticReport;
let bytes: Map<string, number>;
const fileOf = (p: string): ExploreDiagnosticFile => {
const rec = report.files.find((f) => f.path === p);
if (!rec) throw new Error(`${p} absent from the diagnostic report`);
return rec;
};
/** What the render paths actually bound themselves by. */
const budgetOf = (rec: ExploreDiagnosticFile): number => rec.spendable ?? rec.allowance ?? 0;
beforeAll(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg30-'));
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
cg = CodeGraph.initSync(testDir);
await cg.indexAll();
// The per-file budget is only observable through the diagnostic sidecar, and
// the whole gate is "emitted vs what the file was allowed to spend".
const sidecar = path.join(testDir, 'explore-diag.jsonl');
const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
try {
const handler = new ToolHandler(cg);
const result = await handler.execute('codegraph_explore', { query: QUERY });
response = result.content?.[0]?.text ?? '';
} finally {
if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
}
const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport;
bytes = attributeSourceBytes(response);
}, 120_000);
afterAll(() => {
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
// ── Fixture shape — if these rot, the gate below means nothing ─────────────
describe('fixture shape', () => {
it('holds single members far bigger than any budget they can earn', () => {
for (const file of [GIANT, STARVED]) {
const source = fs.readFileSync(path.join(testDir, file), 'utf-8');
const top = cg.getNodesInFile(file)
.filter((n) => n.kind === 'function')
.sort((a, b) => (b.endLine - b.startLine) - (a.endLine - a.startLine))[0];
expect(top, `${file} has no function node`).toBeDefined();
// One symbol, most of the file — the "top member alone is oversize" shape.
expect(top!.endLine - top!.startLine).toBeGreaterThan(180);
expect(source.length).toBeGreaterThan(budgetOf(fileOf(file)) * 2);
}
});
it('is too long to ship whole, so both render through the cluster path', () => {
for (const file of [GIANT, STARVED]) {
const lineCount = fs.readFileSync(path.join(testDir, file), 'utf-8').split('\n').length;
// Past WHOLE_FILE_MAX_LINES (220 for a non-central file), so the
// whole-file paths — grace and buy — cannot claim it.
expect(lineCount, file).toBeGreaterThan(220);
expect(fileOf(file).render, file).toBe('clusters');
}
});
});
// ── The gate ──────────────────────────────────────────────────────────────
describe('bounded overshoot', () => {
it('CG-30 GATE: the giant no longer emits a multiple of its budget', () => {
const rec = fileOf(GIANT);
// Pre-fix this file emitted 12,391 on a 3,334 budget (3.7x).
expect(rec.emittedChars).toBeLessThanOrEqual(
Math.round(budgetOf(rec) * OVERSHOOT_FACTOR) + 1);
});
it('CG-30 GATE: no clustered file emits past 1.5x what it may spend', () => {
const over = report.files
.filter((f) => f.render === 'clusters' && budgetOf(f) > 0)
.filter((f) => f.emittedChars > Math.round(budgetOf(f) * OVERSHOOT_FACTOR) + 1)
.map((f) => `${f.path}: ${f.emittedChars} of ${budgetOf(f)}`);
expect(over).toEqual([]);
});
it('CG-31: the file the overshoot used to starve is delivered', () => {
// Pre-fix: dropped with skip reason `budget-clusters` — the giant above it
// had already spent the headroom this file needed.
expect(fileOf(STARVED).skipped).toBeNull();
expect(bytes.get(STARVED) ?? 0).toBeGreaterThan(0);
});
it('never emits an empty section — the invariant the old rule protected', () => {
for (const rec of report.files) {
if (rec.render !== 'clusters') continue;
expect(rec.emittedChars, rec.path).toBeGreaterThan(0);
}
// And the windowed file still leads with the symbol the query named.
expect(response).toContain('export function buildMonthlyReport');
});
it('cuts on whole lines — a body is never sliced mid-line', () => {
const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split('\n');
const numbered = response
.split('\n')
.map((l) => /^(\d+)\t(.*)$/.exec(l))
.filter((m): m is RegExpExecArray => m !== null)
.filter((m) => Number(m[1]) >= 1 && Number(m[1]) <= source.length);
const matching = numbered.filter((m) => source[Number(m[1]) - 1] === m[2]);
// Every line the response numbers for this file is that whole source line.
expect(matching.length).toBeGreaterThan(20);
});
it('reports the cut rather than presenting a window as the whole file', () => {
expect(fileOf(GIANT).clipped).toBe(true);
});
it('keeps the response inside the hard ceiling', () => {
expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
});
});
});
@@ -0,0 +1,6 @@
{
"name": "oversize-member-fixture",
"version": "1.0.0",
"private": true,
"type": "module"
}
@@ -0,0 +1,14 @@
import { buildMonthlyReport } from './report/monthly';
import { buildWeeklyReport } from './report/weekly';
import { buildQuarterlyReport } from './report/quarterly';
import { formatReportRows } from './report/format';
import type { Ledger, ReportOptions } from './report/types';
/** Run every report for a ledger and render them. */
export function runReports(ledger: Ledger, options: ReportOptions): string {
return [
formatReportRows(buildMonthlyReport(ledger, options)),
formatReportRows(buildWeeklyReport(ledger, options)),
formatReportRows(buildQuarterlyReport(ledger, options)),
].join('\n\n');
}
@@ -0,0 +1,22 @@
import type { ReportRow } from './types';
/** Format one category total as a report row. */
export function formatReportRow(category: string, amountCents: number, currency: string): ReportRow {
return {
category,
amount: formatAmount(amountCents),
currency,
};
}
/** Render cents as a fixed-point amount. */
export function formatAmount(amountCents: number): string {
const sign = amountCents < 0 ? '-' : '';
const abs = Math.abs(amountCents);
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}
/** Render a set of rows as plain text. */
export function formatReportRows(rows: ReportRow[]): string {
return rows.map((row) => `${row.category}\t${row.amount} ${row.currency}`).join('\n');
}
@@ -0,0 +1,509 @@
import { formatReportRow } from './format';
import { persistReport } from './store';
import type { Ledger, ReportOptions, ReportRow } from './types';
/**
* Build the monthly report for one ledger.
*
* Every expense category is accrued in its own block so the finance team can
* read the month end-to-end in one place; the shape is deliberately flat.
*/
export function buildMonthlyReport(ledger: Ledger, options: ReportOptions): ReportRow[] {
const rows: ReportRow[] = [];
const totals = new Map<string, number>();
// 1. payroll — accrue the payroll component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'payroll');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('payroll', adjusted, options.currency));
totals.set('payroll', adjusted);
}
}
// 2. benefits — accrue the benefits component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'benefits');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('benefits', adjusted, options.currency));
totals.set('benefits', adjusted);
}
}
// 3. travel — accrue the travel component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'travel');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('travel', adjusted, options.currency));
totals.set('travel', adjusted);
}
}
// 4. equipment — accrue the equipment component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'equipment');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('equipment', adjusted, options.currency));
totals.set('equipment', adjusted);
}
}
// 5. software — accrue the software component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'software');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('software', adjusted, options.currency));
totals.set('software', adjusted);
}
}
// 6. contractors — accrue the contractors component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'contractors');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('contractors', adjusted, options.currency));
totals.set('contractors', adjusted);
}
}
// 7. marketing — accrue the marketing component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'marketing');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('marketing', adjusted, options.currency));
totals.set('marketing', adjusted);
}
}
// 8. training — accrue the training component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'training');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('training', adjusted, options.currency));
totals.set('training', adjusted);
}
}
// 9. utilities — accrue the utilities component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'utilities');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('utilities', adjusted, options.currency));
totals.set('utilities', adjusted);
}
}
// 10. rent — accrue the rent component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'rent');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('rent', adjusted, options.currency));
totals.set('rent', adjusted);
}
}
// 11. insurance — accrue the insurance component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'insurance');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('insurance', adjusted, options.currency));
totals.set('insurance', adjusted);
}
}
// 12. legal — accrue the legal component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'legal');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('legal', adjusted, options.currency));
totals.set('legal', adjusted);
}
}
// 13. shipping — accrue the shipping component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'shipping');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('shipping', adjusted, options.currency));
totals.set('shipping', adjusted);
}
}
// 14. hosting — accrue the hosting component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'hosting');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('hosting', adjusted, options.currency));
totals.set('hosting', adjusted);
}
}
// 15. support — accrue the support component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'support');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('support', adjusted, options.currency));
totals.set('support', adjusted);
}
}
// 16. recruiting — accrue the recruiting component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'recruiting');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('recruiting', adjusted, options.currency));
totals.set('recruiting', adjusted);
}
}
// 17. licenses — accrue the licenses component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'licenses');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('licenses', adjusted, options.currency));
totals.set('licenses', adjusted);
}
}
// 18. taxes — accrue the taxes component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'taxes');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('taxes', adjusted, options.currency));
totals.set('taxes', adjusted);
}
}
// 19. refunds — accrue the refunds component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'refunds');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('refunds', adjusted, options.currency));
totals.set('refunds', adjusted);
}
}
// 20. discounts — accrue the discounts component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'discounts');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('discounts', adjusted, options.currency));
totals.set('discounts', adjusted);
}
}
// 21. interest — accrue the interest component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'interest');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('interest', adjusted, options.currency));
totals.set('interest', adjusted);
}
}
// 22. depreciation — accrue the depreciation component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'depreciation');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('depreciation', adjusted, options.currency));
totals.set('depreciation', adjusted);
}
}
// 23. maintenance — accrue the maintenance component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'maintenance');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('maintenance', adjusted, options.currency));
totals.set('maintenance', adjusted);
}
}
// 24. subscriptions — accrue the subscriptions component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'subscriptions');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('subscriptions', adjusted, options.currency));
totals.set('subscriptions', adjusted);
}
}
// 25. hardware — accrue the hardware component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'hardware');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('hardware', adjusted, options.currency));
totals.set('hardware', adjusted);
}
}
// 26. catering — accrue the catering component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'catering');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('catering', adjusted, options.currency));
totals.set('catering', adjusted);
}
}
// 27. conferences — accrue the conferences component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'conferences');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('conferences', adjusted, options.currency));
totals.set('conferences', adjusted);
}
}
// 28. advertising — accrue the advertising component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'advertising');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('advertising', adjusted, options.currency));
totals.set('advertising', adjusted);
}
}
// 29. research — accrue the research component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'research');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('research', adjusted, options.currency));
totals.set('research', adjusted);
}
}
// 30. logistics — accrue the logistics component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'logistics');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('logistics', adjusted, options.currency));
totals.set('logistics', adjusted);
}
}
// 31. warranty — accrue the warranty component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'warranty');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('warranty', adjusted, options.currency));
totals.set('warranty', adjusted);
}
}
// 32. penalties — accrue the penalties component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'penalties');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('penalties', adjusted, options.currency));
totals.set('penalties', adjusted);
}
}
// 33. bonuses — accrue the bonuses component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'bonuses');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('bonuses', adjusted, options.currency));
totals.set('bonuses', adjusted);
}
}
// 34. commissions — accrue the commissions component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'commissions');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('commissions', adjusted, options.currency));
totals.set('commissions', adjusted);
}
}
// 35. relocation — accrue the relocation component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'relocation');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('relocation', adjusted, options.currency));
totals.set('relocation', adjusted);
}
}
// 36. tooling — accrue the tooling component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'tooling');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('tooling', adjusted, options.currency));
totals.set('tooling', adjusted);
}
}
// 37. audit — accrue the audit component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'audit');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('audit', adjusted, options.currency));
totals.set('audit', adjusted);
}
}
// 38. compliance — accrue the compliance component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'compliance');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('compliance', adjusted, options.currency));
totals.set('compliance', adjusted);
}
}
// 39. storage — accrue the storage component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'storage');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('storage', adjusted, options.currency));
totals.set('storage', adjusted);
}
}
// 40. bandwidth — accrue the bandwidth component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'bandwidth');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('bandwidth', adjusted, options.currency));
totals.set('bandwidth', adjusted);
}
}
const grandTotal = [...totals.values()].reduce((sum, value) => sum + value, 0);
rows.push(formatReportRow('total', grandTotal, options.currency));
persistReport(ledger.periodId, rows);
return rows;
}
/** Header line for a rendered monthly report. */
export function monthlyReportHeader(ledger: Ledger, options: ReportOptions): string {
return `Monthly report ${ledger.periodId} (${options.currency})`;
}
/** Footer line for a rendered monthly report. */
export function monthlyReportFooter(rows: ReportRow[]): string {
return `${rows.length} categories reported`;
}
@@ -0,0 +1,235 @@
import { formatReportRow } from './format';
import { persistReport } from './store';
import type { Ledger, ReportOptions, ReportRow } from './types';
/** Build the quarterly report for one ledger. */
export function buildQuarterlyReport(ledger: Ledger, options: ReportOptions): ReportRow[] {
const rows: ReportRow[] = [];
const totals = new Map<string, number>();
// 1. insurance — accrue the insurance component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'insurance');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('insurance', adjusted, options.currency));
totals.set('insurance', adjusted);
}
}
// 2. legal — accrue the legal component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'legal');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('legal', adjusted, options.currency));
totals.set('legal', adjusted);
}
}
// 3. shipping — accrue the shipping component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'shipping');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('shipping', adjusted, options.currency));
totals.set('shipping', adjusted);
}
}
// 4. hosting — accrue the hosting component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'hosting');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('hosting', adjusted, options.currency));
totals.set('hosting', adjusted);
}
}
// 5. support — accrue the support component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'support');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('support', adjusted, options.currency));
totals.set('support', adjusted);
}
}
// 6. recruiting — accrue the recruiting component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'recruiting');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('recruiting', adjusted, options.currency));
totals.set('recruiting', adjusted);
}
}
// 7. licenses — accrue the licenses component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'licenses');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('licenses', adjusted, options.currency));
totals.set('licenses', adjusted);
}
}
// 8. taxes — accrue the taxes component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'taxes');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('taxes', adjusted, options.currency));
totals.set('taxes', adjusted);
}
}
// 9. refunds — accrue the refunds component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'refunds');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('refunds', adjusted, options.currency));
totals.set('refunds', adjusted);
}
}
// 10. discounts — accrue the discounts component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'discounts');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('discounts', adjusted, options.currency));
totals.set('discounts', adjusted);
}
}
// 11. interest — accrue the interest component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'interest');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('interest', adjusted, options.currency));
totals.set('interest', adjusted);
}
}
// 12. depreciation — accrue the depreciation component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'depreciation');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('depreciation', adjusted, options.currency));
totals.set('depreciation', adjusted);
}
}
// 13. maintenance — accrue the maintenance component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'maintenance');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('maintenance', adjusted, options.currency));
totals.set('maintenance', adjusted);
}
}
// 14. subscriptions — accrue the subscriptions component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'subscriptions');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('subscriptions', adjusted, options.currency));
totals.set('subscriptions', adjusted);
}
}
// 15. hardware — accrue the hardware component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'hardware');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('hardware', adjusted, options.currency));
totals.set('hardware', adjusted);
}
}
// 16. catering — accrue the catering component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'catering');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('catering', adjusted, options.currency));
totals.set('catering', adjusted);
}
}
// 17. conferences — accrue the conferences component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'conferences');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('conferences', adjusted, options.currency));
totals.set('conferences', adjusted);
}
}
// 18. advertising — accrue the advertising component of the month.
{
const bucket = ledger.entries.filter((entry) => entry.category === 'advertising');
const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0);
const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0);
const adjusted = options.includePending ? gross : gross - pending;
if (adjusted !== 0 || options.includeEmptyCategories) {
rows.push(formatReportRow('advertising', adjusted, options.currency));
totals.set('advertising', adjusted);
}
}
const grandTotal = [...totals.values()].reduce((sum, value) => sum + value, 0);
rows.push(formatReportRow('total', grandTotal, options.currency));
persistReport(ledger.periodId, rows);
return rows;
}
/** Header line for a rendered quarterly report. */
export function buildQuarterlyReportHeader(ledger: Ledger, options: ReportOptions): string {
return `quarterly report ${ledger.periodId} (${options.currency})`;
}
@@ -0,0 +1,18 @@
import type { ReportRow } from './types';
const saved = new Map<string, ReportRow[]>();
/** Persist a built report for a period. */
export function persistReport(periodId: string, rows: ReportRow[]): void {
saved.set(periodId, rows);
}
/** Read back a persisted report. */
export function loadReport(periodId: string): ReportRow[] {
return saved.get(periodId) ?? [];
}
/** Drop a persisted report. */
export function clearReport(periodId: string): void {
saved.delete(periodId);
}
@@ -0,0 +1,28 @@
/** One posted ledger entry. */
export interface LedgerEntry {
id: string;
category: string;
amountCents: number;
pending: boolean;
postedAt: string;
}
/** A period's ledger. */
export interface Ledger {
periodId: string;
entries: LedgerEntry[];
}
/** How a report should be built. */
export interface ReportOptions {
currency: string;
includePending: boolean;
includeEmptyCategories: boolean;
}
/** One rendered report line. */
export interface ReportRow {
category: string;
amount: string;
currency: string;
}
@@ -0,0 +1,372 @@
import { formatReportRow } from './format';
import { persistReport } from './store';
import type { Ledger, ReportOptions, ReportRow } from './types';
/** Total the posted entries in one category. */
function sumOf(ledger: Ledger, category: string): number {
return ledger.entries
.filter((entry) => entry.category === category && !entry.pending)
.reduce((sum, entry) => sum + entry.amountCents, 0);
}
/** Total the still-pending entries in one category. */
function pendingOf(ledger: Ledger, category: string): number {
return ledger.entries
.filter((entry) => entry.category === category && entry.pending)
.reduce((sum, entry) => sum + entry.amountCents, 0);
}
/** Build the weekly report for one ledger. */
export function buildWeeklyReport(ledger: Ledger, options: ReportOptions): ReportRow[] {
const rows: ReportRow[] = [];
const totals = new Map<string, number>();
// 1. payroll
{
const gross = sumOf(ledger, 'payroll');
const held = pendingOf(ledger, 'payroll');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('payroll', net, options.currency));
totals.set('payroll', net);
}
}
// 2. benefits
{
const gross = sumOf(ledger, 'benefits');
const held = pendingOf(ledger, 'benefits');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('benefits', net, options.currency));
totals.set('benefits', net);
}
}
// 3. travel
{
const gross = sumOf(ledger, 'travel');
const held = pendingOf(ledger, 'travel');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('travel', net, options.currency));
totals.set('travel', net);
}
}
// 4. equipment
{
const gross = sumOf(ledger, 'equipment');
const held = pendingOf(ledger, 'equipment');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('equipment', net, options.currency));
totals.set('equipment', net);
}
}
// 5. software
{
const gross = sumOf(ledger, 'software');
const held = pendingOf(ledger, 'software');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('software', net, options.currency));
totals.set('software', net);
}
}
// 6. contractors
{
const gross = sumOf(ledger, 'contractors');
const held = pendingOf(ledger, 'contractors');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('contractors', net, options.currency));
totals.set('contractors', net);
}
}
// 7. marketing
{
const gross = sumOf(ledger, 'marketing');
const held = pendingOf(ledger, 'marketing');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('marketing', net, options.currency));
totals.set('marketing', net);
}
}
// 8. training
{
const gross = sumOf(ledger, 'training');
const held = pendingOf(ledger, 'training');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('training', net, options.currency));
totals.set('training', net);
}
}
// 9. utilities
{
const gross = sumOf(ledger, 'utilities');
const held = pendingOf(ledger, 'utilities');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('utilities', net, options.currency));
totals.set('utilities', net);
}
}
// 10. rent
{
const gross = sumOf(ledger, 'rent');
const held = pendingOf(ledger, 'rent');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('rent', net, options.currency));
totals.set('rent', net);
}
}
// 11. insurance
{
const gross = sumOf(ledger, 'insurance');
const held = pendingOf(ledger, 'insurance');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('insurance', net, options.currency));
totals.set('insurance', net);
}
}
// 12. legal
{
const gross = sumOf(ledger, 'legal');
const held = pendingOf(ledger, 'legal');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('legal', net, options.currency));
totals.set('legal', net);
}
}
// 13. shipping
{
const gross = sumOf(ledger, 'shipping');
const held = pendingOf(ledger, 'shipping');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('shipping', net, options.currency));
totals.set('shipping', net);
}
}
// 14. hosting
{
const gross = sumOf(ledger, 'hosting');
const held = pendingOf(ledger, 'hosting');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('hosting', net, options.currency));
totals.set('hosting', net);
}
}
// 15. support
{
const gross = sumOf(ledger, 'support');
const held = pendingOf(ledger, 'support');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('support', net, options.currency));
totals.set('support', net);
}
}
// 16. recruiting
{
const gross = sumOf(ledger, 'recruiting');
const held = pendingOf(ledger, 'recruiting');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('recruiting', net, options.currency));
totals.set('recruiting', net);
}
}
// 17. licenses
{
const gross = sumOf(ledger, 'licenses');
const held = pendingOf(ledger, 'licenses');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('licenses', net, options.currency));
totals.set('licenses', net);
}
}
// 18. taxes
{
const gross = sumOf(ledger, 'taxes');
const held = pendingOf(ledger, 'taxes');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('taxes', net, options.currency));
totals.set('taxes', net);
}
}
// 19. refunds
{
const gross = sumOf(ledger, 'refunds');
const held = pendingOf(ledger, 'refunds');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('refunds', net, options.currency));
totals.set('refunds', net);
}
}
// 20. discounts
{
const gross = sumOf(ledger, 'discounts');
const held = pendingOf(ledger, 'discounts');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('discounts', net, options.currency));
totals.set('discounts', net);
}
}
// 21. interest
{
const gross = sumOf(ledger, 'interest');
const held = pendingOf(ledger, 'interest');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('interest', net, options.currency));
totals.set('interest', net);
}
}
// 22. depreciation
{
const gross = sumOf(ledger, 'depreciation');
const held = pendingOf(ledger, 'depreciation');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('depreciation', net, options.currency));
totals.set('depreciation', net);
}
}
// 23. maintenance
{
const gross = sumOf(ledger, 'maintenance');
const held = pendingOf(ledger, 'maintenance');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('maintenance', net, options.currency));
totals.set('maintenance', net);
}
}
// 24. subscriptions
{
const gross = sumOf(ledger, 'subscriptions');
const held = pendingOf(ledger, 'subscriptions');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('subscriptions', net, options.currency));
totals.set('subscriptions', net);
}
}
// 25. hardware
{
const gross = sumOf(ledger, 'hardware');
const held = pendingOf(ledger, 'hardware');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('hardware', net, options.currency));
totals.set('hardware', net);
}
}
// 26. catering
{
const gross = sumOf(ledger, 'catering');
const held = pendingOf(ledger, 'catering');
const net = options.includePending
? gross
: gross - held;
if (net !== 0) {
rows.push(formatReportRow('catering', net, options.currency));
totals.set('catering', net);
}
}
const grandTotal = [...totals.values()]
.reduce((sum, value) => sum + value, 0);
rows.push(formatReportRow('total', grandTotal, options.currency));
persistReport(ledger.periodId, rows);
return rows;
}
/** Header line for a rendered weekly report. */
export function buildWeeklyReportHeader(ledger: Ledger, options: ReportOptions): string {
return `weekly report ${ledger.periodId} (${options.currency})`;
}