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:
co-authored by
Claude Opus 5
parent
d6d17288be
commit
765c06aa40
@@ -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})`;
|
||||
}
|
||||
Reference in New Issue
Block a user