@classytic/ledger 0.10.0 → 0.10.2

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/dist/index.d.mts CHANGED
@@ -280,6 +280,39 @@ interface AccountingEngineConfig {
280
280
  currency: string;
281
281
  /** Multi-tenant configuration. Omit for single-tenant apps. */
282
282
  multiTenant?: MultiTenantConfig | undefined;
283
+ /**
284
+ * Field name used to stamp every journal entry with the originating
285
+ * organization / branch — *without* scoping the chart of accounts or any
286
+ * other collection. Use this for single-company-multi-branch deployments
287
+ * where Account / FiscalPeriod stay company-wide but each posting needs a
288
+ * branch attribution for partition-style reports (per-branch P&L, AR aging,
289
+ * partner ledger).
290
+ *
291
+ * When `multiTenant` is set, that takes precedence — `multiTenant.tenantField`
292
+ * already provides the same stamp and additionally scopes every repository.
293
+ *
294
+ * The host MUST declare the field on the JournalEntry schema via
295
+ * `schemaOptions.journalEntry.extraFields.<field>` (the engine doesn't add
296
+ * the schema path for you — keeps schema mutation explicit).
297
+ *
298
+ * Example:
299
+ *
300
+ * createAccountingEngine({
301
+ * // ...no multiTenant — accounts are company-wide
302
+ * journalEntryOrgField: 'organizationId',
303
+ * schemaOptions: {
304
+ * journalEntry: {
305
+ * extraFields: {
306
+ * organizationId: { type: ObjectId, ref: 'organization', default: null, index: true },
307
+ * },
308
+ * },
309
+ * },
310
+ * });
311
+ *
312
+ * await engine.record.sale(branchId, { ... });
313
+ * // → JE doc: { organizationId: branchId, ... }
314
+ */
315
+ journalEntryOrgField?: string | undefined;
283
316
  /** Multi-currency support. Omit for single-currency apps. */
284
317
  multiCurrency?: MultiCurrencyConfig | undefined;
285
318
  /** Fiscal year start month (1-12, default: 1 = January) */
package/dist/index.mjs CHANGED
@@ -909,6 +909,14 @@ async function safePublish$3(events, outboxStore, type, payload, ctx) {
909
909
  await events.publish(event);
910
910
  } catch {}
911
911
  }
912
+ function isDuplicateKeyBulkError(err) {
913
+ if (!err || typeof err !== "object") return false;
914
+ const e = err;
915
+ if (e.code === 11e3) return true;
916
+ if (Array.isArray(e.writeErrors) && e.writeErrors.length > 0) return true;
917
+ if (e.status === 409 && e.duplicate !== void 0) return true;
918
+ return false;
919
+ }
912
920
  /**
913
921
  * Wire seedAccounts, bulkCreate and posting-account validation
914
922
  * onto an existing mongokit Repository.
@@ -1075,24 +1083,42 @@ function wireAccountMethods(repository, country, orgField, integrations = {}) {
1075
1083
  _id: inserted[idx]._id
1076
1084
  }));
1077
1085
  } catch (err) {
1078
- const bulkError = err;
1079
- if (bulkError.code === 11e3 || bulkError.writeErrors) {
1080
- const insertedDocs = bulkError.insertedDocs ?? [];
1081
- const insertedNumbers = new Set(insertedDocs.map((d) => d.accountNumber));
1082
- for (const item of toCreate) if (insertedNumbers.has(item.accountNumber)) {
1083
- const iDoc = insertedDocs.find((d) => d.accountNumber === item.accountNumber);
1084
- results.created.push({
1085
- accountTypeCode: item.accountTypeCode,
1086
- active: item.active,
1087
- isCashAccount: item.isCashAccount,
1088
- _id: iDoc?._id
1089
- });
1090
- } else results.skipped.push({
1091
- index: item.index,
1086
+ if (!isDuplicateKeyBulkError(err)) throw err;
1087
+ const insertedDocs = err.insertedDocs ?? [];
1088
+ const insertedNumbers = new Set(insertedDocs.map((d) => d.accountNumber));
1089
+ const stillUnknown = toCreate.filter((t) => !insertedNumbers.has(t.accountNumber));
1090
+ const concurrentlyPersistedById = /* @__PURE__ */ new Map();
1091
+ if (stillUnknown.length > 0) {
1092
+ const concurrentFilter = { accountNumber: { $in: stillUnknown.map((t) => t.accountNumber) } };
1093
+ if (orgField && orgId != null) concurrentFilter[orgField] = orgId;
1094
+ const persisted = await repository.findAll(concurrentFilter, {
1095
+ select: {
1096
+ _id: 1,
1097
+ accountNumber: 1
1098
+ },
1099
+ lean: true
1100
+ });
1101
+ for (const p of persisted) concurrentlyPersistedById.set(p.accountNumber, p._id);
1102
+ }
1103
+ for (const item of toCreate) if (insertedNumbers.has(item.accountNumber)) {
1104
+ const iDoc = insertedDocs.find((d) => d.accountNumber === item.accountNumber);
1105
+ results.created.push({
1092
1106
  accountTypeCode: item.accountTypeCode,
1093
- reason: "Already exists (concurrent insert)"
1107
+ active: item.active,
1108
+ isCashAccount: item.isCashAccount,
1109
+ _id: iDoc?._id
1094
1110
  });
1095
- } else throw err;
1111
+ } else if (concurrentlyPersistedById.has(item.accountNumber)) results.skipped.push({
1112
+ index: item.index,
1113
+ accountTypeCode: item.accountTypeCode,
1114
+ reason: "Already exists (concurrent insert)",
1115
+ _id: concurrentlyPersistedById.get(item.accountNumber)
1116
+ });
1117
+ else results.skipped.push({
1118
+ index: item.index,
1119
+ accountTypeCode: item.accountTypeCode,
1120
+ reason: "Already exists (concurrent insert)"
1121
+ });
1096
1122
  }
1097
1123
  }
1098
1124
  const summary = {
@@ -2346,6 +2372,7 @@ function buildIntrospectAPI({ models, country, config }) {
2346
2372
  function buildRecordAPI({ models, repositories, config }) {
2347
2373
  const AccountModel = models.Account;
2348
2374
  const orgField = config.multiTenant?.tenantField;
2375
+ const journalTagField = orgField ?? config.journalEntryOrgField;
2349
2376
  const resolveAccounts = async (organizationId, codes, path, session) => {
2350
2377
  const unique = Array.from(new Set(codes));
2351
2378
  const filter = { accountTypeCode: { $in: unique } };
@@ -2374,7 +2401,7 @@ function buildRecordAPI({ models, repositories, config }) {
2374
2401
  }]);
2375
2402
  };
2376
2403
  const postEntry = async (organizationId, payload, options) => {
2377
- if (orgField && organizationId != null) payload[orgField] = organizationId;
2404
+ if (journalTagField && organizationId != null) payload[journalTagField] = organizationId;
2378
2405
  const actorId = options?.actorId ?? (options?.user ? options.user._id?.toString() ?? options.user.id?.toString() : void 0);
2379
2406
  if (actorId) {
2380
2407
  payload.createdBy = actorId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/ledger",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Production-grade double-entry accounting engine for MongoDB — schemas, reports, tax, multi-tenant",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -107,6 +107,7 @@
107
107
  },
108
108
  "devDependencies": {
109
109
  "@biomejs/biome": "^2.4.12",
110
+ "@classytic/dev-tools": "^0.2.0",
110
111
  "@classytic/fin-io": ">=0.1.0",
111
112
  "@classytic/mongokit": ">=3.11.0",
112
113
  "@classytic/primitives": ">=0.1.0",
@@ -139,8 +140,10 @@
139
140
  "format": "biome format src/ --write",
140
141
  "check": "biome ci src/ --diagnostic-level=error",
141
142
  "knip": "knip",
143
+ "push": "classytic-push",
142
144
  "smoke": "node scripts/smoke.mjs",
143
145
  "prepublishOnly": "npm run check && npm run build && npm run typecheck && npm test && npm run smoke",
144
- "release": "npm run check && npm run build && npm run typecheck && npm test && npm run smoke && npm publish --access public"
146
+ "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
147
+ "release": "npm run push -- main && npm run release:tag && npm publish"
145
148
  }
146
149
  }