@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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/account.repository-1C2sZvB2.d.mts +29 -0
  4. package/dist/account.repository-1C2sZvB2.d.mts.map +1 -0
  5. package/dist/account.repository-Crf5DGO4.mjs +393 -0
  6. package/dist/account.repository-Crf5DGO4.mjs.map +1 -0
  7. package/dist/categories-BNJBd4ze.mjs +70 -0
  8. package/dist/categories-BNJBd4ze.mjs.map +1 -0
  9. package/dist/constants/index.d.mts +2 -0
  10. package/dist/constants/index.mjs +5 -0
  11. package/dist/core-Cx0baosR.d.mts +104 -0
  12. package/dist/core-Cx0baosR.d.mts.map +1 -0
  13. package/dist/country/index.d.mts +105 -0
  14. package/dist/country/index.d.mts.map +1 -0
  15. package/dist/country/index.mjs +27 -0
  16. package/dist/country/index.mjs.map +1 -0
  17. package/dist/currencies-BBk3NwXn.mjs +82 -0
  18. package/dist/currencies-BBk3NwXn.mjs.map +1 -0
  19. package/dist/currencies-Bkn3FNkC.d.mts +38 -0
  20. package/dist/currencies-Bkn3FNkC.d.mts.map +1 -0
  21. package/dist/engine-Cd73EOT6.d.mts +72 -0
  22. package/dist/engine-Cd73EOT6.d.mts.map +1 -0
  23. package/dist/errors-CeqRahE-.mjs +28 -0
  24. package/dist/errors-CeqRahE-.mjs.map +1 -0
  25. package/dist/exports/index.d.mts +2 -0
  26. package/dist/exports/index.mjs +3 -0
  27. package/dist/fiscal-close-CNOwv_ud.mjs +934 -0
  28. package/dist/fiscal-close-CNOwv_ud.mjs.map +1 -0
  29. package/dist/fiscal-close-CzUzpnMg.d.mts +270 -0
  30. package/dist/fiscal-close-CzUzpnMg.d.mts.map +1 -0
  31. package/dist/fiscal-period.schema-CbALaaKl.mjs +477 -0
  32. package/dist/fiscal-period.schema-CbALaaKl.mjs.map +1 -0
  33. package/dist/fiscal-period.schema-DI2scngu.d.mts +38 -0
  34. package/dist/fiscal-period.schema-DI2scngu.d.mts.map +1 -0
  35. package/dist/idempotency.plugin-BESs9YPD.d.mts +58 -0
  36. package/dist/idempotency.plugin-BESs9YPD.d.mts.map +1 -0
  37. package/dist/idempotency.plugin-C6r8RI8d.mjs +165 -0
  38. package/dist/idempotency.plugin-C6r8RI8d.mjs.map +1 -0
  39. package/dist/index.d.mts +308 -0
  40. package/dist/index.d.mts.map +1 -0
  41. package/dist/index.mjs +171 -0
  42. package/dist/index.mjs.map +1 -0
  43. package/dist/journals-CI3Wb4EF.mjs +92 -0
  44. package/dist/journals-CI3Wb4EF.mjs.map +1 -0
  45. package/dist/logger-Cv6VVc4r.d.mts +15 -0
  46. package/dist/logger-Cv6VVc4r.d.mts.map +1 -0
  47. package/dist/money.d.mts +129 -0
  48. package/dist/money.d.mts.map +1 -0
  49. package/dist/money.mjs +197 -0
  50. package/dist/money.mjs.map +1 -0
  51. package/dist/plugins/index.d.mts +2 -0
  52. package/dist/plugins/index.mjs +3 -0
  53. package/dist/reports/index.d.mts +2 -0
  54. package/dist/reports/index.mjs +3 -0
  55. package/dist/repositories/index.d.mts +2 -0
  56. package/dist/repositories/index.mjs +3 -0
  57. package/dist/schemas/index.d.mts +2 -0
  58. package/dist/schemas/index.mjs +3 -0
  59. package/dist/session-Dh0s6zG4.mjs +87 -0
  60. package/dist/session-Dh0s6zG4.mjs.map +1 -0
  61. package/dist/universal-CMfrZ2hG.mjs +257 -0
  62. package/dist/universal-CMfrZ2hG.mjs.map +1 -0
  63. package/dist/universal-x33ZJODp.d.mts +137 -0
  64. package/dist/universal-x33ZJODp.d.mts.map +1 -0
  65. package/docs/country-packs.md +117 -0
  66. package/docs/engine.md +147 -0
  67. package/docs/exports.md +81 -0
  68. package/docs/money.md +81 -0
  69. package/docs/plugins.md +136 -0
  70. package/docs/reports.md +154 -0
  71. package/docs/repositories.md +239 -0
  72. package/docs/schemas.md +146 -0
  73. package/docs/subledger-integration.md +287 -0
  74. package/package.json +116 -0
@@ -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.
@@ -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
+ ```