@classytic/revenue 2.0.0 → 2.1.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.
Files changed (41) hide show
  1. package/dist/bank-feed-DJtLvz_7.mjs +133 -0
  2. package/dist/bank-feed.enums-BadqNJTC.d.mts +118 -0
  3. package/dist/bank-feed.enums-kYTLTTbe.mjs +165 -0
  4. package/dist/bridges/index.d.mts +1 -1
  5. package/dist/core/state-machines.d.mts +45 -4
  6. package/dist/core/state-machines.mjs +71 -12
  7. package/dist/{engine-types-CcjIb4Fy.d.mts → engine-types-txFXOiQS.d.mts} +451 -14
  8. package/dist/enums/index.d.mts +4 -3
  9. package/dist/enums/index.mjs +4 -3
  10. package/dist/{errors-DHa8JVQ-.mjs → errors-Dt46UZL_.mjs} +23 -1
  11. package/dist/{escrow.schema-CC8XuD46.d.mts → escrow.schema-9yh4Q-aQ.d.mts} +9 -9
  12. package/dist/{event-constants-CEMitnIV.mjs → event-constants-CTiDNWzc.mjs} +6 -0
  13. package/dist/events/index.d.mts +2 -2
  14. package/dist/events/index.mjs +3 -3
  15. package/dist/index.d.mts +21 -11
  16. package/dist/index.mjs +120 -19
  17. package/dist/providers/index.d.mts +2 -2
  18. package/dist/providers/index.mjs +2 -2
  19. package/dist/registry-h8sasoLh.d.mts +145 -0
  20. package/dist/repositories/create-repositories.d.mts +1 -1
  21. package/dist/repositories/create-repositories.mjs +1 -1
  22. package/dist/{revenue-bridges-sdlrR85c.d.mts → revenue-bridges-BtkWFsJu.d.mts} +107 -1
  23. package/dist/{revenue-event-catalog-LqxPnsU_.mjs → revenue-event-catalog-CgZ57M-f.mjs} +77 -3
  24. package/dist/{revenue-event-catalog-BX3g7RUi.d.mts → revenue-event-catalog-JpJcyK1E.d.mts} +198 -2
  25. package/dist/{settlement.repository-Cy3mMWGH.mjs → settlement.repository-Ba2U17zY.mjs} +559 -17
  26. package/dist/shared/index.d.mts +1 -1
  27. package/dist/shared/index.mjs +2 -2
  28. package/dist/{split.enums-CQE3ekH1.mjs → subscription.enums-DoIr56O6.mjs} +28 -67
  29. package/dist/{split.enums-Dw4zCrcZ.d.mts → subscription.enums-k24kLpF7.d.mts} +48 -83
  30. package/dist/validators/index.d.mts +158 -2
  31. package/dist/validators/index.mjs +95 -2
  32. package/package.json +11 -8
  33. package/dist/registry-DhFMsSn5.mjs +0 -150
  34. package/dist/registry-SvIGPAx_.d.mts +0 -143
  35. /package/dist/{audit-B39B0Sdq.mjs → audit-Ba2XB2C4.mjs} +0 -0
  36. /package/dist/{audit-DZ0eTr9g.d.mts → audit-DRKuLBFO.d.mts} +0 -0
  37. /package/dist/{context-DRqSeTPM.d.mts → context-pjP1QeE3.d.mts} +0 -0
  38. /package/dist/{escrow.schema-BBv9oVEW.mjs → escrow.schema-C-b41z_G.mjs} +0 -0
  39. /package/dist/{monetization.enums-BtiU3t8o.mjs → monetization.enums-B9HBOecd.mjs} +0 -0
  40. /package/dist/{monetization.enums-D2xbxXJM.d.mts → monetization.enums-DzAI4sT7.d.mts} +0 -0
  41. /package/dist/{splits-BAfY-a9P.mjs → splits-D8XkNWgX.mjs} +0 -0
@@ -1,20 +1,12 @@
1
- import { n as createEvent, t as REVENUE_EVENTS } from "./event-constants-CEMitnIV.mjs";
2
- import { F as TRANSACTION_STATUS, u as SETTLEMENT_STATUS, v as SUBSCRIPTION_STATUS, w as HOLD_STATUS } from "./split.enums-CQE3ekH1.mjs";
3
- import { d as SubscriptionNotFoundError, f as TransactionNotFoundError, p as ValidationError, u as SettlementNotFoundError } from "./errors-DHa8JVQ-.mjs";
4
- import { SETTLEMENT_STATE_MACHINE, SUBSCRIPTION_STATE_MACHINE, TRANSACTION_STATE_MACHINE } from "./core/state-machines.mjs";
5
- import { a as reverseTax, c as reverseCommission, n as calculateSplits, s as calculateCommission, t as calculateOrganizationPayout } from "./splits-BAfY-a9P.mjs";
1
+ import { _ as TRANSACTION_STATUS, a as TRANSACTION_KIND, s as initialStatusFor } from "./bank-feed.enums-kYTLTTbe.mjs";
2
+ import { n as createEvent, t as REVENUE_EVENTS } from "./event-constants-CTiDNWzc.mjs";
3
+ import { g as SETTLEMENT_STATUS, r as SUBSCRIPTION_STATUS, w as HOLD_STATUS } from "./subscription.enums-DoIr56O6.mjs";
4
+ import { f as SettlementNotFoundError, g as WrongTransactionKindError, h as ValidationError, m as TransactionNotFoundError, n as BankFeedImportError, p as SubscriptionNotFoundError } from "./errors-Dt46UZL_.mjs";
5
+ import { SETTLEMENT_STATE_MACHINE, SUBSCRIPTION_STATE_MACHINE, TRANSACTION_STATE_MACHINE, smFor } from "./core/state-machines.mjs";
6
+ import { a as reverseTax, c as reverseCommission, n as calculateSplits, s as calculateCommission, t as calculateOrganizationPayout } from "./splits-D8XkNWgX.mjs";
6
7
  import { Repository, withTransaction } from "@classytic/mongokit";
7
8
 
8
9
  //#region src/repositories/transaction.repository.ts
9
- /**
10
- * TransactionRepository — extends mongokit Repository.
11
- *
12
- * CRUD inherited: getAll, getById, getByQuery, create, update, delete, count, exists.
13
- * Domain verbs: createPaymentIntent, verify, refund, handleWebhook, hold, release, split.
14
- *
15
- * All domain verbs return raw mongokit docs — no custom envelopes.
16
- * Composite results (refund creates a new doc) are stored in metadata on the primary doc.
17
- */
18
10
  var TransactionRepository = class extends Repository {
19
11
  deps;
20
12
  constructor(model, plugins = []) {
@@ -91,8 +83,10 @@ var TransactionRepository = class extends Repository {
91
83
  let gatewayData = { type: params.gateway };
92
84
  if (params.amount > 0) {
93
85
  const intent = await provider.createIntent({
94
- amount: params.amount,
95
- currency,
86
+ amount: {
87
+ amount: params.amount,
88
+ currency
89
+ },
96
90
  metadata: params.metadata,
97
91
  ...params.paymentData
98
92
  });
@@ -457,7 +451,555 @@ var TransactionRepository = class extends Repository {
457
451
  for (const ev of pendingEvents) await this.publishToTransport(ev);
458
452
  return updated;
459
453
  }
454
+ /**
455
+ * Idempotent bulk import of bank-feed rows.
456
+ *
457
+ * Each row is upserted by `(orgId, bankAccountId, externalId)` — the
458
+ * partial unique index declared in `create-models.ts`. Re-running the
459
+ * same Plaid sync, OFX upload, or QBO CDC drain produces zero new
460
+ * inserts on the second call (modified counts may rise as
461
+ * descriptions/categories evolve upstream).
462
+ *
463
+ * Signed bank `amount` is normalized into the (`amount` >= 0, `flow`)
464
+ * shape revenue uses internally so downstream queries (`flow:
465
+ * 'inflow'`) work uniformly across kinds.
466
+ *
467
+ * Emits one `revenue:transaction.imported` event per **inserted** row
468
+ * (not per row in `rows` — re-imports do not re-fire). Hosts wanting
469
+ * batch-level signal subscribe to the per-doc events and aggregate.
470
+ *
471
+ * Per-row failures (validation, hash collisions on a non-unique
472
+ * `externalId`) collect into `errors[]` instead of aborting the whole
473
+ * batch — the typical Plaid drain pulls thousands of rows; one bad
474
+ * row should not block the rest.
475
+ *
476
+ * @param rows Canonical bank transactions, structurally compatible
477
+ * with `@classytic/fin-io` parsers' output.
478
+ * @param opts `bankAccountId` (required, polymorphic ID) and
479
+ * `source` (provenance — `'plaid'`, `'ofx'`, …).
480
+ */
481
+ async import(rows, opts, ctx = {}) {
482
+ const startedAt = Date.now();
483
+ if (!Array.isArray(rows) || rows.length === 0) return {
484
+ inserted: 0,
485
+ updated: 0,
486
+ skipped: 0,
487
+ errors: [],
488
+ durationMs: 0
489
+ };
490
+ const repo = this;
491
+ if (!repo.bulkWrite) throw new BankFeedImportError("TransactionRepository requires `batchOperationsPlugin` for `import()`. Pass it via `createRevenue({ repositoryPlugins: { transaction: [batchOperationsPlugin()] } })` — or rely on the engine default which wires it automatically.");
492
+ const errors = [];
493
+ const tenantOption = ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {};
494
+ const ops = [];
495
+ for (const row of rows) {
496
+ if (!row.externalId || typeof row.externalId !== "string") {
497
+ errors.push({
498
+ externalId: String(row.externalId),
499
+ reason: "missing_external_id",
500
+ row
501
+ });
502
+ continue;
503
+ }
504
+ const signed = row.amount.amount;
505
+ if (!Number.isFinite(signed) || !Number.isInteger(signed)) {
506
+ errors.push({
507
+ externalId: row.externalId,
508
+ reason: "invalid_amount",
509
+ row
510
+ });
511
+ continue;
512
+ }
513
+ const isInflow = signed >= 0;
514
+ const absoluteAmount = Math.abs(signed);
515
+ const filter = {
516
+ bankAccountId: opts.bankAccountId,
517
+ externalId: row.externalId
518
+ };
519
+ if (ctx.organizationId !== void 0) filter.organizationId = ctx.organizationId;
520
+ const set = {
521
+ amount: absoluteAmount,
522
+ currency: row.amount.currency,
523
+ flow: isInflow ? "inflow" : "outflow",
524
+ postedDate: row.postedDate,
525
+ description: row.description,
526
+ method: opts.method ?? opts.source
527
+ };
528
+ if (row.valueDate !== void 0) set.valueDate = row.valueDate;
529
+ if (row.counterparty !== void 0) set.counterparty = row.counterparty;
530
+ if (row.reference !== void 0) set.reference = row.reference;
531
+ if (row.category !== void 0) set.vendorCategory = row.category;
532
+ if (row.balanceAfter !== void 0) set.balanceAfter = row.balanceAfter.amount;
533
+ const setOnInsert = {
534
+ kind: TRANSACTION_KIND.BANK_FEED,
535
+ status: initialStatusFor(TRANSACTION_KIND.BANK_FEED),
536
+ bankAccountId: opts.bankAccountId,
537
+ externalId: row.externalId,
538
+ source: opts.source,
539
+ type: "bank_feed",
540
+ tags: ["bank_feed", opts.source],
541
+ fee: 0,
542
+ tax: 0,
543
+ net: absoluteAmount,
544
+ deletedAt: null
545
+ };
546
+ if (ctx.organizationId !== void 0) setOnInsert.organizationId = ctx.organizationId;
547
+ ops.push({ updateOne: {
548
+ filter,
549
+ update: {
550
+ $set: set,
551
+ $setOnInsert: setOnInsert
552
+ },
553
+ upsert: true
554
+ } });
555
+ }
556
+ if (ops.length === 0) return {
557
+ inserted: 0,
558
+ updated: 0,
559
+ skipped: rows.length,
560
+ errors,
561
+ durationMs: Date.now() - startedAt
562
+ };
563
+ const sessionOption = ctx.session !== void 0 ? { session: ctx.session } : {};
564
+ const result = await repo.bulkWrite(ops, {
565
+ ordered: false,
566
+ ...sessionOption,
567
+ ...tenantOption
568
+ });
569
+ const inserted = (result.upsertedCount ?? 0) + (result.insertedCount ?? 0);
570
+ const updated = result.modifiedCount ?? 0;
571
+ const upsertedIds = Object.values(result.upsertedIds ?? {});
572
+ if (upsertedIds.length > 0) for (let i = 0; i < upsertedIds.length; i++) {
573
+ const id = upsertedIds[i];
574
+ if (id === void 0 || id === null) continue;
575
+ const doc = await this.getById(String(id), this.optsFromCtx(ctx, { throwOnNotFound: false }));
576
+ if (!doc) continue;
577
+ const txn = doc;
578
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_IMPORTED, {
579
+ transaction: txn,
580
+ source: opts.source,
581
+ bankAccountId: opts.bankAccountId,
582
+ externalId: txn.externalId ?? ""
583
+ }, ctx, {
584
+ resource: "transaction",
585
+ resourceId: txn.publicId
586
+ }), ctx);
587
+ await this.deps.bridges.ledger?.onTransactionImported?.(txn, ctx);
588
+ }
589
+ return {
590
+ inserted,
591
+ updated,
592
+ skipped: errors.length,
593
+ errors,
594
+ durationMs: Date.now() - startedAt
595
+ };
596
+ }
597
+ /**
598
+ * Drain a bank-feed provider into the collection.
599
+ *
600
+ * Pulls pages from `provider.fetchTransactions()` (Plaid cursor, QBO
601
+ * CDC) and feeds each batch through `import()`. Yields the running
602
+ * report so a host cron can stream-progress-report to logs / metrics.
603
+ *
604
+ * Stops when the provider returns no new rows AND no removals AND no
605
+ * `nextCursor`. Caller is responsible for persisting the final cursor
606
+ * in their own checkpoint table — `result.nextCursor` is returned so
607
+ * the host can write it after a successful drain.
608
+ *
609
+ * Plaid `removed[]` rows (and any provider that retracts entries) are
610
+ * routed through `removeByFeed` so the host's LedgerBridge can void
611
+ * any JE that was already posted.
612
+ */
613
+ async drainSync(providerName, params, ctx = {}) {
614
+ if (!this.deps.bankFeedProviders) throw new ValidationError("`bankFeedProviders` not wired on the engine. Pass `providers.bankFeed` to `createRevenue`.");
615
+ const provider = this.deps.bankFeedProviders.get(providerName);
616
+ let totalImported = 0;
617
+ let totalUpdated = 0;
618
+ let totalRemoved = 0;
619
+ let lastCursor;
620
+ const errors = [];
621
+ for await (const page of provider.drain(params)) {
622
+ if (page.transactions && page.transactions.length > 0) {
623
+ const report = await this.import(page.transactions, {
624
+ bankAccountId: params.bankAccountId,
625
+ source: providerName
626
+ }, ctx);
627
+ totalImported += report.inserted;
628
+ totalUpdated += report.updated;
629
+ if (report.errors.length > 0) errors.push(...report.errors);
630
+ }
631
+ if (page.removed && page.removed.length > 0) {
632
+ const removed = await this.removeByFeed(page.removed.map((r) => r.externalId), {
633
+ bankAccountId: params.bankAccountId,
634
+ source: providerName
635
+ }, ctx);
636
+ totalRemoved += removed.removed;
637
+ }
638
+ if (page.nextCursor) lastCursor = page.nextCursor;
639
+ }
640
+ return {
641
+ totalImported,
642
+ totalUpdated,
643
+ totalRemoved,
644
+ ...lastCursor !== void 0 ? { nextCursor: lastCursor } : {},
645
+ errors
646
+ };
647
+ }
648
+ /**
649
+ * Parse an upload (OFX / CAMT.053 / MT940 / CSV) via a registered
650
+ * bank-feed provider, then `import()` the result.
651
+ *
652
+ * Convenience over manually calling `provider.parseUpload()` and
653
+ * threading the canonical rows into `import()` — the file-upload
654
+ * route handler is one line.
655
+ */
656
+ async parseAndImport(providerName, upload, ctx = {}) {
657
+ if (!this.deps.bankFeedProviders) throw new ValidationError("`bankFeedProviders` not wired on the engine.");
658
+ const provider = this.deps.bankFeedProviders.get(providerName);
659
+ if (!provider.parseUpload) throw new ValidationError(`Provider '${providerName}' does not support parseUpload`);
660
+ const parsed = await provider.parseUpload({
661
+ buffer: upload.buffer,
662
+ ...upload.format !== void 0 ? { format: upload.format } : {},
663
+ bankAccountId: upload.bankAccountId
664
+ });
665
+ return this.import(parsed.transactions, {
666
+ bankAccountId: upload.bankAccountId,
667
+ source: providerName
668
+ }, ctx);
669
+ }
670
+ /**
671
+ * Hand-keyed entry — treasurer logs a cash deposit, owner injects
672
+ * capital, refund correction. Created in `pending` (manual SM); host
673
+ * proceeds with `match()` → `journalize()` to post it to the ledger.
674
+ *
675
+ * `kind: 'manual'` is enforced — calls passing other kinds throw.
676
+ */
677
+ async createManual(data, ctx = {}) {
678
+ return await this.create({
679
+ organizationId: ctx.organizationId,
680
+ kind: TRANSACTION_KIND.MANUAL,
681
+ type: data.type,
682
+ flow: data.flow,
683
+ tags: ["manual"],
684
+ amount: data.amount,
685
+ currency: data.currency,
686
+ fee: 0,
687
+ tax: 0,
688
+ net: data.amount,
689
+ method: "manual",
690
+ status: initialStatusFor(TRANSACTION_KIND.MANUAL),
691
+ source: "manual",
692
+ ...data.description !== void 0 ? { description: data.description } : {},
693
+ ...data.counterparty !== void 0 ? { counterparty: data.counterparty } : {},
694
+ ...data.reference !== void 0 ? { reference: data.reference } : {},
695
+ ...data.postedDate !== void 0 ? { postedDate: data.postedDate } : {},
696
+ ...data.valueDate !== void 0 ? { valueDate: data.valueDate } : {},
697
+ ...data.bankAccountId !== void 0 ? { bankAccountId: data.bankAccountId } : {},
698
+ ...data.sourceId !== void 0 ? { sourceId: data.sourceId } : {},
699
+ ...data.sourceModel !== void 0 ? { sourceModel: data.sourceModel } : {},
700
+ ...data.metadata !== void 0 ? { metadata: data.metadata } : {}
701
+ }, this.optsFromCtx(ctx));
702
+ }
703
+ /**
704
+ * Match a bank-feed / manual transaction to GL accounts, optionally
705
+ * cross-linking to an upstream payment-flow transaction.
706
+ *
707
+ * Atomic state CAS via `claim()` — the `where: { kind: { $in: [...] } }`
708
+ * predicate prevents a payment-flow row from being matched through this
709
+ * verb. Multi-source `from` (`['imported', 'matched']`) supports
710
+ * re-match after `unmatch()` (`matched → imported → matched`) without
711
+ * losing the prior mapping if the host wants to overwrite it.
712
+ *
713
+ * After a successful claim, `LedgerBridge.onTransactionMatched` runs
714
+ * — the canonical implementation creates a journal entry and chains
715
+ * `journalize()` to record the JE ref. The bridge call is OUTSIDE the
716
+ * claim's CAS window because JE posting is a side effect that may
717
+ * take seconds (cross-process call to ledger).
718
+ */
719
+ async match(id, data, ctx = {}) {
720
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
721
+ if (!existing) throw new TransactionNotFoundError(id);
722
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
723
+ smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.MATCHED, id);
724
+ const patch = {
725
+ $set: {
726
+ matching: {
727
+ ...data.mapping,
728
+ ...data.matchedBy !== void 0 ? { matchedBy: data.matchedBy } : {},
729
+ matchedAt: /* @__PURE__ */ new Date()
730
+ },
731
+ ...data.matchedBy !== void 0 ? { verifiedBy: data.matchedBy } : {},
732
+ verifiedAt: /* @__PURE__ */ new Date()
733
+ },
734
+ $unset: { journalEntryRef: 1 }
735
+ };
736
+ if (data.relatedTransactionId !== void 0) patch.$set.relatedTransactionId = data.relatedTransactionId;
737
+ const claimed = await this.claim(existing._id, {
738
+ from: [
739
+ TRANSACTION_STATUS.IMPORTED,
740
+ TRANSACTION_STATUS.MATCHED,
741
+ TRANSACTION_STATUS.PENDING
742
+ ],
743
+ to: TRANSACTION_STATUS.MATCHED,
744
+ where: { kind: existing.kind }
745
+ }, patch, this.optsFromCtx(ctx));
746
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be matched (race-loss or illegal state)`);
747
+ await this.deps.bridges.ledger?.onTransactionMatched?.(claimed, data.mapping, ctx);
748
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_MATCHED, {
749
+ transaction: claimed,
750
+ mapping: data.mapping,
751
+ ...data.relatedTransactionId !== void 0 ? { relatedTransactionId: data.relatedTransactionId } : {},
752
+ ...data.matchedBy !== void 0 ? { matchedBy: data.matchedBy } : {}
753
+ }, ctx, {
754
+ resource: "transaction",
755
+ resourceId: claimed.publicId
756
+ }), ctx);
757
+ return claimed;
758
+ }
759
+ /**
760
+ * Revert a matched transaction back to `imported`. Clears the
761
+ * `matching` block and `relatedTransactionId`. Notifies the
762
+ * LedgerBridge (which typically voids the journal entry) AFTER the
763
+ * state CAS lands.
764
+ *
765
+ * Only legal for `kind: 'bank_feed'` — manual entries don't allow
766
+ * un-match (the manual SM has no `matched → pending` edge).
767
+ */
768
+ async unmatch(id, options = {}, ctx = {}) {
769
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
770
+ if (!existing) throw new TransactionNotFoundError(id);
771
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED) throw new WrongTransactionKindError(id, "bank_feed", existing.kind);
772
+ const priorJournalEntryRef = existing.journalEntryRef;
773
+ const claimed = await this.claim(existing._id, {
774
+ from: TRANSACTION_STATUS.MATCHED,
775
+ to: TRANSACTION_STATUS.IMPORTED,
776
+ where: { kind: TRANSACTION_KIND.BANK_FEED }
777
+ }, { $unset: {
778
+ matching: 1,
779
+ relatedTransactionId: 1,
780
+ journalEntryRef: 1,
781
+ verifiedBy: 1,
782
+ verifiedAt: 1
783
+ } }, this.optsFromCtx(ctx));
784
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be unmatched (current state is not 'matched')`);
785
+ await this.deps.bridges.ledger?.onTransactionUnmatched?.(claimed, priorJournalEntryRef, ctx);
786
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_UNMATCHED, {
787
+ transaction: claimed,
788
+ ...options.unmatchedBy !== void 0 ? { unmatchedBy: options.unmatchedBy } : {}
789
+ }, ctx, {
790
+ resource: "transaction",
791
+ resourceId: claimed.publicId
792
+ }), ctx);
793
+ return claimed;
794
+ }
795
+ /**
796
+ * Stamp the journal entry reference and transition `matched →
797
+ * journalized`. Typical caller is the `LedgerBridge.onTransactionMatched`
798
+ * implementation — after creating a JE, it calls this verb so the row
799
+ * carries the back-reference.
800
+ */
801
+ async journalize(id, data, ctx = {}) {
802
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
803
+ if (!existing) throw new TransactionNotFoundError(id);
804
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
805
+ smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.JOURNALIZED, id);
806
+ const claimed = await this.claim(existing._id, {
807
+ from: TRANSACTION_STATUS.MATCHED,
808
+ to: TRANSACTION_STATUS.JOURNALIZED,
809
+ where: { kind: existing.kind }
810
+ }, { $set: { journalEntryRef: data.journalEntryRef } }, this.optsFromCtx(ctx));
811
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be journalized (current state is not 'matched')`);
812
+ await this.deps.bridges.ledger?.onTransactionJournalized?.(claimed, data.journalEntryRef, ctx);
813
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_JOURNALIZED, {
814
+ transaction: claimed,
815
+ journalEntryRef: data.journalEntryRef,
816
+ ...data.journalizedBy !== void 0 ? { journalizedBy: data.journalizedBy } : {}
817
+ }, ctx, {
818
+ resource: "transaction",
819
+ resourceId: claimed.publicId
820
+ }), ctx);
821
+ return claimed;
822
+ }
823
+ /**
824
+ * Operator skip — marks an imported / matched / pending row as
825
+ * rejected (terminal). Use cases: duplicate of an already-imported
826
+ * row, non-cash entry the host doesn't want in the ledger, manual
827
+ * correction overrides.
828
+ *
829
+ * `relatedTransactionId` is preserved; reversal is the host's call.
830
+ */
831
+ async reject(id, data, ctx = {}) {
832
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
833
+ if (!existing) throw new TransactionNotFoundError(id);
834
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
835
+ smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.REJECTED, id);
836
+ const claimed = await this.claim(existing._id, {
837
+ from: [
838
+ TRANSACTION_STATUS.IMPORTED,
839
+ TRANSACTION_STATUS.MATCHED,
840
+ TRANSACTION_STATUS.PENDING
841
+ ],
842
+ to: TRANSACTION_STATUS.REJECTED,
843
+ where: { kind: existing.kind }
844
+ }, { $set: {
845
+ failureReason: data.reason,
846
+ failedAt: /* @__PURE__ */ new Date(),
847
+ ...data.rejectedBy !== void 0 ? { verifiedBy: data.rejectedBy } : {}
848
+ } }, this.optsFromCtx(ctx));
849
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be rejected (illegal current state)`);
850
+ await this.deps.bridges.ledger?.onTransactionRejected?.(claimed, data.reason, ctx);
851
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_REJECTED, {
852
+ transaction: claimed,
853
+ reason: data.reason,
854
+ ...data.rejectedBy !== void 0 ? { rejectedBy: data.rejectedBy } : {}
855
+ }, ctx, {
856
+ resource: "transaction",
857
+ resourceId: claimed.publicId
858
+ }), ctx);
859
+ return claimed;
860
+ }
861
+ /**
862
+ * Soft-delete bank-feed rows that the upstream feed has retracted
863
+ * (Plaid `removed[]`, OFX correction).
864
+ *
865
+ * Each row is matched by `(orgId, bankAccountId, externalId)`; rows
866
+ * already journalized are NOT silently kept — they're surfaced in
867
+ * `retainedJournalized` so the caller can surface them in the UI
868
+ * ("the feed retracted these N rows but they're posted; reverse
869
+ * manually"). The host's `LedgerBridge` should post a reversing JE
870
+ * for those before any subsequent `delete()` can succeed.
871
+ *
872
+ * @returns `removed` (count soft-deleted), `retainedJournalized`
873
+ * (rows kept because they're already in the GL).
874
+ */
875
+ async removeByFeed(externalIds, opts, ctx = {}) {
876
+ if (externalIds.length === 0) return {
877
+ removed: 0,
878
+ retainedJournalized: []
879
+ };
880
+ const allFilter = {
881
+ kind: TRANSACTION_KIND.BANK_FEED,
882
+ bankAccountId: opts.bankAccountId,
883
+ externalId: { $in: externalIds },
884
+ deletedAt: null
885
+ };
886
+ const allDocs = await this.findAll(allFilter, this.optsFromCtx(ctx));
887
+ if (!Array.isArray(allDocs) || allDocs.length === 0) return {
888
+ removed: 0,
889
+ retainedJournalized: []
890
+ };
891
+ const retainedJournalized = [];
892
+ const removable = [];
893
+ for (const doc of allDocs) if (doc.status === TRANSACTION_STATUS.JOURNALIZED) retainedJournalized.push(doc);
894
+ else removable.push(doc);
895
+ let removed = 0;
896
+ for (const doc of removable) {
897
+ await this.delete(doc._id, this.optsFromCtx(ctx));
898
+ removed += 1;
899
+ await this.deps.bridges.ledger?.onTransactionRemovedByFeed?.(doc, ctx);
900
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_REMOVED_BY_FEED, {
901
+ transaction: doc,
902
+ source: opts.source,
903
+ externalId: doc.externalId ?? ""
904
+ }, ctx, {
905
+ resource: "transaction",
906
+ resourceId: doc.publicId
907
+ }), ctx);
908
+ }
909
+ return {
910
+ removed,
911
+ retainedJournalized
912
+ };
913
+ }
914
+ /**
915
+ * Find candidate matches for cross-referencing a payment-flow row to
916
+ * its bank deposit (or vice-versa).
917
+ *
918
+ * Heuristic:
919
+ * - same currency by default; cross-currency requires `fxRate` on
920
+ * the candidate row (multi-currency reconciliation).
921
+ * - amount within `amountTolerancePct` (default 1%) — accounts for
922
+ * gateway fees / FX rounding.
923
+ * - posted/created within `toleranceDays` of the target date
924
+ * (default 3 days — covers ACH delays, weekend settlement).
925
+ * - terminal verified states only (`verified` / `completed` for
926
+ * payment_flow, `imported` / `matched` for bank_feed).
927
+ *
928
+ * Returned candidates are unsorted; callers rank by their own
929
+ * confidence model (counterparty fuzzy match, currency identity,
930
+ * exact-amount preference, …).
931
+ */
932
+ async findMatchCandidates(filter, ctx = {}) {
933
+ const tolerance = filter.toleranceDays ?? 3;
934
+ const pct = filter.amountTolerancePct ?? .01;
935
+ const start = /* @__PURE__ */ new Date(filter.postedDate.getTime() - tolerance * 864e5);
936
+ const end = new Date(filter.postedDate.getTime() + tolerance * 864e5);
937
+ const minAmount = filter.amount * (1 - pct);
938
+ const maxAmount = filter.amount * (1 + pct);
939
+ const targetKind = filter.kind ?? TRANSACTION_KIND.PAYMENT_FLOW;
940
+ const query = {
941
+ kind: targetKind,
942
+ status: { $in: targetKind === TRANSACTION_KIND.PAYMENT_FLOW ? [TRANSACTION_STATUS.VERIFIED, TRANSACTION_STATUS.COMPLETED] : [TRANSACTION_STATUS.IMPORTED, TRANSACTION_STATUS.MATCHED] },
943
+ amount: {
944
+ $gte: minAmount,
945
+ $lte: maxAmount
946
+ },
947
+ $or: [{ postedDate: {
948
+ $gte: start,
949
+ $lte: end
950
+ } }, { createdAt: {
951
+ $gte: start,
952
+ $lte: end
953
+ } }]
954
+ };
955
+ if (filter.currency !== void 0) query.currency = filter.currency;
956
+ if (filter.counterpartyName !== void 0) query["counterparty.name"] = {
957
+ $regex: escapeRegex(filter.counterpartyName),
958
+ $options: "i"
959
+ };
960
+ const docs = await this.findAll(query, this.optsFromCtx(ctx, { limit: 50 }));
961
+ return Array.isArray(docs) ? docs : [];
962
+ }
963
+ /**
964
+ * Running balance for a bank account as of `asOf` (defaults to now).
965
+ *
966
+ * Uses mongokit's tenant-scoped read via `findAll` — inflows minus
967
+ * outflows over `kind: 'bank_feed'`, terminal states only. For audit
968
+ * pages where exact-to-the-cent reconciliation is required, prefer
969
+ * the most recent row's `balanceAfter` (banks ship that field on
970
+ * every entry).
971
+ */
972
+ async getRunningBalance(bankAccountId, asOf = /* @__PURE__ */ new Date(), ctx = {}) {
973
+ const filter = {
974
+ kind: TRANSACTION_KIND.BANK_FEED,
975
+ bankAccountId,
976
+ postedDate: { $lte: asOf },
977
+ status: { $in: [
978
+ TRANSACTION_STATUS.IMPORTED,
979
+ TRANSACTION_STATUS.MATCHED,
980
+ TRANSACTION_STATUS.JOURNALIZED
981
+ ] },
982
+ deletedAt: null
983
+ };
984
+ const rows = await this.findAll(filter, this.optsFromCtx(ctx));
985
+ let balance = 0;
986
+ let currency = null;
987
+ for (const row of rows) {
988
+ if (currency === null) currency = row.currency;
989
+ balance += row.flow === "inflow" ? row.amount : -row.amount;
990
+ }
991
+ return {
992
+ balance,
993
+ currency,
994
+ rowCount: rows.length,
995
+ asOf
996
+ };
997
+ }
460
998
  };
999
+ /** Escape user-provided strings before embedding in `$regex`. */
1000
+ function escapeRegex(input) {
1001
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1002
+ }
461
1003
 
462
1004
  //#endregion
463
1005
  //#region src/repositories/subscription.repository.ts
@@ -656,7 +1198,7 @@ var SettlementRepository = class extends Repository {
656
1198
  filters: query,
657
1199
  limit: options.limit ?? 50,
658
1200
  sort: { scheduledAt: 1 }
659
- })).docs ?? [];
1201
+ })).data ?? [];
660
1202
  if (options.dryRun) return {
661
1203
  processed: pending.length,
662
1204
  succeeded: 0,
@@ -1,2 +1,2 @@
1
- import { A as SplitInfo, B as validateTaxCalculation, C as multiplyMoney, D as toCurrencyCode, E as sumMoney, F as TaxConfig, H as calculateCommission, I as TaxType, L as calculateTax, M as calculateOrganizationPayout, N as calculateSplits, O as toMajor, P as TaxCalculation, R as getTaxType, S as money, T as subtractMoney, U as reverseCommission, V as CommissionInfo, _ as isMoney, a as CurrencyCode, b as isZeroMoney, c as Money, d as addMoney, f as compareMoney, g as isCurrencyCode, h as fromSmallestUnit, i as CURRENCIES, j as SplitRule, k as toSmallestUnit, l as MoneyValue, m as fromMajor, n as getAuditTrail, o as CurrencyMismatchError, p as equalsMoney, r as getLastStateChange, s as MINOR_UNIT_FACTOR, t as appendAuditEvent, u as absMoney, v as isNegativeMoney, w as negateMoney, x as minorUnitFactor, y as isPositiveMoney, z as reverseTax } from "../audit-DZ0eTr9g.mjs";
1
+ import { A as SplitInfo, B as validateTaxCalculation, C as multiplyMoney, D as toCurrencyCode, E as sumMoney, F as TaxConfig, H as calculateCommission, I as TaxType, L as calculateTax, M as calculateOrganizationPayout, N as calculateSplits, O as toMajor, P as TaxCalculation, R as getTaxType, S as money, T as subtractMoney, U as reverseCommission, V as CommissionInfo, _ as isMoney, a as CurrencyCode, b as isZeroMoney, c as Money, d as addMoney, f as compareMoney, g as isCurrencyCode, h as fromSmallestUnit, i as CURRENCIES, j as SplitRule, k as toSmallestUnit, l as MoneyValue, m as fromMajor, n as getAuditTrail, o as CurrencyMismatchError, p as equalsMoney, r as getLastStateChange, s as MINOR_UNIT_FACTOR, t as appendAuditEvent, u as absMoney, v as isNegativeMoney, w as negateMoney, x as minorUnitFactor, y as isPositiveMoney, z as reverseTax } from "../audit-DRKuLBFO.mjs";
2
2
  export { CURRENCIES, CommissionInfo, type CurrencyCode, CurrencyMismatchError, MINOR_UNIT_FACTOR, type Money, type MoneyValue, SplitInfo, SplitRule, TaxCalculation, TaxConfig, TaxType, absMoney, addMoney, appendAuditEvent, calculateCommission, calculateOrganizationPayout, calculateSplits, calculateTax, compareMoney, equalsMoney, fromMajor, fromSmallestUnit, getAuditTrail, getLastStateChange, getTaxType, isCurrencyCode, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, minorUnitFactor, money, multiplyMoney, negateMoney, reverseCommission, reverseTax, subtractMoney, sumMoney, toCurrencyCode, toMajor, toSmallestUnit, validateTaxCalculation };
@@ -1,4 +1,4 @@
1
- import { a as reverseTax, c as reverseCommission, i as getTaxType, n as calculateSplits, o as validateTaxCalculation, r as calculateTax, s as calculateCommission, t as calculateOrganizationPayout } from "../splits-BAfY-a9P.mjs";
2
- import { C as sumMoney, E as toSmallestUnit, S as subtractMoney, T as toMajor, _ as isZeroMoney, a as CurrencyMismatchError, b as multiplyMoney, c as addMoney, d as fromMajor, f as fromSmallestUnit, g as isPositiveMoney, h as isNegativeMoney, i as CURRENCIES, l as compareMoney, m as isMoney, n as getAuditTrail, o as MINOR_UNIT_FACTOR, p as isCurrencyCode, r as getLastStateChange, s as absMoney, t as appendAuditEvent, u as equalsMoney, v as minorUnitFactor, w as toCurrencyCode, x as negateMoney, y as money } from "../audit-B39B0Sdq.mjs";
1
+ import { a as reverseTax, c as reverseCommission, i as getTaxType, n as calculateSplits, o as validateTaxCalculation, r as calculateTax, s as calculateCommission, t as calculateOrganizationPayout } from "../splits-D8XkNWgX.mjs";
2
+ import { C as sumMoney, E as toSmallestUnit, S as subtractMoney, T as toMajor, _ as isZeroMoney, a as CurrencyMismatchError, b as multiplyMoney, c as addMoney, d as fromMajor, f as fromSmallestUnit, g as isPositiveMoney, h as isNegativeMoney, i as CURRENCIES, l as compareMoney, m as isMoney, n as getAuditTrail, o as MINOR_UNIT_FACTOR, p as isCurrencyCode, r as getLastStateChange, s as absMoney, t as appendAuditEvent, u as equalsMoney, v as minorUnitFactor, w as toCurrencyCode, x as negateMoney, y as money } from "../audit-Ba2XB2C4.mjs";
3
3
 
4
4
  export { CURRENCIES, CurrencyMismatchError, MINOR_UNIT_FACTOR, absMoney, addMoney, appendAuditEvent, calculateCommission, calculateOrganizationPayout, calculateSplits, calculateTax, compareMoney, equalsMoney, fromMajor, fromSmallestUnit, getAuditTrail, getLastStateChange, getTaxType, isCurrencyCode, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, minorUnitFactor, money, multiplyMoney, negateMoney, reverseCommission, reverseTax, subtractMoney, sumMoney, toCurrencyCode, toMajor, toSmallestUnit, validateTaxCalculation };