tallyd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1493 -2
- package/dist/accounts/chart.d.ts +96 -0
- package/dist/accounts/chart.js +244 -0
- package/dist/accounts/index.d.ts +3 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/standard.d.ts +12 -0
- package/dist/accounts/standard.js +95 -0
- package/dist/accounts/types.d.ts +20 -0
- package/dist/accounts/types.js +40 -0
- package/dist/cli/args.d.ts +45 -0
- package/dist/cli/args.js +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/run.d.ts +34 -0
- package/dist/cli/run.js +1660 -0
- package/dist/dashboard/charts.d.ts +38 -0
- package/dist/dashboard/charts.js +173 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/dashboard/model.d.ts +137 -0
- package/dist/dashboard/model.js +190 -0
- package/dist/dashboard/render.d.ts +23 -0
- package/dist/dashboard/render.js +231 -0
- package/dist/dashboard/script.d.ts +28 -0
- package/dist/dashboard/script.js +704 -0
- package/dist/dashboard/styles.d.ts +14 -0
- package/dist/dashboard/styles.js +726 -0
- package/dist/demo/disposal.d.ts +80 -0
- package/dist/demo/disposal.js +215 -0
- package/dist/demo/evidence.d.ts +153 -0
- package/dist/demo/evidence.js +348 -0
- package/dist/demo/foreign.d.ts +29 -0
- package/dist/demo/foreign.js +113 -0
- package/dist/demo/foreignReport.d.ts +10 -0
- package/dist/demo/foreignReport.js +89 -0
- package/dist/demo/generator.d.ts +108 -0
- package/dist/demo/generator.js +445 -0
- package/dist/demo/group.d.ts +70 -0
- package/dist/demo/group.js +195 -0
- package/dist/demo/groupPeriods.d.ts +27 -0
- package/dist/demo/groupPeriods.js +125 -0
- package/dist/demo/groupReport.d.ts +13 -0
- package/dist/demo/groupReport.js +87 -0
- package/dist/demo/ingest.d.ts +6 -0
- package/dist/demo/ingest.js +61 -0
- package/dist/demo/main.d.ts +6 -0
- package/dist/demo/main.js +7 -0
- package/dist/demo/mainDisposal.d.ts +1 -0
- package/dist/demo/mainDisposal.js +2 -0
- package/dist/demo/mainEvidence.d.ts +8 -0
- package/dist/demo/mainEvidence.js +9 -0
- package/dist/demo/mainForeign.d.ts +6 -0
- package/dist/demo/mainForeign.js +7 -0
- package/dist/demo/mainGroup.d.ts +6 -0
- package/dist/demo/mainGroup.js +7 -0
- package/dist/demo/mainGroupPeriods.d.ts +6 -0
- package/dist/demo/mainGroupPeriods.js +7 -0
- package/dist/demo/mainIngest.d.ts +6 -0
- package/dist/demo/mainIngest.js +7 -0
- package/dist/demo/mainMidYear.d.ts +6 -0
- package/dist/demo/mainMidYear.js +7 -0
- package/dist/demo/mainReconcile.d.ts +6 -0
- package/dist/demo/mainReconcile.js +7 -0
- package/dist/demo/mainStatements.d.ts +6 -0
- package/dist/demo/mainStatements.js +7 -0
- package/dist/demo/midYear.d.ts +51 -0
- package/dist/demo/midYear.js +162 -0
- package/dist/demo/month.d.ts +13 -0
- package/dist/demo/month.js +142 -0
- package/dist/demo/random.d.ts +48 -0
- package/dist/demo/random.js +94 -0
- package/dist/demo/receivables.d.ts +36 -0
- package/dist/demo/receivables.js +104 -0
- package/dist/demo/reconcile.d.ts +20 -0
- package/dist/demo/reconcile.js +104 -0
- package/dist/demo/report.d.ts +6 -0
- package/dist/demo/report.js +26 -0
- package/dist/demo/statement.d.ts +13 -0
- package/dist/demo/statement.js +51 -0
- package/dist/demo/statements.d.ts +6 -0
- package/dist/demo/statements.js +29 -0
- package/dist/demo/supplierRun.d.ts +46 -0
- package/dist/demo/supplierRun.js +94 -0
- package/dist/demo/unrealisedEvidence.d.ts +51 -0
- package/dist/demo/unrealisedEvidence.js +183 -0
- package/dist/fx/average.d.ts +60 -0
- package/dist/fx/average.js +139 -0
- package/dist/fx/document.d.ts +60 -0
- package/dist/fx/document.js +205 -0
- package/dist/fx/exposure.d.ts +108 -0
- package/dist/fx/exposure.js +262 -0
- package/dist/fx/index.d.ts +8 -0
- package/dist/fx/index.js +8 -0
- package/dist/fx/rate.d.ts +77 -0
- package/dist/fx/rate.js +200 -0
- package/dist/fx/revaluation.d.ts +92 -0
- package/dist/fx/revaluation.js +197 -0
- package/dist/fx/settlement.d.ts +84 -0
- package/dist/fx/settlement.js +162 -0
- package/dist/fx/table.d.ts +120 -0
- package/dist/fx/table.js +271 -0
- package/dist/fx/translate.d.ts +126 -0
- package/dist/fx/translate.js +258 -0
- package/dist/group/accounts.d.ts +74 -0
- package/dist/group/accounts.js +179 -0
- package/dist/group/acquisition.d.ts +324 -0
- package/dist/group/acquisition.js +416 -0
- package/dist/group/aggregate.d.ts +143 -0
- package/dist/group/aggregate.js +288 -0
- package/dist/group/comparative.d.ts +102 -0
- package/dist/group/comparative.js +166 -0
- package/dist/group/consolidate.d.ts +196 -0
- package/dist/group/consolidate.js +635 -0
- package/dist/group/disposal.d.ts +181 -0
- package/dist/group/disposal.js +233 -0
- package/dist/group/document.d.ts +41 -0
- package/dist/group/document.js +326 -0
- package/dist/group/index.d.ts +12 -0
- package/dist/group/index.js +12 -0
- package/dist/group/intercompany.d.ts +136 -0
- package/dist/group/intercompany.js +238 -0
- package/dist/group/interest.d.ts +109 -0
- package/dist/group/interest.js +224 -0
- package/dist/group/movement.d.ts +206 -0
- package/dist/group/movement.js +432 -0
- package/dist/group/structure.d.ts +151 -0
- package/dist/group/structure.js +327 -0
- package/dist/group/timeline.d.ts +109 -0
- package/dist/group/timeline.js +169 -0
- package/dist/group/unrealised.d.ts +165 -0
- package/dist/group/unrealised.js +314 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ledger/close.d.ts +75 -0
- package/dist/ledger/close.js +142 -0
- package/dist/ledger/date.d.ts +48 -0
- package/dist/ledger/date.js +141 -0
- package/dist/ledger/entry.d.ts +150 -0
- package/dist/ledger/entry.js +264 -0
- package/dist/ledger/index.d.ts +6 -0
- package/dist/ledger/index.js +6 -0
- package/dist/ledger/ledger.d.ts +101 -0
- package/dist/ledger/ledger.js +308 -0
- package/dist/ledger/serialise.d.ts +68 -0
- package/dist/ledger/serialise.js +204 -0
- package/dist/ledger/trialBalance.d.ts +49 -0
- package/dist/ledger/trialBalance.js +103 -0
- package/dist/money/currency.d.ts +37 -0
- package/dist/money/currency.js +67 -0
- package/dist/money/index.d.ts +3 -0
- package/dist/money/index.js +3 -0
- package/dist/money/money.d.ts +106 -0
- package/dist/money/money.js +300 -0
- package/dist/money/rounding.d.ts +41 -0
- package/dist/money/rounding.js +117 -0
- package/dist/reconcile/accuracy.d.ts +65 -0
- package/dist/reconcile/accuracy.js +162 -0
- package/dist/reconcile/assignment.d.ts +83 -0
- package/dist/reconcile/assignment.js +326 -0
- package/dist/reconcile/bankView.d.ts +65 -0
- package/dist/reconcile/bankView.js +86 -0
- package/dist/reconcile/bridge.d.ts +76 -0
- package/dist/reconcile/bridge.js +129 -0
- package/dist/reconcile/candidates.d.ts +84 -0
- package/dist/reconcile/candidates.js +174 -0
- package/dist/reconcile/decisions.d.ts +116 -0
- package/dist/reconcile/decisions.js +168 -0
- package/dist/reconcile/index.d.ts +12 -0
- package/dist/reconcile/index.js +12 -0
- package/dist/reconcile/matcher.d.ts +89 -0
- package/dist/reconcile/matcher.js +306 -0
- package/dist/reconcile/memory.d.ts +120 -0
- package/dist/reconcile/memory.js +281 -0
- package/dist/reconcile/posting.d.ts +185 -0
- package/dist/reconcile/posting.js +481 -0
- package/dist/reconcile/scoring.d.ts +98 -0
- package/dist/reconcile/scoring.js +319 -0
- package/dist/reconcile/similarity.d.ts +88 -0
- package/dist/reconcile/similarity.js +272 -0
- package/dist/reconcile/subsetSum.d.ts +61 -0
- package/dist/reconcile/subsetSum.js +136 -0
- package/dist/reports/ageing.d.ts +75 -0
- package/dist/reports/ageing.js +149 -0
- package/dist/reports/balanceSheet.d.ts +57 -0
- package/dist/reports/balanceSheet.js +107 -0
- package/dist/reports/incomeStatement.d.ts +59 -0
- package/dist/reports/incomeStatement.js +132 -0
- package/dist/reports/index.d.ts +4 -0
- package/dist/reports/index.js +4 -0
- package/dist/reports/period.d.ts +37 -0
- package/dist/reports/period.js +78 -0
- package/dist/statement/amount.d.ts +55 -0
- package/dist/statement/amount.js +211 -0
- package/dist/statement/columns.d.ts +39 -0
- package/dist/statement/columns.js +221 -0
- package/dist/statement/csv.d.ts +50 -0
- package/dist/statement/csv.js +185 -0
- package/dist/statement/dates.d.ts +52 -0
- package/dist/statement/dates.js +204 -0
- package/dist/statement/duplicates.d.ts +51 -0
- package/dist/statement/duplicates.js +101 -0
- package/dist/statement/import.d.ts +52 -0
- package/dist/statement/import.js +172 -0
- package/dist/statement/index.d.ts +20 -0
- package/dist/statement/index.js +32 -0
- package/dist/statement/line.d.ts +60 -0
- package/dist/statement/line.js +73 -0
- package/dist/statement/ofx.d.ts +47 -0
- package/dist/statement/ofx.js +199 -0
- package/package.json +72 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing the books at a date.
|
|
3
|
+
*
|
|
4
|
+
* A year end has a step nothing in this library has needed until now: the
|
|
5
|
+
* income and expense accounts are emptied into reserves, so that they start the
|
|
6
|
+
* next year at nil and the balance sheet carries what was earned. It is
|
|
7
|
+
* mechanical, and for a company keeping its own books it is a housekeeping
|
|
8
|
+
* task. It becomes interesting in a group.
|
|
9
|
+
*
|
|
10
|
+
* A consolidation reads an entity's result as the balance on its income
|
|
11
|
+
* accounts at the reporting date. That is right for books closed at each year
|
|
12
|
+
* end, where the balance is exactly the year's result, and wrong in two
|
|
13
|
+
* situations that are not unusual. A company acquired in March has twelve
|
|
14
|
+
* months on its income accounts and only nine of them belong to the group. A
|
|
15
|
+
* company that has never closed its books has every year since it was formed
|
|
16
|
+
* on them. In both cases the fix is the same: close the books at the date the
|
|
17
|
+
* period of interest begins, and what is left on the income accounts is the
|
|
18
|
+
* period's result and nothing else.
|
|
19
|
+
*
|
|
20
|
+
* Two things about how it is done here.
|
|
21
|
+
*
|
|
22
|
+
* It is an entry, not an adjustment. The same rule the rest of the library
|
|
23
|
+
* follows: if a figure changed, there is a balanced journal entry with a
|
|
24
|
+
* narration that changed it, and a reader can find it. A closing pass that
|
|
25
|
+
* quietly rewrote balances would be the one operation in the system that could
|
|
26
|
+
* not be audited.
|
|
27
|
+
*
|
|
28
|
+
* And it returns a new ledger rather than mutating one. An entity's books are
|
|
29
|
+
* not the group's to rewrite: the group needs a view of them closed at a date
|
|
30
|
+
* for its own purposes, and the file on disk is unchanged. The append-only
|
|
31
|
+
* ledger makes that free.
|
|
32
|
+
*/
|
|
33
|
+
import { Money } from "../money/money.js";
|
|
34
|
+
import type { Currency } from "../money/currency.js";
|
|
35
|
+
import type { CalendarDate } from "./date.js";
|
|
36
|
+
import { JournalEntry } from "./entry.js";
|
|
37
|
+
import { Ledger } from "./ledger.js";
|
|
38
|
+
export declare class CloseError extends Error {
|
|
39
|
+
constructor(message: string);
|
|
40
|
+
}
|
|
41
|
+
export interface CloseOptions {
|
|
42
|
+
/** Where the result goes. Retained earnings by default. */
|
|
43
|
+
reserves?: string;
|
|
44
|
+
/** The entry's id. One is derived from the date when this is not given. */
|
|
45
|
+
id?: string;
|
|
46
|
+
narration?: string;
|
|
47
|
+
/** Which currency's balances to close. Defaults to the ledger's first. */
|
|
48
|
+
currency?: Currency | string;
|
|
49
|
+
tags?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The entry that closes the income and expense accounts as at a date.
|
|
53
|
+
*
|
|
54
|
+
* Null when there is nothing to close, which is the ordinary case for a ledger
|
|
55
|
+
* already closed at that date and for one that has not traded yet. A caller
|
|
56
|
+
* that wants an entry either way would be asking for an unbalanced one.
|
|
57
|
+
*/
|
|
58
|
+
export declare function closingEntry(ledger: Ledger, asAt: CalendarDate | string, options?: CloseOptions): JournalEntry | null;
|
|
59
|
+
/**
|
|
60
|
+
* The same books, with the result to a date taken to reserves.
|
|
61
|
+
*
|
|
62
|
+
* The original ledger is untouched. Where there is nothing to close the same
|
|
63
|
+
* ledger comes back, so this is safe to call on books that are already closed
|
|
64
|
+
* and cheap to call when it turns out not to be needed.
|
|
65
|
+
*/
|
|
66
|
+
export declare function withResultClosed(ledger: Ledger, asAt: CalendarDate | string, options?: CloseOptions): Ledger;
|
|
67
|
+
/**
|
|
68
|
+
* What an entity earned over a window, read off books closed at its start.
|
|
69
|
+
*
|
|
70
|
+
* This is the figure a consolidation wants when an entity was controlled for
|
|
71
|
+
* part of the period: the income and expense balances at the reporting date,
|
|
72
|
+
* on books closed the day the window opens. Returned as a credit-positive
|
|
73
|
+
* figure, the way a result reads.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resultOver(ledger: Ledger, from: CalendarDate | string, to: CalendarDate | string, options?: CloseOptions): Money;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing the books at a date.
|
|
3
|
+
*
|
|
4
|
+
* A year end has a step nothing in this library has needed until now: the
|
|
5
|
+
* income and expense accounts are emptied into reserves, so that they start the
|
|
6
|
+
* next year at nil and the balance sheet carries what was earned. It is
|
|
7
|
+
* mechanical, and for a company keeping its own books it is a housekeeping
|
|
8
|
+
* task. It becomes interesting in a group.
|
|
9
|
+
*
|
|
10
|
+
* A consolidation reads an entity's result as the balance on its income
|
|
11
|
+
* accounts at the reporting date. That is right for books closed at each year
|
|
12
|
+
* end, where the balance is exactly the year's result, and wrong in two
|
|
13
|
+
* situations that are not unusual. A company acquired in March has twelve
|
|
14
|
+
* months on its income accounts and only nine of them belong to the group. A
|
|
15
|
+
* company that has never closed its books has every year since it was formed
|
|
16
|
+
* on them. In both cases the fix is the same: close the books at the date the
|
|
17
|
+
* period of interest begins, and what is left on the income accounts is the
|
|
18
|
+
* period's result and nothing else.
|
|
19
|
+
*
|
|
20
|
+
* Two things about how it is done here.
|
|
21
|
+
*
|
|
22
|
+
* It is an entry, not an adjustment. The same rule the rest of the library
|
|
23
|
+
* follows: if a figure changed, there is a balanced journal entry with a
|
|
24
|
+
* narration that changed it, and a reader can find it. A closing pass that
|
|
25
|
+
* quietly rewrote balances would be the one operation in the system that could
|
|
26
|
+
* not be audited.
|
|
27
|
+
*
|
|
28
|
+
* And it returns a new ledger rather than mutating one. An entity's books are
|
|
29
|
+
* not the group's to rewrite: the group needs a view of them closed at a date
|
|
30
|
+
* for its own purposes, and the file on disk is unchanged. The append-only
|
|
31
|
+
* ledger makes that free.
|
|
32
|
+
*/
|
|
33
|
+
import { Money } from "../money/money.js";
|
|
34
|
+
import { date as parseDate } from "./date.js";
|
|
35
|
+
import { JournalEntry } from "./entry.js";
|
|
36
|
+
import { Ledger } from "./ledger.js";
|
|
37
|
+
import { trialBalance } from "./trialBalance.js";
|
|
38
|
+
export class CloseError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "CloseError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The entry that closes the income and expense accounts as at a date.
|
|
46
|
+
*
|
|
47
|
+
* Null when there is nothing to close, which is the ordinary case for a ledger
|
|
48
|
+
* already closed at that date and for one that has not traded yet. A caller
|
|
49
|
+
* that wants an entry either way would be asking for an unbalanced one.
|
|
50
|
+
*/
|
|
51
|
+
export function closingEntry(ledger, asAt, options = {}) {
|
|
52
|
+
const when = parseDate(String(asAt));
|
|
53
|
+
const currencyCode = options.currency === undefined
|
|
54
|
+
? (ledger.currenciesUsed()[0] ?? ledger.chart?.defaultCurrency.code ?? "GBP")
|
|
55
|
+
: typeof options.currency === "string"
|
|
56
|
+
? options.currency.toUpperCase()
|
|
57
|
+
: options.currency.code;
|
|
58
|
+
const reserves = options.reserves ?? "3200";
|
|
59
|
+
const balances = trialBalance(ledger, { currency: currencyCode, asAt: when });
|
|
60
|
+
const postings = [];
|
|
61
|
+
let total = Money.zero(currencyCode);
|
|
62
|
+
for (const row of balances.rows) {
|
|
63
|
+
if (row.type !== "income" && row.type !== "expense")
|
|
64
|
+
continue;
|
|
65
|
+
if (row.account === reserves)
|
|
66
|
+
continue;
|
|
67
|
+
if (row.signed.isZero)
|
|
68
|
+
continue;
|
|
69
|
+
postings.push({
|
|
70
|
+
account: row.account,
|
|
71
|
+
amount: row.signed.negated(),
|
|
72
|
+
memo: `closed at ${when}`,
|
|
73
|
+
});
|
|
74
|
+
total = total.plus(row.signed);
|
|
75
|
+
}
|
|
76
|
+
if (postings.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
if (total.isZero) {
|
|
79
|
+
// Income and expenses that cancel exactly. There is still something to
|
|
80
|
+
// close — both sides have to come off — but no result to carry across, so
|
|
81
|
+
// the reserves line would be for zero and entries reject zero postings.
|
|
82
|
+
return JournalEntry.create({
|
|
83
|
+
id: options.id ?? `CLOSE-${when}`,
|
|
84
|
+
date: String(when),
|
|
85
|
+
narration: options.narration ?? `Result to ${when} closed to reserves`,
|
|
86
|
+
postings,
|
|
87
|
+
tags: options.tags ?? ["close"],
|
|
88
|
+
}, ledger.chart);
|
|
89
|
+
}
|
|
90
|
+
postings.push({
|
|
91
|
+
account: reserves,
|
|
92
|
+
amount: total,
|
|
93
|
+
memo: `result to ${when}`,
|
|
94
|
+
});
|
|
95
|
+
return JournalEntry.create({
|
|
96
|
+
id: options.id ?? `CLOSE-${when}`,
|
|
97
|
+
date: String(when),
|
|
98
|
+
narration: options.narration ?? `Result to ${when} closed to reserves`,
|
|
99
|
+
postings,
|
|
100
|
+
tags: options.tags ?? ["close"],
|
|
101
|
+
}, ledger.chart);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The same books, with the result to a date taken to reserves.
|
|
105
|
+
*
|
|
106
|
+
* The original ledger is untouched. Where there is nothing to close the same
|
|
107
|
+
* ledger comes back, so this is safe to call on books that are already closed
|
|
108
|
+
* and cheap to call when it turns out not to be needed.
|
|
109
|
+
*/
|
|
110
|
+
export function withResultClosed(ledger, asAt, options = {}) {
|
|
111
|
+
const entry = closingEntry(ledger, asAt, options);
|
|
112
|
+
if (entry === null)
|
|
113
|
+
return ledger;
|
|
114
|
+
if (ledger.has(entry.id)) {
|
|
115
|
+
// The ledger would reject the duplicate id anyway. Saying which operation
|
|
116
|
+
// collided is more use than a bare id clash from somewhere further down.
|
|
117
|
+
throw new CloseError(`These books already carry an entry ${entry.id}, so they have been closed at ` +
|
|
118
|
+
`${asAt} once already. Pass a different id if that is not what happened.`);
|
|
119
|
+
}
|
|
120
|
+
return ledger.post(entry);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* What an entity earned over a window, read off books closed at its start.
|
|
124
|
+
*
|
|
125
|
+
* This is the figure a consolidation wants when an entity was controlled for
|
|
126
|
+
* part of the period: the income and expense balances at the reporting date,
|
|
127
|
+
* on books closed the day the window opens. Returned as a credit-positive
|
|
128
|
+
* figure, the way a result reads.
|
|
129
|
+
*/
|
|
130
|
+
export function resultOver(ledger, from, to, options = {}) {
|
|
131
|
+
const closed = withResultClosed(ledger, from, options);
|
|
132
|
+
const currencyCode = options.currency === undefined
|
|
133
|
+
? (ledger.currenciesUsed()[0] ?? ledger.chart?.defaultCurrency.code ?? "GBP")
|
|
134
|
+
: typeof options.currency === "string"
|
|
135
|
+
? options.currency.toUpperCase()
|
|
136
|
+
: options.currency.code;
|
|
137
|
+
const balances = trialBalance(closed, { currency: currencyCode, asAt: parseDate(String(to)) });
|
|
138
|
+
return balances.rows
|
|
139
|
+
.filter((row) => row.type === "income" || row.type === "expense")
|
|
140
|
+
.reduce((running, row) => running.plus(row.signed), Money.zero(currencyCode))
|
|
141
|
+
.negated();
|
|
142
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar dates as `YYYY-MM-DD` strings.
|
|
3
|
+
*
|
|
4
|
+
* A posting happens on a date, not at an instant. Using `Date` here would drag
|
|
5
|
+
* in a timezone, and a statement line dated 1 March in London would sort before
|
|
6
|
+
* a ledger entry dated 1 March in New York. So: no clocks, no zones, just the
|
|
7
|
+
* proleptic Gregorian calendar and integer day arithmetic.
|
|
8
|
+
*/
|
|
9
|
+
export type CalendarDate = string & {
|
|
10
|
+
readonly __brand: "CalendarDate";
|
|
11
|
+
};
|
|
12
|
+
export declare class InvalidDateError extends Error {
|
|
13
|
+
constructor(value: string);
|
|
14
|
+
}
|
|
15
|
+
export declare function daysInMonth(year: number, month: number): number;
|
|
16
|
+
/** Validate and brand a `YYYY-MM-DD` string. */
|
|
17
|
+
export declare function date(value: string): CalendarDate;
|
|
18
|
+
export declare function isValidDate(value: string): boolean;
|
|
19
|
+
export declare function parts(d: CalendarDate): {
|
|
20
|
+
year: number;
|
|
21
|
+
month: number;
|
|
22
|
+
day: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Days since 1970-01-01, computed arithmetically (Howard Hinnant's civil-days
|
|
26
|
+
* algorithm) so there is no dependence on the host `Date` implementation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function toEpochDay(d: CalendarDate): number;
|
|
29
|
+
export declare function fromEpochDay(epochDay: number): CalendarDate;
|
|
30
|
+
/** Signed whole days from `a` to `b`. */
|
|
31
|
+
export declare function daysBetween(a: CalendarDate, b: CalendarDate): number;
|
|
32
|
+
export declare function addDays(d: CalendarDate, days: number): CalendarDate;
|
|
33
|
+
export declare function compareDates(a: CalendarDate, b: CalendarDate): -1 | 0 | 1;
|
|
34
|
+
export declare function minDate(a: CalendarDate, b: CalendarDate): CalendarDate;
|
|
35
|
+
export declare function maxDate(a: CalendarDate, b: CalendarDate): CalendarDate;
|
|
36
|
+
/** 0 = Sunday through 6 = Saturday. */
|
|
37
|
+
export declare function dayOfWeek(d: CalendarDate): number;
|
|
38
|
+
export declare function isWeekend(d: CalendarDate): boolean;
|
|
39
|
+
/** First and last day of the month containing `d`. */
|
|
40
|
+
export declare function startOfMonth(d: CalendarDate): CalendarDate;
|
|
41
|
+
export declare function endOfMonth(d: CalendarDate): CalendarDate;
|
|
42
|
+
/** An inclusive date range. */
|
|
43
|
+
export interface DateRange {
|
|
44
|
+
readonly from: CalendarDate;
|
|
45
|
+
readonly to: CalendarDate;
|
|
46
|
+
}
|
|
47
|
+
export declare function dateRange(from: string, to: string): DateRange;
|
|
48
|
+
export declare function withinRange(d: CalendarDate, range: DateRange): boolean;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar dates as `YYYY-MM-DD` strings.
|
|
3
|
+
*
|
|
4
|
+
* A posting happens on a date, not at an instant. Using `Date` here would drag
|
|
5
|
+
* in a timezone, and a statement line dated 1 March in London would sort before
|
|
6
|
+
* a ledger entry dated 1 March in New York. So: no clocks, no zones, just the
|
|
7
|
+
* proleptic Gregorian calendar and integer day arithmetic.
|
|
8
|
+
*/
|
|
9
|
+
const PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
10
|
+
export class InvalidDateError extends Error {
|
|
11
|
+
constructor(value) {
|
|
12
|
+
super(`Not a valid calendar date: ${JSON.stringify(value)}`);
|
|
13
|
+
this.name = "InvalidDateError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function isLeapYear(year) {
|
|
17
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
18
|
+
}
|
|
19
|
+
export function daysInMonth(year, month) {
|
|
20
|
+
switch (month) {
|
|
21
|
+
case 1:
|
|
22
|
+
case 3:
|
|
23
|
+
case 5:
|
|
24
|
+
case 7:
|
|
25
|
+
case 8:
|
|
26
|
+
case 10:
|
|
27
|
+
case 12:
|
|
28
|
+
return 31;
|
|
29
|
+
case 4:
|
|
30
|
+
case 6:
|
|
31
|
+
case 9:
|
|
32
|
+
case 11:
|
|
33
|
+
return 30;
|
|
34
|
+
case 2:
|
|
35
|
+
return isLeapYear(year) ? 29 : 28;
|
|
36
|
+
default:
|
|
37
|
+
throw new RangeError(`Month out of range: ${month}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Validate and brand a `YYYY-MM-DD` string. */
|
|
41
|
+
export function date(value) {
|
|
42
|
+
const match = PATTERN.exec(value);
|
|
43
|
+
if (match === null)
|
|
44
|
+
throw new InvalidDateError(value);
|
|
45
|
+
const year = Number(match[1]);
|
|
46
|
+
const month = Number(match[2]);
|
|
47
|
+
const day = Number(match[3]);
|
|
48
|
+
if (month < 1 || month > 12)
|
|
49
|
+
throw new InvalidDateError(value);
|
|
50
|
+
if (day < 1 || day > daysInMonth(year, month))
|
|
51
|
+
throw new InvalidDateError(value);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export function isValidDate(value) {
|
|
55
|
+
try {
|
|
56
|
+
date(value);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function parts(d) {
|
|
64
|
+
const match = PATTERN.exec(d);
|
|
65
|
+
return {
|
|
66
|
+
year: Number(match[1]),
|
|
67
|
+
month: Number(match[2]),
|
|
68
|
+
day: Number(match[3]),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Days since 1970-01-01, computed arithmetically (Howard Hinnant's civil-days
|
|
73
|
+
* algorithm) so there is no dependence on the host `Date` implementation.
|
|
74
|
+
*/
|
|
75
|
+
export function toEpochDay(d) {
|
|
76
|
+
const { year, month, day } = parts(d);
|
|
77
|
+
const y = month <= 2 ? year - 1 : year;
|
|
78
|
+
const era = Math.floor(y / 400);
|
|
79
|
+
const yoe = y - era * 400;
|
|
80
|
+
const mp = (month + 9) % 12;
|
|
81
|
+
const doy = Math.floor((153 * mp + 2) / 5) + day - 1;
|
|
82
|
+
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
|
|
83
|
+
return era * 146097 + doe - 719468;
|
|
84
|
+
}
|
|
85
|
+
export function fromEpochDay(epochDay) {
|
|
86
|
+
const z = epochDay + 719468;
|
|
87
|
+
const era = Math.floor(z / 146097);
|
|
88
|
+
const doe = z - era * 146097;
|
|
89
|
+
const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
|
|
90
|
+
const y = yoe + era * 400;
|
|
91
|
+
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
|
92
|
+
const mp = Math.floor((5 * doy + 2) / 153);
|
|
93
|
+
const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
|
94
|
+
const month = mp < 10 ? mp + 3 : mp - 9;
|
|
95
|
+
const year = month <= 2 ? y + 1 : y;
|
|
96
|
+
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
97
|
+
}
|
|
98
|
+
/** Signed whole days from `a` to `b`. */
|
|
99
|
+
export function daysBetween(a, b) {
|
|
100
|
+
return toEpochDay(b) - toEpochDay(a);
|
|
101
|
+
}
|
|
102
|
+
export function addDays(d, days) {
|
|
103
|
+
return fromEpochDay(toEpochDay(d) + days);
|
|
104
|
+
}
|
|
105
|
+
export function compareDates(a, b) {
|
|
106
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
107
|
+
}
|
|
108
|
+
export function minDate(a, b) {
|
|
109
|
+
return a <= b ? a : b;
|
|
110
|
+
}
|
|
111
|
+
export function maxDate(a, b) {
|
|
112
|
+
return a >= b ? a : b;
|
|
113
|
+
}
|
|
114
|
+
/** 0 = Sunday through 6 = Saturday. */
|
|
115
|
+
export function dayOfWeek(d) {
|
|
116
|
+
return (((toEpochDay(d) + 4) % 7) + 7) % 7;
|
|
117
|
+
}
|
|
118
|
+
export function isWeekend(d) {
|
|
119
|
+
const dow = dayOfWeek(d);
|
|
120
|
+
return dow === 0 || dow === 6;
|
|
121
|
+
}
|
|
122
|
+
/** First and last day of the month containing `d`. */
|
|
123
|
+
export function startOfMonth(d) {
|
|
124
|
+
const { year, month } = parts(d);
|
|
125
|
+
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`;
|
|
126
|
+
}
|
|
127
|
+
export function endOfMonth(d) {
|
|
128
|
+
const { year, month } = parts(d);
|
|
129
|
+
const last = daysInMonth(year, month);
|
|
130
|
+
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(last).padStart(2, "0")}`;
|
|
131
|
+
}
|
|
132
|
+
export function dateRange(from, to) {
|
|
133
|
+
const f = date(from);
|
|
134
|
+
const t = date(to);
|
|
135
|
+
if (f > t)
|
|
136
|
+
throw new RangeError(`Date range is inverted: ${from}..${to}`);
|
|
137
|
+
return Object.freeze({ from: f, to: t });
|
|
138
|
+
}
|
|
139
|
+
export function withinRange(d, range) {
|
|
140
|
+
return d >= range.from && d <= range.to;
|
|
141
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal entries and the balancing invariant.
|
|
3
|
+
*
|
|
4
|
+
* The central rule of this file: an unbalanced `JournalEntry` cannot be
|
|
5
|
+
* constructed. The check lives in the factory, the constructor is private, and
|
|
6
|
+
* every field is frozen — so no code downstream of this module has to remember
|
|
7
|
+
* to validate anything, and no code can un-balance an entry after the fact.
|
|
8
|
+
*
|
|
9
|
+
* Sign convention: a posting's amount is positive for a debit and negative for
|
|
10
|
+
* a credit. One signed number is easier to sum than a (side, magnitude) pair,
|
|
11
|
+
* and "the postings sum to zero" is then literally the invariant.
|
|
12
|
+
*/
|
|
13
|
+
import { Money } from "../money/money.js";
|
|
14
|
+
import type { Currency } from "../money/currency.js";
|
|
15
|
+
import type { ChartOfAccounts } from "../accounts/chart.js";
|
|
16
|
+
import { type CalendarDate } from "./date.js";
|
|
17
|
+
export declare class UnbalancedEntryError extends Error {
|
|
18
|
+
readonly residuals: readonly Money[];
|
|
19
|
+
readonly narration: string;
|
|
20
|
+
constructor(residuals: readonly Money[], narration: string);
|
|
21
|
+
}
|
|
22
|
+
export declare class InvalidEntryError extends Error {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
export interface PostingInput {
|
|
26
|
+
account: string;
|
|
27
|
+
amount: Money;
|
|
28
|
+
memo?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The open item this line belongs to, when that is narrower than the entry's
|
|
31
|
+
* own reference. See `Posting.reference`.
|
|
32
|
+
*/
|
|
33
|
+
reference?: string;
|
|
34
|
+
/**
|
|
35
|
+
* What actually moved, when that was not in the currency the books are kept
|
|
36
|
+
* in. See `Posting.foreign`.
|
|
37
|
+
*/
|
|
38
|
+
foreign?: Money;
|
|
39
|
+
}
|
|
40
|
+
export interface Posting {
|
|
41
|
+
readonly account: string;
|
|
42
|
+
/** Positive is a debit, negative is a credit. */
|
|
43
|
+
readonly amount: Money;
|
|
44
|
+
readonly memo: string;
|
|
45
|
+
/** Index within the entry, stable and used for deterministic ordering. */
|
|
46
|
+
readonly index: number;
|
|
47
|
+
/**
|
|
48
|
+
* The open item this line belongs to, when the entry as a whole is about
|
|
49
|
+
* more than one of them.
|
|
50
|
+
*
|
|
51
|
+
* A revaluation adjusts several invoices in a single entry, and each of its
|
|
52
|
+
* postings belongs to a different one. Without this, the adjustment is
|
|
53
|
+
* attributable to the account but not to the item, and settling one invoice
|
|
54
|
+
* out of several would measure against what that invoice was originally
|
|
55
|
+
* booked at rather than what it is now carried at — booking the same rate
|
|
56
|
+
* movement once as unrealised and again as realised.
|
|
57
|
+
*
|
|
58
|
+
* Null means "whatever the entry says", which is the ordinary case.
|
|
59
|
+
*/
|
|
60
|
+
readonly reference: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* The transaction-currency amount, when the posting was denominated in
|
|
63
|
+
* something other than the functional currency.
|
|
64
|
+
*
|
|
65
|
+
* `amount` is always the figure the books are kept in and always the one the
|
|
66
|
+
* entry has to balance in — the invariant does not become "balances in every
|
|
67
|
+
* currency at once", because it never was true of a real transaction. A euro
|
|
68
|
+
* invoice is one economic event with two numbers attached: 1,000 EUR of
|
|
69
|
+
* receivable, booked at 847.30 GBP. The second is what the trial balance
|
|
70
|
+
* adds up; the first is what the customer owes and what a revaluation at the
|
|
71
|
+
* close has to look at.
|
|
72
|
+
*
|
|
73
|
+
* Signs agree with `amount`. A debit of 847.30 GBP carrying a credit of
|
|
74
|
+
* 1,000 EUR would be describing two different transactions.
|
|
75
|
+
*/
|
|
76
|
+
readonly foreign: Money | null;
|
|
77
|
+
}
|
|
78
|
+
export interface JournalEntryInput {
|
|
79
|
+
id: string;
|
|
80
|
+
date: string;
|
|
81
|
+
narration: string;
|
|
82
|
+
postings: readonly PostingInput[];
|
|
83
|
+
/** External reference: invoice number, statement line id, cheque number. */
|
|
84
|
+
reference?: string;
|
|
85
|
+
tags?: readonly string[];
|
|
86
|
+
/** Set when this entry reverses another. */
|
|
87
|
+
reverses?: string;
|
|
88
|
+
}
|
|
89
|
+
export declare class JournalEntry {
|
|
90
|
+
readonly id: string;
|
|
91
|
+
readonly date: CalendarDate;
|
|
92
|
+
readonly narration: string;
|
|
93
|
+
readonly postings: readonly Posting[];
|
|
94
|
+
readonly reference: string | null;
|
|
95
|
+
readonly tags: readonly string[];
|
|
96
|
+
readonly reverses: string | null;
|
|
97
|
+
private constructor();
|
|
98
|
+
/**
|
|
99
|
+
* Build a validated entry. Throws unless:
|
|
100
|
+
* - there are at least two postings,
|
|
101
|
+
* - no posting is for zero,
|
|
102
|
+
* - the postings sum to zero *within each currency*,
|
|
103
|
+
* - and, when a chart is supplied, every account exists and is postable.
|
|
104
|
+
*/
|
|
105
|
+
static create(input: JournalEntryInput, chart?: ChartOfAccounts): JournalEntry;
|
|
106
|
+
/** Convenience for the overwhelmingly common two-line entry. */
|
|
107
|
+
static simple(input: {
|
|
108
|
+
id: string;
|
|
109
|
+
date: string;
|
|
110
|
+
narration: string;
|
|
111
|
+
debit: string;
|
|
112
|
+
credit: string;
|
|
113
|
+
amount: Money;
|
|
114
|
+
reference?: string;
|
|
115
|
+
tags?: readonly string[];
|
|
116
|
+
}, chart?: ChartOfAccounts): JournalEntry;
|
|
117
|
+
get debits(): readonly Posting[];
|
|
118
|
+
get credits(): readonly Posting[];
|
|
119
|
+
get accounts(): readonly string[];
|
|
120
|
+
get currencies(): readonly Currency[];
|
|
121
|
+
get isMultiCurrency(): boolean;
|
|
122
|
+
/** Postings that record something that happened in another currency. */
|
|
123
|
+
get foreignPostings(): readonly Posting[];
|
|
124
|
+
get hasForeignAmounts(): boolean;
|
|
125
|
+
/** The transaction currencies this entry touches, deduplicated. */
|
|
126
|
+
get foreignCurrencies(): readonly Currency[];
|
|
127
|
+
/** Net foreign-currency effect on one account within this entry. */
|
|
128
|
+
foreignAmountFor(account: string, currency: Currency | string): Money;
|
|
129
|
+
/** Total of the debit side in the given currency — the entry's "size". */
|
|
130
|
+
total(currency?: Currency | string): Money;
|
|
131
|
+
/** The open item a posting belongs to: its own, or the entry's. */
|
|
132
|
+
referenceFor(posting: Posting): string | null;
|
|
133
|
+
touches(account: string): boolean;
|
|
134
|
+
/** Net effect on one account, summed across its postings in this entry. */
|
|
135
|
+
amountFor(account: string, currency?: Currency | string): Money;
|
|
136
|
+
/** A mirror-image entry that undoes this one. */
|
|
137
|
+
reversal(input: {
|
|
138
|
+
id: string;
|
|
139
|
+
date: string;
|
|
140
|
+
narration?: string;
|
|
141
|
+
}): JournalEntry;
|
|
142
|
+
toString(): string;
|
|
143
|
+
toJSON(): unknown;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Per-currency sums that are not zero. An empty result means the set balances.
|
|
147
|
+
* Exported because the ledger uses it to re-check invariants in bulk.
|
|
148
|
+
*/
|
|
149
|
+
export declare function residualsByCurrency(amounts: readonly Money[]): Money[];
|
|
150
|
+
export declare function isBalanced(amounts: readonly Money[]): boolean;
|