@classytic/revenue 2.0.1 → 2.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +33 -10
  3. package/dist/bank-feed-BlQeq2rK.mjs +133 -0
  4. package/dist/bank-feed.enums-BadqNJTC.d.mts +118 -0
  5. package/dist/bank-feed.enums-kYTLTTbe.mjs +165 -0
  6. package/dist/bridges/index.d.mts +1 -1
  7. package/dist/core/state-machines.d.mts +25 -2
  8. package/dist/core/state-machines.mjs +43 -3
  9. package/dist/engine-types-Jctrbasz.d.mts +1160 -0
  10. package/dist/enums/index.d.mts +4 -3
  11. package/dist/enums/index.mjs +4 -3
  12. package/dist/{errors-DHa8JVQ-.mjs → errors-LYYg9wcs.mjs} +23 -1
  13. package/dist/{escrow.schema-D5X32LwX.d.mts → escrow.schema-YuBgjL-I.d.mts} +27 -27
  14. package/dist/{event-constants-CEMitnIV.mjs → event-constants-Dn1TKahe.mjs} +6 -0
  15. package/dist/events/index.d.mts +2 -2
  16. package/dist/events/index.mjs +3 -3
  17. package/dist/index.d.mts +32 -13
  18. package/dist/index.mjs +142 -19
  19. package/dist/providers/index.d.mts +2 -2
  20. package/dist/providers/index.mjs +2 -2
  21. package/dist/registry-h8sasoLh.d.mts +145 -0
  22. package/dist/repositories/create-repositories.d.mts +1 -1
  23. package/dist/repositories/create-repositories.mjs +1 -1
  24. package/dist/{revenue-bridges-sdlrR85c.d.mts → revenue-bridges-BtkWFsJu.d.mts} +107 -1
  25. package/dist/{revenue-event-catalog-LqxPnsU_.mjs → revenue-event-catalog-BvjNVnPd.mjs} +77 -3
  26. package/dist/{revenue-event-catalog-BX3g7RUi.d.mts → revenue-event-catalog-JpJcyK1E.d.mts} +198 -2
  27. package/dist/settlement.repository-BAdc9qGl.mjs +1444 -0
  28. package/dist/shared/index.d.mts +1 -1
  29. package/dist/shared/index.mjs +2 -2
  30. package/dist/{subscription.enums-tfoAgsTv.mjs → subscription.enums-95othr0i.mjs} +1 -40
  31. package/dist/{transaction.enums-u4MshXcL.d.mts → subscription.enums-k24kLpF7.d.mts} +1 -36
  32. package/dist/validators/index.d.mts +158 -2
  33. package/dist/validators/index.mjs +95 -2
  34. package/package.json +7 -7
  35. package/dist/engine-types-CcjIb4Fy.d.mts +0 -611
  36. package/dist/registry-DhFMsSn5.mjs +0 -150
  37. package/dist/registry-SvIGPAx_.d.mts +0 -143
  38. package/dist/settlement.repository-DHIPx5S4.mjs +0 -771
  39. /package/dist/{audit-B39B0Sdq.mjs → audit-Ba2XB2C4.mjs} +0 -0
  40. /package/dist/{audit-DZ0eTr9g.d.mts → audit-DRKuLBFO.d.mts} +0 -0
  41. /package/dist/{context-DRqSeTPM.d.mts → context-pjP1QeE3.d.mts} +0 -0
  42. /package/dist/{escrow.schema-BBv9oVEW.mjs → escrow.schema-C-b41z_G.mjs} +0 -0
  43. /package/dist/{monetization.enums-BtiU3t8o.mjs → monetization.enums-B9HBOecd.mjs} +0 -0
  44. /package/dist/{monetization.enums-D2xbxXJM.d.mts → monetization.enums-DzAI4sT7.d.mts} +0 -0
  45. /package/dist/{splits-BAfY-a9P.mjs → splits-CNfQj92L.mjs} +0 -0
@@ -0,0 +1,1444 @@
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-Dn1TKahe.mjs";
3
+ import { g as SETTLEMENT_STATUS, r as SUBSCRIPTION_STATUS, w as HOLD_STATUS } from "./subscription.enums-95othr0i.mjs";
4
+ import { f as SettlementNotFoundError, g as WrongTransactionKindError, h as ValidationError, m as TransactionNotFoundError, n as BankFeedImportError, p as SubscriptionNotFoundError } from "./errors-LYYg9wcs.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-CNfQj92L.mjs";
7
+ import { Repository, repoOptionsFromCtx, withTransaction } from "@classytic/mongokit";
8
+
9
+ //#region src/repositories/base.repository.ts
10
+ /**
11
+ * RevenueRepositoryBase — shared scaffolding for every revenue repo.
12
+ *
13
+ * Eliminates the three places ctx-threading and event-dispatch were
14
+ * hand-rolled (transaction / subscription / settlement) and previously
15
+ * drifted: subscription + settlement repos forgot to forward
16
+ * `ctx.organizationId` into their inner `getById` / `update` calls,
17
+ * which surfaced as `Missing 'organizationId' in context for 'getById'`
18
+ * the moment a host enabled `multiTenantPlugin` (the canonical 2026-Q2
19
+ * bug — fixed in 2.1.1).
20
+ *
21
+ * Two responsibilities:
22
+ *
23
+ * 1. **Thread request context into mongokit options.** Every domain
24
+ * verb that touches the DB (read or write) ends with a mongokit
25
+ * method whose options bag is what `multiTenantPlugin`,
26
+ * `softDeletePlugin`, the audit plugins, and `withTransaction`
27
+ * read from. {@link optsFromCtx} centralises that translation
28
+ * using mongokit's typed extractor — adding a new canonical field
29
+ * to `RevenueContext` is now a single line, not three.
30
+ *
31
+ * 2. **Dispatch domain events.** Outbox-save (session-bound when the
32
+ * caller threads a Mongoose `ClientSession`) followed by
33
+ * transport-publish, with isolated try/catch so a transport
34
+ * failure never aborts the business write. PACKAGE_RULES P8 / §5.5.
35
+ *
36
+ * Subclasses inject their domain-specific deps via {@link inject}; this
37
+ * base only requires the cross-cutting trio (events / outbox / logger)
38
+ * which every revenue repo has.
39
+ *
40
+ * @typeParam TDoc - The Mongoose document type the subclass operates on.
41
+ * @typeParam TDeps - The full deps shape (must extend `BaseRevenueRepoDeps`).
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * export class SubscriptionRepository extends RevenueRepositoryBase<
46
+ * SubscriptionDocument,
47
+ * SubscriptionRepoDeps
48
+ * > {
49
+ * async activate(id: string, ctx: RevenueContext = {}) {
50
+ * const sub = await this.getById(id, this.optsFromCtx(ctx));
51
+ * // ...
52
+ * await this.dispatch(createEvent(...), ctx);
53
+ * return updated;
54
+ * }
55
+ * }
56
+ * ```
57
+ */
58
+ /**
59
+ * Abstract base for `TransactionRepository`, `SubscriptionRepository`,
60
+ * `SettlementRepository`.
61
+ *
62
+ * Concrete subclasses MUST:
63
+ * - declare a `Deps` interface that extends {@link BaseRevenueRepoDeps}
64
+ * - call `super(model, plugins)` from the constructor
65
+ * - call `inject(deps)` once during engine boot
66
+ *
67
+ * Subclasses SHOULD use {@link optsFromCtx} for every mongokit call
68
+ * that takes an options bag, and {@link dispatch} for every event
69
+ * publish.
70
+ */
71
+ var RevenueRepositoryBase = class extends Repository {
72
+ /**
73
+ * Subclass-specific deps. `!` because the engine wires this once via
74
+ * `inject(deps)` immediately after construction; calling any domain
75
+ * verb before injection is a programming error and the runtime
76
+ * will fail-loud with `Cannot read properties of undefined`.
77
+ */
78
+ deps;
79
+ constructor(model, plugins = []) {
80
+ super(model, plugins);
81
+ }
82
+ /**
83
+ * Wire engine-managed deps. Called exactly once per repository
84
+ * instance during {@link createRevenue} bootstrap. Subclasses with
85
+ * extra steps (caching, prebuilding state machine maps) override and
86
+ * call `super.inject(deps)`.
87
+ */
88
+ inject(deps) {
89
+ this.deps = deps;
90
+ }
91
+ /**
92
+ * Translate {@link RevenueContext} into a mongokit options bag.
93
+ *
94
+ * Forwards every canonical field mongokit's bundled plugins read —
95
+ * `organizationId` (multiTenant), `userId` / `user` (audit),
96
+ * `session` (transactions), `requestId` (observability) — plus
97
+ * the revenue-specific `_bypassTenant` flag for platform-admin
98
+ * cross-org reads.
99
+ *
100
+ * Pass `extra` for caller-specific options like `throwOnNotFound`,
101
+ * `lean`, `populate`, `select` — the spread is `extra`-first so
102
+ * ctx wins on a key collision (intentional: callers shouldn't
103
+ * be smuggling tenant fields through `extra`).
104
+ *
105
+ * @param ctx - The request-scoped revenue context.
106
+ * @param extra - Additional mongokit options merged in.
107
+ */
108
+ optsFromCtx(ctx = {}, extra = {}) {
109
+ const out = {
110
+ ...extra,
111
+ ...repoOptionsFromCtx(ctx)
112
+ };
113
+ if (ctx._bypassTenant === true) out._bypassTenant = true;
114
+ return out;
115
+ }
116
+ /**
117
+ * Persist an event to the host outbox, then publish to the in-process
118
+ * transport. The two sides have asymmetric failure handling — see
119
+ * PACKAGE_RULES §P8.
120
+ *
121
+ * When `ctx.session` is set, the outbox `save` runs inside the same
122
+ * Mongoose transaction (true P8 session-bound write); when absent, the
123
+ * outbox row lands after commit — still durable via the host's relay,
124
+ * with only a small at-most-once window on process crash.
125
+ *
126
+ * 1. **`outbox.save` failures PROPAGATE.** If we can't durably record
127
+ * the event, the caller's transaction MUST roll back so the
128
+ * business doc and the event row land atomically (or neither
129
+ * lands). Swallowing a save failure breaks the transactional-
130
+ * outbox correctness argument — the parent doc would land while
131
+ * the event vanishes.
132
+ *
133
+ * 2. **`events.publish` failures are SWALLOWED.** The host's outbox
134
+ * relay re-publishes from the durable row on its next poll. Even
135
+ * without an outbox, in-process subscribers shouldn't be able to
136
+ * break the business operation — they're best-effort consumers.
137
+ *
138
+ * @param event - Pre-built domain event (use `createEvent(REVENUE_EVENTS.X, payload, ctx, meta)`).
139
+ * @param ctx - The same context that produced the business write.
140
+ */
141
+ async dispatch(event, ctx = {}) {
142
+ if (this.deps.outbox) try {
143
+ await this.deps.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
144
+ } catch (err) {
145
+ this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
146
+ throw err;
147
+ }
148
+ try {
149
+ await this.deps.events.publish(event);
150
+ } catch (err) {
151
+ this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
152
+ }
153
+ }
154
+ };
155
+
156
+ //#endregion
157
+ //#region src/repositories/transaction.repository.ts
158
+ var TransactionRepository = class extends RevenueRepositoryBase {
159
+ constructor(model, plugins = []) {
160
+ super(model, plugins);
161
+ }
162
+ /**
163
+ * Save an event to the host-owned outbox, session-bound when available.
164
+ *
165
+ * Called INSIDE a `withTransaction` body. The outbox row commits
166
+ * atomically with the business write (P8 true session-bound write) —
167
+ * if outbox.save fails, this method re-throws so `withTransaction`
168
+ * rolls the parent write back. Without propagation, the parent doc
169
+ * would commit while the event row vanishes, defeating the whole
170
+ * transactional-outbox correctness argument.
171
+ *
172
+ * Logging happens before the re-throw so the failure surfaces in
173
+ * observability without losing the original stack trace.
174
+ */
175
+ async saveToOutbox(event, session) {
176
+ if (!this.deps.outbox) return;
177
+ try {
178
+ await this.deps.outbox.save(event, session !== void 0 ? { session } : {});
179
+ } catch (err) {
180
+ this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
181
+ throw err;
182
+ }
183
+ }
184
+ /**
185
+ * Publish an event to the in-process `EventTransport` after commit.
186
+ * Transport failure is logged and swallowed — the host relay re-delivers
187
+ * from the durable outbox row on the next poll, so in-process
188
+ * subscribers missing an event is recoverable. Best-effort by design.
189
+ */
190
+ async publishToTransport(event) {
191
+ try {
192
+ await this.deps.events.publish(event);
193
+ } catch (err) {
194
+ this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
195
+ }
196
+ }
197
+ /** Creates transaction + calls provider. Returns the created transaction doc. */
198
+ async createPaymentIntent(params, ctx = {}) {
199
+ const currency = params.currency ?? this.deps.defaultCurrency;
200
+ const provider = this.deps.providers.get(params.gateway);
201
+ if (params.idempotencyKey) {
202
+ const existing = await this.getByQuery({ idempotencyKey: params.idempotencyKey }, this.optsFromCtx(ctx, { throwOnNotFound: false }));
203
+ if (existing) return existing;
204
+ }
205
+ const commissionRate = this.deps.commission?.defaultRate ?? 0;
206
+ const gatewayFeeRate = this.deps.commission?.gatewayFeeRate ?? 0;
207
+ const commission = calculateCommission(params.amount, commissionRate, gatewayFeeRate);
208
+ let gatewayData = { type: params.gateway };
209
+ if (params.amount > 0) {
210
+ const intent = await provider.createIntent({
211
+ amount: {
212
+ amount: params.amount,
213
+ currency
214
+ },
215
+ metadata: params.metadata,
216
+ ...params.paymentData
217
+ });
218
+ gatewayData = {
219
+ type: params.gateway,
220
+ sessionId: intent.sessionId,
221
+ paymentIntentId: intent.paymentIntentId ?? intent.id,
222
+ metadata: {
223
+ clientSecret: intent.clientSecret,
224
+ paymentUrl: intent.paymentUrl,
225
+ instructions: intent.instructions
226
+ }
227
+ };
228
+ }
229
+ const transaction = await this.create({
230
+ organizationId: ctx.organizationId,
231
+ customerId: params.data?.customerId ?? null,
232
+ type: params.monetizationType === "subscription" ? "subscription" : "purchase",
233
+ flow: "inflow",
234
+ tags: params.monetizationType ? [params.monetizationType] : [],
235
+ amount: params.amount,
236
+ currency,
237
+ fee: commission?.gatewayFeeAmount ?? 0,
238
+ tax: 0,
239
+ net: params.amount - (commission?.gatewayFeeAmount ?? 0),
240
+ method: params.gateway,
241
+ status: params.amount === 0 ? TRANSACTION_STATUS.VERIFIED : TRANSACTION_STATUS.PENDING,
242
+ gateway: gatewayData,
243
+ commission: commission ?? void 0,
244
+ sourceId: params.data?.sourceId,
245
+ sourceModel: params.data?.sourceModel,
246
+ idempotencyKey: params.idempotencyKey,
247
+ metadata: params.metadata
248
+ }, this.optsFromCtx(ctx));
249
+ await this.dispatch(createEvent(REVENUE_EVENTS.MONETIZATION_CREATED, {
250
+ monetizationType: params.monetizationType,
251
+ transaction
252
+ }, ctx, {
253
+ resource: "transaction",
254
+ resourceId: transaction.publicId
255
+ }), ctx);
256
+ return transaction;
257
+ }
258
+ /** Verifies payment via provider, updates status. Returns the updated doc. */
259
+ async verify(paymentIntentId, options = {}, ctx = {}) {
260
+ const readOpts = this.optsFromCtx(ctx, { throwOnNotFound: false });
261
+ let transaction = await this.getByQuery({ "gateway.sessionId": paymentIntentId }, readOpts);
262
+ if (!transaction) transaction = await this.getByQuery({ "gateway.paymentIntentId": paymentIntentId }, readOpts);
263
+ if (!transaction) transaction = await this.getById(paymentIntentId, readOpts);
264
+ if (!transaction) throw new TransactionNotFoundError(paymentIntentId);
265
+ const provider = this.deps.providers.get(transaction.method);
266
+ const intentId = transaction.gateway?.paymentIntentId ?? transaction.gateway?.sessionId ?? paymentIntentId;
267
+ const paymentResult = await provider.verifyPayment(intentId);
268
+ let newStatus;
269
+ if (paymentResult.status === "succeeded") newStatus = TRANSACTION_STATUS.VERIFIED;
270
+ else if (paymentResult.status === "failed") newStatus = TRANSACTION_STATUS.FAILED;
271
+ else if (paymentResult.status === "requires_action") newStatus = TRANSACTION_STATUS.REQUIRES_ACTION;
272
+ else newStatus = TRANSACTION_STATUS.PROCESSING;
273
+ TRANSACTION_STATE_MACHINE.validate(transaction.status, newStatus, String(transaction._id));
274
+ const updates = { status: newStatus };
275
+ if (newStatus === TRANSACTION_STATUS.VERIFIED) {
276
+ updates.verifiedAt = /* @__PURE__ */ new Date();
277
+ updates.verifiedBy = options.verifiedBy;
278
+ } else if (newStatus === TRANSACTION_STATUS.FAILED) {
279
+ updates.failedAt = /* @__PURE__ */ new Date();
280
+ updates.failureReason = "Payment verification failed";
281
+ }
282
+ const updated = await this.update(transaction._id, updates, this.optsFromCtx(ctx));
283
+ if (newStatus === TRANSACTION_STATUS.VERIFIED) {
284
+ await this.deps.bridges.ledger?.onPaymentVerified?.(updated, ctx);
285
+ await this.deps.bridges.notification?.onPaymentVerified?.(updated, ctx);
286
+ }
287
+ const eventName = newStatus === TRANSACTION_STATUS.VERIFIED ? REVENUE_EVENTS.PAYMENT_VERIFIED : newStatus === TRANSACTION_STATUS.FAILED ? REVENUE_EVENTS.PAYMENT_FAILED : newStatus === TRANSACTION_STATUS.REQUIRES_ACTION ? REVENUE_EVENTS.PAYMENT_REQUIRES_ACTION : REVENUE_EVENTS.PAYMENT_PROCESSING;
288
+ await this.dispatch(createEvent(eventName, {
289
+ transaction: updated,
290
+ paymentResult,
291
+ verifiedBy: options.verifiedBy
292
+ }, ctx, {
293
+ resource: "transaction",
294
+ resourceId: updated?.publicId
295
+ }), ctx);
296
+ return updated;
297
+ }
298
+ /**
299
+ * Creates refund transaction, updates original. Returns the refund transaction doc.
300
+ *
301
+ * The provider call happens OUTSIDE the transaction — it's a non-idempotent external
302
+ * side effect we can't roll back. The two Mongo writes (create refund + update original)
303
+ * run inside `withTransaction` so they commit atomically or both abort. Bridges and
304
+ * event emission run AFTER commit because they're independent side effects; rolling
305
+ * them back would not undo external state anyway.
306
+ *
307
+ * Powered by mongokit 3.6's module-level `withTransaction` helper. Automatically
308
+ * retries on `TransientTransactionError` / `UnknownTransactionCommitResult`.
309
+ */
310
+ async refund(transactionId, amount, options = {}, ctx = {}) {
311
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
312
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
313
+ const refundAmount = amount ?? transaction.amount;
314
+ const existingRefunded = transaction.refundedAmount ?? 0;
315
+ const isPartialRefund = existingRefunded + refundAmount < transaction.amount;
316
+ const newStatus = isPartialRefund ? TRANSACTION_STATUS.PARTIALLY_REFUNDED : TRANSACTION_STATUS.REFUNDED;
317
+ TRANSACTION_STATE_MACHINE.validate(transaction.status, newStatus, transactionId);
318
+ const provider = this.deps.providers.get(transaction.method);
319
+ const paymentId = transaction.gateway?.paymentIntentId ?? transaction.gateway?.sessionId ?? transactionId;
320
+ await provider.refund(paymentId, refundAmount, { reason: options.reason });
321
+ const reversedCommission = reverseCommission(transaction.commission, transaction.amount, refundAmount);
322
+ const reversedTax = transaction.tax ? reverseTax({
323
+ isApplicable: true,
324
+ rate: 0,
325
+ baseAmount: transaction.amount,
326
+ taxAmount: transaction.tax,
327
+ totalAmount: transaction.amount + transaction.tax,
328
+ pricesIncludeTax: false
329
+ }, transaction.amount, refundAmount) : void 0;
330
+ const pendingEvents = [];
331
+ const refundTransaction = await withTransaction(this.Model.db, async (session) => {
332
+ const writeOpts = this.optsFromCtx(ctx, { session });
333
+ const refundTxn = await this.create({
334
+ organizationId: transaction.organizationId,
335
+ customerId: transaction.customerId,
336
+ type: "refund",
337
+ flow: "outflow",
338
+ tags: ["refund"],
339
+ amount: refundAmount,
340
+ currency: transaction.currency,
341
+ fee: reversedCommission?.gatewayFeeAmount ?? 0,
342
+ tax: reversedTax?.taxAmount ?? 0,
343
+ net: refundAmount - (reversedCommission?.gatewayFeeAmount ?? 0) - (reversedTax?.taxAmount ?? 0),
344
+ method: transaction.method,
345
+ status: TRANSACTION_STATUS.VERIFIED,
346
+ gateway: transaction.gateway,
347
+ commission: reversedCommission ?? void 0,
348
+ relatedTransactionId: transaction._id,
349
+ sourceId: transaction.sourceId,
350
+ sourceModel: transaction.sourceModel,
351
+ verifiedAt: /* @__PURE__ */ new Date(),
352
+ metadata: { reason: options.reason }
353
+ }, writeOpts);
354
+ await this.update(transactionId, {
355
+ status: newStatus,
356
+ refundedAmount: existingRefunded + refundAmount,
357
+ refundedAt: /* @__PURE__ */ new Date()
358
+ }, writeOpts);
359
+ const event = createEvent(REVENUE_EVENTS.PAYMENT_REFUNDED, {
360
+ transaction,
361
+ refundTransaction: refundTxn,
362
+ refundAmount,
363
+ reason: options.reason,
364
+ isPartialRefund
365
+ }, ctx, {
366
+ resource: "transaction",
367
+ resourceId: transaction.publicId
368
+ });
369
+ await this.saveToOutbox(event, session);
370
+ pendingEvents.push(event);
371
+ return refundTxn;
372
+ });
373
+ await this.deps.bridges.ledger?.onRefundProcessed?.(transaction, refundTransaction, ctx);
374
+ await this.deps.bridges.notification?.onRefundProcessed?.(refundTransaction, ctx);
375
+ for (const ev of pendingEvents) await this.publishToTransport(ev);
376
+ return refundTransaction;
377
+ }
378
+ /** Handles provider webhook. Returns the updated transaction doc (or null if not found). */
379
+ async handleWebhook(providerName, payload, headers, ctx = {}) {
380
+ const webhookEvent = await this.deps.providers.get(providerName).handleWebhook(payload, headers);
381
+ const readOpts = this.optsFromCtx(ctx, { throwOnNotFound: false });
382
+ const sessionId = webhookEvent.data?.sessionId;
383
+ const intentId = webhookEvent.data?.paymentIntentId;
384
+ let transaction = sessionId ? await this.getByQuery({ "gateway.sessionId": sessionId }, readOpts) : null;
385
+ if (!transaction && intentId) transaction = await this.getByQuery({ "gateway.paymentIntentId": intentId }, readOpts);
386
+ if (!transaction) return null;
387
+ if (transaction.webhook?.eventId === webhookEvent.id) return transaction;
388
+ const nextWebhook = {
389
+ eventId: webhookEvent.id,
390
+ eventType: webhookEvent.type,
391
+ receivedAt: /* @__PURE__ */ new Date(),
392
+ processedAt: /* @__PURE__ */ new Date(),
393
+ data: webhookEvent.data
394
+ };
395
+ const updated = await this.findOneAndUpdate({
396
+ _id: transaction._id,
397
+ "webhook.eventId": { $ne: webhookEvent.id }
398
+ }, { $set: { webhook: nextWebhook } }, { returnDocument: "after" });
399
+ if (!updated) return await this.getByQuery({ _id: transaction._id }, readOpts) ?? transaction;
400
+ await this.dispatch(createEvent(REVENUE_EVENTS.WEBHOOK_PROCESSED, {
401
+ webhookType: webhookEvent.type,
402
+ provider: providerName,
403
+ event: webhookEvent,
404
+ transaction: updated
405
+ }, ctx, {
406
+ resource: "transaction",
407
+ resourceId: updated?.publicId
408
+ }), ctx);
409
+ return updated;
410
+ }
411
+ /** Places hold on verified transaction. Returns the updated doc. */
412
+ async hold(transactionId, options = {}, ctx = {}) {
413
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
414
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
415
+ if (transaction.status !== TRANSACTION_STATUS.VERIFIED) throw new ValidationError("Can only hold verified transactions", { status: transaction.status });
416
+ const holdAmount = options.amount ?? transaction.amount;
417
+ const updated = await this.update(transactionId, { hold: {
418
+ status: HOLD_STATUS.HELD,
419
+ heldAmount: holdAmount,
420
+ releasedAmount: 0,
421
+ reason: options.reason ?? "manual_hold",
422
+ heldAt: /* @__PURE__ */ new Date(),
423
+ holdUntil: options.holdUntil,
424
+ releases: [],
425
+ metadata: options.metadata
426
+ } }, this.optsFromCtx(ctx));
427
+ await this.dispatch(createEvent(REVENUE_EVENTS.ESCROW_HELD, {
428
+ transaction: updated,
429
+ heldAmount: holdAmount,
430
+ reason: options.reason
431
+ }, ctx, {
432
+ resource: "transaction",
433
+ resourceId: updated?.publicId
434
+ }), ctx);
435
+ return updated;
436
+ }
437
+ /**
438
+ * Releases held funds. Returns the updated transaction doc.
439
+ *
440
+ * The hold update and the escrow_release transaction create happen inside
441
+ * `withTransaction` — a mid-flow crash can't leave the hold marked released
442
+ * without the corresponding outflow record (or vice versa).
443
+ */
444
+ async release(transactionId, options, ctx = {}) {
445
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
446
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
447
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD && transaction.hold.status !== HOLD_STATUS.PARTIALLY_RELEASED) throw new ValidationError("Transaction does not have an active hold");
448
+ const releaseAmount = options.amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
449
+ const newReleasedAmount = transaction.hold.releasedAmount + releaseAmount;
450
+ const isFullRelease = newReleasedAmount >= transaction.hold.heldAmount;
451
+ const release = {
452
+ amount: releaseAmount,
453
+ recipientId: options.recipientId,
454
+ recipientType: options.recipientType,
455
+ releasedAt: /* @__PURE__ */ new Date(),
456
+ releasedBy: options.releasedBy,
457
+ reason: options.reason,
458
+ metadata: options.metadata
459
+ };
460
+ const pendingEvents = [];
461
+ const updated = await withTransaction(this.Model.db, async (session) => {
462
+ const writeOpts = this.optsFromCtx(ctx, { session });
463
+ const result = await this.update(transactionId, { hold: {
464
+ ...transaction.hold,
465
+ status: isFullRelease ? HOLD_STATUS.RELEASED : HOLD_STATUS.PARTIALLY_RELEASED,
466
+ releasedAmount: newReleasedAmount,
467
+ releasedAt: isFullRelease ? /* @__PURE__ */ new Date() : transaction.hold.releasedAt,
468
+ releases: [...transaction.hold.releases ?? [], release]
469
+ } }, writeOpts);
470
+ if (options.createTransaction !== false) await this.create({
471
+ organizationId: transaction.organizationId,
472
+ customerId: options.recipientId,
473
+ type: "escrow_release",
474
+ flow: "outflow",
475
+ tags: ["escrow", "release"],
476
+ amount: releaseAmount,
477
+ currency: transaction.currency,
478
+ fee: 0,
479
+ tax: 0,
480
+ net: releaseAmount,
481
+ method: transaction.method,
482
+ status: TRANSACTION_STATUS.VERIFIED,
483
+ relatedTransactionId: transaction._id,
484
+ sourceId: transaction.sourceId,
485
+ sourceModel: transaction.sourceModel,
486
+ verifiedAt: /* @__PURE__ */ new Date(),
487
+ metadata: options.metadata
488
+ }, writeOpts);
489
+ const event = createEvent(REVENUE_EVENTS.ESCROW_RELEASED, {
490
+ transaction: result,
491
+ releaseAmount,
492
+ recipientId: options.recipientId,
493
+ recipientType: options.recipientType,
494
+ isFullRelease,
495
+ isPartialRelease: !isFullRelease
496
+ }, ctx, {
497
+ resource: "transaction",
498
+ resourceId: result?.publicId
499
+ });
500
+ await this.saveToOutbox(event, session);
501
+ pendingEvents.push(event);
502
+ return result;
503
+ });
504
+ for (const ev of pendingEvents) await this.publishToTransport(ev);
505
+ return updated;
506
+ }
507
+ /**
508
+ * Splits payment among recipients. Returns the updated transaction doc.
509
+ *
510
+ * N + 2 writes (one create per recipient, one update on the parent, one
511
+ * platform_revenue create) all commit atomically. Partial splits are the
512
+ * worst class of bug in a payments system — this is exactly what
513
+ * `withTransaction` is for.
514
+ */
515
+ async split(transactionId, rules, ctx = {}) {
516
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
517
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
518
+ const gatewayFeeRate = this.deps.commission?.gatewayFeeRate ?? 0;
519
+ const splits = calculateSplits(transaction.amount, rules, gatewayFeeRate);
520
+ const orgPayout = calculateOrganizationPayout(transaction.amount, splits);
521
+ const pendingEvents = [];
522
+ const updated = await withTransaction(this.Model.db, async (session) => {
523
+ const writeOpts = this.optsFromCtx(ctx, { session });
524
+ for (const s of splits) await this.create({
525
+ organizationId: transaction.organizationId,
526
+ customerId: s.recipientId,
527
+ type: "commission",
528
+ flow: "outflow",
529
+ tags: ["split", s.type],
530
+ amount: s.grossAmount,
531
+ currency: transaction.currency,
532
+ fee: s.gatewayFeeAmount,
533
+ tax: 0,
534
+ net: s.netAmount,
535
+ method: transaction.method,
536
+ status: TRANSACTION_STATUS.VERIFIED,
537
+ relatedTransactionId: transaction._id,
538
+ sourceId: transaction.sourceId,
539
+ sourceModel: transaction.sourceModel,
540
+ verifiedAt: /* @__PURE__ */ new Date()
541
+ }, writeOpts);
542
+ const result = await this.update(transactionId, {
543
+ splits,
544
+ metadata: {
545
+ ...transaction.metadata,
546
+ organizationPayout: orgPayout
547
+ }
548
+ }, writeOpts);
549
+ await this.create({
550
+ organizationId: transaction.organizationId,
551
+ type: "platform_revenue",
552
+ flow: "inflow",
553
+ tags: ["split", "platform"],
554
+ amount: orgPayout,
555
+ currency: transaction.currency,
556
+ fee: 0,
557
+ tax: 0,
558
+ net: orgPayout,
559
+ method: transaction.method,
560
+ status: TRANSACTION_STATUS.VERIFIED,
561
+ relatedTransactionId: transaction._id,
562
+ verifiedAt: /* @__PURE__ */ new Date()
563
+ }, writeOpts);
564
+ const event = createEvent(REVENUE_EVENTS.ESCROW_SPLIT, {
565
+ transaction: result,
566
+ splits,
567
+ organizationPayout: orgPayout
568
+ }, ctx, {
569
+ resource: "transaction",
570
+ resourceId: transaction.publicId
571
+ });
572
+ await this.saveToOutbox(event, session);
573
+ pendingEvents.push(event);
574
+ return result;
575
+ });
576
+ for (const ev of pendingEvents) await this.publishToTransport(ev);
577
+ return updated;
578
+ }
579
+ /**
580
+ * Idempotent bulk import of bank-feed rows.
581
+ *
582
+ * Each row is upserted by `(orgId, bankAccountId, externalId)` — the
583
+ * partial unique index declared in `create-models.ts`. Re-running the
584
+ * same Plaid sync, OFX upload, or QBO CDC drain produces zero new
585
+ * inserts on the second call (modified counts may rise as
586
+ * descriptions/categories evolve upstream).
587
+ *
588
+ * Signed bank `amount` is normalized into the (`amount` >= 0, `flow`)
589
+ * shape revenue uses internally so downstream queries (`flow:
590
+ * 'inflow'`) work uniformly across kinds.
591
+ *
592
+ * Emits one `revenue:transaction.imported` event per **inserted** row
593
+ * (not per row in `rows` — re-imports do not re-fire). Hosts wanting
594
+ * batch-level signal subscribe to the per-doc events and aggregate.
595
+ *
596
+ * Per-row failures (validation, hash collisions on a non-unique
597
+ * `externalId`) collect into `errors[]` instead of aborting the whole
598
+ * batch — the typical Plaid drain pulls thousands of rows; one bad
599
+ * row should not block the rest.
600
+ *
601
+ * @param rows Canonical bank transactions, structurally compatible
602
+ * with `@classytic/fin-io` parsers' output.
603
+ * @param opts `bankAccountId` (required, polymorphic ID) and
604
+ * `source` (provenance — `'plaid'`, `'ofx'`, …).
605
+ */
606
+ async import(rows, opts, ctx = {}) {
607
+ const startedAt = Date.now();
608
+ if (!Array.isArray(rows) || rows.length === 0) return {
609
+ inserted: 0,
610
+ updated: 0,
611
+ skipped: 0,
612
+ errors: [],
613
+ durationMs: 0
614
+ };
615
+ const repo = this;
616
+ 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.");
617
+ const errors = [];
618
+ const tenantOption = ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {};
619
+ const ops = [];
620
+ for (const row of rows) {
621
+ if (!row.externalId || typeof row.externalId !== "string") {
622
+ errors.push({
623
+ externalId: String(row.externalId),
624
+ reason: "missing_external_id",
625
+ row
626
+ });
627
+ continue;
628
+ }
629
+ const signed = row.amount.amount;
630
+ if (!Number.isFinite(signed) || !Number.isInteger(signed)) {
631
+ errors.push({
632
+ externalId: row.externalId,
633
+ reason: "invalid_amount",
634
+ row
635
+ });
636
+ continue;
637
+ }
638
+ const isInflow = signed >= 0;
639
+ const absoluteAmount = Math.abs(signed);
640
+ const filter = {
641
+ bankAccountId: opts.bankAccountId,
642
+ externalId: row.externalId
643
+ };
644
+ if (ctx.organizationId !== void 0) filter.organizationId = ctx.organizationId;
645
+ const set = {
646
+ amount: absoluteAmount,
647
+ currency: row.amount.currency,
648
+ flow: isInflow ? "inflow" : "outflow",
649
+ postedDate: row.postedDate,
650
+ description: row.description,
651
+ method: opts.method ?? opts.source
652
+ };
653
+ if (row.valueDate !== void 0) set.valueDate = row.valueDate;
654
+ if (row.counterparty !== void 0) set.counterparty = row.counterparty;
655
+ if (row.reference !== void 0) set.reference = row.reference;
656
+ if (row.category !== void 0) set.vendorCategory = row.category;
657
+ if (row.balanceAfter !== void 0) set.balanceAfter = row.balanceAfter.amount;
658
+ const setOnInsert = {
659
+ kind: TRANSACTION_KIND.BANK_FEED,
660
+ status: initialStatusFor(TRANSACTION_KIND.BANK_FEED),
661
+ bankAccountId: opts.bankAccountId,
662
+ externalId: row.externalId,
663
+ source: opts.source,
664
+ type: "bank_feed",
665
+ tags: ["bank_feed", opts.source],
666
+ fee: 0,
667
+ tax: 0,
668
+ net: absoluteAmount,
669
+ deletedAt: null
670
+ };
671
+ if (ctx.organizationId !== void 0) setOnInsert.organizationId = ctx.organizationId;
672
+ ops.push({ updateOne: {
673
+ filter,
674
+ update: {
675
+ $set: set,
676
+ $setOnInsert: setOnInsert
677
+ },
678
+ upsert: true
679
+ } });
680
+ }
681
+ if (ops.length === 0) return {
682
+ inserted: 0,
683
+ updated: 0,
684
+ skipped: rows.length,
685
+ errors,
686
+ durationMs: Date.now() - startedAt
687
+ };
688
+ const sessionOption = ctx.session !== void 0 ? { session: ctx.session } : {};
689
+ const result = await repo.bulkWrite(ops, {
690
+ ordered: false,
691
+ ...sessionOption,
692
+ ...tenantOption
693
+ });
694
+ const inserted = (result.upsertedCount ?? 0) + (result.insertedCount ?? 0);
695
+ const updated = result.modifiedCount ?? 0;
696
+ const upsertedIds = Object.values(result.upsertedIds ?? {});
697
+ if (upsertedIds.length > 0) for (let i = 0; i < upsertedIds.length; i++) {
698
+ const id = upsertedIds[i];
699
+ if (id === void 0 || id === null) continue;
700
+ const doc = await this.getById(String(id), this.optsFromCtx(ctx, { throwOnNotFound: false }));
701
+ if (!doc) continue;
702
+ const txn = doc;
703
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_IMPORTED, {
704
+ transaction: txn,
705
+ source: opts.source,
706
+ bankAccountId: opts.bankAccountId,
707
+ externalId: txn.externalId ?? ""
708
+ }, ctx, {
709
+ resource: "transaction",
710
+ resourceId: txn.publicId
711
+ }), ctx);
712
+ await this.deps.bridges.ledger?.onTransactionImported?.(txn, ctx);
713
+ }
714
+ return {
715
+ inserted,
716
+ updated,
717
+ skipped: errors.length,
718
+ errors,
719
+ durationMs: Date.now() - startedAt
720
+ };
721
+ }
722
+ /**
723
+ * Drain a bank-feed provider into the collection.
724
+ *
725
+ * Pulls pages from `provider.fetchTransactions()` (Plaid cursor, QBO
726
+ * CDC) and feeds each batch through `import()`. Yields the running
727
+ * report so a host cron can stream-progress-report to logs / metrics.
728
+ *
729
+ * Stops when the provider returns no new rows AND no removals AND no
730
+ * `nextCursor`. Caller is responsible for persisting the final cursor
731
+ * in their own checkpoint table — `result.nextCursor` is returned so
732
+ * the host can write it after a successful drain.
733
+ *
734
+ * Plaid `removed[]` rows (and any provider that retracts entries) are
735
+ * routed through `removeByFeed` so the host's LedgerBridge can void
736
+ * any JE that was already posted.
737
+ */
738
+ async drainSync(providerName, params, ctx = {}) {
739
+ if (!this.deps.bankFeedProviders) throw new ValidationError("`bankFeedProviders` not wired on the engine. Pass `providers.bankFeed` to `createRevenue`.");
740
+ const provider = this.deps.bankFeedProviders.get(providerName);
741
+ let totalImported = 0;
742
+ let totalUpdated = 0;
743
+ let totalRemoved = 0;
744
+ let lastCursor;
745
+ const errors = [];
746
+ for await (const page of provider.drain(params)) {
747
+ if (page.transactions && page.transactions.length > 0) {
748
+ const report = await this.import(page.transactions, {
749
+ bankAccountId: params.bankAccountId,
750
+ source: providerName
751
+ }, ctx);
752
+ totalImported += report.inserted;
753
+ totalUpdated += report.updated;
754
+ if (report.errors.length > 0) errors.push(...report.errors);
755
+ }
756
+ if (page.removed && page.removed.length > 0) {
757
+ const removed = await this.removeByFeed(page.removed.map((r) => r.externalId), {
758
+ bankAccountId: params.bankAccountId,
759
+ source: providerName
760
+ }, ctx);
761
+ totalRemoved += removed.removed;
762
+ }
763
+ if (page.nextCursor) lastCursor = page.nextCursor;
764
+ }
765
+ return {
766
+ totalImported,
767
+ totalUpdated,
768
+ totalRemoved,
769
+ ...lastCursor !== void 0 ? { nextCursor: lastCursor } : {},
770
+ errors
771
+ };
772
+ }
773
+ /**
774
+ * Parse an upload (OFX / CAMT.053 / MT940 / CSV) via a registered
775
+ * bank-feed provider, then `import()` the result.
776
+ *
777
+ * Convenience over manually calling `provider.parseUpload()` and
778
+ * threading the canonical rows into `import()` — the file-upload
779
+ * route handler is one line.
780
+ */
781
+ async parseAndImport(providerName, upload, ctx = {}) {
782
+ if (!this.deps.bankFeedProviders) throw new ValidationError("`bankFeedProviders` not wired on the engine.");
783
+ const provider = this.deps.bankFeedProviders.get(providerName);
784
+ if (!provider.parseUpload) throw new ValidationError(`Provider '${providerName}' does not support parseUpload`);
785
+ const parsed = await provider.parseUpload({
786
+ buffer: upload.buffer,
787
+ ...upload.format !== void 0 ? { format: upload.format } : {},
788
+ bankAccountId: upload.bankAccountId
789
+ });
790
+ return this.import(parsed.transactions, {
791
+ bankAccountId: upload.bankAccountId,
792
+ source: providerName
793
+ }, ctx);
794
+ }
795
+ /**
796
+ * Hand-keyed entry — treasurer logs a cash deposit, owner injects
797
+ * capital, refund correction. Created in `pending` (manual SM); host
798
+ * proceeds with `match()` → `journalize()` to post it to the ledger.
799
+ *
800
+ * `kind: 'manual'` is enforced — calls passing other kinds throw.
801
+ */
802
+ async createManual(data, ctx = {}) {
803
+ return await this.create({
804
+ organizationId: ctx.organizationId,
805
+ kind: TRANSACTION_KIND.MANUAL,
806
+ type: data.type,
807
+ flow: data.flow,
808
+ tags: ["manual"],
809
+ amount: data.amount,
810
+ currency: data.currency,
811
+ fee: 0,
812
+ tax: 0,
813
+ net: data.amount,
814
+ method: "manual",
815
+ status: initialStatusFor(TRANSACTION_KIND.MANUAL),
816
+ source: "manual",
817
+ ...data.description !== void 0 ? { description: data.description } : {},
818
+ ...data.counterparty !== void 0 ? { counterparty: data.counterparty } : {},
819
+ ...data.reference !== void 0 ? { reference: data.reference } : {},
820
+ ...data.postedDate !== void 0 ? { postedDate: data.postedDate } : {},
821
+ ...data.valueDate !== void 0 ? { valueDate: data.valueDate } : {},
822
+ ...data.bankAccountId !== void 0 ? { bankAccountId: data.bankAccountId } : {},
823
+ ...data.sourceId !== void 0 ? { sourceId: data.sourceId } : {},
824
+ ...data.sourceModel !== void 0 ? { sourceModel: data.sourceModel } : {},
825
+ ...data.metadata !== void 0 ? { metadata: data.metadata } : {}
826
+ }, this.optsFromCtx(ctx));
827
+ }
828
+ /**
829
+ * Match a bank-feed / manual transaction to GL accounts, optionally
830
+ * cross-linking to an upstream payment-flow transaction.
831
+ *
832
+ * Atomic state CAS via `claim()` — the `where: { kind: { $in: [...] } }`
833
+ * predicate prevents a payment-flow row from being matched through this
834
+ * verb. Multi-source `from` (`['imported', 'matched']`) supports
835
+ * re-match after `unmatch()` (`matched → imported → matched`) without
836
+ * losing the prior mapping if the host wants to overwrite it.
837
+ *
838
+ * After a successful claim, `LedgerBridge.onTransactionMatched` runs
839
+ * — the canonical implementation creates a journal entry and chains
840
+ * `journalize()` to record the JE ref. The bridge call is OUTSIDE the
841
+ * claim's CAS window because JE posting is a side effect that may
842
+ * take seconds (cross-process call to ledger).
843
+ */
844
+ async match(id, data, ctx = {}) {
845
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
846
+ if (!existing) throw new TransactionNotFoundError(id);
847
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
848
+ smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.MATCHED, id);
849
+ const patch = {
850
+ $set: {
851
+ matching: {
852
+ ...data.mapping,
853
+ ...data.matchedBy !== void 0 ? { matchedBy: data.matchedBy } : {},
854
+ matchedAt: /* @__PURE__ */ new Date()
855
+ },
856
+ ...data.matchedBy !== void 0 ? { verifiedBy: data.matchedBy } : {},
857
+ verifiedAt: /* @__PURE__ */ new Date()
858
+ },
859
+ $unset: { journalEntryRef: 1 }
860
+ };
861
+ if (data.relatedTransactionId !== void 0) patch.$set.relatedTransactionId = data.relatedTransactionId;
862
+ const claimed = await this.claim(existing._id, {
863
+ from: [
864
+ TRANSACTION_STATUS.IMPORTED,
865
+ TRANSACTION_STATUS.MATCHED,
866
+ TRANSACTION_STATUS.PENDING
867
+ ],
868
+ to: TRANSACTION_STATUS.MATCHED,
869
+ where: { kind: existing.kind }
870
+ }, patch, this.optsFromCtx(ctx));
871
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be matched (race-loss or illegal state)`);
872
+ await this.deps.bridges.ledger?.onTransactionMatched?.(claimed, data.mapping, ctx);
873
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_MATCHED, {
874
+ transaction: claimed,
875
+ mapping: data.mapping,
876
+ ...data.relatedTransactionId !== void 0 ? { relatedTransactionId: data.relatedTransactionId } : {},
877
+ ...data.matchedBy !== void 0 ? { matchedBy: data.matchedBy } : {}
878
+ }, ctx, {
879
+ resource: "transaction",
880
+ resourceId: claimed.publicId
881
+ }), ctx);
882
+ return claimed;
883
+ }
884
+ /**
885
+ * Revert a matched transaction back to `imported`. Clears the
886
+ * `matching` block and `relatedTransactionId`. Notifies the
887
+ * LedgerBridge (which typically voids the journal entry) AFTER the
888
+ * state CAS lands.
889
+ *
890
+ * Only legal for `kind: 'bank_feed'` — manual entries don't allow
891
+ * un-match (the manual SM has no `matched → pending` edge).
892
+ */
893
+ async unmatch(id, options = {}, ctx = {}) {
894
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
895
+ if (!existing) throw new TransactionNotFoundError(id);
896
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED) throw new WrongTransactionKindError(id, "bank_feed", existing.kind);
897
+ const priorJournalEntryRef = existing.journalEntryRef;
898
+ const claimed = await this.claim(existing._id, {
899
+ from: TRANSACTION_STATUS.MATCHED,
900
+ to: TRANSACTION_STATUS.IMPORTED,
901
+ where: { kind: TRANSACTION_KIND.BANK_FEED }
902
+ }, { $unset: {
903
+ matching: 1,
904
+ relatedTransactionId: 1,
905
+ journalEntryRef: 1,
906
+ verifiedBy: 1,
907
+ verifiedAt: 1
908
+ } }, this.optsFromCtx(ctx));
909
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be unmatched (current state is not 'matched')`);
910
+ await this.deps.bridges.ledger?.onTransactionUnmatched?.(claimed, priorJournalEntryRef, ctx);
911
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_UNMATCHED, {
912
+ transaction: claimed,
913
+ ...options.unmatchedBy !== void 0 ? { unmatchedBy: options.unmatchedBy } : {}
914
+ }, ctx, {
915
+ resource: "transaction",
916
+ resourceId: claimed.publicId
917
+ }), ctx);
918
+ return claimed;
919
+ }
920
+ /**
921
+ * Stamp the journal entry reference and transition `matched →
922
+ * journalized`. Typical caller is the `LedgerBridge.onTransactionMatched`
923
+ * implementation — after creating a JE, it calls this verb so the row
924
+ * carries the back-reference.
925
+ */
926
+ async journalize(id, data, ctx = {}) {
927
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
928
+ if (!existing) throw new TransactionNotFoundError(id);
929
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
930
+ smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.JOURNALIZED, id);
931
+ const claimed = await this.claim(existing._id, {
932
+ from: TRANSACTION_STATUS.MATCHED,
933
+ to: TRANSACTION_STATUS.JOURNALIZED,
934
+ where: { kind: existing.kind }
935
+ }, { $set: { journalEntryRef: data.journalEntryRef } }, this.optsFromCtx(ctx));
936
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be journalized (current state is not 'matched')`);
937
+ await this.deps.bridges.ledger?.onTransactionJournalized?.(claimed, data.journalEntryRef, ctx);
938
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_JOURNALIZED, {
939
+ transaction: claimed,
940
+ journalEntryRef: data.journalEntryRef,
941
+ ...data.journalizedBy !== void 0 ? { journalizedBy: data.journalizedBy } : {}
942
+ }, ctx, {
943
+ resource: "transaction",
944
+ resourceId: claimed.publicId
945
+ }), ctx);
946
+ return claimed;
947
+ }
948
+ /**
949
+ * Operator skip — marks an imported / matched / pending row as
950
+ * rejected (terminal). Use cases: duplicate of an already-imported
951
+ * row, non-cash entry the host doesn't want in the ledger, manual
952
+ * correction overrides.
953
+ *
954
+ * `relatedTransactionId` is preserved; reversal is the host's call.
955
+ */
956
+ async reject(id, data, ctx = {}) {
957
+ const existing = await this.getById(id, this.optsFromCtx(ctx));
958
+ if (!existing) throw new TransactionNotFoundError(id);
959
+ if (existing.kind !== TRANSACTION_KIND.BANK_FEED && existing.kind !== TRANSACTION_KIND.MANUAL) throw new WrongTransactionKindError(id, "bank_feed | manual", existing.kind);
960
+ smFor(existing.kind).validate(existing.status, TRANSACTION_STATUS.REJECTED, id);
961
+ const claimed = await this.claim(existing._id, {
962
+ from: [
963
+ TRANSACTION_STATUS.IMPORTED,
964
+ TRANSACTION_STATUS.MATCHED,
965
+ TRANSACTION_STATUS.PENDING
966
+ ],
967
+ to: TRANSACTION_STATUS.REJECTED,
968
+ where: { kind: existing.kind }
969
+ }, { $set: {
970
+ failureReason: data.reason,
971
+ failedAt: /* @__PURE__ */ new Date(),
972
+ ...data.rejectedBy !== void 0 ? { verifiedBy: data.rejectedBy } : {}
973
+ } }, this.optsFromCtx(ctx));
974
+ if (!claimed) throw new ValidationError(`Transaction ${id} could not be rejected (illegal current state)`);
975
+ await this.deps.bridges.ledger?.onTransactionRejected?.(claimed, data.reason, ctx);
976
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_REJECTED, {
977
+ transaction: claimed,
978
+ reason: data.reason,
979
+ ...data.rejectedBy !== void 0 ? { rejectedBy: data.rejectedBy } : {}
980
+ }, ctx, {
981
+ resource: "transaction",
982
+ resourceId: claimed.publicId
983
+ }), ctx);
984
+ return claimed;
985
+ }
986
+ /**
987
+ * Soft-delete bank-feed rows that the upstream feed has retracted
988
+ * (Plaid `removed[]`, OFX correction).
989
+ *
990
+ * Each row is matched by `(orgId, bankAccountId, externalId)`; rows
991
+ * already journalized are NOT silently kept — they're surfaced in
992
+ * `retainedJournalized` so the caller can surface them in the UI
993
+ * ("the feed retracted these N rows but they're posted; reverse
994
+ * manually"). The host's `LedgerBridge` should post a reversing JE
995
+ * for those before any subsequent `delete()` can succeed.
996
+ *
997
+ * @returns `removed` (count soft-deleted), `retainedJournalized`
998
+ * (rows kept because they're already in the GL).
999
+ */
1000
+ async removeByFeed(externalIds, opts, ctx = {}) {
1001
+ if (externalIds.length === 0) return {
1002
+ removed: 0,
1003
+ retainedJournalized: []
1004
+ };
1005
+ const allFilter = {
1006
+ kind: TRANSACTION_KIND.BANK_FEED,
1007
+ bankAccountId: opts.bankAccountId,
1008
+ externalId: { $in: externalIds },
1009
+ deletedAt: null
1010
+ };
1011
+ const allDocs = await this.findAll(allFilter, this.optsFromCtx(ctx));
1012
+ if (!Array.isArray(allDocs) || allDocs.length === 0) return {
1013
+ removed: 0,
1014
+ retainedJournalized: []
1015
+ };
1016
+ const retainedJournalized = [];
1017
+ const removable = [];
1018
+ for (const doc of allDocs) if (doc.status === TRANSACTION_STATUS.JOURNALIZED) retainedJournalized.push(doc);
1019
+ else removable.push(doc);
1020
+ let removed = 0;
1021
+ for (const doc of removable) {
1022
+ await this.delete(doc._id, this.optsFromCtx(ctx));
1023
+ removed += 1;
1024
+ await this.deps.bridges.ledger?.onTransactionRemovedByFeed?.(doc, ctx);
1025
+ await this.dispatch(createEvent(REVENUE_EVENTS.TRANSACTION_REMOVED_BY_FEED, {
1026
+ transaction: doc,
1027
+ source: opts.source,
1028
+ externalId: doc.externalId ?? ""
1029
+ }, ctx, {
1030
+ resource: "transaction",
1031
+ resourceId: doc.publicId
1032
+ }), ctx);
1033
+ }
1034
+ return {
1035
+ removed,
1036
+ retainedJournalized
1037
+ };
1038
+ }
1039
+ /**
1040
+ * Find candidate matches for cross-referencing a payment-flow row to
1041
+ * its bank deposit (or vice-versa).
1042
+ *
1043
+ * Heuristic:
1044
+ * - same currency by default; cross-currency requires `fxRate` on
1045
+ * the candidate row (multi-currency reconciliation).
1046
+ * - amount within `amountTolerancePct` (default 1%) — accounts for
1047
+ * gateway fees / FX rounding.
1048
+ * - posted/created within `toleranceDays` of the target date
1049
+ * (default 3 days — covers ACH delays, weekend settlement).
1050
+ * - terminal verified states only (`verified` / `completed` for
1051
+ * payment_flow, `imported` / `matched` for bank_feed).
1052
+ *
1053
+ * Returned candidates are unsorted; callers rank by their own
1054
+ * confidence model (counterparty fuzzy match, currency identity,
1055
+ * exact-amount preference, …).
1056
+ */
1057
+ async findMatchCandidates(filter, ctx = {}) {
1058
+ const tolerance = filter.toleranceDays ?? 3;
1059
+ const pct = filter.amountTolerancePct ?? .01;
1060
+ const start = /* @__PURE__ */ new Date(filter.postedDate.getTime() - tolerance * 864e5);
1061
+ const end = new Date(filter.postedDate.getTime() + tolerance * 864e5);
1062
+ const minAmount = filter.amount * (1 - pct);
1063
+ const maxAmount = filter.amount * (1 + pct);
1064
+ const targetKind = filter.kind ?? TRANSACTION_KIND.PAYMENT_FLOW;
1065
+ const validStatuses = targetKind === TRANSACTION_KIND.PAYMENT_FLOW ? [TRANSACTION_STATUS.VERIFIED, TRANSACTION_STATUS.COMPLETED] : [TRANSACTION_STATUS.IMPORTED, TRANSACTION_STATUS.MATCHED];
1066
+ const dateClauses = targetKind === TRANSACTION_KIND.BANK_FEED ? [{ postedDate: {
1067
+ $gte: start,
1068
+ $lte: end
1069
+ } }] : [{ verifiedAt: {
1070
+ $gte: start,
1071
+ $lte: end
1072
+ } }, { createdAt: {
1073
+ $gte: start,
1074
+ $lte: end
1075
+ } }];
1076
+ const query = {
1077
+ kind: targetKind,
1078
+ status: { $in: validStatuses },
1079
+ amount: {
1080
+ $gte: minAmount,
1081
+ $lte: maxAmount
1082
+ },
1083
+ $or: dateClauses
1084
+ };
1085
+ if (filter.currency !== void 0) query.currency = filter.currency;
1086
+ if (filter.counterpartyName !== void 0) query["counterparty.name"] = {
1087
+ $regex: escapeRegex(filter.counterpartyName),
1088
+ $options: "i"
1089
+ };
1090
+ const docs = await this.findAll(query, this.optsFromCtx(ctx, { limit: 50 }));
1091
+ return Array.isArray(docs) ? docs : [];
1092
+ }
1093
+ /**
1094
+ * Running balance for a bank account as of `asOf` (defaults to now).
1095
+ *
1096
+ * Uses mongokit's tenant-scoped read via `findAll` — inflows minus
1097
+ * outflows over `kind: 'bank_feed'`, terminal states only. For audit
1098
+ * pages where exact-to-the-cent reconciliation is required, prefer
1099
+ * the most recent row's `balanceAfter` (banks ship that field on
1100
+ * every entry).
1101
+ */
1102
+ async getRunningBalance(bankAccountId, asOf = /* @__PURE__ */ new Date(), ctx = {}) {
1103
+ const filter = {
1104
+ kind: TRANSACTION_KIND.BANK_FEED,
1105
+ bankAccountId,
1106
+ postedDate: { $lte: asOf },
1107
+ status: { $in: [
1108
+ TRANSACTION_STATUS.IMPORTED,
1109
+ TRANSACTION_STATUS.MATCHED,
1110
+ TRANSACTION_STATUS.JOURNALIZED
1111
+ ] },
1112
+ deletedAt: null
1113
+ };
1114
+ const rows = await this.findAll(filter, this.optsFromCtx(ctx));
1115
+ let balance = 0;
1116
+ let currency = null;
1117
+ for (const row of rows) {
1118
+ if (currency === null) currency = row.currency;
1119
+ balance += row.flow === "inflow" ? row.amount : -row.amount;
1120
+ }
1121
+ return {
1122
+ balance,
1123
+ currency,
1124
+ rowCount: rows.length,
1125
+ asOf
1126
+ };
1127
+ }
1128
+ };
1129
+ /** Escape user-provided strings before embedding in `$regex`. */
1130
+ function escapeRegex(input) {
1131
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1132
+ }
1133
+
1134
+ //#endregion
1135
+ //#region src/repositories/subscription.repository.ts
1136
+ /**
1137
+ * SubscriptionRepository — data layer + domain verbs for the recurring-
1138
+ * billing lifecycle.
1139
+ *
1140
+ * **CRUD inherited** from mongokit (via {@link RevenueRepositoryBase}):
1141
+ * `getById`, `getByQuery`, `getAll`, `create`, `update`, `delete`,
1142
+ * `findOneAndUpdate`, `count`, `exists`, `claim`, `cursor`, `updateMany`,
1143
+ * `deleteMany`. All participate in `multiTenantPlugin` scope filtering
1144
+ * when wired.
1145
+ *
1146
+ * **Domain verbs (state transitions):** `activate`, `cancel`, `pause`,
1147
+ * `resume`. Each runs the state-machine guard (`SUBSCRIPTION_STATE_MACHINE`
1148
+ * — invalid transitions throw, never silently no-op), persists the
1149
+ * resulting writes through {@link RevenueRepositoryBase.optsFromCtx} so
1150
+ * tenant scope is preserved end-to-end, then dispatches its
1151
+ * `revenue:subscription.*` event via {@link RevenueRepositoryBase.dispatch}.
1152
+ *
1153
+ * **Multi-tenant correctness.** Every internal `getById`/`update` call
1154
+ * threads `ctx.organizationId` through `optsFromCtx(ctx)`. Without this
1155
+ * threading the inner read would either throw
1156
+ * `Missing 'organizationId' in context` (when `multiTenantPlugin` is
1157
+ * required) or — worse — return another tenant's subscription matching
1158
+ * the same `_id` shape (when `required: false`). 2.1.0 had this bug; 2.1.1+
1159
+ * is correct.
1160
+ *
1161
+ * @example Activate a pending sub
1162
+ * ```ts
1163
+ * const ctx = { organizationId: 'org_42', actorId: 'user_99' };
1164
+ * const sub = await subRepo.create(
1165
+ * { customerId: 'cust_1', planKey: 'monthly', amount: 999, currency: 'USD',
1166
+ * status: SUBSCRIPTION_STATUS.PENDING, isActive: false },
1167
+ * ctx,
1168
+ * );
1169
+ * await subRepo.activate(String(sub._id), {}, ctx);
1170
+ * ```
1171
+ */
1172
+ var SubscriptionRepository = class extends RevenueRepositoryBase {
1173
+ constructor(model, plugins = []) {
1174
+ super(model, plugins);
1175
+ }
1176
+ async activate(subscriptionId, options = {}, ctx = {}) {
1177
+ const opts = this.optsFromCtx(ctx);
1178
+ const sub = await this.getById(subscriptionId, opts);
1179
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
1180
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.ACTIVE, subscriptionId);
1181
+ const now = options.timestamp ?? /* @__PURE__ */ new Date();
1182
+ const endDate = new Date(now);
1183
+ if (sub.planKey === "monthly") endDate.setMonth(endDate.getMonth() + 1);
1184
+ else if (sub.planKey === "quarterly") endDate.setMonth(endDate.getMonth() + 3);
1185
+ else if (sub.planKey === "yearly") endDate.setFullYear(endDate.getFullYear() + 1);
1186
+ else endDate.setDate(endDate.getDate() + 30);
1187
+ const updated = await this.update(subscriptionId, {
1188
+ status: SUBSCRIPTION_STATUS.ACTIVE,
1189
+ isActive: true,
1190
+ activatedAt: now,
1191
+ endDate
1192
+ }, this.optsFromCtx(ctx, { throwOnNotFound: true }));
1193
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
1194
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_ACTIVATED, {
1195
+ subscription: updated,
1196
+ activatedAt: now
1197
+ }, ctx, {
1198
+ resource: "subscription",
1199
+ resourceId: updated?.publicId
1200
+ }), ctx);
1201
+ return updated;
1202
+ }
1203
+ async cancel(subscriptionId, options = {}, ctx = {}) {
1204
+ const opts = this.optsFromCtx(ctx);
1205
+ const sub = await this.getById(subscriptionId, opts);
1206
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
1207
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.CANCELLED, subscriptionId);
1208
+ const updates = {
1209
+ status: SUBSCRIPTION_STATUS.CANCELLED,
1210
+ isActive: false,
1211
+ canceledAt: /* @__PURE__ */ new Date(),
1212
+ cancellationReason: options.reason
1213
+ };
1214
+ if (!options.immediate && sub.endDate) {
1215
+ updates.cancelAt = sub.endDate;
1216
+ updates.status = sub.status;
1217
+ updates.isActive = sub.isActive;
1218
+ delete updates.canceledAt;
1219
+ }
1220
+ const updated = await this.update(subscriptionId, updates, this.optsFromCtx(ctx, { throwOnNotFound: true }));
1221
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
1222
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
1223
+ subscription: updated,
1224
+ immediate: options.immediate,
1225
+ reason: options.reason
1226
+ }, ctx, {
1227
+ resource: "subscription",
1228
+ resourceId: updated?.publicId
1229
+ }), ctx);
1230
+ return updated;
1231
+ }
1232
+ async pause(subscriptionId, options = {}, ctx = {}) {
1233
+ const opts = this.optsFromCtx(ctx);
1234
+ const sub = await this.getById(subscriptionId, opts);
1235
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
1236
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.PAUSED, subscriptionId);
1237
+ const updated = await this.update(subscriptionId, {
1238
+ status: SUBSCRIPTION_STATUS.PAUSED,
1239
+ isActive: false,
1240
+ pausedAt: /* @__PURE__ */ new Date(),
1241
+ pauseReason: options.reason
1242
+ }, this.optsFromCtx(ctx, { throwOnNotFound: true }));
1243
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
1244
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_PAUSED, {
1245
+ subscription: updated,
1246
+ reason: options.reason
1247
+ }, ctx, {
1248
+ resource: "subscription",
1249
+ resourceId: updated?.publicId
1250
+ }), ctx);
1251
+ return updated;
1252
+ }
1253
+ async resume(subscriptionId, options = {}, ctx = {}) {
1254
+ const opts = this.optsFromCtx(ctx);
1255
+ const sub = await this.getById(subscriptionId, opts);
1256
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
1257
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.ACTIVE, subscriptionId);
1258
+ const updates = {
1259
+ status: SUBSCRIPTION_STATUS.ACTIVE,
1260
+ isActive: true
1261
+ };
1262
+ if (options.extendPeriod && sub.pausedAt && sub.endDate) {
1263
+ const pauseDuration = Date.now() - sub.pausedAt.getTime();
1264
+ updates.endDate = new Date(sub.endDate.getTime() + pauseDuration);
1265
+ }
1266
+ const updated = await this.update(subscriptionId, updates, this.optsFromCtx(ctx, { throwOnNotFound: true }));
1267
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
1268
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_RESUMED, {
1269
+ subscription: updated,
1270
+ extendPeriod: options.extendPeriod
1271
+ }, ctx, {
1272
+ resource: "subscription",
1273
+ resourceId: updated?.publicId
1274
+ }), ctx);
1275
+ return updated;
1276
+ }
1277
+ };
1278
+
1279
+ //#endregion
1280
+ //#region src/repositories/settlement.repository.ts
1281
+ /**
1282
+ * SettlementRepository — payouts to recipients (organizations, vendors,
1283
+ * affiliates).
1284
+ *
1285
+ * **CRUD inherited** via {@link RevenueRepositoryBase}. **Domain verbs:**
1286
+ * `schedule`, `processPending`, `complete`, `fail`. State machine:
1287
+ * `pending → processing → completed | failed`; `failed` with
1288
+ * `retry: true` resets to `pending` with a delayed `scheduledAt`.
1289
+ *
1290
+ * **Multi-tenant correctness.** Every read/write threads `ctx` through
1291
+ * {@link RevenueRepositoryBase.optsFromCtx} so `multiTenantPlugin`
1292
+ * scope filters apply. 2.1.0 had several `getById`/`update` calls that
1293
+ * dropped ctx — fixed in 2.1.1+.
1294
+ *
1295
+ * Bridges (`ledger`, `notification`) fire on `complete()` so a host
1296
+ * can pin double-entry book-keeping or push a "you got paid" email
1297
+ * without the repo knowing about either subsystem.
1298
+ */
1299
+ var SettlementRepository = class extends RevenueRepositoryBase {
1300
+ constructor(model, plugins = []) {
1301
+ super(model, plugins);
1302
+ }
1303
+ async schedule(params, ctx = {}) {
1304
+ const settlement = await this.create({
1305
+ ...params,
1306
+ status: SETTLEMENT_STATUS.PENDING,
1307
+ scheduledAt: params.scheduledAt ?? /* @__PURE__ */ new Date(),
1308
+ retryCount: 0
1309
+ }, this.optsFromCtx(ctx));
1310
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_SCHEDULED, {
1311
+ settlement,
1312
+ scheduledAt: settlement.scheduledAt
1313
+ }, ctx, {
1314
+ resource: "settlement",
1315
+ resourceId: settlement.publicId
1316
+ }), ctx);
1317
+ return settlement;
1318
+ }
1319
+ async processPending(options = {}, ctx = {}) {
1320
+ const query = {
1321
+ status: "pending",
1322
+ scheduledAt: { $lte: /* @__PURE__ */ new Date() }
1323
+ };
1324
+ if (options.organizationId) query.organizationId = options.organizationId;
1325
+ if (options.payoutMethod) query.payoutMethod = options.payoutMethod;
1326
+ const pending = (await this.getAll({
1327
+ filters: query,
1328
+ limit: options.limit ?? 50,
1329
+ sort: { scheduledAt: 1 }
1330
+ }, this.optsFromCtx(ctx))).data ?? [];
1331
+ if (options.dryRun) return {
1332
+ processed: pending.length,
1333
+ succeeded: 0,
1334
+ failed: 0,
1335
+ settlements: pending,
1336
+ errors: []
1337
+ };
1338
+ const out = {
1339
+ processed: 0,
1340
+ succeeded: 0,
1341
+ failed: 0,
1342
+ settlements: [],
1343
+ errors: []
1344
+ };
1345
+ for (const settlement of pending) {
1346
+ try {
1347
+ SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.PROCESSING, String(settlement._id));
1348
+ await this.update(settlement._id, {
1349
+ status: SETTLEMENT_STATUS.PROCESSING,
1350
+ processedAt: /* @__PURE__ */ new Date()
1351
+ }, this.optsFromCtx(ctx));
1352
+ out.succeeded++;
1353
+ out.settlements.push(settlement);
1354
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_PROCESSING, {
1355
+ settlement,
1356
+ processedAt: /* @__PURE__ */ new Date()
1357
+ }, ctx, {
1358
+ resource: "settlement",
1359
+ resourceId: settlement.publicId
1360
+ }), ctx);
1361
+ } catch (err) {
1362
+ out.failed++;
1363
+ out.errors.push({
1364
+ settlementId: settlement._id,
1365
+ error: err
1366
+ });
1367
+ }
1368
+ out.processed++;
1369
+ }
1370
+ return out;
1371
+ }
1372
+ async complete(settlementId, details = {}, ctx = {}) {
1373
+ const opts = this.optsFromCtx(ctx);
1374
+ const settlement = await this.getById(settlementId, opts);
1375
+ if (!settlement) throw new SettlementNotFoundError(settlementId);
1376
+ SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.COMPLETED, settlementId);
1377
+ const updates = {
1378
+ status: SETTLEMENT_STATUS.COMPLETED,
1379
+ completedAt: /* @__PURE__ */ new Date(),
1380
+ notes: details.notes,
1381
+ metadata: {
1382
+ ...settlement.metadata,
1383
+ ...details.metadata
1384
+ }
1385
+ };
1386
+ if (details.transferReference) updates.bankTransferDetails = {
1387
+ ...settlement.bankTransferDetails,
1388
+ transferReference: details.transferReference,
1389
+ transferredAt: details.transferredAt ?? /* @__PURE__ */ new Date()
1390
+ };
1391
+ if (details.transactionHash) updates.cryptoDetails = {
1392
+ ...settlement.cryptoDetails,
1393
+ transactionHash: details.transactionHash,
1394
+ transferredAt: details.transferredAt ?? /* @__PURE__ */ new Date()
1395
+ };
1396
+ const updated = await this.update(settlementId, updates, this.optsFromCtx(ctx, { throwOnNotFound: true }));
1397
+ if (!updated) throw new SettlementNotFoundError(settlementId);
1398
+ await this.deps.bridges.ledger?.onSettlementCompleted?.(updated, ctx);
1399
+ await this.deps.bridges.notification?.onSettlementCompleted?.(updated, ctx);
1400
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_COMPLETED, {
1401
+ settlement: updated,
1402
+ completedAt: /* @__PURE__ */ new Date()
1403
+ }, ctx, {
1404
+ resource: "settlement",
1405
+ resourceId: updated?.publicId
1406
+ }), ctx);
1407
+ return updated;
1408
+ }
1409
+ async fail(settlementId, reason, options = {}, ctx = {}) {
1410
+ const opts = this.optsFromCtx(ctx);
1411
+ const settlement = await this.getById(settlementId, opts);
1412
+ if (!settlement) throw new SettlementNotFoundError(settlementId);
1413
+ if (options.retry) await this.update(settlementId, {
1414
+ status: SETTLEMENT_STATUS.PENDING,
1415
+ retryCount: (settlement.retryCount ?? 0) + 1,
1416
+ failureReason: reason,
1417
+ failureCode: options.code,
1418
+ scheduledAt: new Date(Date.now() + 36e5)
1419
+ }, opts);
1420
+ else {
1421
+ SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.FAILED, settlementId);
1422
+ await this.update(settlementId, {
1423
+ status: SETTLEMENT_STATUS.FAILED,
1424
+ failedAt: /* @__PURE__ */ new Date(),
1425
+ failureReason: reason,
1426
+ failureCode: options.code
1427
+ }, opts);
1428
+ }
1429
+ const updated = await this.getById(settlementId, opts);
1430
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_FAILED, {
1431
+ settlement: updated,
1432
+ reason,
1433
+ code: options.code,
1434
+ retry: options.retry
1435
+ }, ctx, {
1436
+ resource: "settlement",
1437
+ resourceId: updated?.publicId
1438
+ }), ctx);
1439
+ return updated;
1440
+ }
1441
+ };
1442
+
1443
+ //#endregion
1444
+ export { SubscriptionRepository as n, TransactionRepository as r, SettlementRepository as t };