@classytic/ledger 0.1.3
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 +108 -0
- package/dist/account.repository-1C2sZvB2.d.mts +29 -0
- package/dist/account.repository-1C2sZvB2.d.mts.map +1 -0
- package/dist/account.repository-Crf5DGO4.mjs +393 -0
- package/dist/account.repository-Crf5DGO4.mjs.map +1 -0
- package/dist/categories-BNJBd4ze.mjs +70 -0
- package/dist/categories-BNJBd4ze.mjs.map +1 -0
- package/dist/constants/index.d.mts +2 -0
- package/dist/constants/index.mjs +5 -0
- package/dist/core-Cx0baosR.d.mts +104 -0
- package/dist/core-Cx0baosR.d.mts.map +1 -0
- package/dist/country/index.d.mts +105 -0
- package/dist/country/index.d.mts.map +1 -0
- package/dist/country/index.mjs +27 -0
- package/dist/country/index.mjs.map +1 -0
- package/dist/currencies-BBk3NwXn.mjs +82 -0
- package/dist/currencies-BBk3NwXn.mjs.map +1 -0
- package/dist/currencies-Bkn3FNkC.d.mts +38 -0
- package/dist/currencies-Bkn3FNkC.d.mts.map +1 -0
- package/dist/engine-Cd73EOT6.d.mts +72 -0
- package/dist/engine-Cd73EOT6.d.mts.map +1 -0
- package/dist/errors-CeqRahE-.mjs +28 -0
- package/dist/errors-CeqRahE-.mjs.map +1 -0
- package/dist/exports/index.d.mts +2 -0
- package/dist/exports/index.mjs +3 -0
- package/dist/fiscal-close-CNOwv_ud.mjs +934 -0
- package/dist/fiscal-close-CNOwv_ud.mjs.map +1 -0
- package/dist/fiscal-close-CzUzpnMg.d.mts +270 -0
- package/dist/fiscal-close-CzUzpnMg.d.mts.map +1 -0
- package/dist/fiscal-period.schema-CbALaaKl.mjs +477 -0
- package/dist/fiscal-period.schema-CbALaaKl.mjs.map +1 -0
- package/dist/fiscal-period.schema-DI2scngu.d.mts +38 -0
- package/dist/fiscal-period.schema-DI2scngu.d.mts.map +1 -0
- package/dist/idempotency.plugin-BESs9YPD.d.mts +58 -0
- package/dist/idempotency.plugin-BESs9YPD.d.mts.map +1 -0
- package/dist/idempotency.plugin-C6r8RI8d.mjs +165 -0
- package/dist/idempotency.plugin-C6r8RI8d.mjs.map +1 -0
- package/dist/index.d.mts +308 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +171 -0
- package/dist/index.mjs.map +1 -0
- package/dist/journals-CI3Wb4EF.mjs +92 -0
- package/dist/journals-CI3Wb4EF.mjs.map +1 -0
- package/dist/logger-Cv6VVc4r.d.mts +15 -0
- package/dist/logger-Cv6VVc4r.d.mts.map +1 -0
- package/dist/money.d.mts +129 -0
- package/dist/money.d.mts.map +1 -0
- package/dist/money.mjs +197 -0
- package/dist/money.mjs.map +1 -0
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/reports/index.d.mts +2 -0
- package/dist/reports/index.mjs +3 -0
- package/dist/repositories/index.d.mts +2 -0
- package/dist/repositories/index.mjs +3 -0
- package/dist/schemas/index.d.mts +2 -0
- package/dist/schemas/index.mjs +3 -0
- package/dist/session-Dh0s6zG4.mjs +87 -0
- package/dist/session-Dh0s6zG4.mjs.map +1 -0
- package/dist/universal-CMfrZ2hG.mjs +257 -0
- package/dist/universal-CMfrZ2hG.mjs.map +1 -0
- package/dist/universal-x33ZJODp.d.mts +137 -0
- package/dist/universal-x33ZJODp.d.mts.map +1 -0
- package/docs/country-packs.md +117 -0
- package/docs/engine.md +147 -0
- package/docs/exports.md +81 -0
- package/docs/money.md +81 -0
- package/docs/plugins.md +136 -0
- package/docs/reports.md +154 -0
- package/docs/repositories.md +239 -0
- package/docs/schemas.md +146 -0
- package/docs/subledger-integration.md +287 -0
- package/package.json +116 -0
package/docs/reports.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Reports
|
|
2
|
+
|
|
3
|
+
All reports are generated from live aggregation pipelines — no cached balances. Multi-tenant isolation is enforced automatically.
|
|
4
|
+
|
|
5
|
+
## Using via Engine
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const reports = accounting.createReports({ Account, JournalEntry });
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
All report methods accept:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
{
|
|
15
|
+
organizationId?: unknown; // required in multi-tenant mode
|
|
16
|
+
dateOption: 'month' | 'quarter' | 'year' | 'custom';
|
|
17
|
+
dateValue: unknown; // month: '2025-03', quarter: '2025-Q1', year: 2025, custom: { start, end }
|
|
18
|
+
filters?: Record<string, unknown>; // dimension filters (see below)
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Dimension Filters
|
|
23
|
+
|
|
24
|
+
All reports accept an optional `filters` parameter for filtering by custom dimension fields on journal items:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
const bs = await reports.balanceSheet({
|
|
28
|
+
organizationId: orgId,
|
|
29
|
+
dateOption: 'year',
|
|
30
|
+
dateValue: 2025,
|
|
31
|
+
filters: {
|
|
32
|
+
'journalItems.departmentId': departmentId,
|
|
33
|
+
'journalItems.projectId': { $in: [proj1, proj2] },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Filters are injected into aggregation `$match` stages after `$unwind`. Dangerous MongoDB operators (`$where`, `$expr`, `$function`, `$accumulator`, `$merge`, `$out`, `$unionWith`) are blocked by `buildItemFilters()`. Top-level `$`-prefixed keys are also blocked to prevent query injection.
|
|
39
|
+
|
|
40
|
+
### Account Identity in Reports
|
|
41
|
+
|
|
42
|
+
Reports use the **actual account row** for display names and codes, not the account type template. When `acc.name` or `acc.accountNumber` is set on the account document, that value is used. Falls back to `accountType.name` / `accountType.code` from the country pack when the account row doesn't have these fields.
|
|
43
|
+
|
|
44
|
+
This means tenants with multiple accounts under one type (e.g. three bank accounts all typed as "1000-Cash") will see their distinct names in report output.
|
|
45
|
+
|
|
46
|
+
## Trial Balance
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
const tb = await reports.trialBalance({
|
|
50
|
+
organizationId: orgId,
|
|
51
|
+
dateOption: 'year',
|
|
52
|
+
dateValue: 2025,
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Three-column report: initial balance + current period activity + ending balance. Returns `TrialBalanceReport` with `rows: TrialBalanceRow[]` and `period`.
|
|
57
|
+
|
|
58
|
+
## Balance Sheet
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
const bs = await reports.balanceSheet({
|
|
62
|
+
organizationId: orgId,
|
|
63
|
+
dateOption: 'year',
|
|
64
|
+
dateValue: 2025,
|
|
65
|
+
businessName: 'Acme Corp',
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Returns `BalanceSheetReport` with `assets`, `liabilities`, `equity` categories, each containing groups of accounts. Net income is computed from income statement accounts for the fiscal year and injected into equity as retained earnings.
|
|
70
|
+
|
|
71
|
+
## Income Statement
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const is = await reports.incomeStatement({
|
|
75
|
+
organizationId: orgId,
|
|
76
|
+
dateOption: 'quarter',
|
|
77
|
+
dateValue: '2025-Q1',
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Returns `IncomeStatementReport` with revenue, cost of sales, operating expenses, and net income.
|
|
82
|
+
|
|
83
|
+
## General Ledger
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
const gl = await reports.generalLedger({
|
|
87
|
+
organizationId: orgId,
|
|
88
|
+
dateOption: 'month',
|
|
89
|
+
dateValue: '2025-01',
|
|
90
|
+
accountId: optionalAccountId, // filter to single account
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Returns `GeneralLedgerReport` with per-account transaction listings and running balances.
|
|
95
|
+
|
|
96
|
+
## Cash Flow
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const cf = await reports.cashFlow({
|
|
100
|
+
organizationId: orgId,
|
|
101
|
+
dateOption: 'year',
|
|
102
|
+
dateValue: 2025,
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Groups posted transactions by `cashFlowCategory` from the country pack's account type definitions. Returns `CashFlowReport` with three sections — Operating, Investing, Financing — and `netCashFlow`.
|
|
107
|
+
|
|
108
|
+
**Scope and limitations:** The cash flow statement is derived from the `cashFlowCategory` assigned to account types in the country pack. It works by summing journal items posted to accounts that have a cash flow category defined. This is an **indirect method** classification — it does not perform direct method cash flow analysis. For the report to be meaningful, the country pack must assign `cashFlowCategory` values to the relevant account types.
|
|
109
|
+
|
|
110
|
+
## Fiscal Period Close
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { closeFiscalPeriod } from '@classytic/ledger';
|
|
114
|
+
|
|
115
|
+
const result = await closeFiscalPeriod(
|
|
116
|
+
{ AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField },
|
|
117
|
+
{ periodId, organizationId, closedBy: 'admin' },
|
|
118
|
+
);
|
|
119
|
+
// result: { periodId, netIncome, closingEntryId, accountsClosed, closedAt }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- Zeroes all income/expense account balances via a `YEAR_END` closing journal entry
|
|
123
|
+
- Transfers net income to retained earnings (default code: `3660`, configurable via `retainedEarningsCode`)
|
|
124
|
+
- Marks period as `closed: true`
|
|
125
|
+
- Atomic by default (internal transaction)
|
|
126
|
+
|
|
127
|
+
## Fiscal Period Reopen
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { reopenFiscalPeriod } from '@classytic/ledger';
|
|
131
|
+
|
|
132
|
+
const result = await reopenFiscalPeriod(
|
|
133
|
+
{ JournalEntryModel, FiscalPeriodModel, orgField },
|
|
134
|
+
{ periodId, organizationId, reopenedBy: 'admin' },
|
|
135
|
+
);
|
|
136
|
+
// result: { periodId, deletedEntryId, reopenedAt }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Validates no later period is already closed (cascade protection)
|
|
140
|
+
- Deletes the closing journal entry
|
|
141
|
+
- Records audit trail (`reopenedAt`, `reopenedBy`)
|
|
142
|
+
|
|
143
|
+
## Using Standalone Functions
|
|
144
|
+
|
|
145
|
+
Reports can also be used without the engine:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { generateTrialBalance } from '@classytic/ledger/reports';
|
|
149
|
+
|
|
150
|
+
const tb = await generateTrialBalance(
|
|
151
|
+
{ AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth },
|
|
152
|
+
{ organizationId, dateOption: 'year', dateValue: 2025, filters: { 'journalItems.departmentId': deptId } },
|
|
153
|
+
);
|
|
154
|
+
```
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Repositories
|
|
2
|
+
|
|
3
|
+
Repository wiring adds domain methods onto mongokit `Repository` instances.
|
|
4
|
+
|
|
5
|
+
## Journal Entry Repository
|
|
6
|
+
|
|
7
|
+
### Recommended: Engine Factory (secure by default)
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { createRepository } from '@classytic/mongokit';
|
|
11
|
+
|
|
12
|
+
const journalRepo = accounting.createJournalEntryRepository(
|
|
13
|
+
createRepository,
|
|
14
|
+
{ JournalEntryModel: JournalEntry, AccountModel: Account, FiscalPeriodModel: FiscalPeriod },
|
|
15
|
+
);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This creates a repository with double-entry validation, account existence + tenant integrity checks, fiscal lock enforcement, idempotency (when enabled), and wired domain methods — all configured securely from the engine's settings.
|
|
19
|
+
|
|
20
|
+
### Manual Wiring (advanced)
|
|
21
|
+
|
|
22
|
+
If you need custom plugin ordering or additional plugins:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { createRepository } from '@classytic/mongokit';
|
|
26
|
+
import { doubleEntryPlugin, fiscalLockPlugin } from '@classytic/ledger';
|
|
27
|
+
|
|
28
|
+
const journalRepo = createRepository(JournalEntry, [
|
|
29
|
+
doubleEntryPlugin({
|
|
30
|
+
JournalEntryModel: JournalEntry,
|
|
31
|
+
AccountModel: Account, // required — fail-closed on posted creates
|
|
32
|
+
orgField: 'business', // validates tenant-account integrity
|
|
33
|
+
}),
|
|
34
|
+
fiscalLockPlugin({ FiscalPeriodModel: FiscalPeriod, JournalEntryModel: JournalEntry, orgField: 'business' }),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
accounting.wireJournalEntryRepository(journalRepo, JournalEntry);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> **Important:** `AccountModel` is required. The double-entry plugin will throw on posted creates if `AccountModel` is not provided.
|
|
41
|
+
|
|
42
|
+
### `repo.post(id, orgId?, options?)`
|
|
43
|
+
|
|
44
|
+
Transitions a draft entry to posted state.
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
const posted = await journalRepo.post(entryId, orgId);
|
|
48
|
+
|
|
49
|
+
// With strictness options
|
|
50
|
+
const posted = await journalRepo.post(entryId, orgId, { actorId: userId });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Validates:**
|
|
54
|
+
- Entry exists and belongs to org (multi-tenant)
|
|
55
|
+
- Entry is in `draft` state
|
|
56
|
+
- At least 2 journal items
|
|
57
|
+
- Every item has a valid account
|
|
58
|
+
- Every item has debit or credit > 0 (not both)
|
|
59
|
+
- Total debits === total credits (exact integer match)
|
|
60
|
+
- `actorId` is present (when `strictness.requireActor` enabled)
|
|
61
|
+
- `approvedBy` AND `approvedAt` are set (when `strictness.requireApproval` enabled)
|
|
62
|
+
|
|
63
|
+
**Options:** `{ session?: ClientSession, actorId?: unknown }`
|
|
64
|
+
|
|
65
|
+
### `repo.unpost(id, orgId?, options?)`
|
|
66
|
+
|
|
67
|
+
Transitions a posted entry back to draft state, allowing re-editing.
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const draft = await journalRepo.unpost(entryId, orgId);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Behavior:**
|
|
74
|
+
- Sets state to `draft`, clears `reversed`/`reversedBy` flags
|
|
75
|
+
- Disabled when `strictness.immutable` is enabled (throws error)
|
|
76
|
+
|
|
77
|
+
**Options:** `{ session?: ClientSession, actorId?: unknown }`
|
|
78
|
+
|
|
79
|
+
### `repo.archive(id, orgId?, options?)`
|
|
80
|
+
|
|
81
|
+
Archives a draft entry (draft → archived). Used to discard unneeded drafts without deleting them, preserving the audit trail.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const archived = await journalRepo.archive(entryId, orgId);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Behavior:**
|
|
88
|
+
- Only `draft` entries can be archived (posted and already-archived entries are rejected)
|
|
89
|
+
- Sets state to `archived` and updates `stateChangedAt`
|
|
90
|
+
- Archived entries do not appear in reports (reports filter `state: 'posted'`)
|
|
91
|
+
|
|
92
|
+
**Options:** `{ session?: ClientSession, actorId?: unknown }`
|
|
93
|
+
|
|
94
|
+
### `repo.reverse(id, orgId?, options?)`
|
|
95
|
+
|
|
96
|
+
Creates a mirror entry with flipped debits/credits. Marks the original as reversed.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const { original, reversal } = await journalRepo.reverse(entryId, orgId);
|
|
100
|
+
|
|
101
|
+
// With actor tracking
|
|
102
|
+
const { original, reversal } = await journalRepo.reverse(entryId, orgId, { actorId: userId });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Behavior:**
|
|
106
|
+
- Creates a new `posted` entry with swapped debit/credit on each line
|
|
107
|
+
- **Preserves all dimension fields** (departmentId, projectId, locationId, etc.) on reversal items
|
|
108
|
+
- Sets `reversed: true` and `reversedBy` on the original
|
|
109
|
+
- Sets `reversalOf` on the reversal entry
|
|
110
|
+
- Stamps `postedBy` on reversal and `reversedByUser` on original (when `actorId` provided)
|
|
111
|
+
- Routes through `repository.create()` so all plugins (fiscal-lock, double-entry) run
|
|
112
|
+
- Atomic by default (internal transaction). Falls back to non-atomic on standalone MongoDB
|
|
113
|
+
|
|
114
|
+
**Options:** `{ session?: ClientSession, reversalDate?: Date, actorId?: unknown }`
|
|
115
|
+
|
|
116
|
+
### `repo.duplicate(id, orgId?, options?)`
|
|
117
|
+
|
|
118
|
+
Creates a copy of an entry as a new draft.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const copy = await journalRepo.duplicate(entryId, orgId);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Behavior:**
|
|
125
|
+
- Copies journal type, label, and all journal items as a new `draft`
|
|
126
|
+
- **Preserves all dimension fields** (departmentId, projectId, locationId, etc.) on duplicated items
|
|
127
|
+
- Does NOT copy `_id`, `id`, `referenceNumber`, `state`, or reversal flags
|
|
128
|
+
- Sets date to today, prefixes label with "Copy of"
|
|
129
|
+
- Routes through `repository.create()` so all plugins run
|
|
130
|
+
|
|
131
|
+
**Options:** `{ session?: ClientSession }`
|
|
132
|
+
|
|
133
|
+
### Posted-Entry Protection
|
|
134
|
+
|
|
135
|
+
The double-entry plugin blocks direct modifications to posted entries through `repository.update()`:
|
|
136
|
+
- Blocks any field change on a posted entry except idempotent `state: 'posted'`
|
|
137
|
+
- Blocks any state transition away from `posted` (e.g. `{ state: 'draft' }`) via the update path
|
|
138
|
+
|
|
139
|
+
To correct a posted entry, use `reverse()` to create a correcting entry. When `strictness.immutable` is **not** enabled, `unpost()` is also available to transition back to draft for re-editing. When `strictness.immutable` is enabled, `unpost()` is disabled and `reverse()` is the only correction path.
|
|
140
|
+
|
|
141
|
+
### Strictness Configuration
|
|
142
|
+
|
|
143
|
+
When the engine is configured with `strictness`, all domain methods enforce additional rules:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
const accounting = createAccountingEngine({
|
|
147
|
+
// ...
|
|
148
|
+
strictness: {
|
|
149
|
+
immutable: true, // unpost() disabled — correction only via reverse()
|
|
150
|
+
requireActor: true, // actorId required on post/reverse/unpost
|
|
151
|
+
requireApproval: true, // approvedBy + approvedAt required before posting
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Rule | Effect |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `immutable` | `unpost()` throws. Only `reverse()` can correct posted entries. |
|
|
159
|
+
| `requireActor` | `post()`, `reverse()`, `unpost()` require `options.actorId`. |
|
|
160
|
+
| `requireApproval` | `post()` requires both `approvedBy` and `approvedAt` to be set on the entry before it can be posted. |
|
|
161
|
+
|
|
162
|
+
## Account Repository
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const accountRepo = createRepository(Account, []);
|
|
166
|
+
accounting.wireAccountRepository(accountRepo, Account);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `repo.seedAccounts(orgId, options?)`
|
|
170
|
+
|
|
171
|
+
Seeds standard posting accounts from the country pack for an organization.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const { created, skipped } = await accountRepo.seedAccounts(orgId);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- Deduplicates by `accountNumber` (not `accountTypeCode`)
|
|
178
|
+
- Only creates posting accounts (not groups or totals)
|
|
179
|
+
- Sets `accountNumber = code` and `name = typeName` from country pack
|
|
180
|
+
|
|
181
|
+
**Options:** `{ session?: ClientSession }`
|
|
182
|
+
|
|
183
|
+
### `repo.bulkCreate(accounts, orgId)`
|
|
184
|
+
|
|
185
|
+
Bulk creates accounts with validation and skip-if-exists logic.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const result = await accountRepo.bulkCreate([
|
|
189
|
+
{ accountTypeCode: '1000', accountNumber: 'CASH-001', name: 'Main Cash' },
|
|
190
|
+
{ accountTypeCode: '1000', accountNumber: 'CASH-002', name: 'Petty Cash' },
|
|
191
|
+
], orgId);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Returns:**
|
|
195
|
+
```typescript
|
|
196
|
+
{
|
|
197
|
+
summary: { total, created, skipped, errors },
|
|
198
|
+
created: [...],
|
|
199
|
+
skipped: [...],
|
|
200
|
+
errors: [...]
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- Validates `accountTypeCode` against country pack (must be a posting account)
|
|
205
|
+
- Single batch query for dedup (no N+1)
|
|
206
|
+
- `ordered: false` on insertMany for concurrent safety
|
|
207
|
+
- `accountNumber` defaults to `accountTypeCode` if omitted
|
|
208
|
+
- `name` defaults to country pack type name if omitted
|
|
209
|
+
|
|
210
|
+
## Multi-Tenant Enforcement
|
|
211
|
+
|
|
212
|
+
When `orgField` is configured, `post()`, `reverse()`, `duplicate()`, and `unpost()` require `orgId`. Calling without it throws:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
organizationId is required when multi-tenant mode is configured
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
This is fail-closed: unscoped queries are blocked, not silently allowed.
|
|
219
|
+
|
|
220
|
+
## Session Management
|
|
221
|
+
|
|
222
|
+
`reverse()` and fiscal operations use shared session helpers:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { acquireSession, finalizeSession } from '@classytic/ledger';
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- **With replica set:** Creates internal transaction, commits on success, aborts on error
|
|
229
|
+
- **Standalone MongoDB:** Detects topology proactively, falls back to non-atomic with a warning
|
|
230
|
+
- **External session:** Pass `{ session }` in options to join a caller-managed transaction
|
|
231
|
+
|
|
232
|
+
## What the ledger does NOT do
|
|
233
|
+
|
|
234
|
+
The ledger provides the core accounting engine — double-entry posting, validation, and reporting. It does **not** implement:
|
|
235
|
+
|
|
236
|
+
- **Subledger business logic** — invoice workflows, inventory costing, payroll calculations. These belong in your app layer or dedicated subledger packages. See [Subledger Integration](subledger-integration.md) for the contract pattern.
|
|
237
|
+
- **Account resolution** — mapping subledger codes to account ObjectIds. The app layer resolves account references before calling `repository.create()`.
|
|
238
|
+
- **Approval workflows** — the ledger enforces that `approvedBy`/`approvedAt` are set (when `requireApproval` is enabled), but the approval UI and routing logic belong to the app.
|
|
239
|
+
- **Tax calculation** — the ledger stores `taxDetails` on journal items as an audit trail, but tax computation (rates, jurisdiction rules) belongs to the country pack or tax engine.
|
package/docs/schemas.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Schemas
|
|
2
|
+
|
|
3
|
+
Schema factories create Mongoose schemas configured for your engine. They handle multi-tenant fields, indexes, and validation automatically.
|
|
4
|
+
|
|
5
|
+
## Account Schema
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const AccountSchema = accounting.createAccountSchema({
|
|
9
|
+
indexes: true, // default
|
|
10
|
+
extraFields: {}, // merge additional fields
|
|
11
|
+
extraIndexes: [], // add custom indexes
|
|
12
|
+
});
|
|
13
|
+
const Account = mongoose.model('Account', AccountSchema);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Fields
|
|
17
|
+
|
|
18
|
+
| Field | Type | Required | Description |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `accountTypeCode` | String | Yes | Classification code from country pack (e.g. GIFI code) |
|
|
21
|
+
| `accountNumber` | String | Yes | Unique business-facing identifier per org |
|
|
22
|
+
| `name` | String | Yes | User-facing display name |
|
|
23
|
+
| `active` | Boolean | No | Default `true` |
|
|
24
|
+
| `isCashAccount` | Boolean | No | Default `false`. Used by cash flow report |
|
|
25
|
+
| `{orgField}` | ObjectId | Multi-tenant only | Organization reference |
|
|
26
|
+
|
|
27
|
+
### Indexes (when enabled)
|
|
28
|
+
|
|
29
|
+
- `(orgField, accountNumber)` — unique per org
|
|
30
|
+
- `(orgField, accountTypeCode)` — non-unique, for classification queries
|
|
31
|
+
|
|
32
|
+
### Validation
|
|
33
|
+
|
|
34
|
+
- `accountTypeCode` is validated against the country pack on save
|
|
35
|
+
- Pre-validate hook auto-populates `accountNumber` (from `accountTypeCode`) and `name` (from country pack type name) if not explicitly set
|
|
36
|
+
|
|
37
|
+
## Journal Entry Schema
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
const JournalEntrySchema = accounting.createJournalEntrySchema('Account', {
|
|
41
|
+
autoReference: true, // auto-generate reference numbers
|
|
42
|
+
textSearch: true, // text index on reference + label
|
|
43
|
+
extraItemFields: { // custom dimension fields on journal items
|
|
44
|
+
departmentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Department' },
|
|
45
|
+
projectId: { type: mongoose.Schema.Types.ObjectId, ref: 'Project' },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const JournalEntry = mongoose.model('JournalEntry', JournalEntrySchema);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Fields
|
|
52
|
+
|
|
53
|
+
| Field | Type | Required | Description |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `journalType` | String | Yes | One of: `GENERAL`, `SALES`, `PURCHASE`, `CASH_RECEIPT`, `CASH_DISBURSEMENT`, `PAYROLL`, `ADJUSTMENT`, `YEAR_END`, `MISC` |
|
|
56
|
+
| `referenceNumber` | String | Auto | Auto-generated (e.g. `GJ-0001`). Unique per org |
|
|
57
|
+
| `label` | String | No | Description/memo |
|
|
58
|
+
| `date` | Date | No | Defaults to `new Date()` |
|
|
59
|
+
| `state` | String | No | `draft` (default), `posted`, or `archived` |
|
|
60
|
+
| `stateChangedAt` | Date | No | Set when state changes |
|
|
61
|
+
| `journalItems` | Array | Yes | Embedded items (see below) |
|
|
62
|
+
| `totalDebit` | Number | No | Synced by double-entry plugin |
|
|
63
|
+
| `totalCredit` | Number | No | Synced by double-entry plugin |
|
|
64
|
+
| `reversed` | Boolean | No | Set by `reverse()` |
|
|
65
|
+
| `reversedBy` | ObjectId | No | Points to reversal entry |
|
|
66
|
+
| `reversalOf` | ObjectId | No | Points to original entry (on the reversal) |
|
|
67
|
+
| `{orgField}` | ObjectId | Multi-tenant only | Organization reference |
|
|
68
|
+
|
|
69
|
+
**Conditional fields** (added when engine config enables them):
|
|
70
|
+
|
|
71
|
+
| Field | Condition | Description |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `createdBy` | `audit.trackActor` | Actor who created the entry |
|
|
74
|
+
| `postedBy` | `audit.trackActor` | Actor who posted the entry |
|
|
75
|
+
| `reversedByUser` | `audit.trackActor` | Actor who reversed the entry |
|
|
76
|
+
| `approvedBy` | `audit.trackActor` or `strictness.requireApproval` | Actor who approved the entry |
|
|
77
|
+
| `approvedAt` | `audit.trackActor` or `strictness.requireApproval` | Timestamp of approval |
|
|
78
|
+
| `idempotencyKey` | `idempotency: true` | Unique key for at-most-once posting (sparse index) |
|
|
79
|
+
|
|
80
|
+
### Journal Item Sub-document
|
|
81
|
+
|
|
82
|
+
| Field | Type | Required | Description |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| `account` | ObjectId ref | Yes | Reference to Account model |
|
|
85
|
+
| `label` | String | No | Line-level description |
|
|
86
|
+
| `date` | Date | No | Line-level date override |
|
|
87
|
+
| `debit` | Number | No | Default 0, non-negative integer (cents) |
|
|
88
|
+
| `credit` | Number | No | Default 0, non-negative integer (cents) |
|
|
89
|
+
| `taxDetails` | Array | No | `[{ taxCode, taxName }]` audit trail |
|
|
90
|
+
|
|
91
|
+
**Custom dimension fields:** Pass `extraItemFields` in schema options to add fields like `departmentId`, `projectId`, `locationId` to the journal item sub-document. These fields are preserved through `duplicate()` and `reverse()` operations and can be filtered on in reports via the `filters` parameter.
|
|
92
|
+
|
|
93
|
+
### Validation Rules
|
|
94
|
+
|
|
95
|
+
- Each line must have debit OR credit > 0, not both (mutual exclusion)
|
|
96
|
+
- Amounts must be non-negative integers (cents). Use `Money.fromDecimal()` to convert from dollars at the API boundary
|
|
97
|
+
- Posted entries must have >= 2 items and sum(debits) === sum(credits)
|
|
98
|
+
|
|
99
|
+
## Fiscal Period Schema
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
const FiscalPeriodSchema = accounting.createFiscalPeriodSchema();
|
|
103
|
+
const FiscalPeriod = mongoose.model('FiscalPeriod', FiscalPeriodSchema);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Fields
|
|
107
|
+
|
|
108
|
+
| Field | Type | Required | Description |
|
|
109
|
+
|---|---|---|---|
|
|
110
|
+
| `name` | String | Yes | e.g. "FY 2025" |
|
|
111
|
+
| `startDate` | Date | Yes | Period start |
|
|
112
|
+
| `endDate` | Date | Yes | Period end |
|
|
113
|
+
| `closed` | Boolean | No | Default `false` |
|
|
114
|
+
| `closedAt` | Date | No | Timestamp of closing |
|
|
115
|
+
| `closedBy` | String | No | User who closed |
|
|
116
|
+
| `closingEntryId` | ObjectId | No | Reference to YEAR_END journal entry |
|
|
117
|
+
| `reopenedAt` | Date | No | Timestamp of last reopen |
|
|
118
|
+
| `reopenedBy` | String | No | User who reopened |
|
|
119
|
+
|
|
120
|
+
### Overlap Protection
|
|
121
|
+
|
|
122
|
+
The schema includes a `pre('validate')` hook that prevents overlapping date ranges within a tenant. If a new period's date range overlaps with an existing period (same org in multi-tenant mode), validation fails with a descriptive error message.
|
|
123
|
+
|
|
124
|
+
This prevents fiscal lock/close from becoming inconsistent — two overlapping periods could otherwise both claim authority over the same date range.
|
|
125
|
+
|
|
126
|
+
### Schema Options
|
|
127
|
+
|
|
128
|
+
All schema factories accept:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
interface SchemaOptions {
|
|
132
|
+
indexes?: boolean; // default true
|
|
133
|
+
extraFields?: Record<string, unknown>;
|
|
134
|
+
extraIndexes?: Array<{ fields: Record<string, 1 | -1>; options?: Record<string, unknown> }>;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Journal entry schema also accepts:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
interface JournalSchemaOptions extends SchemaOptions {
|
|
142
|
+
autoReference?: boolean; // default true
|
|
143
|
+
textSearch?: boolean; // default true
|
|
144
|
+
extraItemFields?: Record<string, unknown>; // custom fields on journal items
|
|
145
|
+
}
|
|
146
|
+
```
|