@classytic/ledger 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/account.repository-1C2sZvB2.d.mts +29 -0
- package/dist/account.repository-1C2sZvB2.d.mts.map +1 -0
- package/dist/account.repository-Crf5DGO4.mjs +393 -0
- package/dist/account.repository-Crf5DGO4.mjs.map +1 -0
- package/dist/categories-BNJBd4ze.mjs +70 -0
- package/dist/categories-BNJBd4ze.mjs.map +1 -0
- package/dist/constants/index.d.mts +2 -0
- package/dist/constants/index.mjs +5 -0
- package/dist/core-Cx0baosR.d.mts +104 -0
- package/dist/core-Cx0baosR.d.mts.map +1 -0
- package/dist/country/index.d.mts +105 -0
- package/dist/country/index.d.mts.map +1 -0
- package/dist/country/index.mjs +27 -0
- package/dist/country/index.mjs.map +1 -0
- package/dist/currencies-BBk3NwXn.mjs +82 -0
- package/dist/currencies-BBk3NwXn.mjs.map +1 -0
- package/dist/currencies-Bkn3FNkC.d.mts +38 -0
- package/dist/currencies-Bkn3FNkC.d.mts.map +1 -0
- package/dist/engine-Cd73EOT6.d.mts +72 -0
- package/dist/engine-Cd73EOT6.d.mts.map +1 -0
- package/dist/errors-CeqRahE-.mjs +28 -0
- package/dist/errors-CeqRahE-.mjs.map +1 -0
- package/dist/exports/index.d.mts +2 -0
- package/dist/exports/index.mjs +3 -0
- package/dist/fiscal-close-CNOwv_ud.mjs +934 -0
- package/dist/fiscal-close-CNOwv_ud.mjs.map +1 -0
- package/dist/fiscal-close-CzUzpnMg.d.mts +270 -0
- package/dist/fiscal-close-CzUzpnMg.d.mts.map +1 -0
- package/dist/fiscal-period.schema-CbALaaKl.mjs +477 -0
- package/dist/fiscal-period.schema-CbALaaKl.mjs.map +1 -0
- package/dist/fiscal-period.schema-DI2scngu.d.mts +38 -0
- package/dist/fiscal-period.schema-DI2scngu.d.mts.map +1 -0
- package/dist/idempotency.plugin-BESs9YPD.d.mts +58 -0
- package/dist/idempotency.plugin-BESs9YPD.d.mts.map +1 -0
- package/dist/idempotency.plugin-C6r8RI8d.mjs +165 -0
- package/dist/idempotency.plugin-C6r8RI8d.mjs.map +1 -0
- package/dist/index.d.mts +308 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +171 -0
- package/dist/index.mjs.map +1 -0
- package/dist/journals-CI3Wb4EF.mjs +92 -0
- package/dist/journals-CI3Wb4EF.mjs.map +1 -0
- package/dist/logger-Cv6VVc4r.d.mts +15 -0
- package/dist/logger-Cv6VVc4r.d.mts.map +1 -0
- package/dist/money.d.mts +129 -0
- package/dist/money.d.mts.map +1 -0
- package/dist/money.mjs +197 -0
- package/dist/money.mjs.map +1 -0
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/reports/index.d.mts +2 -0
- package/dist/reports/index.mjs +3 -0
- package/dist/repositories/index.d.mts +2 -0
- package/dist/repositories/index.mjs +3 -0
- package/dist/schemas/index.d.mts +2 -0
- package/dist/schemas/index.mjs +3 -0
- package/dist/session-Dh0s6zG4.mjs +87 -0
- package/dist/session-Dh0s6zG4.mjs.map +1 -0
- package/dist/universal-CMfrZ2hG.mjs +257 -0
- package/dist/universal-CMfrZ2hG.mjs.map +1 -0
- package/dist/universal-x33ZJODp.d.mts +137 -0
- package/dist/universal-x33ZJODp.d.mts.map +1 -0
- package/docs/country-packs.md +117 -0
- package/docs/engine.md +147 -0
- package/docs/exports.md +81 -0
- package/docs/money.md +81 -0
- package/docs/plugins.md +136 -0
- package/docs/reports.md +154 -0
- package/docs/repositories.md +239 -0
- package/docs/schemas.md +146 -0
- package/docs/subledger-integration.md +287 -0
- package/package.json +116 -0
|
@@ -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.
|
package/docs/exports.md
ADDED
|
@@ -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.
|
package/docs/plugins.md
ADDED
|
@@ -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).
|