@classytic/ledger 0.1.5 → 0.3.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 +161 -64
- package/dist/{account.repository-Crf5DGO4.mjs → account.repository-BpkSd6q3.mjs} +190 -41
- package/dist/{categories-BNJBd4ze.mjs → categories-CclX7Q94.mjs} +0 -2
- package/dist/constants/index.d.mts +1 -1
- package/dist/constants/index.mjs +4 -5
- package/dist/{core-Cx0baosR.d.mts → core-8Xfnpn6g.d.mts} +1 -2
- package/dist/country/index.d.mts +2 -105
- package/dist/country/index.mjs +0 -2
- package/dist/{currencies-Bkn3FNkC.d.mts → currencies-4WAbFRlw.d.mts} +2 -3
- package/dist/{currencies-BBk3NwXn.mjs → currencies-W8kQAkm0.mjs} +0 -2
- package/dist/{idempotency.plugin-C6r8RI8d.mjs → date-lock.plugin-eYAJ9h_u.mjs} +50 -13
- package/dist/{engine-Cd73EOT6.d.mts → engine-Cn-9yerQ.d.mts} +38 -8
- package/dist/{errors-CeqRahE-.mjs → errors-B7yC-Jfw.mjs} +0 -2
- package/dist/exports/index.d.mts +2 -2
- package/dist/exports/index.mjs +2 -3
- package/dist/{universal-CMfrZ2hG.mjs → exports-I5Xkq-9_.mjs} +0 -7
- package/dist/{fiscal-close-DuXDgVvb.mjs → fiscal-close-B6LhQ10f.mjs} +742 -32
- package/dist/fiscal-period.schema-BMnlI9H5.d.mts +103 -0
- package/dist/{idempotency.plugin-BESs9YPD.d.mts → idempotency.plugin-B_CNsInz.d.mts} +19 -17
- package/dist/{universal-x33ZJODp.d.mts → index-BPukb3L8.d.mts} +1 -2
- package/dist/index-CxZqRaOU.d.mts +119 -0
- package/dist/index.d.mts +251 -29
- package/dist/index.mjs +124 -27
- package/dist/{journals-CI3Wb4EF.mjs → journals-oH-FK3g8.mjs} +0 -2
- package/dist/{logger-Cv6VVc4r.d.mts → logger-CbHWZl7v.d.mts} +1 -2
- package/dist/money.d.mts +1 -2
- package/dist/money.mjs +3 -3
- package/dist/plugins/index.d.mts +38 -2
- package/dist/plugins/index.mjs +57 -3
- package/dist/reconciliation.repository-CW4-8q90.d.mts +135 -0
- package/dist/{fiscal-period.schema-CbALaaKl.mjs → reconciliation.schema-BuetvZTd.mjs} +218 -30
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -3
- package/dist/repositories/index.d.mts +2 -2
- package/dist/repositories/index.mjs +2 -3
- package/dist/revaluation-D9x0NE8w.d.mts +530 -0
- package/dist/schemas/index.d.mts +71 -2
- package/dist/schemas/index.mjs +2 -3
- package/dist/tenant-guard-Fm6AID_6.mjs +13 -0
- package/docs/reports.md +1 -1
- package/package.json +3 -3
- package/dist/account.repository-1C2sZvB2.d.mts +0 -29
- package/dist/account.repository-1C2sZvB2.d.mts.map +0 -1
- package/dist/account.repository-Crf5DGO4.mjs.map +0 -1
- package/dist/categories-BNJBd4ze.mjs.map +0 -1
- package/dist/core-Cx0baosR.d.mts.map +0 -1
- package/dist/country/index.d.mts.map +0 -1
- package/dist/country/index.mjs.map +0 -1
- package/dist/currencies-BBk3NwXn.mjs.map +0 -1
- package/dist/currencies-Bkn3FNkC.d.mts.map +0 -1
- package/dist/engine-Cd73EOT6.d.mts.map +0 -1
- package/dist/errors-CeqRahE-.mjs.map +0 -1
- package/dist/fiscal-close-CzUzpnMg.d.mts +0 -270
- package/dist/fiscal-close-CzUzpnMg.d.mts.map +0 -1
- package/dist/fiscal-close-DuXDgVvb.mjs.map +0 -1
- package/dist/fiscal-period.schema-CbALaaKl.mjs.map +0 -1
- package/dist/fiscal-period.schema-DI2scngu.d.mts +0 -38
- package/dist/fiscal-period.schema-DI2scngu.d.mts.map +0 -1
- package/dist/idempotency.plugin-BESs9YPD.d.mts.map +0 -1
- package/dist/idempotency.plugin-C6r8RI8d.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/journals-CI3Wb4EF.mjs.map +0 -1
- package/dist/logger-Cv6VVc4r.d.mts.map +0 -1
- package/dist/money.d.mts.map +0 -1
- package/dist/money.mjs.map +0 -1
- package/dist/session-Dh0s6zG4.mjs +0 -87
- package/dist/session-Dh0s6zG4.mjs.map +0 -1
- package/dist/universal-CMfrZ2hG.mjs.map +0 -1
- package/dist/universal-x33ZJODp.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
# @classytic/ledger
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- **Double-entry bookkeeping** with balance validation and posted-entry protection (optionally immutable via strictness config)
|
|
8
|
-
- **Multi-tenant** isolation via configurable org field
|
|
9
|
-
- **Country packs** for localized chart of accounts and tax codes
|
|
10
|
-
- **Financial reports** — trial balance, balance sheet, income statement, general ledger, cash flow
|
|
11
|
-
- **Fiscal period management** — close and reopen with automatic year-end entries, overlap protection
|
|
12
|
-
- **CSV export** — QuickBooks-compatible and universal field maps
|
|
13
|
-
- **Cents-based Money** arithmetic for precision
|
|
14
|
-
- **Plugin system** — fiscal lock, double-entry validation, idempotency (via mongokit hooks)
|
|
15
|
-
- **Dimension fields** — custom fields on journal items (departmentId, projectId, etc.) preserved through all workflows
|
|
16
|
-
- **Dimension filters** — filter all reports by custom journal item fields
|
|
17
|
-
- **Strictness controls** — configurable immutability, actor tracking, and approval requirements
|
|
18
|
-
- **Subledger contracts** — typed interfaces for integrating billing, inventory, payroll, and other subledgers
|
|
3
|
+
Embeddable double-entry accounting engine for MongoDB. Integer-cents arithmetic, plugin-based, country-agnostic.
|
|
4
|
+
|
|
5
|
+
Build QuickBooks, Xero, or TaxCycle-grade apps — the engine handles the accounting, you handle the UX.
|
|
19
6
|
|
|
20
7
|
## Install
|
|
21
8
|
|
|
22
9
|
```bash
|
|
23
10
|
npm install @classytic/ledger @classytic/mongokit mongoose
|
|
11
|
+
npm install @classytic/ledger-ca # Canada (GIFI, GST/HST, CRA)
|
|
12
|
+
npm install @classytic/ledger-bd # Bangladesh (BFRS, VAT/TDS, Mushak)
|
|
24
13
|
```
|
|
25
14
|
|
|
26
15
|
## Quick Start
|
|
@@ -28,80 +17,188 @@ npm install @classytic/ledger @classytic/mongokit mongoose
|
|
|
28
17
|
```typescript
|
|
29
18
|
import { createAccountingEngine } from '@classytic/ledger';
|
|
30
19
|
import { canadaPack } from '@classytic/ledger-ca';
|
|
31
|
-
import mongoose from 'mongoose';
|
|
32
20
|
|
|
33
|
-
// 1. Create engine
|
|
34
21
|
const accounting = createAccountingEngine({
|
|
35
22
|
country: canadaPack,
|
|
36
23
|
currency: 'CAD',
|
|
37
|
-
multiTenant: { orgField: '
|
|
38
|
-
audit: { trackActor: true },
|
|
39
|
-
idempotency: true,
|
|
40
|
-
strictness: { immutable: true, requireActor: true },
|
|
24
|
+
multiTenant: { orgField: 'organization', orgRef: 'Organization' },
|
|
41
25
|
});
|
|
42
26
|
|
|
43
|
-
//
|
|
27
|
+
// Schemas
|
|
44
28
|
const Account = mongoose.model('Account', accounting.createAccountSchema());
|
|
45
|
-
const JournalEntry = mongoose.model('JournalEntry', accounting.createJournalEntrySchema('Account'
|
|
46
|
-
extraItemFields: {
|
|
47
|
-
departmentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Department' },
|
|
48
|
-
},
|
|
49
|
-
}));
|
|
29
|
+
const JournalEntry = mongoose.model('JournalEntry', accounting.createJournalEntrySchema('Account'));
|
|
50
30
|
const FiscalPeriod = mongoose.model('FiscalPeriod', accounting.createFiscalPeriodSchema());
|
|
51
31
|
|
|
52
|
-
//
|
|
53
|
-
|
|
32
|
+
// Reports
|
|
33
|
+
const reports = accounting.createReports({ Account, JournalEntry });
|
|
34
|
+
const bs = await reports.balanceSheet({ organizationId, dateOption: 'year', dateValue: 2025 });
|
|
35
|
+
```
|
|
54
36
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
37
|
+
## Core Features
|
|
38
|
+
|
|
39
|
+
**Accounting Engine**
|
|
40
|
+
- Double-entry validation with balance enforcement
|
|
41
|
+
- Integer-cents storage — zero floating-point drift
|
|
42
|
+
- Draft → Posted → Reversed state machine
|
|
43
|
+
- Configurable immutability (corrections only via reversal)
|
|
44
|
+
- Multi-tenant isolation at every layer
|
|
45
|
+
- Country packs for localized charts of accounts and tax codes
|
|
46
|
+
|
|
47
|
+
**10 Reports**
|
|
48
|
+
- Trial Balance (3-column: initial + period + ending)
|
|
49
|
+
- Balance Sheet (with computed retained earnings)
|
|
50
|
+
- Income Statement (revenue, COGS, gross profit, operating expenses, net income)
|
|
51
|
+
- General Ledger (per-account with running balances)
|
|
52
|
+
- Cash Flow (Operating / Investing / Financing)
|
|
53
|
+
- Aged Receivable / Payable (configurable buckets: current, 30, 60, 90+)
|
|
54
|
+
- Budget vs Actual (variance analysis)
|
|
55
|
+
- Dimension Breakdown (by department, project, cost center)
|
|
56
|
+
- Foreign Exchange Revaluation (unrealized gain/loss computation)
|
|
57
|
+
- Fiscal Year Close / Reopen (automatic closing entries)
|
|
58
|
+
|
|
59
|
+
**Plugins**
|
|
60
|
+
- `doubleEntryPlugin` — validates debits = credits, account existence, tenant integrity
|
|
61
|
+
- `fiscalLockPlugin` — prevents posting to closed fiscal periods
|
|
62
|
+
- `dateLockPlugin` — blocks entries before a configurable lock date
|
|
63
|
+
- `taxHookPlugin` — auto-generates tax lines via user-defined `TaxLineGenerator`
|
|
64
|
+
- `idempotencyPlugin` — prevents duplicate entries by key
|
|
65
|
+
|
|
66
|
+
**Utilities**
|
|
67
|
+
- `Money` — cents arithmetic, tax splitting, allocation with zero-sum guarantee
|
|
68
|
+
- `buildDimensionFields` — schema helpers for analytic dimensions
|
|
69
|
+
- `suggestMatches` — reconciliation matching suggestions
|
|
70
|
+
- `computeRevaluation` — FX gain/loss computation
|
|
71
|
+
|
|
72
|
+
## Engine Configuration
|
|
59
73
|
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
```typescript
|
|
75
|
+
createAccountingEngine({
|
|
76
|
+
country: canadaPack, // required
|
|
77
|
+
currency: 'CAD', // required — base/functional currency
|
|
78
|
+
multiTenant: { orgField, orgRef }, // optional — multi-tenant scoping
|
|
79
|
+
multiCurrency: { enabled: true, currencies: ['USD', 'EUR'] },
|
|
80
|
+
fiscalYearStartMonth: 1, // 1=Jan (default), 4=Apr, 7=Jul
|
|
81
|
+
retainedEarningsAccountCode: '3600', // overrides country pack
|
|
82
|
+
audit: { trackActor: true },
|
|
83
|
+
idempotency: true,
|
|
84
|
+
strictness: {
|
|
85
|
+
immutable: true, // disable unpost, corrections via reverse only
|
|
86
|
+
requireActor: true, // actorId required on post/reverse
|
|
87
|
+
requireApproval: true // entries must be approved before posting
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
62
91
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
## Reports API
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const reports = accounting.createReports({ Account, JournalEntry, Budget });
|
|
96
|
+
|
|
97
|
+
// All reports accept: { organizationId, dateOption, dateValue, filters? }
|
|
98
|
+
await reports.trialBalance({ ... });
|
|
99
|
+
await reports.balanceSheet({ ... });
|
|
100
|
+
await reports.incomeStatement({ ... });
|
|
101
|
+
await reports.generalLedger({ ... });
|
|
102
|
+
await reports.cashFlow({ ... });
|
|
103
|
+
await reports.agedBalance({ type: 'receivable', asOfDate: new Date() });
|
|
104
|
+
await reports.budgetVsActual({ ... }); // requires Budget model
|
|
105
|
+
await reports.dimensionBreakdown({ dimension: 'departmentId', ... });
|
|
106
|
+
await reports.revaluation({ rates: [{ currency: 'USD', rate: 1.40 }], ... });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
All report data is sorted by account code. All monetary values are integer cents — use `Money.toDecimal()` at your API boundary.
|
|
110
|
+
|
|
111
|
+
## Schemas
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
accounting.createAccountSchema(options?)
|
|
115
|
+
accounting.createJournalEntrySchema(accountModelName, {
|
|
116
|
+
extraItemFields: { departmentId: { type: ObjectId, ref: 'Department' } },
|
|
117
|
+
})
|
|
118
|
+
accounting.createFiscalPeriodSchema(options?)
|
|
119
|
+
accounting.createBudgetSchema(options?)
|
|
120
|
+
accounting.createReconciliationSchema(accountModelName, journalEntryModelName, options?)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Plugins
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { dateLockPlugin, taxHookPlugin } from '@classytic/ledger';
|
|
127
|
+
|
|
128
|
+
// Date lock — block posting before a date
|
|
129
|
+
dateLockPlugin({
|
|
130
|
+
getLockDate: async (orgId) => db.getOrgLockDate(orgId),
|
|
131
|
+
JournalEntryModel,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Tax hook — auto-generate tax lines
|
|
135
|
+
taxHookPlugin({
|
|
136
|
+
generator: {
|
|
137
|
+
generateTaxLines(input) {
|
|
138
|
+
if (!input.taxCode) return [];
|
|
139
|
+
const tax = Money.percentage(input.amount, 1300); // 13%
|
|
140
|
+
return [{ account: hstAccountId, debit: 0, credit: tax, taxDetails: [{ taxCode: 'HST' }] }];
|
|
141
|
+
},
|
|
142
|
+
},
|
|
70
143
|
});
|
|
71
144
|
```
|
|
72
145
|
|
|
73
146
|
## Subpath Exports
|
|
74
147
|
|
|
75
|
-
|
|
|
76
|
-
|
|
77
|
-
| `@classytic/ledger` | Engine, Money, schemas, plugins, reports,
|
|
78
|
-
| `@classytic/ledger/money` | `Money` class
|
|
79
|
-
| `@classytic/ledger/schemas` |
|
|
80
|
-
| `@classytic/ledger/reports` | Report generators
|
|
81
|
-
| `@classytic/ledger/plugins` |
|
|
82
|
-
| `@classytic/ledger/repositories` |
|
|
83
|
-
| `@classytic/ledger/exports` | CSV export
|
|
84
|
-
| `@classytic/ledger/constants` | Categories, journal types, currencies |
|
|
148
|
+
| Path | Contents |
|
|
149
|
+
|------|----------|
|
|
150
|
+
| `@classytic/ledger` | Engine, Money, all schemas, plugins, reports, types |
|
|
151
|
+
| `@classytic/ledger/money` | `Money` class |
|
|
152
|
+
| `@classytic/ledger/schemas` | Schema factories |
|
|
153
|
+
| `@classytic/ledger/reports` | Report generators |
|
|
154
|
+
| `@classytic/ledger/plugins` | All plugins |
|
|
155
|
+
| `@classytic/ledger/repositories` | Repository wiring |
|
|
156
|
+
| `@classytic/ledger/exports` | CSV export + QuickBooks field maps |
|
|
85
157
|
| `@classytic/ledger/country` | `defineCountryPack`, `CountryPack` interface |
|
|
158
|
+
| `@classytic/ledger/constants` | Categories, journal types, currencies |
|
|
159
|
+
|
|
160
|
+
## Country Packs
|
|
86
161
|
|
|
87
|
-
|
|
162
|
+
Build your own or use an existing one:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { defineCountryPack } from '@classytic/ledger';
|
|
166
|
+
|
|
167
|
+
export const myPack = defineCountryPack({
|
|
168
|
+
code: 'US',
|
|
169
|
+
name: 'United States',
|
|
170
|
+
defaultCurrency: 'USD',
|
|
171
|
+
retainedEarningsAccountCode: '3200',
|
|
172
|
+
accountTypes: [ /* your chart of accounts */ ],
|
|
173
|
+
taxCodes: { /* your tax codes */ },
|
|
174
|
+
taxCodesByRegion: {},
|
|
175
|
+
regions: [],
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Available packs: `@classytic/ledger-ca` (Canada), `@classytic/ledger-bd` (Bangladesh).
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
949 tests covering unit, integration, and end-to-end scenarios:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm test # run all
|
|
187
|
+
npx vitest run tests/e2e/ # e2e scenarios only
|
|
188
|
+
```
|
|
88
189
|
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
- [Exports](docs/exports.md)
|
|
95
|
-
- [Country Packs](docs/country-packs.md)
|
|
96
|
-
- [Money](docs/money.md)
|
|
97
|
-
- [Subledger Integration](docs/subledger-integration.md)
|
|
190
|
+
E2E suites include:
|
|
191
|
+
- Canadian small business full-year lifecycle
|
|
192
|
+
- Multi-currency trading with FX revaluation
|
|
193
|
+
- All plugins + dimensions + budgets + fiscal close
|
|
194
|
+
- O-Level / A-Level / university textbook accounting problems
|
|
98
195
|
|
|
99
196
|
## Requirements
|
|
100
197
|
|
|
101
198
|
- Node.js >= 22
|
|
102
199
|
- MongoDB (replica set recommended for transactions)
|
|
103
200
|
- Mongoose >= 9
|
|
104
|
-
- @classytic/mongokit >= 3
|
|
201
|
+
- @classytic/mongokit >= 3
|
|
105
202
|
|
|
106
203
|
## License
|
|
107
204
|
|
|
@@ -1,6 +1,104 @@
|
|
|
1
|
-
import { n as Errors } from "./errors-
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { n as Errors } from "./errors-B7yC-Jfw.mjs";
|
|
2
|
+
import { t as requireOrgScope } from "./tenant-guard-Fm6AID_6.mjs";
|
|
3
|
+
//#region src/repositories/reconciliation.repository.ts
|
|
4
|
+
/**
|
|
5
|
+
* Wire reconciliation methods onto an existing mongokit Repository.
|
|
6
|
+
*
|
|
7
|
+
* - reconcile() uses repository.create() so hooks (multi-tenant, audit) fire
|
|
8
|
+
* - unreconcile() uses repository.delete() so hooks fire
|
|
9
|
+
* - Cross-repo reads (JournalEntryModel) use direct Model access (acceptable)
|
|
10
|
+
*/
|
|
11
|
+
function wireReconciliationMethods(repository, _ReconciliationModel, JournalEntryModel, orgField) {
|
|
12
|
+
const create = repository.create.bind(repository);
|
|
13
|
+
const deleteById = repository.delete.bind(repository);
|
|
14
|
+
/**
|
|
15
|
+
* Create a reconciliation record linking matched journal entries.
|
|
16
|
+
* Validates that all entries exist, are posted, and belong to the same account/org.
|
|
17
|
+
*/
|
|
18
|
+
repository.reconcile = async function(input) {
|
|
19
|
+
const { account, journalEntryIds, note, reconciledBy, organizationId } = input;
|
|
20
|
+
requireOrgScope(orgField, organizationId);
|
|
21
|
+
if (!journalEntryIds || journalEntryIds.length === 0) throw Errors.validation("journalEntryIds must contain at least one entry.");
|
|
22
|
+
const query = { _id: { $in: journalEntryIds } };
|
|
23
|
+
if (orgField && organizationId != null) query[orgField] = organizationId;
|
|
24
|
+
const entries = await JournalEntryModel.find(query).lean();
|
|
25
|
+
if (entries.length !== journalEntryIds.length) throw Errors.notFound(`Expected ${journalEntryIds.length} entries but found ${entries.length}. Some entries do not exist or belong to a different organization.`);
|
|
26
|
+
const notPosted = entries.filter((e) => e.state !== "posted");
|
|
27
|
+
if (notPosted.length > 0) throw Errors.validation(`${notPosted.length} entry(ies) are not posted. Only posted entries can be reconciled.`);
|
|
28
|
+
const accountStr = String(account);
|
|
29
|
+
for (const entry of entries) if (!entry.journalItems.some((item) => String(item.account) === accountStr)) throw Errors.validation(`Entry ${entry._id} does not contain any items for account ${account}.`);
|
|
30
|
+
let debitTotal = 0;
|
|
31
|
+
let creditTotal = 0;
|
|
32
|
+
for (const entry of entries) for (const item of entry.journalItems) if (String(item.account) === accountStr) {
|
|
33
|
+
debitTotal += item.debit ?? 0;
|
|
34
|
+
creditTotal += item.credit ?? 0;
|
|
35
|
+
}
|
|
36
|
+
const reconciliationData = {
|
|
37
|
+
account,
|
|
38
|
+
journalEntryIds,
|
|
39
|
+
debitTotal,
|
|
40
|
+
creditTotal,
|
|
41
|
+
difference: debitTotal - creditTotal,
|
|
42
|
+
note,
|
|
43
|
+
reconciledBy,
|
|
44
|
+
reconciledAt: /* @__PURE__ */ new Date()
|
|
45
|
+
};
|
|
46
|
+
if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
|
|
47
|
+
return await create(reconciliationData);
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Remove a reconciliation record via repository.delete().
|
|
51
|
+
*/
|
|
52
|
+
repository.unreconcile = async function(input) {
|
|
53
|
+
const { reconciliationId, organizationId } = input;
|
|
54
|
+
requireOrgScope(orgField, organizationId);
|
|
55
|
+
if (orgField && organizationId != null) {
|
|
56
|
+
if (!await repository._executeQuery(async (Model) => Model.findOne({
|
|
57
|
+
_id: reconciliationId,
|
|
58
|
+
[orgField]: organizationId
|
|
59
|
+
}).select("_id").lean())) throw Errors.notFound("Reconciliation record not found.");
|
|
60
|
+
}
|
|
61
|
+
const result = await deleteById(String(reconciliationId));
|
|
62
|
+
if (!result) throw Errors.notFound("Reconciliation record not found.");
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Find journal entries for an account that are NOT in any reconciliation record.
|
|
67
|
+
* Uses repository.getAll() for reconciliation lookups (hooks fire),
|
|
68
|
+
* and direct JournalEntryModel for cross-repo reads (acceptable).
|
|
69
|
+
*/
|
|
70
|
+
repository.getUnreconciled = async function(input) {
|
|
71
|
+
const { accountId, organizationId, limit = 100, skip = 0 } = input;
|
|
72
|
+
requireOrgScope(orgField, organizationId);
|
|
73
|
+
const reconFilter = { account: accountId };
|
|
74
|
+
if (orgField && organizationId != null) reconFilter[orgField] = organizationId;
|
|
75
|
+
const reconciliations = await repository._executeQuery(async (Model) => Model.find(reconFilter).select("journalEntryIds").lean());
|
|
76
|
+
const reconciledIds = /* @__PURE__ */ new Set();
|
|
77
|
+
for (const rec of reconciliations) for (const id of rec.journalEntryIds) reconciledIds.add(String(id));
|
|
78
|
+
const entryFilter = {
|
|
79
|
+
state: "posted",
|
|
80
|
+
"journalItems.account": accountId
|
|
81
|
+
};
|
|
82
|
+
if (orgField && organizationId != null) entryFilter[orgField] = organizationId;
|
|
83
|
+
if (reconciledIds.size > 0) entryFilter._id = { $nin: Array.from(reconciledIds) };
|
|
84
|
+
return await JournalEntryModel.find(entryFilter).sort({ date: -1 }).skip(skip).limit(limit).lean();
|
|
85
|
+
};
|
|
86
|
+
if (typeof repository.registerMethod === "function") for (const name of [
|
|
87
|
+
"reconcile",
|
|
88
|
+
"unreconcile",
|
|
89
|
+
"getUnreconciled"
|
|
90
|
+
]) {
|
|
91
|
+
const fn = repository[name];
|
|
92
|
+
try {
|
|
93
|
+
delete repository[name];
|
|
94
|
+
repository.registerMethod(name, fn);
|
|
95
|
+
} catch {
|
|
96
|
+
repository[name] = fn;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return repository;
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
4
102
|
//#region src/repositories/journal-entry.repository.ts
|
|
5
103
|
/** Keys that are either handled explicitly or must not be copied */
|
|
6
104
|
const ITEM_CORE_KEYS = new Set([
|
|
@@ -16,12 +114,40 @@ const ITEM_CORE_KEYS = new Set([
|
|
|
16
114
|
/**
|
|
17
115
|
* Wire post/reverse onto an existing mongokit Repository.
|
|
18
116
|
*
|
|
117
|
+
* All reads use `repository.getByQuery()` so registered plugins
|
|
118
|
+
* (multi-tenant, audit, cache) fire on every operation.
|
|
119
|
+
*
|
|
19
120
|
* @param repository - A mongokit Repository instance (already created)
|
|
20
|
-
* @param
|
|
121
|
+
* @param _JournalEntryModel - (Deprecated) The Mongoose model — no longer used internally; kept for API compat
|
|
21
122
|
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
22
123
|
* @param strictness - Strictness rules (immutable, requireActor, requireApproval)
|
|
23
124
|
*/
|
|
24
|
-
function wireJournalEntryMethods(repository,
|
|
125
|
+
function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
|
|
126
|
+
const getByQuery = repository.getByQuery.bind(repository);
|
|
127
|
+
const create = repository.create.bind(repository);
|
|
128
|
+
const withTransaction = repository.withTransaction.bind(repository);
|
|
129
|
+
/** Build a tenant-scoped query for a single entry by ID (injection-safe) */
|
|
130
|
+
function buildQuery(id, orgId) {
|
|
131
|
+
validateScalarId(id, "entry ID");
|
|
132
|
+
if (orgId != null) validateScalarId(orgId, "organization ID");
|
|
133
|
+
const query = { _id: id };
|
|
134
|
+
if (orgField && orgId != null) query[orgField] = orgId;
|
|
135
|
+
return query;
|
|
136
|
+
}
|
|
137
|
+
/** Reject operator-injected objects like { $ne: null } but allow ObjectIds */
|
|
138
|
+
function validateScalarId(value, label) {
|
|
139
|
+
if (value == null || typeof value !== "object") return;
|
|
140
|
+
const obj = value;
|
|
141
|
+
if (typeof obj.toHexString === "function" || obj._bsontype === "ObjectId") return;
|
|
142
|
+
if (Object.keys(obj).some((k) => k.startsWith("$"))) throw Errors.validation(`Invalid ${label} — MongoDB operators are not allowed.`);
|
|
143
|
+
}
|
|
144
|
+
/** Fetch an entry via the repository (fires all hooks) */
|
|
145
|
+
async function findEntry(query, options) {
|
|
146
|
+
const opts = { lean: false };
|
|
147
|
+
if (options.populate) opts.populate = options.populate;
|
|
148
|
+
if (options.session) opts.session = options.session;
|
|
149
|
+
return await getByQuery(query, opts);
|
|
150
|
+
}
|
|
25
151
|
/**
|
|
26
152
|
* Post an entry (draft → posted).
|
|
27
153
|
* Validates items, balance, and accounts before changing state.
|
|
@@ -29,9 +155,10 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
29
155
|
repository.post = async function(id, orgId, options = {}) {
|
|
30
156
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for post operations.");
|
|
31
157
|
requireOrgScope(orgField, orgId);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
158
|
+
const entry = await findEntry(buildQuery(id, orgId), {
|
|
159
|
+
session: options.session,
|
|
160
|
+
populate: "journalItems.account"
|
|
161
|
+
});
|
|
35
162
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
36
163
|
if (entry.idempotencyKey && entry.state === "posted") return entry;
|
|
37
164
|
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be posted");
|
|
@@ -41,6 +168,14 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
41
168
|
if (!entry.journalItems || entry.journalItems.length < 2) throw Errors.validation("Journal entry must have at least 2 items to post");
|
|
42
169
|
const missing = entry.journalItems.filter((i) => !i.account || i.account === "");
|
|
43
170
|
if (missing.length > 0) throw Errors.validation(`${missing.length} item(s) missing an account`);
|
|
171
|
+
const nullAccounts = entry.journalItems.filter((i) => {
|
|
172
|
+
const acct = i.account;
|
|
173
|
+
if (!acct) return true;
|
|
174
|
+
if (typeof acct === "string") return true;
|
|
175
|
+
if (typeof acct === "object" && !acct._id) return true;
|
|
176
|
+
return false;
|
|
177
|
+
});
|
|
178
|
+
if (nullAccounts.length > 0) throw Errors.validation(`${nullAccounts.length} item(s) reference accounts that do not exist. Ensure all accounts are created before posting.`);
|
|
44
179
|
if (orgField && orgId != null) {
|
|
45
180
|
const crossTenant = entry.journalItems.filter((i) => {
|
|
46
181
|
const acct = i.account;
|
|
@@ -71,17 +206,12 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
71
206
|
if (strictness?.immutable) throw Errors.immutable("Unpost is disabled in strict mode. Use reverse() to correct posted entries.");
|
|
72
207
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for unpost operations.");
|
|
73
208
|
requireOrgScope(orgField, orgId);
|
|
74
|
-
const
|
|
75
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
76
|
-
const entry = await JournalEntryModel.findOne(query).session(options.session || null);
|
|
209
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
77
210
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
78
211
|
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be unposted");
|
|
212
|
+
if (entry.reversed) throw Errors.validation("Cannot unpost a reversed entry. The reversal entry is still posted and linked to this entry. Reverse the reversal entry first, or create a new correcting entry instead.");
|
|
79
213
|
entry.state = "draft";
|
|
80
214
|
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
81
|
-
if (entry.reversed) {
|
|
82
|
-
entry.reversed = false;
|
|
83
|
-
entry.reversedBy = void 0;
|
|
84
|
-
}
|
|
85
215
|
await entry.save({ session: options.session });
|
|
86
216
|
return entry;
|
|
87
217
|
};
|
|
@@ -93,9 +223,7 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
93
223
|
repository.archive = async function(id, orgId, options = {}) {
|
|
94
224
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for archive operations.");
|
|
95
225
|
requireOrgScope(orgField, orgId);
|
|
96
|
-
const
|
|
97
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
98
|
-
const entry = await JournalEntryModel.findOne(query).session(options.session || null);
|
|
226
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
99
227
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
100
228
|
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
|
|
101
229
|
entry.state = "archived";
|
|
@@ -109,9 +237,7 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
109
237
|
*/
|
|
110
238
|
repository.duplicate = async function(id, orgId, options = {}) {
|
|
111
239
|
requireOrgScope(orgField, orgId);
|
|
112
|
-
const
|
|
113
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
114
|
-
const entry = await JournalEntryModel.findOne(query).session(options.session || null);
|
|
240
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
115
241
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
116
242
|
const duplicateData = {
|
|
117
243
|
journalType: entry.journalType,
|
|
@@ -134,15 +260,14 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
134
260
|
})
|
|
135
261
|
};
|
|
136
262
|
if (orgField && entry[orgField] != null) duplicateData[orgField] = entry[orgField];
|
|
137
|
-
return await
|
|
263
|
+
return await create(duplicateData, options.session ? { session: options.session } : {});
|
|
138
264
|
};
|
|
139
265
|
/**
|
|
140
266
|
* Reverse a posted entry by creating a mirror entry with flipped debits/credits.
|
|
141
267
|
* Marks the original as reversed and links both entries bidirectionally.
|
|
142
268
|
*
|
|
143
|
-
*
|
|
144
|
-
* to join a caller-managed transaction instead.
|
|
145
|
-
* replica set), falls back to non-atomic execution with a warning.
|
|
269
|
+
* Uses repository.withTransaction() for automatic retry on transient failures.
|
|
270
|
+
* Pass an external session to join a caller-managed transaction instead.
|
|
146
271
|
*
|
|
147
272
|
* Routes the reversal through repository.create() so all plugins (fiscal-lock,
|
|
148
273
|
* double-entry) enforce policy on the reversal entry.
|
|
@@ -150,12 +275,12 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
150
275
|
repository.reverse = async function(id, orgId, options = {}) {
|
|
151
276
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for reverse operations.");
|
|
152
277
|
requireOrgScope(orgField, orgId);
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
278
|
+
const query = buildQuery(id, orgId);
|
|
279
|
+
const doReverse = async (session) => {
|
|
280
|
+
const entry = await findEntry(query, {
|
|
281
|
+
session,
|
|
282
|
+
populate: "journalItems.account"
|
|
283
|
+
});
|
|
159
284
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
160
285
|
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be reversed");
|
|
161
286
|
if (entry.reversed) throw Errors.validation("Entry has already been reversed");
|
|
@@ -188,22 +313,38 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
188
313
|
};
|
|
189
314
|
if (orgField && entry[orgField] != null) reversalData[orgField] = entry[orgField];
|
|
190
315
|
if (options.actorId) reversalData.postedBy = options.actorId;
|
|
191
|
-
const reversalEntry = await
|
|
316
|
+
const reversalEntry = await create(reversalData, session ? { session } : {});
|
|
192
317
|
entry.reversed = true;
|
|
193
|
-
entry.reversedBy = reversalEntry
|
|
318
|
+
entry.reversedBy = reversalEntry["_id"];
|
|
194
319
|
if (options.actorId) entry.reversedByUser = options.actorId;
|
|
195
320
|
await entry.save({ session });
|
|
196
|
-
success = true;
|
|
197
321
|
return {
|
|
198
322
|
original: entry,
|
|
199
323
|
reversal: reversalEntry
|
|
200
324
|
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
}
|
|
325
|
+
};
|
|
326
|
+
if (options.session) return await doReverse(options.session);
|
|
327
|
+
if (withTransaction) return await withTransaction((session) => doReverse(session), { allowFallback: true });
|
|
328
|
+
return await doReverse();
|
|
204
329
|
};
|
|
330
|
+
const methodNames = [
|
|
331
|
+
"post",
|
|
332
|
+
"unpost",
|
|
333
|
+
"archive",
|
|
334
|
+
"duplicate",
|
|
335
|
+
"reverse"
|
|
336
|
+
];
|
|
337
|
+
if (typeof repository.registerMethod === "function") for (const name of methodNames) {
|
|
338
|
+
const fn = repository[name];
|
|
339
|
+
try {
|
|
340
|
+
delete repository[name];
|
|
341
|
+
repository.registerMethod(name, fn);
|
|
342
|
+
} catch {
|
|
343
|
+
repository[name] = fn;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return repository;
|
|
205
347
|
}
|
|
206
|
-
|
|
207
348
|
//#endregion
|
|
208
349
|
//#region src/repositories/account.repository.ts
|
|
209
350
|
/**
|
|
@@ -386,8 +527,16 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
386
527
|
...results
|
|
387
528
|
};
|
|
388
529
|
};
|
|
530
|
+
if (typeof repository.registerMethod === "function") for (const name of ["seedAccounts", "bulkCreate"]) {
|
|
531
|
+
const fn = repository[name];
|
|
532
|
+
try {
|
|
533
|
+
delete repository[name];
|
|
534
|
+
repository.registerMethod(name, fn);
|
|
535
|
+
} catch {
|
|
536
|
+
repository[name] = fn;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return repository;
|
|
389
540
|
}
|
|
390
|
-
|
|
391
541
|
//#endregion
|
|
392
|
-
export { wireJournalEntryMethods as n, wireAccountMethods as t };
|
|
393
|
-
//# sourceMappingURL=account.repository-Crf5DGO4.mjs.map
|
|
542
|
+
export { wireJournalEntryMethods as n, wireReconciliationMethods as r, wireAccountMethods as t };
|
|
@@ -64,7 +64,5 @@ function extractStatementType(key) {
|
|
|
64
64
|
const parts = key.split("-");
|
|
65
65
|
return parts.length === 2 ? parts[0] : null;
|
|
66
66
|
}
|
|
67
|
-
|
|
68
67
|
//#endregion
|
|
69
68
|
export { extractStatementType as a, getNormalBalance as c, isValidCategory as d, extractMainType as i, isBalanceSheet as l, CATEGORY_KEYS as n, getCategoryMainType as o, categoryKey as r, getCategoryStatementType as s, CATEGORIES as t, isIncomeStatement as u };
|
|
70
|
-
//# sourceMappingURL=categories-BNJBd4ze.mjs.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as getNormalBalance, a as JOURNAL_CODES, b as isValidCategory, c as getJournalTypeCodes, d as CATEGORY_KEYS, f as categoryKey, g as getCategoryStatementType, h as getCategoryMainType, i as isValidCurrency, l as isValidJournalType, m as extractStatementType, n as getCurrency, o as JOURNAL_TYPES, p as extractMainType, r as getMinorUnit, s as getJournalType, t as CURRENCIES, u as CATEGORIES, v as isBalanceSheet, y as isIncomeStatement } from "../currencies-
|
|
1
|
+
import { _ as getNormalBalance, a as JOURNAL_CODES, b as isValidCategory, c as getJournalTypeCodes, d as CATEGORY_KEYS, f as categoryKey, g as getCategoryStatementType, h as getCategoryMainType, i as isValidCurrency, l as isValidJournalType, m as extractStatementType, n as getCurrency, o as JOURNAL_TYPES, p as extractMainType, r as getMinorUnit, s as getJournalType, t as CURRENCIES, u as CATEGORIES, v as isBalanceSheet, y as isIncomeStatement } from "../currencies-4WAbFRlw.mjs";
|
|
2
2
|
export { CATEGORIES, CATEGORY_KEYS, CURRENCIES, JOURNAL_CODES, JOURNAL_TYPES, categoryKey, extractMainType, extractStatementType, getCategoryMainType, getCategoryStatementType, getCurrency, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType };
|
package/dist/constants/index.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { a as isValidJournalType, i as getJournalTypeCodes, n as JOURNAL_TYPES, r as getJournalType, t as JOURNAL_CODES } from "../journals-
|
|
2
|
-
import { a as extractStatementType, c as getNormalBalance, d as isValidCategory, i as extractMainType, l as isBalanceSheet, n as CATEGORY_KEYS, o as getCategoryMainType, r as categoryKey, s as getCategoryStatementType, t as CATEGORIES, u as isIncomeStatement } from "../categories-
|
|
3
|
-
import { i as isValidCurrency, n as getCurrency, r as getMinorUnit, t as CURRENCIES } from "../currencies-
|
|
4
|
-
|
|
5
|
-
export { CATEGORIES, CATEGORY_KEYS, CURRENCIES, JOURNAL_CODES, JOURNAL_TYPES, categoryKey, extractMainType, extractStatementType, getCategoryMainType, getCategoryStatementType, getCurrency, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType };
|
|
1
|
+
import { a as isValidJournalType, i as getJournalTypeCodes, n as JOURNAL_TYPES, r as getJournalType, t as JOURNAL_CODES } from "../journals-oH-FK3g8.mjs";
|
|
2
|
+
import { a as extractStatementType, c as getNormalBalance, d as isValidCategory, i as extractMainType, l as isBalanceSheet, n as CATEGORY_KEYS, o as getCategoryMainType, r as categoryKey, s as getCategoryStatementType, t as CATEGORIES, u as isIncomeStatement } from "../categories-CclX7Q94.mjs";
|
|
3
|
+
import { i as isValidCurrency, n as getCurrency, r as getMinorUnit, t as CURRENCIES } from "../currencies-W8kQAkm0.mjs";
|
|
4
|
+
export { CATEGORIES, CATEGORY_KEYS, CURRENCIES, JOURNAL_CODES, JOURNAL_TYPES, categoryKey, extractMainType, extractStatementType, getCategoryMainType, getCategoryStatementType, getCurrency, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType };
|
|
@@ -100,5 +100,4 @@ interface DateRange {
|
|
|
100
100
|
endDate: Date;
|
|
101
101
|
}
|
|
102
102
|
//#endregion
|
|
103
|
-
export { TaxMetadata as _, Cents as a, DateRange as c, JournalType as d, MainType as f, TaxDetail as g, StatementType as h, CategoryKey as i, EntryState as l, ObjectId as m, CashFlowCategory as n, Currency as o, NormalBalance as p, Category as r, DateOption as s, AccountType as t, JournalItem as u, TotalAccountOp as v };
|
|
104
|
-
//# sourceMappingURL=core-Cx0baosR.d.mts.map
|
|
103
|
+
export { TaxMetadata as _, Cents as a, DateRange as c, JournalType as d, MainType as f, TaxDetail as g, StatementType as h, CategoryKey as i, EntryState as l, ObjectId as m, CashFlowCategory as n, Currency as o, NormalBalance as p, Category as r, DateOption as s, AccountType as t, JournalItem as u, TotalAccountOp as v };
|