pluts 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/README.md +240 -0
- package/package.json +44 -0
- package/src/db/repository.ts +42 -0
- package/src/db/schema.ts +83 -0
- package/src/db/sqlite-storage-repository.ts +419 -0
- package/src/domain/account.ts +61 -0
- package/src/domain/amount.ts +153 -0
- package/src/domain/entry.ts +181 -0
- package/src/domain/errors.ts +61 -0
- package/src/domain/ledger.ts +188 -0
- package/src/domain/schemas.ts +118 -0
- package/src/domain/types.ts +45 -0
- package/src/index.ts +49 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Account } from "./account.js";
|
|
2
|
+
import type { Amount } from "./amount.js";
|
|
3
|
+
import { ValidationError, type ValidationIssue } from "./errors.js";
|
|
4
|
+
import {
|
|
5
|
+
type AmountLine,
|
|
6
|
+
type EntryInput,
|
|
7
|
+
entryInputSchema,
|
|
8
|
+
toIssues,
|
|
9
|
+
} from "./schemas.js";
|
|
10
|
+
import { toDateISO } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export type AmountKind = "credit" | "debit";
|
|
13
|
+
|
|
14
|
+
/** A debit/credit line with a resolved account (no name lookup pending). */
|
|
15
|
+
export interface ResolvedAmountLine {
|
|
16
|
+
readonly account: Account;
|
|
17
|
+
readonly amount: Amount;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A validated, unpersisted entry: description, date, and resolved debit/credit
|
|
22
|
+
* lines (each carrying an `Account` and an `Amount`). Has no id; persistence
|
|
23
|
+
* assigns one. Immutable. Carries an optional client-supplied
|
|
24
|
+
* {@link idempotencyKey} so the repository can dedup retries atomically.
|
|
25
|
+
*/
|
|
26
|
+
export interface EntryPayload {
|
|
27
|
+
readonly idempotencyKey?: string;
|
|
28
|
+
readonly description: string;
|
|
29
|
+
readonly date: string;
|
|
30
|
+
readonly debits: readonly ResolvedAmountLine[];
|
|
31
|
+
readonly credits: readonly ResolvedAmountLine[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A persisted debit or credit leg of an entry. Immutable.
|
|
36
|
+
*/
|
|
37
|
+
export class AmountRecord {
|
|
38
|
+
constructor(
|
|
39
|
+
readonly id: string,
|
|
40
|
+
readonly kind: AmountKind,
|
|
41
|
+
readonly account: Account,
|
|
42
|
+
readonly amount: Amount,
|
|
43
|
+
readonly entryId: string,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
toJSON(): Record<string, unknown> {
|
|
47
|
+
return {
|
|
48
|
+
id: this.id,
|
|
49
|
+
kind: this.kind,
|
|
50
|
+
accountId: this.account.id,
|
|
51
|
+
amount: this.amount.toMajor(),
|
|
52
|
+
entryId: this.entryId,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A persisted journal entry: one or more debits and credits that balance.
|
|
59
|
+
* Immutable; constructed fully-formed with an assigned id and a posted-at
|
|
60
|
+
* timestamp (when it was recorded). The `date` field is the transaction date
|
|
61
|
+
* (when the economic event occurred) — kept distinct from `postedAt` for audit
|
|
62
|
+
* clarity.
|
|
63
|
+
*/
|
|
64
|
+
export class Entry {
|
|
65
|
+
constructor(
|
|
66
|
+
readonly id: string,
|
|
67
|
+
readonly description: string,
|
|
68
|
+
readonly date: string,
|
|
69
|
+
readonly debitAmounts: readonly AmountRecord[],
|
|
70
|
+
readonly creditAmounts: readonly AmountRecord[],
|
|
71
|
+
readonly postedAt: string,
|
|
72
|
+
) {}
|
|
73
|
+
|
|
74
|
+
toJSON(): Record<string, unknown> {
|
|
75
|
+
return {
|
|
76
|
+
id: this.id,
|
|
77
|
+
description: this.description,
|
|
78
|
+
date: this.date,
|
|
79
|
+
debitAmounts: this.debitAmounts.map((d) => ({
|
|
80
|
+
...d,
|
|
81
|
+
amount: d.amount.toMajor(),
|
|
82
|
+
})),
|
|
83
|
+
creditAmounts: this.creditAmounts.map((c) => ({
|
|
84
|
+
...c,
|
|
85
|
+
amount: c.amount.toMajor(),
|
|
86
|
+
})),
|
|
87
|
+
postedAt: this.postedAt,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function newId(): string {
|
|
93
|
+
return crypto.randomUUID();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate input and assemble an {@link EntryPayload}. Throws
|
|
98
|
+
* {@link ValidationError} on failure with a flat list of path-tagged issues.
|
|
99
|
+
*
|
|
100
|
+
* Validation:
|
|
101
|
+
* - shape and per-line rules via {@link entryInputSchema} (Zod)
|
|
102
|
+
* - entry-level invariants (≥1 debit, ≥1 credit, debits-sum === credits-sum)
|
|
103
|
+
* via the schema's `superRefine`
|
|
104
|
+
* - account-name resolution happens *after* schema validation (DB lookups
|
|
105
|
+
* aren't schema concerns); unresolved accounts are reported as issues
|
|
106
|
+
* with path `[root, index, 'account']`
|
|
107
|
+
*
|
|
108
|
+
* @param resolveAccount looks up an account by name; returns null if missing
|
|
109
|
+
*/
|
|
110
|
+
export function buildEntry(
|
|
111
|
+
input: EntryInput,
|
|
112
|
+
resolveAccount: (name: string) => Account | null = () => null,
|
|
113
|
+
now: () => Date = () => new Date(),
|
|
114
|
+
): EntryPayload {
|
|
115
|
+
const parsed = entryInputSchema.safeParse(input);
|
|
116
|
+
if (!parsed.success) {
|
|
117
|
+
throw new ValidationError(toIssues(parsed.error.issues));
|
|
118
|
+
}
|
|
119
|
+
const { description, debits, credits } = parsed.data;
|
|
120
|
+
const date = parsed.data.date ?? toDateISO(now());
|
|
121
|
+
const { idempotencyKey } = parsed.data;
|
|
122
|
+
|
|
123
|
+
// Resolve accounts by name (post-parse). Unresolved names become issues.
|
|
124
|
+
const issues: ValidationIssue[] = [];
|
|
125
|
+
const resolveLine = (
|
|
126
|
+
line: AmountLine,
|
|
127
|
+
index: number,
|
|
128
|
+
root: "debits" | "credits",
|
|
129
|
+
): ResolvedAmountLine | null => {
|
|
130
|
+
if (line.account) return { account: line.account, amount: line.amount };
|
|
131
|
+
const name = line.accountName;
|
|
132
|
+
if (name) {
|
|
133
|
+
const found = resolveAccount(name);
|
|
134
|
+
if (found) return { account: found, amount: line.amount };
|
|
135
|
+
issues.push({
|
|
136
|
+
path: [root, index, "account"],
|
|
137
|
+
message: `Account "${name}" not found`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const resolvedDebits: ResolvedAmountLine[] = [];
|
|
144
|
+
const resolvedCredits: ResolvedAmountLine[] = [];
|
|
145
|
+
debits.forEach((l, i) => {
|
|
146
|
+
const r = resolveLine(l, i, "debits");
|
|
147
|
+
if (r) resolvedDebits.push(r);
|
|
148
|
+
});
|
|
149
|
+
credits.forEach((l, i) => {
|
|
150
|
+
const r = resolveLine(l, i, "credits");
|
|
151
|
+
if (r) resolvedCredits.push(r);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (issues.length > 0) {
|
|
155
|
+
throw new ValidationError(issues);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const payload: EntryPayload = {
|
|
159
|
+
description,
|
|
160
|
+
date,
|
|
161
|
+
debits: resolvedDebits,
|
|
162
|
+
credits: resolvedCredits,
|
|
163
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
164
|
+
};
|
|
165
|
+
return payload;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Build {@link AmountRecord}s from a payload, assigning fresh ids. */
|
|
169
|
+
export function amountsFromPayload(
|
|
170
|
+
payload: EntryPayload,
|
|
171
|
+
entryId: string,
|
|
172
|
+
): { debits: AmountRecord[]; credits: AmountRecord[] } {
|
|
173
|
+
return {
|
|
174
|
+
debits: payload.debits.map(
|
|
175
|
+
(l) => new AmountRecord(newId(), "debit", l.account, l.amount, entryId),
|
|
176
|
+
),
|
|
177
|
+
credits: payload.credits.map(
|
|
178
|
+
(l) => new AmountRecord(newId(), "credit", l.account, l.amount, entryId),
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single validation issue. `path` follows Zod's convention: an array of
|
|
3
|
+
* property keys / indices locating the offending field within the input.
|
|
4
|
+
* Record-level invariants (e.g. "debit and credit totals must cancel") use
|
|
5
|
+
* an empty path `[]`.
|
|
6
|
+
*/
|
|
7
|
+
export interface ValidationIssue {
|
|
8
|
+
path: PropertyKey[];
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Thrown when a domain operation (e.g. posting an entry, creating an account)
|
|
14
|
+
* fails validation. Carries a flat list of {@link ValidationIssue}s rather than
|
|
15
|
+
* an ActiveRecord-style field-keyed map, preserving path precision (you can tell
|
|
16
|
+
* *which* debit's amount was bad).
|
|
17
|
+
*/
|
|
18
|
+
export class ValidationError extends Error {
|
|
19
|
+
readonly issues: ValidationIssue[];
|
|
20
|
+
|
|
21
|
+
constructor(issues: ValidationIssue[], message = "Validation failed") {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ValidationError";
|
|
24
|
+
this.issues = issues;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collapses issue paths to a field-keyed map for form binding.
|
|
29
|
+
* - Issues with an empty path go under `'_base'`.
|
|
30
|
+
* - Otherwise the root key of the path is used (e.g. `['debits', 0, 'account']` → `'account'`).
|
|
31
|
+
*
|
|
32
|
+
* Note: this loses path precision (which array index). Use `issues` directly
|
|
33
|
+
* when you need that.
|
|
34
|
+
*/
|
|
35
|
+
errorsByField(): Record<string, string[]> {
|
|
36
|
+
const out: Record<string, string[]> = {};
|
|
37
|
+
for (const issue of this.issues) {
|
|
38
|
+
const key = issue.path.length === 0 ? "_base" : String(issue.path[0]);
|
|
39
|
+
const list = out[key];
|
|
40
|
+
if (list) {
|
|
41
|
+
list.push(issue.message);
|
|
42
|
+
} else {
|
|
43
|
+
out[key] = [issue.message];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Thrown when the persistence layer rejects an operation for a reason that
|
|
52
|
+
* isn't input validation (e.g. a foreign-key violation at the storage level).
|
|
53
|
+
* Carries the underlying cause for diagnostics. Used by the SqlStorage
|
|
54
|
+
* repository to give callers a typed alternative to raw storage errors.
|
|
55
|
+
*/
|
|
56
|
+
export class RepositoryError extends Error {
|
|
57
|
+
constructor(message: string, cause?: unknown) {
|
|
58
|
+
super(message, { cause });
|
|
59
|
+
this.name = "RepositoryError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Repository } from "../db/repository";
|
|
2
|
+
import { type Account, aggregateBalances, computeBalance } from "./account";
|
|
3
|
+
import {
|
|
4
|
+
type AmountRecord,
|
|
5
|
+
buildEntry,
|
|
6
|
+
type Entry,
|
|
7
|
+
type EntryPayload,
|
|
8
|
+
} from "./entry";
|
|
9
|
+
import { ValidationError } from "./errors";
|
|
10
|
+
import {
|
|
11
|
+
type CreateAccountInput,
|
|
12
|
+
createAccountSchema,
|
|
13
|
+
type EntryInput,
|
|
14
|
+
toIssues,
|
|
15
|
+
} from "./schemas";
|
|
16
|
+
import { AccountType, type DateRange } from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Balances are signed `bigint` minor units. A balance may legitimately be
|
|
20
|
+
* negative (e.g. an overdrawn asset), which the strictly-non-negative
|
|
21
|
+
* {@link Amount} type cannot represent, so report fields expose raw `bigint`.
|
|
22
|
+
* Format for display with {@link formatAmount} from `./amount.js`.
|
|
23
|
+
*/
|
|
24
|
+
export interface BalanceSheet {
|
|
25
|
+
assets: bigint;
|
|
26
|
+
liabilities: bigint;
|
|
27
|
+
equity: bigint;
|
|
28
|
+
/** Assets - (Liabilities + Equity + Net Income). Should be zero in a balanced ledger. */
|
|
29
|
+
balanced: bigint;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IncomeStatement {
|
|
33
|
+
revenue: bigint;
|
|
34
|
+
expenses: bigint;
|
|
35
|
+
netIncome: bigint;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* High-level facade over a {@link Repository}. Provides the accounting
|
|
40
|
+
* operations of the Pluts domain: account creation, entry posting, and
|
|
41
|
+
* balance/report queries. This is the primary public API surface.
|
|
42
|
+
*/
|
|
43
|
+
export class Ledger {
|
|
44
|
+
constructor(private readonly repo: Repository) {}
|
|
45
|
+
|
|
46
|
+
async createAccount(input: CreateAccountInput): Promise<Account> {
|
|
47
|
+
const parsed = createAccountSchema.safeParse(input);
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
throw new ValidationError(
|
|
50
|
+
toIssues(parsed.error.issues),
|
|
51
|
+
"Invalid account input",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
// The DB unique index (name, type) is the source of truth; insertAccount
|
|
55
|
+
// catches the constraint violation and re-throws as ValidationError. This
|
|
56
|
+
// avoids a check-then-act TOCTOU window under concurrent DO requests.
|
|
57
|
+
return this.repo.insertAccount(parsed.data);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate and persist an entry. Each amount may reference an account either
|
|
62
|
+
* directly (`account`) or by name (`accountName`, resolved against the repo).
|
|
63
|
+
* Amounts accept `number | string | Amount`. Throws {@link ValidationError}
|
|
64
|
+
* on failure with a flat list of path-tagged issues.
|
|
65
|
+
*/
|
|
66
|
+
async postEntry(input: EntryInput): Promise<Entry> {
|
|
67
|
+
// Exactly-once posting: if a client-supplied idempotency key is present and
|
|
68
|
+
// a matching entry was already persisted, return it instead of re-posting.
|
|
69
|
+
// This guards against retries in a Durable Object (network replays or
|
|
70
|
+
// re-execution after eviction) that would otherwise create a silent duplicate.
|
|
71
|
+
if (input.idempotencyKey) {
|
|
72
|
+
const existing = await this.repo.getEntryByKey(input.idempotencyKey);
|
|
73
|
+
if (existing) return existing;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const names = new Set<string>();
|
|
77
|
+
for (const a of [...input.debits, ...input.credits]) {
|
|
78
|
+
if (a.accountName) names.add(a.accountName);
|
|
79
|
+
}
|
|
80
|
+
const accountMap = new Map<string, Account>();
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const acc = await this.repo.getAccountByName(name);
|
|
83
|
+
if (acc) accountMap.set(name, acc);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const payload: EntryPayload = buildEntry(
|
|
87
|
+
input,
|
|
88
|
+
(name) => accountMap.get(name) ?? null,
|
|
89
|
+
);
|
|
90
|
+
return this.repo.insertEntry(payload);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getAccount(id: string): Promise<Account | null> {
|
|
94
|
+
return this.repo.getAccount(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async getAccountByName(name: string): Promise<Account | null> {
|
|
98
|
+
return this.repo.getAccountByName(name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** All accounts, ordered by name. */
|
|
102
|
+
async allAccounts(): Promise<Account[]> {
|
|
103
|
+
return this.repo.allAccounts();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Balance of a single account, optionally within a date range. */
|
|
107
|
+
async accountBalance(account: Account, range?: DateRange): Promise<bigint> {
|
|
108
|
+
const [credits, debits] = await Promise.all([
|
|
109
|
+
this.repo.sumCredits(account.id, range),
|
|
110
|
+
this.repo.sumDebits(account.id, range),
|
|
111
|
+
]);
|
|
112
|
+
return computeBalance(account.type, account.contra, credits, debits);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Aggregate balance of all accounts of a type (contra accounts subtracted). */
|
|
116
|
+
async balanceByType(type: AccountType, range?: DateRange): Promise<bigint> {
|
|
117
|
+
const accounts = await this.repo.getAccountsByType(type);
|
|
118
|
+
const balances = await Promise.all(
|
|
119
|
+
accounts.map(async (a) => ({
|
|
120
|
+
type: a.type,
|
|
121
|
+
contra: a.contra,
|
|
122
|
+
balance: await this.accountBalance(a, range),
|
|
123
|
+
})),
|
|
124
|
+
);
|
|
125
|
+
return aggregateBalances(balances, type);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Trial balance: should always be zero for a balanced ledger.
|
|
130
|
+
* Asset - (Liability + Equity + Revenue - Expense). Optionally scoped to a
|
|
131
|
+
* date range (all entries up to/within the range), matching `balanceSheet`
|
|
132
|
+
* and `incomeStatement`.
|
|
133
|
+
*/
|
|
134
|
+
async trialBalance(range?: DateRange): Promise<bigint> {
|
|
135
|
+
const [assets, liabilities, equity, revenue, expenses] = await Promise.all([
|
|
136
|
+
this.balanceByType(AccountType.Asset, range),
|
|
137
|
+
this.balanceByType(AccountType.Liability, range),
|
|
138
|
+
this.balanceByType(AccountType.Equity, range),
|
|
139
|
+
this.balanceByType(AccountType.Revenue, range),
|
|
140
|
+
this.balanceByType(AccountType.Expense, range),
|
|
141
|
+
]);
|
|
142
|
+
return assets - (liabilities + equity + revenue - expenses);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async balanceSheet(range?: DateRange): Promise<BalanceSheet> {
|
|
146
|
+
const [assets, liabilities, equity, revenue, expenses] = await Promise.all([
|
|
147
|
+
this.balanceByType(AccountType.Asset, range),
|
|
148
|
+
this.balanceByType(AccountType.Liability, range),
|
|
149
|
+
this.balanceByType(AccountType.Equity, range),
|
|
150
|
+
this.balanceByType(AccountType.Revenue, range),
|
|
151
|
+
this.balanceByType(AccountType.Expense, range),
|
|
152
|
+
]);
|
|
153
|
+
// Net income (revenue - expenses) is retained earnings, part of equity on
|
|
154
|
+
// a real balance sheet. The balanced check includes it so the accounting
|
|
155
|
+
// equation holds: Assets = Liabilities + Equity + Net Income.
|
|
156
|
+
const netIncome = revenue - expenses;
|
|
157
|
+
return {
|
|
158
|
+
assets,
|
|
159
|
+
liabilities,
|
|
160
|
+
equity,
|
|
161
|
+
balanced: assets - (liabilities + equity + netIncome),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async incomeStatement(range?: DateRange): Promise<IncomeStatement> {
|
|
166
|
+
const [revenue, expenses] = await Promise.all([
|
|
167
|
+
this.balanceByType(AccountType.Revenue, range),
|
|
168
|
+
this.balanceByType(AccountType.Expense, range),
|
|
169
|
+
]);
|
|
170
|
+
return {
|
|
171
|
+
revenue,
|
|
172
|
+
expenses,
|
|
173
|
+
netIncome: revenue - expenses,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async entriesForAccount(account: Account): Promise<Entry[]> {
|
|
178
|
+
return this.repo.entriesForAccount(account.id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async amountsForAccount(account: Account): Promise<AmountRecord[]> {
|
|
182
|
+
return this.repo.amountsForAccount(account.id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async allEntries(order: "asc" | "desc" = "desc"): Promise<Entry[]> {
|
|
186
|
+
return this.repo.allEntries(order);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Account } from "./account";
|
|
3
|
+
import { Amount } from "./amount";
|
|
4
|
+
import type { ValidationIssue } from "./errors";
|
|
5
|
+
import { AccountType, toDateISO } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Amount input: accepts an already-built {@link Amount}, a non-negative finite
|
|
9
|
+
* number, or a digit-only string. The transform parses raw values to an
|
|
10
|
+
* `Amount` via half-up rounding at the supported scale, so float imprecision
|
|
11
|
+
* (e.g. `0.1 + 0.2`) is resolved at the input boundary. Stored values stay
|
|
12
|
+
* exact integers.
|
|
13
|
+
*
|
|
14
|
+
* `z.custom<Amount>` (rather than `z.instanceof(Amount)`) is used because
|
|
15
|
+
* {@link Amount} has a private constructor and Zod v4's `z.instanceof`
|
|
16
|
+
* requires a public one. `amountLineSchema` below uses `z.instanceof(Account)`
|
|
17
|
+
* because `Account`'s constructor is public.
|
|
18
|
+
*/
|
|
19
|
+
export const amountSchema = z
|
|
20
|
+
.union([
|
|
21
|
+
z.custom<Amount>((v) => v instanceof Amount, { message: "Invalid Amount" }),
|
|
22
|
+
z.number().finite().nonnegative(),
|
|
23
|
+
z.string().regex(/^\d+(\.\d+)?$/),
|
|
24
|
+
])
|
|
25
|
+
.transform((v) => (v instanceof Amount ? v : Amount.fromMajor(v)));
|
|
26
|
+
|
|
27
|
+
/** A `Date | string` normalized to an ISO `yyyy-mm-dd` string. */
|
|
28
|
+
const isoDateSchema = z.union([z.date(), z.string()]).transform(toDateISO);
|
|
29
|
+
|
|
30
|
+
/** Optional inclusive date range, normalized to ISO strings. */
|
|
31
|
+
export const dateRangeSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
fromDate: isoDateSchema.optional(),
|
|
34
|
+
toDate: isoDateSchema.optional(),
|
|
35
|
+
})
|
|
36
|
+
.optional();
|
|
37
|
+
|
|
38
|
+
/** Input for account creation. `name` is trimmed; `contra` defaults to false. */
|
|
39
|
+
export const createAccountSchema = z.object({
|
|
40
|
+
name: z.string().trim().min(1),
|
|
41
|
+
type: z.nativeEnum(AccountType),
|
|
42
|
+
contra: z.boolean().default(false),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type CreateAccountInput = z.input<typeof createAccountSchema>;
|
|
46
|
+
|
|
47
|
+
/** A single debit/credit line. Either `account` or `accountName` is required. */
|
|
48
|
+
const amountLineSchema = z
|
|
49
|
+
.object({
|
|
50
|
+
account: z.instanceof(Account).optional(),
|
|
51
|
+
accountName: z.string().min(1).optional(),
|
|
52
|
+
amount: amountSchema,
|
|
53
|
+
})
|
|
54
|
+
.refine((v) => v.account || v.accountName, {
|
|
55
|
+
message: "can't be blank",
|
|
56
|
+
path: ["account"],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type AmountLine = z.output<typeof amountLineSchema>;
|
|
60
|
+
export type AmountInput = z.input<typeof amountLineSchema>;
|
|
61
|
+
|
|
62
|
+
/** Input shape for building an entry (mirrors Ruby's `Entry.new` hash). */
|
|
63
|
+
export const entryInputSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
idempotencyKey: z.string().min(1).optional(),
|
|
66
|
+
description: z.string().min(1),
|
|
67
|
+
date: isoDateSchema.optional(),
|
|
68
|
+
debits: z.array(amountLineSchema),
|
|
69
|
+
credits: z.array(amountLineSchema),
|
|
70
|
+
})
|
|
71
|
+
.superRefine((v, ctx) => {
|
|
72
|
+
if (v.debits.length === 0) {
|
|
73
|
+
ctx.addIssue({
|
|
74
|
+
code: "custom",
|
|
75
|
+
path: ["debits"],
|
|
76
|
+
message: "Entry must have at least one debit amount",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (v.credits.length === 0) {
|
|
80
|
+
ctx.addIssue({
|
|
81
|
+
code: "custom",
|
|
82
|
+
path: ["credits"],
|
|
83
|
+
message: "Entry must have at least one credit amount",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Only reconcile totals once every line parsed to an `Amount`. If a line's
|
|
87
|
+
// amount failed the union (e.g. a negative number), its per-line issue
|
|
88
|
+
// already explains the failure, and summing the raw value here would throw
|
|
89
|
+
// `TypeError: Cannot mix BigInt and other types` — turning a clean
|
|
90
|
+
// validation failure into a crash (and `safeParse` must never throw).
|
|
91
|
+
const amounts = [...v.debits, ...v.credits].map((l) => l.amount);
|
|
92
|
+
if (amounts.every((a) => a instanceof Amount)) {
|
|
93
|
+
const debitSum = v.debits.reduce((acc, l) => acc + l.amount.minor, 0n);
|
|
94
|
+
const creditSum = v.credits.reduce((acc, l) => acc + l.amount.minor, 0n);
|
|
95
|
+
if (debitSum !== creditSum) {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: "custom",
|
|
98
|
+
path: [],
|
|
99
|
+
message: "The credit and debit amounts are not equal",
|
|
100
|
+
});
|
|
101
|
+
} else if (debitSum === 0n) {
|
|
102
|
+
ctx.addIssue({
|
|
103
|
+
code: "custom",
|
|
104
|
+
path: [],
|
|
105
|
+
message: "Entry amounts must be greater than zero",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export type EntryInput = z.input<typeof entryInputSchema>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Maps Zod issues to {@link ValidationIssue}s, preserving paths.
|
|
115
|
+
*/
|
|
116
|
+
export function toIssues(zodIssues: z.ZodIssue[]): ValidationIssue[] {
|
|
117
|
+
return zodIssues.map((i) => ({ path: i.path, message: i.message }));
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The five account types, mirroring Plutus' single-table-inheritance subclasses. */
|
|
2
|
+
export enum AccountType {
|
|
3
|
+
Asset = "Asset",
|
|
4
|
+
Liability = "Liability",
|
|
5
|
+
Equity = "Equity",
|
|
6
|
+
Revenue = "Revenue",
|
|
7
|
+
Expense = "Expense",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ACCOUNT_TYPES: readonly AccountType[] = [
|
|
11
|
+
AccountType.Asset,
|
|
12
|
+
AccountType.Liability,
|
|
13
|
+
AccountType.Equity,
|
|
14
|
+
AccountType.Revenue,
|
|
15
|
+
AccountType.Expense,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether an account type normally has a credit balance.
|
|
20
|
+
* Asset/Expense => debit normal balance (false); others => credit (true).
|
|
21
|
+
*/
|
|
22
|
+
export function normalCreditBalance(type: AccountType): boolean {
|
|
23
|
+
return (
|
|
24
|
+
type === AccountType.Liability ||
|
|
25
|
+
type === AccountType.Equity ||
|
|
26
|
+
type === AccountType.Revenue
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Optional inclusive date range for balance calculations. Strings are "yyyy-mm-dd". */
|
|
31
|
+
export interface DateRange {
|
|
32
|
+
fromDate?: Date | string;
|
|
33
|
+
toDate?: Date | string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Normalizes a Date | string to an ISO yyyy-mm-dd string. */
|
|
37
|
+
export function toDateISO(d: Date | string): string {
|
|
38
|
+
if (typeof d === "string") {
|
|
39
|
+
return d;
|
|
40
|
+
}
|
|
41
|
+
const year = d.getUTCFullYear();
|
|
42
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
43
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
44
|
+
return `${year}-${month}-${day}`;
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type { Repository } from "./db/repository.js";
|
|
2
|
+
export {
|
|
3
|
+
migrate,
|
|
4
|
+
SCHEMA_SQL,
|
|
5
|
+
SCHEMA_STATEMENTS,
|
|
6
|
+
} from "./db/schema.js";
|
|
7
|
+
export { SqlStorageRepository } from "./db/sqlite-storage-repository.js";
|
|
8
|
+
export {
|
|
9
|
+
Account,
|
|
10
|
+
aggregateBalances,
|
|
11
|
+
computeBalance,
|
|
12
|
+
} from "./domain/account.js";
|
|
13
|
+
export { Amount, formatAmount, SCALE } from "./domain/amount.js";
|
|
14
|
+
export {
|
|
15
|
+
type AmountKind,
|
|
16
|
+
AmountRecord,
|
|
17
|
+
amountsFromPayload,
|
|
18
|
+
buildEntry,
|
|
19
|
+
Entry,
|
|
20
|
+
type EntryPayload,
|
|
21
|
+
type ResolvedAmountLine,
|
|
22
|
+
} from "./domain/entry.js";
|
|
23
|
+
export {
|
|
24
|
+
RepositoryError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
type ValidationIssue,
|
|
27
|
+
} from "./domain/errors.js";
|
|
28
|
+
export {
|
|
29
|
+
type BalanceSheet,
|
|
30
|
+
type IncomeStatement,
|
|
31
|
+
Ledger,
|
|
32
|
+
} from "./domain/ledger.js";
|
|
33
|
+
export {
|
|
34
|
+
type AmountInput,
|
|
35
|
+
amountSchema,
|
|
36
|
+
type CreateAccountInput,
|
|
37
|
+
createAccountSchema,
|
|
38
|
+
dateRangeSchema,
|
|
39
|
+
type EntryInput,
|
|
40
|
+
entryInputSchema,
|
|
41
|
+
toIssues,
|
|
42
|
+
} from "./domain/schemas.js";
|
|
43
|
+
export {
|
|
44
|
+
ACCOUNT_TYPES,
|
|
45
|
+
AccountType,
|
|
46
|
+
type DateRange,
|
|
47
|
+
normalCreditBalance,
|
|
48
|
+
toDateISO,
|
|
49
|
+
} from "./domain/types.js";
|