@classytic/ledger 0.5.0 → 0.6.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 +2 -0
- package/dist/country/index.d.mts +2 -2
- package/dist/{errors-BmRjW38t.mjs → errors-CSDQPNyt.mjs} +1 -1
- package/dist/fx-realization.plugin-CgQFDGv2.mjs +459 -0
- package/dist/index-BthGypsI.d.mts +228 -0
- package/dist/index.d.mts +50 -105
- package/dist/index.mjs +644 -124
- package/dist/{fiscal-close-Dk3yRT9i.mjs → partner-ledger-D9H5hegI.mjs} +143 -6
- package/dist/plugins/index.d.mts +2 -24
- package/dist/plugins/index.mjs +2 -2
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -2
- package/dist/tax-hooks-BnVenul5.d.mts +513 -0
- package/dist/{trial-balance-BZ7yOOFD.d.mts → trial-balance-s92GEvRR.d.mts} +75 -2
- package/package.json +6 -5
- package/dist/date-lock.plugin-B2Jy0ukX.mjs +0 -253
- package/dist/idempotency.plugin-CK7LHnBn.d.mts +0 -60
- package/dist/index-GmfEFxVn.d.mts +0 -119
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { a as JOURNAL_CODES, c as getCustomJournalTypes, d as isValidJournalType, f as registerJournalType, i as isValidCurrency, l as getJournalType, n as getCurrency, o as JOURNAL_TYPES, r as getMinorUnit, s as _freezeJournalTypes, t as CURRENCIES, u as getJournalTypeCodes } from "./currencies-CsuBGfgs.mjs";
|
|
2
2
|
import { Money, add, allocate, format, formatPlain, fromDecimal, multiply, parseCents, percentage, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal } from "./money.mjs";
|
|
3
|
-
import { n as Errors, t as AccountingError } from "./errors-
|
|
4
|
-
import { C as
|
|
3
|
+
import { n as Errors, t as AccountingError } from "./errors-CSDQPNyt.mjs";
|
|
4
|
+
import { C as isVirtualTaxAccount, E as requireOrgScope, S as computeEndingBalance, T as generateAgedBalance, _ as buildItemFilters, a as finalizeSession, b as buildAccountTypeMap, c as generateRevaluation, d as generateIncomeStatement, f as generateGeneralLedger, g as generateBalanceSheet, h as generateBudgetVsActual, i as acquireSession, l as buildRevaluationEntry, m as generateCashFlow, n as closeFiscalPeriod, o as defaultLogger, p as generateDimensionBreakdown, r as reopenFiscalPeriod, s as generateTrialBalance, t as generatePartnerLedger, u as computeRevaluation, v as getDateRange, w as DEFAULT_BUCKETS, x as calculateTotal, y as getFiscalYearStart } from "./partner-ledger-D9H5hegI.mjs";
|
|
5
5
|
import { c as getNormalBalance, d as isValidCategory, l as isBalanceSheet, n as CATEGORY_KEYS, t as CATEGORIES, u as isIncomeStatement } from "./categories-BkKdv16V.mjs";
|
|
6
|
-
import { i as
|
|
6
|
+
import { a as taxLockPlugin, c as createLockPlugin, i as fiscalLockPlugin, l as idempotencyPlugin, n as creditLimitPlugin, o as watermarkResolver, r as dailyLockPlugin, s as periodResolver, t as fxRealizationPlugin, u as doubleEntryPlugin } from "./fx-realization.plugin-CgQFDGv2.mjs";
|
|
7
7
|
import { defineCountryPack } from "./country/index.mjs";
|
|
8
8
|
import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-BP-0Ni5W.mjs";
|
|
9
9
|
import mongoose, { Schema } from "mongoose";
|
|
@@ -273,6 +273,121 @@ function createFiscalPeriodSchema(config, options = {}) {
|
|
|
273
273
|
return schema;
|
|
274
274
|
}
|
|
275
275
|
//#endregion
|
|
276
|
+
//#region src/schemas/journal.schema.ts
|
|
277
|
+
/**
|
|
278
|
+
* Journal Schema Factory (0.6.0 — first-class Journal resource)
|
|
279
|
+
*
|
|
280
|
+
* A Journal is an organization-owned posting channel with its own
|
|
281
|
+
* reference-number sequence, permitted payment methods, optional default
|
|
282
|
+
* accounts, and source configuration for bank/statement imports.
|
|
283
|
+
*
|
|
284
|
+
* Journals are **optional** — a consumer that never seeds journals keeps
|
|
285
|
+
* the 0.5.x enum-only flow on journal entries. Consumers that do seed
|
|
286
|
+
* journals gain:
|
|
287
|
+
*
|
|
288
|
+
* - Per-journal reference-number prefix (e.g. `INV/2026/03/0042`)
|
|
289
|
+
* - Per-journal lock wiring (sale-lock on the sales journal, not globally)
|
|
290
|
+
* - Payment method restrictions
|
|
291
|
+
* - Bank-statement source binding for automated imports (0.7+)
|
|
292
|
+
*
|
|
293
|
+
* The schema is additive: existing journal entries without a `journal` ref
|
|
294
|
+
* keep working unchanged, and seed-from-pack is opt-in via
|
|
295
|
+
* `engine.repositories.journals.seedDefaults(orgId)`.
|
|
296
|
+
*/
|
|
297
|
+
function createJournalSchema(config, accountModelName, options = {}) {
|
|
298
|
+
const { multiTenant } = config;
|
|
299
|
+
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
300
|
+
const fields = {
|
|
301
|
+
code: {
|
|
302
|
+
type: String,
|
|
303
|
+
required: true,
|
|
304
|
+
trim: true
|
|
305
|
+
},
|
|
306
|
+
name: {
|
|
307
|
+
type: String,
|
|
308
|
+
required: true,
|
|
309
|
+
trim: true
|
|
310
|
+
},
|
|
311
|
+
journalType: {
|
|
312
|
+
type: String,
|
|
313
|
+
required: true
|
|
314
|
+
},
|
|
315
|
+
kind: {
|
|
316
|
+
type: String,
|
|
317
|
+
enum: [
|
|
318
|
+
"general",
|
|
319
|
+
"sale",
|
|
320
|
+
"purchase",
|
|
321
|
+
"bank",
|
|
322
|
+
"cash",
|
|
323
|
+
"misc"
|
|
324
|
+
],
|
|
325
|
+
default: "general",
|
|
326
|
+
required: true
|
|
327
|
+
},
|
|
328
|
+
sequencePrefix: {
|
|
329
|
+
type: String,
|
|
330
|
+
default: null
|
|
331
|
+
},
|
|
332
|
+
sequenceNextNum: {
|
|
333
|
+
type: Number,
|
|
334
|
+
default: 1,
|
|
335
|
+
min: 1
|
|
336
|
+
},
|
|
337
|
+
defaultDebitAccount: {
|
|
338
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
339
|
+
ref: accountModelName,
|
|
340
|
+
default: null
|
|
341
|
+
},
|
|
342
|
+
defaultCreditAccount: {
|
|
343
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
344
|
+
ref: accountModelName,
|
|
345
|
+
default: null
|
|
346
|
+
},
|
|
347
|
+
allowedPaymentMethods: {
|
|
348
|
+
type: [String],
|
|
349
|
+
default: []
|
|
350
|
+
},
|
|
351
|
+
source: {
|
|
352
|
+
type: String,
|
|
353
|
+
default: "manual"
|
|
354
|
+
},
|
|
355
|
+
active: {
|
|
356
|
+
type: Boolean,
|
|
357
|
+
default: true
|
|
358
|
+
},
|
|
359
|
+
...extraFields
|
|
360
|
+
};
|
|
361
|
+
if (multiTenant) fields[multiTenant.orgField] = {
|
|
362
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
363
|
+
ref: multiTenant.orgRef,
|
|
364
|
+
required: true
|
|
365
|
+
};
|
|
366
|
+
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
367
|
+
if (indexes) {
|
|
368
|
+
const org = multiTenant?.orgField;
|
|
369
|
+
if (org) {
|
|
370
|
+
schema.index({
|
|
371
|
+
[org]: 1,
|
|
372
|
+
code: 1
|
|
373
|
+
}, { unique: true });
|
|
374
|
+
schema.index({
|
|
375
|
+
[org]: 1,
|
|
376
|
+
kind: 1,
|
|
377
|
+
active: 1
|
|
378
|
+
});
|
|
379
|
+
} else {
|
|
380
|
+
schema.index({ code: 1 }, { unique: true });
|
|
381
|
+
schema.index({
|
|
382
|
+
kind: 1,
|
|
383
|
+
active: 1
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
388
|
+
return schema;
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
276
391
|
//#region src/schemas/journal-entry.schema.ts
|
|
277
392
|
/**
|
|
278
393
|
* Journal Entry Schema Factory
|
|
@@ -308,17 +423,19 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
308
423
|
message: "exchangeRate must be greater than zero when set, got {VALUE}"
|
|
309
424
|
}
|
|
310
425
|
};
|
|
426
|
+
const originalAmountValidator = {
|
|
427
|
+
validator: (v) => v === null || v === void 0 || Number.isInteger(v) && v >= 0,
|
|
428
|
+
message: "{PATH} must be a non-negative integer (cents), got {VALUE}"
|
|
429
|
+
};
|
|
311
430
|
currencyItemFields.originalDebit = {
|
|
312
431
|
type: Number,
|
|
313
432
|
default: null,
|
|
314
|
-
|
|
315
|
-
validate: amountValidator
|
|
433
|
+
validate: originalAmountValidator
|
|
316
434
|
};
|
|
317
435
|
currencyItemFields.originalCredit = {
|
|
318
436
|
type: Number,
|
|
319
437
|
default: null,
|
|
320
|
-
|
|
321
|
-
validate: amountValidator
|
|
438
|
+
validate: originalAmountValidator
|
|
322
439
|
};
|
|
323
440
|
}
|
|
324
441
|
const JournalItemSchema = new mongoose.Schema({
|
|
@@ -345,6 +462,14 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
345
462
|
type: [TaxDetailSchema],
|
|
346
463
|
default: []
|
|
347
464
|
},
|
|
465
|
+
matchingNumber: {
|
|
466
|
+
type: String,
|
|
467
|
+
default: null
|
|
468
|
+
},
|
|
469
|
+
maturityDate: {
|
|
470
|
+
type: Date,
|
|
471
|
+
default: null
|
|
472
|
+
},
|
|
348
473
|
...currencyItemFields,
|
|
349
474
|
...extraItemFields
|
|
350
475
|
}, { _id: false });
|
|
@@ -356,6 +481,10 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
356
481
|
default: JOURNAL_CODES.MISC,
|
|
357
482
|
required: true
|
|
358
483
|
},
|
|
484
|
+
journal: {
|
|
485
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
486
|
+
default: null
|
|
487
|
+
},
|
|
359
488
|
referenceNumber: { type: String },
|
|
360
489
|
label: { type: String },
|
|
361
490
|
date: {
|
|
@@ -572,6 +701,11 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
572
701
|
});
|
|
573
702
|
}
|
|
574
703
|
schema.index({ reversed: 1 });
|
|
704
|
+
if (org) schema.index({
|
|
705
|
+
[org]: 1,
|
|
706
|
+
"journalItems.matchingNumber": 1
|
|
707
|
+
});
|
|
708
|
+
else schema.index({ "journalItems.matchingNumber": 1 });
|
|
575
709
|
if (config.idempotency) {
|
|
576
710
|
const idempotencyIdx = {};
|
|
577
711
|
if (org) idempotencyIdx[org] = 1;
|
|
@@ -601,50 +735,112 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
601
735
|
//#endregion
|
|
602
736
|
//#region src/schemas/reconciliation.schema.ts
|
|
603
737
|
/**
|
|
604
|
-
* Reconciliation Schema Factory
|
|
738
|
+
* Reconciliation Schema Factory (0.6.0 — item-level open-item matching)
|
|
739
|
+
*
|
|
740
|
+
* A reconciliation is a **matching group** of journal items that together
|
|
741
|
+
* settle each other in whole or in part. Each group carries a stable
|
|
742
|
+
* `matchingNumber` string that is stamped onto every referenced journal
|
|
743
|
+
* item. A group is `isFullReconcile = true` iff debit/credit totals
|
|
744
|
+
* balance to zero — partial matches (a cheque paying 2 of 3 invoices) are
|
|
745
|
+
* represented by isFullReconcile=false.
|
|
746
|
+
*
|
|
747
|
+
* This replaces 0.5.x entry-level reconciliation, which could not represent
|
|
748
|
+
* the canonical AR/AP flow where one cheque covers multiple invoices or
|
|
749
|
+
* one invoice is paid by multiple cheques.
|
|
605
750
|
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
751
|
+
* A dedicated collection exists so that match/unmatch can atomically stamp
|
|
752
|
+
* `journalItems[i].matchingNumber`, update totals, and trigger the
|
|
753
|
+
* fxRealizationPlugin via mongokit hooks — without bumping the entries'
|
|
754
|
+
* `updatedAt` timestamps.
|
|
609
755
|
*/
|
|
610
756
|
function createReconciliationSchema(config, accountModelName, journalEntryModelName, options = {}) {
|
|
611
757
|
const { multiTenant } = config;
|
|
612
758
|
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
759
|
+
const MatchedItemRefSchema = new mongoose.Schema({
|
|
760
|
+
entry: {
|
|
761
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
762
|
+
ref: journalEntryModelName,
|
|
763
|
+
required: true
|
|
764
|
+
},
|
|
765
|
+
itemIndex: {
|
|
766
|
+
type: Number,
|
|
767
|
+
required: true,
|
|
768
|
+
min: 0,
|
|
769
|
+
validate: {
|
|
770
|
+
validator: Number.isInteger,
|
|
771
|
+
message: "itemIndex must be a non-negative integer"
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
debit: {
|
|
775
|
+
type: Number,
|
|
776
|
+
default: 0,
|
|
777
|
+
min: 0
|
|
778
|
+
},
|
|
779
|
+
credit: {
|
|
780
|
+
type: Number,
|
|
781
|
+
default: 0,
|
|
782
|
+
min: 0
|
|
783
|
+
},
|
|
784
|
+
amountCurrency: {
|
|
785
|
+
type: Number,
|
|
786
|
+
default: null
|
|
787
|
+
},
|
|
788
|
+
exchangeRate: {
|
|
789
|
+
type: Number,
|
|
790
|
+
default: null
|
|
791
|
+
}
|
|
792
|
+
}, { _id: false });
|
|
613
793
|
const fields = {
|
|
794
|
+
matchingNumber: {
|
|
795
|
+
type: String,
|
|
796
|
+
required: true
|
|
797
|
+
},
|
|
614
798
|
account: {
|
|
615
799
|
type: mongoose.Schema.Types.ObjectId,
|
|
616
800
|
ref: accountModelName,
|
|
617
801
|
required: true
|
|
618
802
|
},
|
|
619
|
-
|
|
620
|
-
type: [
|
|
621
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
622
|
-
ref: journalEntryModelName
|
|
623
|
-
}],
|
|
803
|
+
items: {
|
|
804
|
+
type: [MatchedItemRefSchema],
|
|
624
805
|
required: true,
|
|
625
806
|
validate: {
|
|
626
|
-
validator: (v) => Array.isArray(v) && v.length
|
|
627
|
-
message: "
|
|
807
|
+
validator: (v) => Array.isArray(v) && v.length >= 2,
|
|
808
|
+
message: "a reconciliation must reference at least two items"
|
|
628
809
|
}
|
|
629
810
|
},
|
|
630
811
|
debitTotal: {
|
|
631
812
|
type: Number,
|
|
632
|
-
required: true
|
|
813
|
+
required: true,
|
|
814
|
+
min: 0
|
|
633
815
|
},
|
|
634
816
|
creditTotal: {
|
|
635
817
|
type: Number,
|
|
636
|
-
required: true
|
|
818
|
+
required: true,
|
|
819
|
+
min: 0
|
|
637
820
|
},
|
|
638
821
|
difference: {
|
|
639
822
|
type: Number,
|
|
640
823
|
default: 0
|
|
641
824
|
},
|
|
825
|
+
isFullReconcile: {
|
|
826
|
+
type: Boolean,
|
|
827
|
+
default: false
|
|
828
|
+
},
|
|
829
|
+
currency: {
|
|
830
|
+
type: String,
|
|
831
|
+
default: null
|
|
832
|
+
},
|
|
642
833
|
note: { type: String },
|
|
643
834
|
reconciledBy: { type: String },
|
|
644
835
|
reconciledAt: {
|
|
645
836
|
type: Date,
|
|
646
837
|
default: Date.now
|
|
647
838
|
},
|
|
839
|
+
fxRealizationEntry: {
|
|
840
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
841
|
+
ref: journalEntryModelName,
|
|
842
|
+
default: null
|
|
843
|
+
},
|
|
648
844
|
...extraFields
|
|
649
845
|
};
|
|
650
846
|
if (multiTenant) fields[multiTenant.orgField] = {
|
|
@@ -654,18 +850,31 @@ function createReconciliationSchema(config, accountModelName, journalEntryModelN
|
|
|
654
850
|
};
|
|
655
851
|
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
656
852
|
if (indexes) {
|
|
657
|
-
|
|
658
|
-
|
|
853
|
+
const org = multiTenant?.orgField;
|
|
854
|
+
if (org) {
|
|
855
|
+
schema.index({
|
|
856
|
+
[org]: 1,
|
|
857
|
+
matchingNumber: 1
|
|
858
|
+
}, { unique: true });
|
|
659
859
|
schema.index({
|
|
660
860
|
[org]: 1,
|
|
661
861
|
account: 1,
|
|
862
|
+
isFullReconcile: 1,
|
|
662
863
|
reconciledAt: 1
|
|
663
864
|
});
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
865
|
+
schema.index({
|
|
866
|
+
[org]: 1,
|
|
867
|
+
"items.entry": 1
|
|
868
|
+
});
|
|
869
|
+
} else {
|
|
870
|
+
schema.index({ matchingNumber: 1 }, { unique: true });
|
|
871
|
+
schema.index({
|
|
872
|
+
account: 1,
|
|
873
|
+
isFullReconcile: 1,
|
|
874
|
+
reconciledAt: 1
|
|
875
|
+
});
|
|
876
|
+
schema.index({ "items.entry": 1 });
|
|
877
|
+
}
|
|
669
878
|
}
|
|
670
879
|
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
671
880
|
return schema;
|
|
@@ -678,33 +887,29 @@ function resolveModelNames(overrides) {
|
|
|
678
887
|
journalEntry: overrides?.journalEntry ?? "JournalEntry",
|
|
679
888
|
fiscalPeriod: overrides?.fiscalPeriod ?? "FiscalPeriod",
|
|
680
889
|
budget: overrides?.budget ?? "Budget",
|
|
681
|
-
reconciliation: overrides?.reconciliation ?? "Reconciliation"
|
|
890
|
+
reconciliation: overrides?.reconciliation ?? "Reconciliation",
|
|
891
|
+
journal: overrides?.journal ?? "Journal"
|
|
682
892
|
};
|
|
683
893
|
}
|
|
684
|
-
/**
|
|
685
|
-
* Create (or reuse) all ledger models on the given connection.
|
|
686
|
-
*
|
|
687
|
-
* If a model with the same name is already registered on the connection,
|
|
688
|
-
* the existing model is reused — this allows multiple engine instances
|
|
689
|
-
* to share models and prevents "OverwriteModelError".
|
|
690
|
-
*/
|
|
691
894
|
function createModels(connection, config) {
|
|
692
895
|
const names = resolveModelNames(config.modelNames);
|
|
693
896
|
const so = config.schemaOptions ?? {};
|
|
694
897
|
const existing = connection.models;
|
|
695
|
-
if (existing[names.account] && existing[names.journalEntry] && existing[names.fiscalPeriod] && existing[names.budget] && existing[names.reconciliation]) return {
|
|
898
|
+
if (existing[names.account] && existing[names.journalEntry] && existing[names.fiscalPeriod] && existing[names.budget] && existing[names.reconciliation] && existing[names.journal]) return {
|
|
696
899
|
Account: existing[names.account],
|
|
697
900
|
JournalEntry: existing[names.journalEntry],
|
|
698
901
|
FiscalPeriod: existing[names.fiscalPeriod],
|
|
699
902
|
Budget: existing[names.budget],
|
|
700
|
-
Reconciliation: existing[names.reconciliation]
|
|
903
|
+
Reconciliation: existing[names.reconciliation],
|
|
904
|
+
Journal: existing[names.journal]
|
|
701
905
|
};
|
|
702
906
|
return {
|
|
703
907
|
Account: existing[names.account] ?? connection.model(names.account, createAccountSchema(config, so.account)),
|
|
704
908
|
JournalEntry: existing[names.journalEntry] ?? connection.model(names.journalEntry, createJournalEntrySchema(config, names.account, so.journalEntry)),
|
|
705
909
|
FiscalPeriod: existing[names.fiscalPeriod] ?? connection.model(names.fiscalPeriod, createFiscalPeriodSchema(config, so.fiscalPeriod)),
|
|
706
910
|
Budget: existing[names.budget] ?? connection.model(names.budget, createBudgetSchema(config, so.budget)),
|
|
707
|
-
Reconciliation: existing[names.reconciliation] ?? connection.model(names.reconciliation, createReconciliationSchema(config, names.account, names.journalEntry, so.reconciliation))
|
|
911
|
+
Reconciliation: existing[names.reconciliation] ?? connection.model(names.reconciliation, createReconciliationSchema(config, names.account, names.journalEntry, so.reconciliation)),
|
|
912
|
+
Journal: existing[names.journal] ?? connection.model(names.journal, createJournalSchema(config, names.account, so.journal))
|
|
708
913
|
};
|
|
709
914
|
}
|
|
710
915
|
//#endregion
|
|
@@ -901,6 +1106,104 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
901
1106
|
return repository;
|
|
902
1107
|
}
|
|
903
1108
|
//#endregion
|
|
1109
|
+
//#region src/repositories/journal.repository.ts
|
|
1110
|
+
/**
|
|
1111
|
+
* Lean default set used when a country pack doesn't provide
|
|
1112
|
+
* `journalTemplates`. Covers the Stripe/QuickBooks/Xero baseline.
|
|
1113
|
+
*/
|
|
1114
|
+
const DEFAULT_TEMPLATES = [
|
|
1115
|
+
{
|
|
1116
|
+
code: "SALES",
|
|
1117
|
+
name: "Sales",
|
|
1118
|
+
journalType: "SALES",
|
|
1119
|
+
kind: "sale",
|
|
1120
|
+
sequencePrefix: "INV"
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
code: "PURCHASE",
|
|
1124
|
+
name: "Purchases",
|
|
1125
|
+
journalType: "PURCHASES",
|
|
1126
|
+
kind: "purchase",
|
|
1127
|
+
sequencePrefix: "BILL"
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
code: "BANK",
|
|
1131
|
+
name: "Bank",
|
|
1132
|
+
journalType: "CASH_RECEIPTS",
|
|
1133
|
+
kind: "bank",
|
|
1134
|
+
sequencePrefix: "BNK"
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
code: "CASH",
|
|
1138
|
+
name: "Cash",
|
|
1139
|
+
journalType: "CASH_PAYMENTS",
|
|
1140
|
+
kind: "cash",
|
|
1141
|
+
sequencePrefix: "CSH"
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
code: "MISC",
|
|
1145
|
+
name: "Miscellaneous",
|
|
1146
|
+
journalType: "MISC",
|
|
1147
|
+
kind: "general",
|
|
1148
|
+
sequencePrefix: "JE"
|
|
1149
|
+
}
|
|
1150
|
+
];
|
|
1151
|
+
function wireJournalMethods(repository, country, orgField) {
|
|
1152
|
+
const create = repository.create.bind(repository);
|
|
1153
|
+
const exists = repository.exists.bind(repository);
|
|
1154
|
+
repository.seedDefaults = async (orgId) => {
|
|
1155
|
+
requireOrgScope(orgField, orgId);
|
|
1156
|
+
const templates = country.journalTemplates ?? DEFAULT_TEMPLATES;
|
|
1157
|
+
let created = 0;
|
|
1158
|
+
let skipped = 0;
|
|
1159
|
+
for (const tpl of templates) {
|
|
1160
|
+
const existingQuery = { code: tpl.code };
|
|
1161
|
+
if (orgField && orgId != null) existingQuery[orgField] = orgId;
|
|
1162
|
+
if (await exists(existingQuery)) {
|
|
1163
|
+
skipped += 1;
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
const data = {
|
|
1167
|
+
code: tpl.code,
|
|
1168
|
+
name: tpl.name,
|
|
1169
|
+
journalType: tpl.journalType,
|
|
1170
|
+
kind: tpl.kind ?? "general",
|
|
1171
|
+
sequencePrefix: tpl.sequencePrefix ?? tpl.code,
|
|
1172
|
+
sequenceNextNum: tpl.sequenceStartNum ?? 1,
|
|
1173
|
+
active: true
|
|
1174
|
+
};
|
|
1175
|
+
if (orgField && orgId != null) data[orgField] = orgId;
|
|
1176
|
+
await create(data);
|
|
1177
|
+
created += 1;
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
created,
|
|
1181
|
+
skipped
|
|
1182
|
+
};
|
|
1183
|
+
};
|
|
1184
|
+
repository.nextSequenceNumber = async (journalId, orgId) => {
|
|
1185
|
+
requireOrgScope(orgField, orgId);
|
|
1186
|
+
const query = { _id: journalId };
|
|
1187
|
+
if (orgField && orgId != null) query[orgField] = orgId;
|
|
1188
|
+
const updated = await repository._executeQuery(async (Model) => Model.findOneAndUpdate(query, { $inc: { sequenceNextNum: 1 } }, { returnDocument: "after" }).lean());
|
|
1189
|
+
if (!updated) throw Errors.notFound(`Journal ${String(journalId)} not found`);
|
|
1190
|
+
const next = (updated.sequenceNextNum ?? 1) - 1;
|
|
1191
|
+
const prefix = updated.sequencePrefix ?? updated.code ?? "JE";
|
|
1192
|
+
const now = /* @__PURE__ */ new Date();
|
|
1193
|
+
return `${prefix}/${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(next).padStart(4, "0")}`;
|
|
1194
|
+
};
|
|
1195
|
+
if (typeof repository.registerMethod === "function") for (const name of ["seedDefaults", "nextSequenceNumber"]) {
|
|
1196
|
+
const fn = repository[name];
|
|
1197
|
+
try {
|
|
1198
|
+
delete repository[name];
|
|
1199
|
+
repository.registerMethod(name, fn);
|
|
1200
|
+
} catch {
|
|
1201
|
+
repository[name] = fn;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return repository;
|
|
1205
|
+
}
|
|
1206
|
+
//#endregion
|
|
904
1207
|
//#region src/repositories/journal-entry.repository.ts
|
|
905
1208
|
/** Keys that are either handled explicitly or must not be copied */
|
|
906
1209
|
const ITEM_CORE_KEYS = new Set([
|
|
@@ -927,7 +1230,43 @@ const ITEM_CORE_KEYS = new Set([
|
|
|
927
1230
|
function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
|
|
928
1231
|
const getByQuery = repository.getByQuery.bind(repository);
|
|
929
1232
|
const create = repository.create.bind(repository);
|
|
1233
|
+
const update = repository.update.bind(repository);
|
|
930
1234
|
const withTransaction = repository.withTransaction.bind(repository);
|
|
1235
|
+
const RESERVED_TOPLEVEL = new Set([
|
|
1236
|
+
"_id",
|
|
1237
|
+
"__v",
|
|
1238
|
+
"id",
|
|
1239
|
+
"journalType",
|
|
1240
|
+
"state",
|
|
1241
|
+
"date",
|
|
1242
|
+
"label",
|
|
1243
|
+
"journalItems",
|
|
1244
|
+
"totalDebit",
|
|
1245
|
+
"totalCredit",
|
|
1246
|
+
"reversalOf",
|
|
1247
|
+
"reversedBy",
|
|
1248
|
+
"reversedByUser",
|
|
1249
|
+
"reversed",
|
|
1250
|
+
"stateChangedAt",
|
|
1251
|
+
"createdAt",
|
|
1252
|
+
"updatedAt",
|
|
1253
|
+
"referenceNumber",
|
|
1254
|
+
"idempotencyKey",
|
|
1255
|
+
"postedBy",
|
|
1256
|
+
"approvedBy",
|
|
1257
|
+
"approvedAt"
|
|
1258
|
+
]);
|
|
1259
|
+
/** Copy non-reserved top-level fields from `source` onto `target`. */
|
|
1260
|
+
function copyExtraTopLevel(source, target) {
|
|
1261
|
+
const obj = typeof source.toObject === "function" ? source.toObject() : source;
|
|
1262
|
+
for (const key of Object.keys(obj)) {
|
|
1263
|
+
if (RESERVED_TOPLEVEL.has(key)) continue;
|
|
1264
|
+
if (key in target) continue;
|
|
1265
|
+
const value = obj[key];
|
|
1266
|
+
if (value === void 0 || value === null) continue;
|
|
1267
|
+
target[key] = value;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
931
1270
|
/** Build a tenant-scoped query for a single entry by ID (injection-safe) */
|
|
932
1271
|
function buildQuery(id, orgId) {
|
|
933
1272
|
validateScalarId(id, "entry ID");
|
|
@@ -993,11 +1332,16 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
993
1332
|
const totalDebit = entry.journalItems.reduce((s, i) => s + (i.debit || 0), 0);
|
|
994
1333
|
const totalCredit = entry.journalItems.reduce((s, i) => s + (i.credit || 0), 0);
|
|
995
1334
|
if (totalDebit !== totalCredit) throw Errors.validation(`Entry is not balanced. Debit: ${totalDebit}, Credit: ${totalCredit}`);
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1335
|
+
const patch = {
|
|
1336
|
+
state: "posted",
|
|
1337
|
+
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1338
|
+
};
|
|
1339
|
+
if (options.actorId) patch.postedBy = options.actorId;
|
|
1340
|
+
const updateOptions = {
|
|
1341
|
+
_ledgerInternal: "post",
|
|
1342
|
+
...options.session ? { session: options.session } : {}
|
|
1343
|
+
};
|
|
1344
|
+
return await update(entry._id, patch, updateOptions) ?? entry;
|
|
1001
1345
|
};
|
|
1002
1346
|
/**
|
|
1003
1347
|
* Unpost an entry (posted → draft).
|
|
@@ -1012,10 +1356,14 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1012
1356
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
1013
1357
|
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be unposted");
|
|
1014
1358
|
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.");
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1359
|
+
const updateOptions = {
|
|
1360
|
+
_ledgerInternal: "unpost",
|
|
1361
|
+
...options.session ? { session: options.session } : {}
|
|
1362
|
+
};
|
|
1363
|
+
return await update(entry._id, {
|
|
1364
|
+
state: "draft",
|
|
1365
|
+
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1366
|
+
}, updateOptions) ?? entry;
|
|
1019
1367
|
};
|
|
1020
1368
|
/**
|
|
1021
1369
|
* Archive a draft entry (draft → archived).
|
|
@@ -1028,10 +1376,14 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1028
1376
|
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
1029
1377
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
1030
1378
|
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1379
|
+
const updateOptions = {
|
|
1380
|
+
_ledgerInternal: "archive",
|
|
1381
|
+
...options.session ? { session: options.session } : {}
|
|
1382
|
+
};
|
|
1383
|
+
return await update(entry._id, {
|
|
1384
|
+
state: "archived",
|
|
1385
|
+
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1386
|
+
}, updateOptions) ?? entry;
|
|
1035
1387
|
};
|
|
1036
1388
|
/**
|
|
1037
1389
|
* Duplicate an entry as a new draft.
|
|
@@ -1061,7 +1413,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1061
1413
|
};
|
|
1062
1414
|
})
|
|
1063
1415
|
};
|
|
1064
|
-
|
|
1416
|
+
copyExtraTopLevel(entry, duplicateData);
|
|
1065
1417
|
return await create(duplicateData, options.session ? { session: options.session } : {});
|
|
1066
1418
|
};
|
|
1067
1419
|
/**
|
|
@@ -1113,15 +1465,20 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1113
1465
|
reversalOf: entry._id,
|
|
1114
1466
|
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1115
1467
|
};
|
|
1116
|
-
|
|
1468
|
+
copyExtraTopLevel(entry, reversalData);
|
|
1117
1469
|
if (options.actorId) reversalData.postedBy = options.actorId;
|
|
1118
1470
|
const reversalEntry = await create(reversalData, session ? { session } : {});
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1471
|
+
const markPatch = {
|
|
1472
|
+
reversed: true,
|
|
1473
|
+
reversedBy: reversalEntry._id
|
|
1474
|
+
};
|
|
1475
|
+
if (options.actorId) markPatch.reversedByUser = options.actorId;
|
|
1476
|
+
const markOptions = {
|
|
1477
|
+
_ledgerInternal: "reverseMark",
|
|
1478
|
+
...session ? { session } : {}
|
|
1479
|
+
};
|
|
1123
1480
|
return {
|
|
1124
|
-
original: entry,
|
|
1481
|
+
original: await update(entry._id, markPatch, markOptions) ?? entry,
|
|
1125
1482
|
reversal: reversalEntry
|
|
1126
1483
|
};
|
|
1127
1484
|
};
|
|
@@ -1150,91 +1507,186 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1150
1507
|
//#endregion
|
|
1151
1508
|
//#region src/repositories/reconciliation.repository.ts
|
|
1152
1509
|
/**
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
* - Cross-repo reads (JournalEntryModel) use direct Model access (acceptable)
|
|
1510
|
+
* Default matching-number generator — atomic counter stored in the
|
|
1511
|
+
* reconciliation collection. Uses a dedicated sentinel document keyed
|
|
1512
|
+
* `{ matchingNumber: '__counter__', [org]: orgId }` so each org has its
|
|
1513
|
+
* own counter, safe under concurrent match calls.
|
|
1158
1514
|
*/
|
|
1159
|
-
function
|
|
1515
|
+
async function nextMatchingNumber(ReconciliationModel, orgField, orgId, session) {
|
|
1516
|
+
const counterQuery = { matchingNumber: "__counter__" };
|
|
1517
|
+
if (orgField && orgId != null) counterQuery[orgField] = orgId;
|
|
1518
|
+
const seq = (await ReconciliationModel.findOneAndUpdate(counterQuery, {
|
|
1519
|
+
$inc: { seq: 1 },
|
|
1520
|
+
$setOnInsert: {
|
|
1521
|
+
account: null,
|
|
1522
|
+
items: [{
|
|
1523
|
+
entry: null,
|
|
1524
|
+
itemIndex: 0
|
|
1525
|
+
}, {
|
|
1526
|
+
entry: null,
|
|
1527
|
+
itemIndex: 1
|
|
1528
|
+
}],
|
|
1529
|
+
debitTotal: 0,
|
|
1530
|
+
creditTotal: 0
|
|
1531
|
+
}
|
|
1532
|
+
}, {
|
|
1533
|
+
new: true,
|
|
1534
|
+
upsert: true,
|
|
1535
|
+
session,
|
|
1536
|
+
strict: false
|
|
1537
|
+
}).lean())?.seq ?? 1;
|
|
1538
|
+
return `RECN-${String(seq).padStart(6, "0")}`;
|
|
1539
|
+
}
|
|
1540
|
+
function wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField) {
|
|
1160
1541
|
const create = repository.create.bind(repository);
|
|
1161
1542
|
const deleteById = repository.delete.bind(repository);
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
repository.reconcile = async (input) => {
|
|
1167
|
-
const { account, journalEntryIds, note, reconciledBy, organizationId } = input;
|
|
1543
|
+
const repoInstance = repository;
|
|
1544
|
+
repository.match = async (input) => {
|
|
1545
|
+
const { account, items, note, reconciledBy, organizationId, session = null } = input;
|
|
1546
|
+
let { matchingNumber } = input;
|
|
1168
1547
|
requireOrgScope(orgField, organizationId);
|
|
1169
|
-
if (!
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1548
|
+
if (!Array.isArray(items) || items.length < 2) throw Errors.validation("match() requires at least two items");
|
|
1549
|
+
const entryIds = Array.from(new Set(items.map((i) => String(i.entry))));
|
|
1550
|
+
const entryQuery = { _id: { $in: entryIds } };
|
|
1551
|
+
if (orgField && organizationId != null) entryQuery[orgField] = organizationId;
|
|
1552
|
+
const entries = await JournalEntryModel.find(entryQuery).session(session).lean();
|
|
1553
|
+
if (entries.length !== entryIds.length) throw Errors.notFound(`Expected ${entryIds.length} entries but found ${entries.length}. Some do not exist or belong to a different organization.`);
|
|
1554
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
1555
|
+
for (const e of entries) entryMap.set(String(e._id), e);
|
|
1176
1556
|
const accountStr = String(account);
|
|
1177
|
-
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}.`);
|
|
1178
1557
|
let debitTotal = 0;
|
|
1179
1558
|
let creditTotal = 0;
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1559
|
+
const currencies = /* @__PURE__ */ new Set();
|
|
1560
|
+
const itemSnapshots = [];
|
|
1561
|
+
for (const ref of items) {
|
|
1562
|
+
const entry = entryMap.get(String(ref.entry));
|
|
1563
|
+
if (!entry) throw Errors.notFound(`Entry ${String(ref.entry)} not found in match input`);
|
|
1564
|
+
if (entry.state !== "posted") throw Errors.validation(`Entry ${String(entry._id)} is not posted — only posted entries can be matched`);
|
|
1565
|
+
const item = entry.journalItems[ref.itemIndex];
|
|
1566
|
+
if (!item) throw Errors.validation(`Entry ${String(entry._id)} has no item at index ${ref.itemIndex}`);
|
|
1567
|
+
if (String(item.account) !== accountStr) throw Errors.validation(`Item ${String(entry._id)}[${ref.itemIndex}] is on a different account than the match`);
|
|
1568
|
+
if (item.matchingNumber) throw Errors.conflict(`Item ${String(entry._id)}[${ref.itemIndex}] is already matched (${item.matchingNumber})`);
|
|
1569
|
+
const debit = item.debit ?? 0;
|
|
1570
|
+
const credit = item.credit ?? 0;
|
|
1571
|
+
debitTotal += debit;
|
|
1572
|
+
creditTotal += credit;
|
|
1573
|
+
if (item.currency) currencies.add(item.currency);
|
|
1574
|
+
const amountCurrency = item.originalDebit != null || item.originalCredit != null ? (item.originalDebit ?? 0) - (item.originalCredit ?? 0) : null;
|
|
1575
|
+
itemSnapshots.push({
|
|
1576
|
+
entry: entry._id,
|
|
1577
|
+
itemIndex: ref.itemIndex,
|
|
1578
|
+
debit,
|
|
1579
|
+
credit,
|
|
1580
|
+
amountCurrency,
|
|
1581
|
+
exchangeRate: item.exchangeRate ?? null
|
|
1582
|
+
});
|
|
1183
1583
|
}
|
|
1584
|
+
const difference = debitTotal - creditTotal;
|
|
1585
|
+
const isFullReconcile = difference === 0;
|
|
1586
|
+
const sharedCurrency = currencies.size === 1 ? Array.from(currencies)[0] : null;
|
|
1587
|
+
if (!matchingNumber) matchingNumber = await nextMatchingNumber(ReconciliationModel, orgField, organizationId, session);
|
|
1588
|
+
const bulkOps = itemSnapshots.map((snap) => ({ updateOne: {
|
|
1589
|
+
filter: { _id: snap.entry },
|
|
1590
|
+
update: { $set: { [`journalItems.${snap.itemIndex}.matchingNumber`]: matchingNumber } }
|
|
1591
|
+
} }));
|
|
1592
|
+
await JournalEntryModel.bulkWrite(bulkOps, { session: session ?? void 0 });
|
|
1184
1593
|
const reconciliationData = {
|
|
1594
|
+
matchingNumber,
|
|
1185
1595
|
account,
|
|
1186
|
-
|
|
1596
|
+
items: itemSnapshots.map((s) => ({
|
|
1597
|
+
entry: s.entry,
|
|
1598
|
+
itemIndex: s.itemIndex,
|
|
1599
|
+
debit: s.debit,
|
|
1600
|
+
credit: s.credit,
|
|
1601
|
+
amountCurrency: s.amountCurrency,
|
|
1602
|
+
exchangeRate: s.exchangeRate
|
|
1603
|
+
})),
|
|
1187
1604
|
debitTotal,
|
|
1188
1605
|
creditTotal,
|
|
1189
|
-
difference
|
|
1606
|
+
difference,
|
|
1607
|
+
isFullReconcile,
|
|
1608
|
+
currency: sharedCurrency,
|
|
1190
1609
|
note,
|
|
1191
1610
|
reconciledBy,
|
|
1192
1611
|
reconciledAt: /* @__PURE__ */ new Date()
|
|
1193
1612
|
};
|
|
1194
1613
|
if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
|
|
1195
|
-
|
|
1614
|
+
const record = await create(reconciliationData);
|
|
1615
|
+
const emit = repoInstance._emitHook;
|
|
1616
|
+
if (emit) await emit.call(repoInstance, "after:match", {
|
|
1617
|
+
reconciliation: record,
|
|
1618
|
+
items: itemSnapshots,
|
|
1619
|
+
sharedCurrency,
|
|
1620
|
+
session
|
|
1621
|
+
});
|
|
1622
|
+
return record;
|
|
1196
1623
|
};
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
*/
|
|
1200
|
-
repository.unreconcile = async (input) => {
|
|
1201
|
-
const { reconciliationId, organizationId } = input;
|
|
1624
|
+
repository.unmatch = async (input) => {
|
|
1625
|
+
const { matchingNumber, organizationId, session = null } = input;
|
|
1202
1626
|
requireOrgScope(orgField, organizationId);
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1627
|
+
const query = { matchingNumber };
|
|
1628
|
+
if (orgField && organizationId != null) query[orgField] = organizationId;
|
|
1629
|
+
const existing = await ReconciliationModel.findOne(query).session(session).lean();
|
|
1630
|
+
if (!existing) throw Errors.notFound(`Reconciliation ${matchingNumber} not found`);
|
|
1631
|
+
const bulkOps = (existing.items ?? []).map((it) => ({ updateOne: {
|
|
1632
|
+
filter: { _id: it.entry },
|
|
1633
|
+
update: { $set: { [`journalItems.${it.itemIndex}.matchingNumber`]: null } }
|
|
1634
|
+
} }));
|
|
1635
|
+
if (bulkOps.length > 0) await JournalEntryModel.bulkWrite(bulkOps, { session: session ?? void 0 });
|
|
1636
|
+
const result = await deleteById(String(existing._id));
|
|
1637
|
+
if (!result.success) throw Errors.notFound("Failed to delete reconciliation record");
|
|
1211
1638
|
return result;
|
|
1212
1639
|
};
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
* Uses repository.getAll() for reconciliation lookups (hooks fire),
|
|
1216
|
-
* and direct JournalEntryModel for cross-repo reads (acceptable).
|
|
1217
|
-
*/
|
|
1218
|
-
repository.getUnreconciled = async (input) => {
|
|
1219
|
-
const { accountId, organizationId, limit = 100, skip = 0 } = input;
|
|
1640
|
+
repository.getOpenItems = async (params) => {
|
|
1641
|
+
const { accountId, organizationId, filter, asOfDate, limit = 100, skip = 0 } = params;
|
|
1220
1642
|
requireOrgScope(orgField, organizationId);
|
|
1221
|
-
const
|
|
1222
|
-
if (orgField && organizationId != null)
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1643
|
+
const match = { state: "posted" };
|
|
1644
|
+
if (orgField && organizationId != null) match[orgField] = organizationId;
|
|
1645
|
+
if (asOfDate) match.date = { $lte: asOfDate };
|
|
1646
|
+
const pipeline = [
|
|
1647
|
+
{ $match: match },
|
|
1648
|
+
{ $project: {
|
|
1649
|
+
_id: 1,
|
|
1650
|
+
date: 1,
|
|
1651
|
+
referenceNumber: 1,
|
|
1652
|
+
journalItems: 1
|
|
1653
|
+
} },
|
|
1654
|
+
{ $addFields: { journalItems: { $map: {
|
|
1655
|
+
input: { $range: [0, { $size: "$journalItems" }] },
|
|
1656
|
+
as: "idx",
|
|
1657
|
+
in: { $mergeObjects: [{ $arrayElemAt: ["$journalItems", "$$idx"] }, { _itemIndex: "$$idx" }] }
|
|
1658
|
+
} } } },
|
|
1659
|
+
{ $unwind: "$journalItems" },
|
|
1660
|
+
{ $match: {
|
|
1661
|
+
"journalItems.account": accountId,
|
|
1662
|
+
$or: [{ "journalItems.matchingNumber": null }, { "journalItems.matchingNumber": { $exists: false } }],
|
|
1663
|
+
...filter ? Object.fromEntries(Object.entries(filter).map(([k, v]) => [`journalItems.${k}`, v])) : {}
|
|
1664
|
+
} },
|
|
1665
|
+
{ $project: {
|
|
1666
|
+
_id: 0,
|
|
1667
|
+
entry: "$_id",
|
|
1668
|
+
itemIndex: "$journalItems._itemIndex",
|
|
1669
|
+
debit: { $ifNull: ["$journalItems.debit", 0] },
|
|
1670
|
+
credit: { $ifNull: ["$journalItems.credit", 0] },
|
|
1671
|
+
date: { $ifNull: ["$journalItems.date", "$date"] },
|
|
1672
|
+
maturityDate: "$journalItems.maturityDate",
|
|
1673
|
+
account: "$journalItems.account",
|
|
1674
|
+
currency: "$journalItems.currency",
|
|
1675
|
+
exchangeRate: "$journalItems.exchangeRate",
|
|
1676
|
+
label: "$journalItems.label",
|
|
1677
|
+
referenceNumber: 1,
|
|
1678
|
+
item: "$journalItems"
|
|
1679
|
+
} },
|
|
1680
|
+
{ $sort: { date: 1 } },
|
|
1681
|
+
{ $skip: skip },
|
|
1682
|
+
{ $limit: limit }
|
|
1683
|
+
];
|
|
1684
|
+
return await JournalEntryModel.aggregate(pipeline);
|
|
1233
1685
|
};
|
|
1234
1686
|
if (typeof repository.registerMethod === "function") for (const name of [
|
|
1235
|
-
"
|
|
1236
|
-
"
|
|
1237
|
-
"
|
|
1687
|
+
"match",
|
|
1688
|
+
"unmatch",
|
|
1689
|
+
"getOpenItems"
|
|
1238
1690
|
]) {
|
|
1239
1691
|
const fn = repository[name];
|
|
1240
1692
|
try {
|
|
@@ -1293,12 +1745,18 @@ function createRepositories(models, config, plugins = {}, pagination = {}) {
|
|
|
1293
1745
|
JournalEntryModel: models.JournalEntry,
|
|
1294
1746
|
orgField
|
|
1295
1747
|
}));
|
|
1748
|
+
const journalEntries = wireJournalEntryMethods(new Repository(models.JournalEntry, jePlugins, jePagination), models.JournalEntry, orgField, strictness);
|
|
1749
|
+
const fiscalPeriods = new Repository(models.FiscalPeriod, plugins.fiscalPeriod ?? [], fpPagination);
|
|
1750
|
+
const budgets = new Repository(models.Budget, plugins.budget ?? [], budgetPagination);
|
|
1751
|
+
const reconciliations = wireReconciliationMethods(new Repository(models.Reconciliation, plugins.reconciliation ?? [], reconPagination), models.Reconciliation, models.JournalEntry, orgField);
|
|
1752
|
+
const journalPagination = pagination.journal ?? {};
|
|
1296
1753
|
return {
|
|
1297
1754
|
accounts,
|
|
1298
|
-
journalEntries
|
|
1299
|
-
fiscalPeriods
|
|
1300
|
-
budgets
|
|
1301
|
-
reconciliations
|
|
1755
|
+
journalEntries,
|
|
1756
|
+
fiscalPeriods,
|
|
1757
|
+
budgets,
|
|
1758
|
+
reconciliations,
|
|
1759
|
+
journals: wireJournalMethods(new Repository(models.Journal, plugins.journal ?? [], journalPagination), country, orgField)
|
|
1302
1760
|
};
|
|
1303
1761
|
}
|
|
1304
1762
|
//#endregion
|
|
@@ -1992,6 +2450,68 @@ function createAccountingEngine(config) {
|
|
|
1992
2450
|
return new AccountingEngine(config);
|
|
1993
2451
|
}
|
|
1994
2452
|
//#endregion
|
|
2453
|
+
//#region src/utils/repartition-tax.ts
|
|
2454
|
+
/**
|
|
2455
|
+
* Default role resolver used when the country pack doesn't override.
|
|
2456
|
+
* Walks `taxCodes` to find a code whose `direction` matches the role.
|
|
2457
|
+
*/
|
|
2458
|
+
function defaultResolveRoleCode(role, _tax, country) {
|
|
2459
|
+
const direction = role === "collected" ? "collected" : role === "recoverable" ? "recoverable" : null;
|
|
2460
|
+
if (!direction) return void 0;
|
|
2461
|
+
for (const tc of Object.values(country.taxCodes)) if (tc.direction === direction) return tc.code;
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Build a `TaxLineGenerator` that expands each hit `taxCode` into one
|
|
2465
|
+
* journal item per repartition line. Taxes without `repartition` fall
|
|
2466
|
+
* back to a single-line generator using the direction-implied account.
|
|
2467
|
+
*/
|
|
2468
|
+
function createRepartitionTaxGenerator(options) {
|
|
2469
|
+
const { country, resolveAccount, documentType = "invoice" } = options;
|
|
2470
|
+
return { generateTaxLines(input) {
|
|
2471
|
+
const code = input.taxCode;
|
|
2472
|
+
if (!code) return [];
|
|
2473
|
+
const tax = country.taxCodes[code];
|
|
2474
|
+
if (!tax) return [];
|
|
2475
|
+
const baseTax = Math.round(input.amount * tax.rate / 100);
|
|
2476
|
+
if (baseTax === 0) return [];
|
|
2477
|
+
const lines = tax.repartition && tax.repartition.length > 0 ? tax.repartition.filter((line) => !line.documentTypes || line.documentTypes.includes(documentType)) : [{
|
|
2478
|
+
factor: 1,
|
|
2479
|
+
accountRole: tax.direction === "recoverable" ? "recoverable" : "collected",
|
|
2480
|
+
gridCode: tax.reportLines?.[0]
|
|
2481
|
+
}];
|
|
2482
|
+
const generated = [];
|
|
2483
|
+
for (const rep of lines) {
|
|
2484
|
+
const signed = Math.round(baseTax * rep.factor);
|
|
2485
|
+
if (signed === 0) continue;
|
|
2486
|
+
const account = resolveAccount(rep.accountRole, tax, input);
|
|
2487
|
+
if (!account) throw new Error(`repartitionTax: cannot resolve account for role "${rep.accountRole}" on tax "${tax.code}"`);
|
|
2488
|
+
const absAmount = Math.abs(signed);
|
|
2489
|
+
let onCredit = rep.accountRole === "collected" || rep.accountRole === "transition";
|
|
2490
|
+
if (signed < 0) onCredit = !onCredit;
|
|
2491
|
+
generated.push({
|
|
2492
|
+
account,
|
|
2493
|
+
debit: onCredit ? 0 : absAmount,
|
|
2494
|
+
credit: onCredit ? absAmount : 0,
|
|
2495
|
+
label: rep.label ?? `${tax.name} ${rep.accountRole}`,
|
|
2496
|
+
taxDetails: [{
|
|
2497
|
+
taxCode: tax.code,
|
|
2498
|
+
taxName: tax.name,
|
|
2499
|
+
...rep.gridCode != null ? { gridCode: String(rep.gridCode) } : {}
|
|
2500
|
+
}]
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
return generated;
|
|
2504
|
+
} };
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Helper for packs that want the standard "role → account-type code"
|
|
2508
|
+
* mapping without writing their own resolver. Returns the function you
|
|
2509
|
+
* stuff into `CountryPackInput.resolveTaxRepartitionAccountCode`.
|
|
2510
|
+
*/
|
|
2511
|
+
function defaultResolveTaxRepartitionAccountCode(country) {
|
|
2512
|
+
return (role, tax) => defaultResolveRoleCode(role, tax, country);
|
|
2513
|
+
}
|
|
2514
|
+
//#endregion
|
|
1995
2515
|
//#region src/utils/dimensions.ts
|
|
1996
2516
|
/**
|
|
1997
2517
|
* Analytic Dimensions — Helpers for defining analytic dimensions
|
|
@@ -2051,4 +2571,4 @@ function buildDimensionIndexes(dimensions, orgField) {
|
|
|
2051
2571
|
});
|
|
2052
2572
|
}
|
|
2053
2573
|
//#endregion
|
|
2054
|
-
export { AccountingEngine, AccountingError, CATEGORIES, CATEGORY_KEYS, CURRENCIES, DEFAULT_BUCKETS, Errors, JOURNAL_CODES, JOURNAL_TYPES, Money, acquireSession, add, allocate, buildAccountTypeMap, buildDimensionFields, buildDimensionIndexes, buildItemFilters, buildRevaluationEntry, calculateTotal, closeFiscalPeriod, computeEndingBalance, computeRevaluation, createAccountingEngine, createModels, createRepositories,
|
|
2574
|
+
export { AccountingEngine, AccountingError, CATEGORIES, CATEGORY_KEYS, CURRENCIES, DEFAULT_BUCKETS, Errors, JOURNAL_CODES, JOURNAL_TYPES, Money, acquireSession, add, allocate, buildAccountTypeMap, buildDimensionFields, buildDimensionIndexes, buildItemFilters, buildRevaluationEntry, calculateTotal, closeFiscalPeriod, computeEndingBalance, computeRevaluation, createAccountingEngine, createLockPlugin, createModels, createRepartitionTaxGenerator, createRepositories, creditLimitPlugin, dailyLockPlugin, defaultLogger, defaultResolveTaxRepartitionAccountCode, defineCountryPack, doubleEntryPlugin, exportToCsv, finalizeSession, fiscalLockPlugin, flattenJournalEntries, format, formatPlain, fromDecimal, fxRealizationPlugin, generateAgedBalance, generateBalanceSheet, generateBudgetVsActual, generateCashFlow, generateDimensionBreakdown, generateGeneralLedger, generateIncomeStatement, generatePartnerLedger, generateRevaluation, generateTrialBalance, getCurrency, getCustomJournalTypes, getDateRange, getFiscalYearStart, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, idempotencyPlugin, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType, isVirtualTaxAccount, multiply, parseCents, percentage, periodResolver, quickbooksFieldMap, registerJournalType, reopenFiscalPeriod, resolveModelNames, splitTaxExclusive, splitTaxInclusive, subtract, taxLockPlugin, toDecimal, universalFieldMap, watermarkResolver };
|