@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,117 @@
1
+ # Country Packs
2
+
3
+ A country pack provides everything country-specific: chart of accounts template, tax codes, tax report templates, and region definitions.
4
+
5
+ ## Using a Country Pack
6
+
7
+ ```typescript
8
+ import { createAccountingEngine } from '@classytic/ledger';
9
+ import { canadaPack } from '@classytic/ledger-ca';
10
+
11
+ const accounting = createAccountingEngine({
12
+ country: canadaPack,
13
+ currency: 'CAD',
14
+ });
15
+ ```
16
+
17
+ ## Available Packs
18
+
19
+ | Package | Country | Account Types | Tax Codes |
20
+ |---|---|---|---|
21
+ | `@classytic/ledger-ca` | Canada | GIFI (CRA) | GST/HST/PST/QST |
22
+
23
+ ## Creating a Custom Country Pack
24
+
25
+ ```typescript
26
+ import { defineCountryPack } from '@classytic/ledger';
27
+ import type { AccountType, TaxCode } from '@classytic/ledger';
28
+
29
+ const myPack = defineCountryPack({
30
+ code: 'US',
31
+ name: 'United States',
32
+ defaultCurrency: 'USD',
33
+ accountTypes: [
34
+ {
35
+ code: '1000',
36
+ name: 'Cash',
37
+ category: 'Balance Sheet-Assets',
38
+ mainType: 'Assets',
39
+ normalBalance: 'debit',
40
+ isGroup: false,
41
+ isTotal: false,
42
+ },
43
+ // ... more account types
44
+ ],
45
+ taxCodes: {
46
+ STATE_TAX: {
47
+ code: 'STATE_TAX',
48
+ name: 'State Sales Tax',
49
+ taxType: 'sales',
50
+ rate: 0.06,
51
+ direction: 'collected',
52
+ description: 'State sales tax',
53
+ active: true,
54
+ },
55
+ },
56
+ taxCodesByRegion: { CA: ['STATE_TAX'] },
57
+ regions: ['CA', 'NY', 'TX'],
58
+ taxReport: undefined, // optional tax return template
59
+ });
60
+ ```
61
+
62
+ ## CountryPack Interface
63
+
64
+ ```typescript
65
+ interface CountryPack {
66
+ code: string; // ISO 3166-1 alpha-2
67
+ name: string;
68
+ defaultCurrency: string;
69
+ accountTypes: AccountType[];
70
+ taxCodes: Record<string, TaxCode>;
71
+ taxCodesByRegion: Record<string, string[]>;
72
+ regions: string[];
73
+ taxReport?: TaxReportTemplate;
74
+
75
+ // Auto-generated helpers:
76
+ getPostingAccountTypes(): AccountType[];
77
+ getAccountType(code: string): AccountType | undefined;
78
+ isValidAccountType(code: string): boolean;
79
+ isPostingAccount(code: string): boolean;
80
+ getTaxCodesForRegion(region: string): TaxCode[];
81
+ flattenAccountTypes(): AccountType[];
82
+ }
83
+ ```
84
+
85
+ ## AccountType Structure
86
+
87
+ ```typescript
88
+ interface AccountType {
89
+ code: string;
90
+ name: string;
91
+ category: CategoryKey; // e.g. 'Balance Sheet-Assets', 'Income Statement-Income'
92
+ mainType: MainType; // 'Assets', 'Liabilities', 'Equity', etc.
93
+ normalBalance: 'debit' | 'credit';
94
+ isGroup: boolean; // structural grouping header (not postable)
95
+ isTotal: boolean; // calculated total row (not postable)
96
+ cashFlowCategory?: string; // 'Operating', 'Investing', 'Financing'
97
+ taxMetadata?: TaxMetadata; // for virtual tax sub-accounts
98
+ }
99
+ ```
100
+
101
+ Only accounts where `isGroup === false && isTotal === false` are posting accounts.
102
+
103
+ ## TaxCode Structure
104
+
105
+ ```typescript
106
+ interface TaxCode {
107
+ code: string;
108
+ name: string;
109
+ taxType: string; // e.g. 'GST', 'HST', 'PST'
110
+ rate: number; // e.g. 0.05 for 5%
111
+ direction: 'collected' | 'recoverable' | 'paid';
112
+ province?: string;
113
+ reportLines?: number[]; // lines on the tax return
114
+ description: string;
115
+ active: boolean;
116
+ }
117
+ ```
package/docs/engine.md ADDED
@@ -0,0 +1,147 @@
1
+ # Engine & Configuration
2
+
3
+ The `AccountingEngine` is the main entry point. It holds your configuration and provides factory methods for schemas, repositories, and reports.
4
+
5
+ ## Creating an Engine
6
+
7
+ ```typescript
8
+ import { createAccountingEngine } from '@classytic/ledger';
9
+ import { canadaPack } from '@classytic/ledger-ca';
10
+
11
+ const accounting = createAccountingEngine({
12
+ country: canadaPack,
13
+ currency: 'CAD',
14
+ multiTenant: { orgField: 'business', orgRef: 'Business' },
15
+ fiscalYearStartMonth: 4, // April (default: 1 = January)
16
+ logger: winstonLogger, // optional, defaults to console
17
+ });
18
+ ```
19
+
20
+ ## Configuration Options
21
+
22
+ | Option | Type | Required | Description |
23
+ |---|---|---|---|
24
+ | `country` | `CountryPack` | Yes | Country pack (account types, tax codes) |
25
+ | `currency` | `string` | Yes | ISO 4217 currency code |
26
+ | `multiTenant` | `{ orgField, orgRef }` | No | Multi-tenant configuration |
27
+ | `fiscalYearStartMonth` | `number` | No | 1-12, default 1 (January) |
28
+ | `logger` | `Logger` | No | `{ warn, error, info }` interface; defaults to console |
29
+ | `audit` | `AuditConfig` | No | Actor tracking on journal entries (see below) |
30
+ | `idempotency` | `boolean` | No | Enable `idempotencyKey` field on journal entries |
31
+ | `strictness` | `StrictnessConfig` | No | Immutability, actor, and approval requirements |
32
+
33
+ ### Multi-Tenant Config
34
+
35
+ When `multiTenant` is set, all schemas add an org reference field and compound indexes are scoped per-org. All repository methods and report generators enforce org isolation.
36
+
37
+ ```typescript
38
+ multiTenant: {
39
+ orgField: 'business', // field name on documents
40
+ orgRef: 'Business', // Mongoose model name for ObjectId ref
41
+ }
42
+ ```
43
+
44
+ Omit `multiTenant` for single-tenant applications.
45
+
46
+ ### Audit Config
47
+
48
+ ```typescript
49
+ audit: {
50
+ trackActor: true, // adds createdBy, postedBy, reversedByUser fields to journal entries
51
+ }
52
+ ```
53
+
54
+ When enabled, the journal entry schema gains actor-tracking fields. These are populated by `post()` and `reverse()` when `actorId` is passed in options.
55
+
56
+ ### Idempotency
57
+
58
+ ```typescript
59
+ idempotency: true
60
+ ```
61
+
62
+ Adds an `idempotencyKey` field (unique sparse index) to journal entries. When used with `idempotencyPlugin`, prevents duplicate postings on retry. See [Plugins](plugins.md#idempotency-plugin).
63
+
64
+ ### Strictness Config
65
+
66
+ ```typescript
67
+ strictness: {
68
+ immutable: true, // unpost() disabled — correction only via reverse()
69
+ requireActor: true, // actorId required on post/reverse/unpost
70
+ requireApproval: true, // approvedBy + approvedAt required before posting
71
+ }
72
+ ```
73
+
74
+ All strictness options are opt-in and default to `false`. See [Repositories](repositories.md#strictness-configuration) for behavioral details.
75
+
76
+ ## Engine Methods
77
+
78
+ ### Schema Factories
79
+
80
+ ```typescript
81
+ accounting.createAccountSchema(options?) // → Mongoose Schema
82
+ accounting.createJournalEntrySchema('Account', options?) // → Mongoose Schema
83
+ accounting.createFiscalPeriodSchema(options?) // → Mongoose Schema
84
+ ```
85
+
86
+ See [Schemas](schemas.md) for details.
87
+
88
+ ### Repository Factory (recommended)
89
+
90
+ ```typescript
91
+ import { createRepository } from '@classytic/mongokit';
92
+
93
+ const journalRepo = accounting.createJournalEntryRepository(
94
+ createRepository,
95
+ { JournalEntryModel: JournalEntry, AccountModel: Account, FiscalPeriodModel: FiscalPeriod },
96
+ );
97
+ // Includes: double-entry + fiscal lock + idempotency plugins, post(), reverse(), duplicate(), unpost()
98
+ ```
99
+
100
+ ### Manual Repository Wiring (advanced)
101
+
102
+ ```typescript
103
+ accounting.wireJournalEntryRepository(repo, JournalEntryModel)
104
+ // Adds: repo.post(id, orgId), repo.reverse(id, orgId), repo.duplicate(id, orgId), repo.unpost(id, orgId)
105
+
106
+ accounting.wireAccountRepository(repo, AccountModel)
107
+ // Adds: repo.seedAccounts(orgId), repo.bulkCreate(accounts, orgId)
108
+ ```
109
+
110
+ See [Repositories](repositories.md) for details.
111
+
112
+ ### Report Engine
113
+
114
+ ```typescript
115
+ const reports = accounting.createReports({ Account, JournalEntry });
116
+
117
+ await reports.trialBalance({ organizationId, dateOption, dateValue, filters? });
118
+ await reports.balanceSheet({ organizationId, dateOption, dateValue, filters? });
119
+ await reports.incomeStatement({ organizationId, dateOption, dateValue, filters? });
120
+ await reports.generalLedger({ organizationId, dateOption, dateValue, accountId?, filters? });
121
+ await reports.cashFlow({ organizationId, dateOption, dateValue, filters? });
122
+ ```
123
+
124
+ See [Reports](reports.md) for details.
125
+
126
+ ### Account Type Helpers
127
+
128
+ ```typescript
129
+ accounting.getPostingAccountTypes() // → AccountType[]
130
+ accounting.isValidAccountType('1000') // → boolean
131
+ accounting.getAccountType('1000') // → AccountType | undefined
132
+ accounting.getTaxCodesForRegion('ON') // → TaxCode[]
133
+ ```
134
+
135
+ ## Logger Interface
136
+
137
+ The engine accepts a logger that implements:
138
+
139
+ ```typescript
140
+ interface Logger {
141
+ warn(message: string, meta?: Record<string, unknown>): void;
142
+ error(message: string, meta?: Record<string, unknown>): void;
143
+ info(message: string, meta?: Record<string, unknown>): void;
144
+ }
145
+ ```
146
+
147
+ Used for transaction fallback warnings (standalone MongoDB) and operational messages. Defaults to `console.warn/error/info` with `[accounting]` prefix.
@@ -0,0 +1,81 @@
1
+ # Exports
2
+
3
+ Pure data transformation pipeline for exporting journal entries to CSV. No database dependencies, no side effects.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import { exportToCsv, flattenJournalEntries, quickbooksFieldMap, universalFieldMap } from '@classytic/ledger/exports';
9
+ ```
10
+
11
+ ## Pipeline
12
+
13
+ ```
14
+ PopulatedJournalEntry[] → flattenJournalEntries() → FlatJournalRow[] → exportToCsv() → CSV string
15
+ ```
16
+
17
+ ### Step 1: Flatten
18
+
19
+ ```typescript
20
+ const rows = flattenJournalEntries(populatedEntries);
21
+ ```
22
+
23
+ Converts nested journal entries (with embedded items) into flat rows — one row per journal item. Populated account references are resolved to `accountNumber` and `accountName`.
24
+
25
+ ### Step 2: Export to CSV
26
+
27
+ ```typescript
28
+ const csv = exportToCsv(quickbooksFieldMap, rows);
29
+ ```
30
+
31
+ Maps flat rows through a field map and serializes to CSV string.
32
+
33
+ ## Field Maps
34
+
35
+ ### QuickBooks (`quickbooksFieldMap`)
36
+
37
+ Produces a CSV compatible with QuickBooks Desktop "General Journal" import.
38
+
39
+ ### Universal (`universalFieldMap`)
40
+
41
+ Exports all available fields for maximum data preservation.
42
+
43
+ ### Custom Field Map
44
+
45
+ ```typescript
46
+ import type { ExportFieldMap, FlatJournalRow } from '@classytic/ledger/exports';
47
+
48
+ const myFieldMap: ExportFieldMap<FlatJournalRow> = {
49
+ Date: { header: 'Date', value: row => row.date?.toISOString().split('T')[0] ?? '' },
50
+ Account: { header: 'Account', value: row => row.accountNumber ?? '' },
51
+ Debit: { header: 'Debit', value: row => row.debit ? Money.formatPlain(row.debit) : '0' },
52
+ Credit: { header: 'Credit', value: row => row.credit ? Money.formatPlain(row.credit) : '0' },
53
+ };
54
+
55
+ const csv = exportToCsv(myFieldMap, rows);
56
+ ```
57
+
58
+ ## Types
59
+
60
+ ```typescript
61
+ interface FlatJournalRow {
62
+ referenceNumber?: string;
63
+ journalType?: string;
64
+ date?: Date;
65
+ label?: string;
66
+ state?: string;
67
+ accountNumber?: string;
68
+ accountName?: string;
69
+ accountTypeCode?: string;
70
+ itemLabel?: string;
71
+ debit?: number;
72
+ credit?: number;
73
+ taxCode?: string;
74
+ taxName?: string;
75
+ }
76
+ ```
77
+
78
+ ## Notes
79
+
80
+ - DB stores debit/credit as integer cents (e.g. `10050` = $100.50). Built-in field maps convert cents to dollars at the CSV boundary via `Money.formatPlain()`.
81
+ - CSV cells are escaped per RFC 4180 (quotes, commas, newlines).
package/docs/money.md ADDED
@@ -0,0 +1,81 @@
1
+ # Money
2
+
3
+ Integer-cents arithmetic helpers for safe financial computation. Avoids floating-point rounding errors by operating on integer minor-unit values.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import { Money } from '@classytic/ledger';
9
+ // or
10
+ import { fromDecimal, toDecimal, percentage } from '@classytic/ledger/money';
11
+ ```
12
+
13
+ ## DB Storage Contract
14
+
15
+ All monetary fields (`debit`, `credit`, `totalDebit`, `totalCredit`, report balances/totals) are stored as **integer minor units (cents)**. For example, `10050` represents $100.50.
16
+
17
+ Use `fromDecimal()` at the HTTP/API boundary to convert user-facing dollar inputs to cents. Use `toDecimal()` or `formatPlain()` to convert back for display or CSV export.
18
+
19
+ ```typescript
20
+ // Input boundary (HTTP request):
21
+ const cents = Money.fromDecimal(req.body.debit); // 100.50 → 10050
22
+
23
+ // Arithmetic (already in cents):
24
+ const taxCents = Money.percentage(cents, 5); // 5% of 10050 → 502
25
+
26
+ // Output boundary (display/CSV):
27
+ const display = Money.formatPlain(taxCents); // 502 → "5.02"
28
+ ```
29
+
30
+ ## API
31
+
32
+ ### Conversion
33
+
34
+ | Function | Signature | Description |
35
+ |---|---|---|
36
+ | `fromDecimal` | `(dollars, minorUnit?) → cents` | `10.50 → 1050` |
37
+ | `toDecimal` | `(cents, minorUnit?) → dollars` | `1050 → 10.50` |
38
+ | `parseCents` | `(input) → cents` | Parse string/number to cents |
39
+ | `round` | `(amount) → integer` | Round to nearest integer |
40
+
41
+ ### Arithmetic (all operate on cents)
42
+
43
+ | Function | Signature | Description |
44
+ |---|---|---|
45
+ | `add` | `(a, b) → cents` | Add two amounts |
46
+ | `subtract` | `(a, b) → cents` | Subtract b from a |
47
+ | `multiply` | `(cents, factor) → cents` | Multiply by factor, rounded |
48
+ | `percentage` | `(cents, rate) → cents` | `percentage(10000, 5) → 500` |
49
+
50
+ ### Tax Helpers
51
+
52
+ | Function | Signature | Description |
53
+ |---|---|---|
54
+ | `splitTaxInclusive` | `(inclusive, rate) → { base, tax }` | Extract tax from inclusive amount |
55
+ | `splitTaxExclusive` | `(exclusive, rate) → { base, tax, total }` | Calculate tax on exclusive amount |
56
+
57
+ ### Allocation
58
+
59
+ | Function | Signature | Description |
60
+ |---|---|---|
61
+ | `allocate` | `(cents, weights) → cents[]` | Split amount by weights, remainder to largest |
62
+
63
+ ### Formatting
64
+
65
+ | Function | Signature | Description |
66
+ |---|---|---|
67
+ | `format` | `(cents, currency?) → string` | `1050 → "$10.50"` |
68
+ | `formatPlain` | `(cents, minorUnit?) → string` | `1050 → "10.50"` |
69
+
70
+ ## Money Class
71
+
72
+ All functions are also available as static methods on the `Money` class:
73
+
74
+ ```typescript
75
+ Money.fromDecimal(10.50) // 1050
76
+ Money.toDecimal(1050) // 10.50
77
+ Money.percentage(1050, 5) // 52
78
+ Money.round(10.7) // 11 ← rounds to nearest INTEGER (cents)
79
+ ```
80
+
81
+ > **Warning:** `Money.round()` rounds to the nearest **integer** (cents-based). If you need dollar-level rounding to 2 decimal places, use your own helper.
@@ -0,0 +1,136 @@
1
+ # Plugins
2
+
3
+ Plugins hook into mongokit's `before:create` and `before:update` events to enforce accounting rules.
4
+
5
+ ## Double-Entry Plugin
6
+
7
+ Validates that every posted journal entry satisfies `sum(debits) === sum(credits)`.
8
+
9
+ ```typescript
10
+ import { doubleEntryPlugin } from '@classytic/ledger';
11
+
12
+ const plugin = doubleEntryPlugin({
13
+ JournalEntryModel: JournalEntry, // required for immutability guard + partial updates
14
+ AccountModel: Account, // validates account existence on posted creates AND updates
15
+ orgField: 'business', // validates tenant-account integrity
16
+ });
17
+ ```
18
+
19
+ ### What it validates
20
+
21
+ On `before:create` and `before:update` (when state is `posted`):
22
+
23
+ 1. Each line has debit OR credit > 0 (not both, not zero)
24
+ 2. At least 2 journal items
25
+ 3. `sum(debits) === sum(credits)` (exact integer match)
26
+ 4. Syncs `totalDebit` and `totalCredit` onto the data
27
+ 5. All journal item accounts exist (when `AccountModel` provided)
28
+ 6. All referenced accounts belong to the same org as the entry (when `orgField` set)
29
+
30
+ ### Account Validation
31
+
32
+ Account validation runs on **both** `before:create` and `before:update` when `AccountModel` is provided. This closes the `repository.update(id, { state: 'posted' })` bypass — a draft with invalid accounts cannot be posted through the generic update path.
33
+
34
+ On `before:create` for posted entries, `AccountModel` is **required** and the plugin throws if it's missing (fail-closed). On `before:update`, account validation runs when `AccountModel` is available but does not throw if it's absent (allows unit tests that only check balancing).
35
+
36
+ ### Posted-Entry Protection
37
+
38
+ When `JournalEntryModel` is provided, the plugin protects posted entries from direct modification via updates:
39
+
40
+ - Blocks any state transition away from `posted` (e.g. `{ state: 'draft' }`)
41
+ - Blocks any field change on posted entries except idempotent `state: 'posted'`
42
+ - `reversed`/`reversedBy` are NOT allowed through `repository.update()` — `reverse()` uses `entry.save()` directly
43
+ - Partial updates that set `state: 'posted'` without `journalItems` fetch the persisted doc for validation
44
+
45
+ ### Options
46
+
47
+ | Option | Type | Required | Description |
48
+ |---|---|---|---|
49
+ | `JournalEntryModel` | Model | No | Required for immutability guard and partial update validation |
50
+ | `AccountModel` | Model | Yes* | Validates account existence and tenant integrity. *Required on `before:create` (throws if missing). On `before:update`, runs when available. |
51
+ | `orgField` | string | No | Multi-tenant org field name (enables tenant-account integrity check) |
52
+
53
+ ## Fiscal Lock Plugin
54
+
55
+ Prevents journal entries from being created or posted in a closed fiscal period.
56
+
57
+ ```typescript
58
+ import { fiscalLockPlugin } from '@classytic/ledger';
59
+
60
+ const plugin = fiscalLockPlugin({
61
+ FiscalPeriodModel: FiscalPeriod,
62
+ JournalEntryModel: JournalEntry, // needed for partial update date resolution
63
+ orgField: 'business', // optional, for multi-tenant
64
+ });
65
+ ```
66
+
67
+ ### What it validates
68
+
69
+ On `before:create` and `before:update`:
70
+
71
+ 1. Gets the entry date from the payload, or falls back to the persisted document
72
+ 2. Checks if any closed fiscal period covers that date (org-scoped)
73
+ 3. Throws if the entry date falls in a closed period
74
+
75
+ ### Options
76
+
77
+ | Option | Type | Required | Description |
78
+ |---|---|---|---|
79
+ | `FiscalPeriodModel` | Model | Yes | Mongoose model for fiscal periods |
80
+ | `JournalEntryModel` | Model | No | Needed to resolve date from persisted doc on partial updates |
81
+ | `orgField` | string | No | Multi-tenant org field name |
82
+
83
+ ## Idempotency Plugin
84
+
85
+ Prevents duplicate journal entries by checking for existing entries with the same `idempotencyKey`.
86
+
87
+ ```typescript
88
+ import { idempotencyPlugin } from '@classytic/ledger';
89
+
90
+ const plugin = idempotencyPlugin({
91
+ JournalEntryModel: JournalEntry,
92
+ });
93
+ ```
94
+
95
+ ### Behavior
96
+
97
+ On `before:create`: if the entry has an `idempotencyKey` and a document with the same key already exists, the plugin throws a 409 Conflict error.
98
+
99
+ Enable the `idempotencyKey` schema field by setting `idempotency: true` in the engine config. The field has a unique sparse index — entries without a key are not affected.
100
+
101
+ ### When to use
102
+
103
+ Idempotency keys are essential for subledger integrations where a retry (network failure, queue redelivery) could otherwise create duplicate postings. The subledger generates a deterministic key (e.g. `billing:invoice:INV-001`) and the ledger guarantees at-most-once posting.
104
+
105
+ ### Options
106
+
107
+ | Option | Type | Required | Description |
108
+ |---|---|---|---|
109
+ | `JournalEntryModel` | Model | Yes | Mongoose model for journal entries |
110
+
111
+ ## Plugin Composition
112
+
113
+ **Recommended:** Use `accounting.createJournalEntryRepository()` which handles all plugin wiring:
114
+
115
+ ```typescript
116
+ const repo = accounting.createJournalEntryRepository(
117
+ createRepository,
118
+ { JournalEntryModel: JournalEntry, AccountModel: Account, FiscalPeriodModel: FiscalPeriod },
119
+ );
120
+ ```
121
+
122
+ For manual composition, plugins are passed as an array to `createRepository()`:
123
+
124
+ ```typescript
125
+ const repo = createRepository(JournalEntry, [
126
+ doubleEntryPlugin({
127
+ JournalEntryModel: JournalEntry,
128
+ AccountModel: Account,
129
+ orgField: 'business',
130
+ }),
131
+ fiscalLockPlugin({ FiscalPeriodModel: FiscalPeriod, JournalEntryModel: JournalEntry, orgField: 'business' }),
132
+ idempotencyPlugin({ JournalEntryModel: JournalEntry }),
133
+ ]);
134
+ ```
135
+
136
+ Plugins fire in registration order on the same hooks. Double-entry runs first (validates balance + accounts), then fiscal-lock (checks period), then idempotency (checks key uniqueness).