@classytic/ledger 0.2.0 → 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-kDKwDt0I.mjs → account.repository-BpkSd6q3.mjs} +189 -38
- package/dist/categories-CclX7Q94.mjs +0 -2
- package/dist/core-8Xfnpn6g.d.mts +1 -2
- package/dist/country/index.d.mts +1 -1
- package/dist/country/index.mjs +0 -2
- package/dist/currencies-4WAbFRlw.d.mts +1 -2
- package/dist/currencies-W8kQAkm0.mjs +0 -2
- package/dist/{idempotency.plugin-v9NQ_ta-.mjs → date-lock.plugin-eYAJ9h_u.mjs} +49 -9
- package/dist/{engine-BzBMpWuy.d.mts → engine-Cn-9yerQ.d.mts} +11 -7
- package/dist/errors-B7yC-Jfw.mjs +0 -2
- package/dist/exports-I5Xkq-9_.mjs +0 -2
- package/dist/{fiscal-close-L631E3De.mjs → fiscal-close-B6LhQ10f.mjs} +737 -20
- package/dist/fiscal-period.schema-BMnlI9H5.d.mts +103 -0
- package/dist/{idempotency.plugin-CPxPt4vX.d.mts → idempotency.plugin-B_CNsInz.d.mts} +19 -17
- package/dist/index-BPukb3L8.d.mts +1 -2
- package/dist/{index-ZnSiqHYV.d.mts → index-CxZqRaOU.d.mts} +20 -6
- package/dist/index.d.mts +248 -26
- package/dist/index.mjs +119 -21
- package/dist/journals-oH-FK3g8.mjs +0 -2
- package/dist/{logger-UbTdBb1x.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 -2
- package/dist/reconciliation.repository-CW4-8q90.d.mts +135 -0
- package/dist/{fiscal-period.schema-BQ5wsAq3.mjs → reconciliation.schema-BuetvZTd.mjs} +168 -24
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -2
- package/dist/repositories/index.d.mts +2 -2
- package/dist/repositories/index.mjs +2 -2
- package/dist/revaluation-D9x0NE8w.d.mts +530 -0
- package/dist/schemas/index.d.mts +71 -2
- package/dist/schemas/index.mjs +2 -2
- package/dist/tenant-guard-Fm6AID_6.mjs +13 -0
- package/docs/reports.md +1 -1
- package/package.json +2 -2
- package/dist/account.repository-C7gwFLfM.d.mts +0 -29
- package/dist/account.repository-C7gwFLfM.d.mts.map +0 -1
- package/dist/account.repository-kDKwDt0I.mjs.map +0 -1
- package/dist/categories-CclX7Q94.mjs.map +0 -1
- package/dist/core-8Xfnpn6g.d.mts.map +0 -1
- package/dist/country/index.mjs.map +0 -1
- package/dist/currencies-4WAbFRlw.d.mts.map +0 -1
- package/dist/currencies-W8kQAkm0.mjs.map +0 -1
- package/dist/engine-BzBMpWuy.d.mts.map +0 -1
- package/dist/errors-B7yC-Jfw.mjs.map +0 -1
- package/dist/exports-I5Xkq-9_.mjs.map +0 -1
- package/dist/fiscal-close-L631E3De.mjs.map +0 -1
- package/dist/fiscal-close-dNlzB37y.d.mts +0 -270
- package/dist/fiscal-close-dNlzB37y.d.mts.map +0 -1
- package/dist/fiscal-period.schema-BQ5wsAq3.mjs.map +0 -1
- package/dist/fiscal-period.schema-BRdKAjrr.d.mts +0 -38
- package/dist/fiscal-period.schema-BRdKAjrr.d.mts.map +0 -1
- package/dist/idempotency.plugin-CPxPt4vX.d.mts.map +0 -1
- package/dist/idempotency.plugin-v9NQ_ta-.mjs.map +0 -1
- package/dist/index-BPukb3L8.d.mts.map +0 -1
- package/dist/index-ZnSiqHYV.d.mts.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/journals-oH-FK3g8.mjs.map +0 -1
- package/dist/logger-UbTdBb1x.d.mts.map +0 -1
- package/dist/money.d.mts.map +0 -1
- package/dist/money.mjs.map +0 -1
- package/dist/session-Ba8E3Ufa.mjs +0 -84
- package/dist/session-Ba8E3Ufa.mjs.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,5 +1,104 @@
|
|
|
1
1
|
import { n as Errors } from "./errors-B7yC-Jfw.mjs";
|
|
2
|
-
import {
|
|
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
|
|
3
102
|
//#region src/repositories/journal-entry.repository.ts
|
|
4
103
|
/** Keys that are either handled explicitly or must not be copied */
|
|
5
104
|
const ITEM_CORE_KEYS = new Set([
|
|
@@ -15,12 +114,40 @@ const ITEM_CORE_KEYS = new Set([
|
|
|
15
114
|
/**
|
|
16
115
|
* Wire post/reverse onto an existing mongokit Repository.
|
|
17
116
|
*
|
|
117
|
+
* All reads use `repository.getByQuery()` so registered plugins
|
|
118
|
+
* (multi-tenant, audit, cache) fire on every operation.
|
|
119
|
+
*
|
|
18
120
|
* @param repository - A mongokit Repository instance (already created)
|
|
19
|
-
* @param
|
|
121
|
+
* @param _JournalEntryModel - (Deprecated) The Mongoose model — no longer used internally; kept for API compat
|
|
20
122
|
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
21
123
|
* @param strictness - Strictness rules (immutable, requireActor, requireApproval)
|
|
22
124
|
*/
|
|
23
|
-
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
|
+
}
|
|
24
151
|
/**
|
|
25
152
|
* Post an entry (draft → posted).
|
|
26
153
|
* Validates items, balance, and accounts before changing state.
|
|
@@ -28,9 +155,10 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
28
155
|
repository.post = async function(id, orgId, options = {}) {
|
|
29
156
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for post operations.");
|
|
30
157
|
requireOrgScope(orgField, orgId);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
158
|
+
const entry = await findEntry(buildQuery(id, orgId), {
|
|
159
|
+
session: options.session,
|
|
160
|
+
populate: "journalItems.account"
|
|
161
|
+
});
|
|
34
162
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
35
163
|
if (entry.idempotencyKey && entry.state === "posted") return entry;
|
|
36
164
|
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be posted");
|
|
@@ -40,6 +168,14 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
40
168
|
if (!entry.journalItems || entry.journalItems.length < 2) throw Errors.validation("Journal entry must have at least 2 items to post");
|
|
41
169
|
const missing = entry.journalItems.filter((i) => !i.account || i.account === "");
|
|
42
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.`);
|
|
43
179
|
if (orgField && orgId != null) {
|
|
44
180
|
const crossTenant = entry.journalItems.filter((i) => {
|
|
45
181
|
const acct = i.account;
|
|
@@ -70,17 +206,12 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
70
206
|
if (strictness?.immutable) throw Errors.immutable("Unpost is disabled in strict mode. Use reverse() to correct posted entries.");
|
|
71
207
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for unpost operations.");
|
|
72
208
|
requireOrgScope(orgField, orgId);
|
|
73
|
-
const
|
|
74
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
75
|
-
const entry = await JournalEntryModel.findOne(query).session(options.session || null);
|
|
209
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
76
210
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
77
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.");
|
|
78
213
|
entry.state = "draft";
|
|
79
214
|
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
80
|
-
if (entry.reversed) {
|
|
81
|
-
entry.reversed = false;
|
|
82
|
-
entry.reversedBy = void 0;
|
|
83
|
-
}
|
|
84
215
|
await entry.save({ session: options.session });
|
|
85
216
|
return entry;
|
|
86
217
|
};
|
|
@@ -92,9 +223,7 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
92
223
|
repository.archive = async function(id, orgId, options = {}) {
|
|
93
224
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for archive operations.");
|
|
94
225
|
requireOrgScope(orgField, orgId);
|
|
95
|
-
const
|
|
96
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
97
|
-
const entry = await JournalEntryModel.findOne(query).session(options.session || null);
|
|
226
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
98
227
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
99
228
|
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
|
|
100
229
|
entry.state = "archived";
|
|
@@ -108,9 +237,7 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
108
237
|
*/
|
|
109
238
|
repository.duplicate = async function(id, orgId, options = {}) {
|
|
110
239
|
requireOrgScope(orgField, orgId);
|
|
111
|
-
const
|
|
112
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
113
|
-
const entry = await JournalEntryModel.findOne(query).session(options.session || null);
|
|
240
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
114
241
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
115
242
|
const duplicateData = {
|
|
116
243
|
journalType: entry.journalType,
|
|
@@ -133,15 +260,14 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
133
260
|
})
|
|
134
261
|
};
|
|
135
262
|
if (orgField && entry[orgField] != null) duplicateData[orgField] = entry[orgField];
|
|
136
|
-
return await
|
|
263
|
+
return await create(duplicateData, options.session ? { session: options.session } : {});
|
|
137
264
|
};
|
|
138
265
|
/**
|
|
139
266
|
* Reverse a posted entry by creating a mirror entry with flipped debits/credits.
|
|
140
267
|
* Marks the original as reversed and links both entries bidirectionally.
|
|
141
268
|
*
|
|
142
|
-
*
|
|
143
|
-
* to join a caller-managed transaction instead.
|
|
144
|
-
* 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.
|
|
145
271
|
*
|
|
146
272
|
* Routes the reversal through repository.create() so all plugins (fiscal-lock,
|
|
147
273
|
* double-entry) enforce policy on the reversal entry.
|
|
@@ -149,12 +275,12 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
149
275
|
repository.reverse = async function(id, orgId, options = {}) {
|
|
150
276
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for reverse operations.");
|
|
151
277
|
requireOrgScope(orgField, orgId);
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
278
|
+
const query = buildQuery(id, orgId);
|
|
279
|
+
const doReverse = async (session) => {
|
|
280
|
+
const entry = await findEntry(query, {
|
|
281
|
+
session,
|
|
282
|
+
populate: "journalItems.account"
|
|
283
|
+
});
|
|
158
284
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
159
285
|
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be reversed");
|
|
160
286
|
if (entry.reversed) throw Errors.validation("Entry has already been reversed");
|
|
@@ -187,20 +313,37 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
|
|
|
187
313
|
};
|
|
188
314
|
if (orgField && entry[orgField] != null) reversalData[orgField] = entry[orgField];
|
|
189
315
|
if (options.actorId) reversalData.postedBy = options.actorId;
|
|
190
|
-
const reversalEntry = await
|
|
316
|
+
const reversalEntry = await create(reversalData, session ? { session } : {});
|
|
191
317
|
entry.reversed = true;
|
|
192
|
-
entry.reversedBy = reversalEntry
|
|
318
|
+
entry.reversedBy = reversalEntry["_id"];
|
|
193
319
|
if (options.actorId) entry.reversedByUser = options.actorId;
|
|
194
320
|
await entry.save({ session });
|
|
195
|
-
success = true;
|
|
196
321
|
return {
|
|
197
322
|
original: entry,
|
|
198
323
|
reversal: reversalEntry
|
|
199
324
|
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
}
|
|
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();
|
|
203
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;
|
|
204
347
|
}
|
|
205
348
|
//#endregion
|
|
206
349
|
//#region src/repositories/account.repository.ts
|
|
@@ -384,8 +527,16 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
384
527
|
...results
|
|
385
528
|
};
|
|
386
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;
|
|
387
540
|
}
|
|
388
541
|
//#endregion
|
|
389
|
-
export { wireJournalEntryMethods as n, wireAccountMethods as t };
|
|
390
|
-
|
|
391
|
-
//# sourceMappingURL=account.repository-kDKwDt0I.mjs.map
|
|
542
|
+
export { wireJournalEntryMethods as n, wireReconciliationMethods as r, wireAccountMethods as t };
|
|
@@ -66,5 +66,3 @@ function extractStatementType(key) {
|
|
|
66
66
|
}
|
|
67
67
|
//#endregion
|
|
68
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 };
|
|
69
|
-
|
|
70
|
-
//# sourceMappingURL=categories-CclX7Q94.mjs.map
|
package/dist/core-8Xfnpn6g.d.mts
CHANGED
|
@@ -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-8Xfnpn6g.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 };
|
package/dist/country/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as TaxReportLine, i as TaxCodesByRegion, n as CountryPackInput, o as TaxReportTemplate, r as TaxCode, s as defineCountryPack, t as CountryPack } from "../index-
|
|
1
|
+
import { a as TaxReportLine, i as TaxCodesByRegion, n as CountryPackInput, o as TaxReportTemplate, r as TaxCode, s as defineCountryPack, t as CountryPack } from "../index-CxZqRaOU.mjs";
|
|
2
2
|
export { CountryPack, CountryPackInput, TaxCode, TaxCodesByRegion, TaxReportLine, TaxReportTemplate, defineCountryPack };
|
package/dist/country/index.mjs
CHANGED
|
@@ -34,5 +34,4 @@ declare function getCurrency(code: string): Currency | null;
|
|
|
34
34
|
declare function isValidCurrency(code: string): boolean;
|
|
35
35
|
declare function getMinorUnit(code: string): number;
|
|
36
36
|
//#endregion
|
|
37
|
-
export { getNormalBalance as _, JOURNAL_CODES as a, isValidCategory as b, getJournalTypeCodes as c, CATEGORY_KEYS as d, categoryKey as f, getCategoryStatementType as g, getCategoryMainType as h, isValidCurrency as i, isValidJournalType as l, extractStatementType as m, getCurrency as n, JOURNAL_TYPES as o, extractMainType as p, getMinorUnit as r, getJournalType as s, CURRENCIES as t, CATEGORIES as u, isBalanceSheet as v, isIncomeStatement as y };
|
|
38
|
-
//# sourceMappingURL=currencies-4WAbFRlw.d.mts.map
|
|
37
|
+
export { getNormalBalance as _, JOURNAL_CODES as a, isValidCategory as b, getJournalTypeCodes as c, CATEGORY_KEYS as d, categoryKey as f, getCategoryStatementType as g, getCategoryMainType as h, isValidCurrency as i, isValidJournalType as l, extractStatementType as m, getCurrency as n, JOURNAL_TYPES as o, extractMainType as p, getMinorUnit as r, getJournalType as s, CURRENCIES as t, CATEGORIES as u, isBalanceSheet as v, isIncomeStatement as y };
|