@classytic/revenue 1.1.4 → 2.0.1

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 (82) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +638 -632
  3. package/dist/audit-B39B0Sdq.mjs +53 -0
  4. package/dist/audit-DZ0eTr9g.d.mts +89 -0
  5. package/dist/bridges/index.d.mts +2 -0
  6. package/dist/bridges/index.mjs +1 -0
  7. package/dist/context-DRqSeTPM.d.mts +35 -0
  8. package/dist/core/state-machines.d.mts +53 -0
  9. package/dist/core/state-machines.mjs +153 -0
  10. package/dist/engine-types-CcjIb4Fy.d.mts +611 -0
  11. package/dist/enums/index.d.mts +3 -157
  12. package/dist/enums/index.mjs +3 -55
  13. package/dist/errors-DHa8JVQ-.mjs +92 -0
  14. package/dist/escrow.schema-BBv9oVEW.mjs +322 -0
  15. package/dist/escrow.schema-D5X32LwX.d.mts +629 -0
  16. package/dist/event-constants-CEMitnIV.mjs +53 -0
  17. package/dist/events/index.d.mts +3 -0
  18. package/dist/events/index.mjs +4 -0
  19. package/dist/index.d.mts +77 -9
  20. package/dist/index.mjs +465 -29
  21. package/dist/monetization.enums-BtiU3t8o.mjs +39 -0
  22. package/dist/monetization.enums-D2xbxXJM.d.mts +34 -0
  23. package/dist/plugins/plugin.interface.d.mts +28 -0
  24. package/dist/plugins/plugin.interface.mjs +26 -0
  25. package/dist/providers/index.d.mts +2 -3
  26. package/dist/providers/index.mjs +2 -2
  27. package/dist/{base-DCoyIUj6.mjs → registry-DhFMsSn5.mjs} +34 -36
  28. package/dist/{base-CsTlVQJe.d.mts → registry-SvIGPAx_.d.mts} +73 -66
  29. package/dist/repositories/create-repositories.d.mts +21 -0
  30. package/dist/repositories/create-repositories.mjs +12 -0
  31. package/dist/revenue-bridges-sdlrR85c.d.mts +145 -0
  32. package/dist/revenue-event-catalog-BX3g7RUi.d.mts +823 -0
  33. package/dist/revenue-event-catalog-LqxPnsU_.mjs +388 -0
  34. package/dist/settlement.repository-DHIPx5S4.mjs +771 -0
  35. package/dist/shared/index.d.mts +2 -0
  36. package/dist/shared/index.mjs +4 -0
  37. package/dist/splits-BAfY-a9P.mjs +123 -0
  38. package/dist/subscription.enums-tfoAgsTv.mjs +172 -0
  39. package/dist/transaction.enums-u4MshXcL.d.mts +154 -0
  40. package/dist/validators/index.d.mts +2 -0
  41. package/dist/validators/index.mjs +3 -0
  42. package/package.json +37 -38
  43. package/dist/application/services/index.d.mts +0 -4
  44. package/dist/application/services/index.mjs +0 -3
  45. package/dist/category-resolver-DV83N8ok.mjs +0 -284
  46. package/dist/commission-split-BzB8cd39.mjs +0 -485
  47. package/dist/core/events.d.mts +0 -294
  48. package/dist/core/events.mjs +0 -100
  49. package/dist/core/index.d.mts +0 -9
  50. package/dist/core/index.mjs +0 -8
  51. package/dist/errors-rRdOqnWx.d.mts +0 -787
  52. package/dist/escrow.enums-CZGrrdg7.mjs +0 -101
  53. package/dist/escrow.enums-DwdLuuve.d.mts +0 -78
  54. package/dist/idempotency-DaYcUGY1.mjs +0 -172
  55. package/dist/index-Dsp7H5Wb.d.mts +0 -471
  56. package/dist/infrastructure/plugins/index.d.mts +0 -239
  57. package/dist/infrastructure/plugins/index.mjs +0 -345
  58. package/dist/money-CvrDOijQ.mjs +0 -271
  59. package/dist/money-DPG8AtJ8.d.mts +0 -112
  60. package/dist/payment.enums-HAuAS9Pp.d.mts +0 -70
  61. package/dist/payment.enums-tEFVa-Xp.mjs +0 -69
  62. package/dist/plugin-BbK0OVHy.d.mts +0 -327
  63. package/dist/plugin-Cd_V04Em.mjs +0 -210
  64. package/dist/reconciliation/index.d.mts +0 -193
  65. package/dist/reconciliation/index.mjs +0 -192
  66. package/dist/retry-HHCOXYdn.d.mts +0 -186
  67. package/dist/revenue-BhdS7nXh.mjs +0 -553
  68. package/dist/schemas/index.d.mts +0 -2665
  69. package/dist/schemas/index.mjs +0 -717
  70. package/dist/schemas/validation.d.mts +0 -375
  71. package/dist/schemas/validation.mjs +0 -325
  72. package/dist/settlement.enums-DFhkqZEY.d.mts +0 -132
  73. package/dist/settlement.schema-DnNSFpGd.d.mts +0 -344
  74. package/dist/settlement.service-DjzAjezU.d.mts +0 -594
  75. package/dist/settlement.service-DmdKv0Zu.mjs +0 -2511
  76. package/dist/split.enums-BrjabxIX.mjs +0 -86
  77. package/dist/split.enums-DmskfLOM.d.mts +0 -43
  78. package/dist/tax-BoCt5cEd.d.mts +0 -61
  79. package/dist/tax-EQ15DO81.mjs +0 -162
  80. package/dist/transaction.enums-pCyMFT4Z.mjs +0 -96
  81. package/dist/utils/index.d.mts +0 -428
  82. package/dist/utils/index.mjs +0 -346
@@ -0,0 +1,771 @@
1
+ import { n as createEvent, t as REVENUE_EVENTS } from "./event-constants-CEMitnIV.mjs";
2
+ import { F as TRANSACTION_STATUS, g as SETTLEMENT_STATUS, r as SUBSCRIPTION_STATUS, w as HOLD_STATUS } from "./subscription.enums-tfoAgsTv.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";
6
+ import { Repository, withTransaction } from "@classytic/mongokit";
7
+
8
+ //#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
+ var TransactionRepository = class extends Repository {
19
+ deps;
20
+ constructor(model, plugins = []) {
21
+ super(model, plugins);
22
+ }
23
+ inject(deps) {
24
+ this.deps = deps;
25
+ }
26
+ /**
27
+ * Thread `ctx.organizationId` (and future ctx fields) into mongokit
28
+ * options so the `multiTenantPlugin` can auto-scope filters, queries,
29
+ * and inserts. Merges any caller-supplied extras. Centralizing this
30
+ * here means every domain verb participates in scope isolation without
31
+ * per-call boilerplate.
32
+ */
33
+ optsFromCtx(ctx, extra = {}) {
34
+ const out = { ...extra };
35
+ if (ctx.organizationId !== void 0) out.organizationId = ctx.organizationId;
36
+ if (ctx.session !== void 0) out.session = ctx.session;
37
+ return out;
38
+ }
39
+ /**
40
+ * Save an event to the host-owned outbox, session-bound when available.
41
+ *
42
+ * When `session` is passed, the outbox row commits atomically with the
43
+ * business write (P8 true session-bound write). When absent, the save
44
+ * happens after commit — still durable via the host's relay, but with a
45
+ * small at-most-once window on process crash.
46
+ *
47
+ * Isolated try/catch: an outbox failure never throws out of this helper;
48
+ * the caller still issues a transport.publish.
49
+ */
50
+ async saveToOutbox(event, session) {
51
+ if (!this.deps.outbox) return;
52
+ try {
53
+ await this.deps.outbox.save(event, session !== void 0 ? { session } : {});
54
+ } catch (err) {
55
+ this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
56
+ }
57
+ }
58
+ /**
59
+ * Publish an event to the in-process `EventTransport` after commit.
60
+ * Transport failure is logged — the host relay will still redeliver from
61
+ * the outbox, so in-process subscribers missing an event is recoverable.
62
+ */
63
+ async publishToTransport(event) {
64
+ try {
65
+ await this.deps.events.publish(event);
66
+ } catch (err) {
67
+ this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
68
+ }
69
+ }
70
+ /**
71
+ * Non-transactional dispatch (used by verbs that don't open their own
72
+ * `withTransaction` block): outbox.save (session-bound when ctx provides
73
+ * one) → transport.publish. Matches arc's EventOutbox + MemoryEventTransport
74
+ * wiring bit-for-bit.
75
+ */
76
+ async dispatch(event, ctx = {}) {
77
+ await this.saveToOutbox(event, ctx.session);
78
+ await this.publishToTransport(event);
79
+ }
80
+ /** Creates transaction + calls provider. Returns the created transaction doc. */
81
+ async createPaymentIntent(params, ctx = {}) {
82
+ const currency = params.currency ?? this.deps.defaultCurrency;
83
+ const provider = this.deps.providers.get(params.gateway);
84
+ if (params.idempotencyKey) {
85
+ const existing = await this.getByQuery({ idempotencyKey: params.idempotencyKey }, this.optsFromCtx(ctx, { throwOnNotFound: false }));
86
+ if (existing) return existing;
87
+ }
88
+ const commissionRate = this.deps.commission?.defaultRate ?? 0;
89
+ const gatewayFeeRate = this.deps.commission?.gatewayFeeRate ?? 0;
90
+ const commission = calculateCommission(params.amount, commissionRate, gatewayFeeRate);
91
+ let gatewayData = { type: params.gateway };
92
+ if (params.amount > 0) {
93
+ const intent = await provider.createIntent({
94
+ amount: params.amount,
95
+ currency,
96
+ metadata: params.metadata,
97
+ ...params.paymentData
98
+ });
99
+ gatewayData = {
100
+ type: params.gateway,
101
+ sessionId: intent.sessionId,
102
+ paymentIntentId: intent.paymentIntentId ?? intent.id,
103
+ metadata: {
104
+ clientSecret: intent.clientSecret,
105
+ paymentUrl: intent.paymentUrl,
106
+ instructions: intent.instructions
107
+ }
108
+ };
109
+ }
110
+ const transaction = await this.create({
111
+ organizationId: ctx.organizationId,
112
+ customerId: params.data?.customerId ?? null,
113
+ type: params.monetizationType === "subscription" ? "subscription" : "purchase",
114
+ flow: "inflow",
115
+ tags: params.monetizationType ? [params.monetizationType] : [],
116
+ amount: params.amount,
117
+ currency,
118
+ fee: commission?.gatewayFeeAmount ?? 0,
119
+ tax: 0,
120
+ net: params.amount - (commission?.gatewayFeeAmount ?? 0),
121
+ method: params.gateway,
122
+ status: params.amount === 0 ? TRANSACTION_STATUS.VERIFIED : TRANSACTION_STATUS.PENDING,
123
+ gateway: gatewayData,
124
+ commission: commission ?? void 0,
125
+ sourceId: params.data?.sourceId,
126
+ sourceModel: params.data?.sourceModel,
127
+ idempotencyKey: params.idempotencyKey,
128
+ metadata: params.metadata
129
+ }, this.optsFromCtx(ctx));
130
+ await this.dispatch(createEvent(REVENUE_EVENTS.MONETIZATION_CREATED, {
131
+ monetizationType: params.monetizationType,
132
+ transaction
133
+ }, ctx, {
134
+ resource: "transaction",
135
+ resourceId: transaction.publicId
136
+ }), ctx);
137
+ return transaction;
138
+ }
139
+ /** Verifies payment via provider, updates status. Returns the updated doc. */
140
+ async verify(paymentIntentId, options = {}, ctx = {}) {
141
+ const readOpts = this.optsFromCtx(ctx, { throwOnNotFound: false });
142
+ let transaction = await this.getByQuery({ "gateway.sessionId": paymentIntentId }, readOpts);
143
+ if (!transaction) transaction = await this.getByQuery({ "gateway.paymentIntentId": paymentIntentId }, readOpts);
144
+ if (!transaction) transaction = await this.getById(paymentIntentId, readOpts);
145
+ if (!transaction) throw new TransactionNotFoundError(paymentIntentId);
146
+ const provider = this.deps.providers.get(transaction.method);
147
+ const intentId = transaction.gateway?.paymentIntentId ?? transaction.gateway?.sessionId ?? paymentIntentId;
148
+ const paymentResult = await provider.verifyPayment(intentId);
149
+ let newStatus;
150
+ if (paymentResult.status === "succeeded") newStatus = TRANSACTION_STATUS.VERIFIED;
151
+ else if (paymentResult.status === "failed") newStatus = TRANSACTION_STATUS.FAILED;
152
+ else if (paymentResult.status === "requires_action") newStatus = TRANSACTION_STATUS.REQUIRES_ACTION;
153
+ else newStatus = TRANSACTION_STATUS.PROCESSING;
154
+ TRANSACTION_STATE_MACHINE.validate(transaction.status, newStatus, String(transaction._id));
155
+ const updates = { status: newStatus };
156
+ if (newStatus === TRANSACTION_STATUS.VERIFIED) {
157
+ updates.verifiedAt = /* @__PURE__ */ new Date();
158
+ updates.verifiedBy = options.verifiedBy;
159
+ } else if (newStatus === TRANSACTION_STATUS.FAILED) {
160
+ updates.failedAt = /* @__PURE__ */ new Date();
161
+ updates.failureReason = "Payment verification failed";
162
+ }
163
+ const updated = await this.update(transaction._id, updates, this.optsFromCtx(ctx));
164
+ if (newStatus === TRANSACTION_STATUS.VERIFIED) {
165
+ await this.deps.bridges.ledger?.onPaymentVerified?.(updated, ctx);
166
+ await this.deps.bridges.notification?.onPaymentVerified?.(updated, ctx);
167
+ }
168
+ 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;
169
+ await this.dispatch(createEvent(eventName, {
170
+ transaction: updated,
171
+ paymentResult,
172
+ verifiedBy: options.verifiedBy
173
+ }, ctx, {
174
+ resource: "transaction",
175
+ resourceId: updated?.publicId
176
+ }), ctx);
177
+ return updated;
178
+ }
179
+ /**
180
+ * Creates refund transaction, updates original. Returns the refund transaction doc.
181
+ *
182
+ * The provider call happens OUTSIDE the transaction — it's a non-idempotent external
183
+ * side effect we can't roll back. The two Mongo writes (create refund + update original)
184
+ * run inside `withTransaction` so they commit atomically or both abort. Bridges and
185
+ * event emission run AFTER commit because they're independent side effects; rolling
186
+ * them back would not undo external state anyway.
187
+ *
188
+ * Powered by mongokit 3.6's module-level `withTransaction` helper. Automatically
189
+ * retries on `TransientTransactionError` / `UnknownTransactionCommitResult`.
190
+ */
191
+ async refund(transactionId, amount, options = {}, ctx = {}) {
192
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
193
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
194
+ const refundAmount = amount ?? transaction.amount;
195
+ const existingRefunded = transaction.refundedAmount ?? 0;
196
+ const isPartialRefund = existingRefunded + refundAmount < transaction.amount;
197
+ const newStatus = isPartialRefund ? TRANSACTION_STATUS.PARTIALLY_REFUNDED : TRANSACTION_STATUS.REFUNDED;
198
+ TRANSACTION_STATE_MACHINE.validate(transaction.status, newStatus, transactionId);
199
+ const provider = this.deps.providers.get(transaction.method);
200
+ const paymentId = transaction.gateway?.paymentIntentId ?? transaction.gateway?.sessionId ?? transactionId;
201
+ await provider.refund(paymentId, refundAmount, { reason: options.reason });
202
+ const reversedCommission = reverseCommission(transaction.commission, transaction.amount, refundAmount);
203
+ const reversedTax = transaction.tax ? reverseTax({
204
+ isApplicable: true,
205
+ rate: 0,
206
+ baseAmount: transaction.amount,
207
+ taxAmount: transaction.tax,
208
+ totalAmount: transaction.amount + transaction.tax,
209
+ pricesIncludeTax: false
210
+ }, transaction.amount, refundAmount) : void 0;
211
+ const pendingEvents = [];
212
+ const refundTransaction = await withTransaction(this.Model.db, async (session) => {
213
+ const writeOpts = this.optsFromCtx(ctx, { session });
214
+ const refundTxn = await this.create({
215
+ organizationId: transaction.organizationId,
216
+ customerId: transaction.customerId,
217
+ type: "refund",
218
+ flow: "outflow",
219
+ tags: ["refund"],
220
+ amount: refundAmount,
221
+ currency: transaction.currency,
222
+ fee: reversedCommission?.gatewayFeeAmount ?? 0,
223
+ tax: reversedTax?.taxAmount ?? 0,
224
+ net: refundAmount - (reversedCommission?.gatewayFeeAmount ?? 0) - (reversedTax?.taxAmount ?? 0),
225
+ method: transaction.method,
226
+ status: TRANSACTION_STATUS.VERIFIED,
227
+ gateway: transaction.gateway,
228
+ commission: reversedCommission ?? void 0,
229
+ relatedTransactionId: transaction._id,
230
+ sourceId: transaction.sourceId,
231
+ sourceModel: transaction.sourceModel,
232
+ verifiedAt: /* @__PURE__ */ new Date(),
233
+ metadata: { reason: options.reason }
234
+ }, writeOpts);
235
+ await this.update(transactionId, {
236
+ status: newStatus,
237
+ refundedAmount: existingRefunded + refundAmount,
238
+ refundedAt: /* @__PURE__ */ new Date()
239
+ }, writeOpts);
240
+ const event = createEvent(REVENUE_EVENTS.PAYMENT_REFUNDED, {
241
+ transaction,
242
+ refundTransaction: refundTxn,
243
+ refundAmount,
244
+ reason: options.reason,
245
+ isPartialRefund
246
+ }, ctx, {
247
+ resource: "transaction",
248
+ resourceId: transaction.publicId
249
+ });
250
+ await this.saveToOutbox(event, session);
251
+ pendingEvents.push(event);
252
+ return refundTxn;
253
+ });
254
+ await this.deps.bridges.ledger?.onRefundProcessed?.(transaction, refundTransaction, ctx);
255
+ await this.deps.bridges.notification?.onRefundProcessed?.(refundTransaction, ctx);
256
+ for (const ev of pendingEvents) await this.publishToTransport(ev);
257
+ return refundTransaction;
258
+ }
259
+ /** Handles provider webhook. Returns the updated transaction doc (or null if not found). */
260
+ async handleWebhook(providerName, payload, headers, ctx = {}) {
261
+ const webhookEvent = await this.deps.providers.get(providerName).handleWebhook(payload, headers);
262
+ const readOpts = this.optsFromCtx(ctx, { throwOnNotFound: false });
263
+ const sessionId = webhookEvent.data?.sessionId;
264
+ const intentId = webhookEvent.data?.paymentIntentId;
265
+ let transaction = sessionId ? await this.getByQuery({ "gateway.sessionId": sessionId }, readOpts) : null;
266
+ if (!transaction && intentId) transaction = await this.getByQuery({ "gateway.paymentIntentId": intentId }, readOpts);
267
+ if (!transaction) return null;
268
+ if (transaction.webhook?.eventId === webhookEvent.id) return transaction;
269
+ const nextWebhook = {
270
+ eventId: webhookEvent.id,
271
+ eventType: webhookEvent.type,
272
+ receivedAt: /* @__PURE__ */ new Date(),
273
+ processedAt: /* @__PURE__ */ new Date(),
274
+ data: webhookEvent.data
275
+ };
276
+ const updated = await this.Model.findOneAndUpdate({
277
+ _id: transaction._id,
278
+ "webhook.eventId": { $ne: webhookEvent.id }
279
+ }, { $set: { webhook: nextWebhook } }, { returnDocument: "after" }).lean();
280
+ if (!updated) return await this.getByQuery({ _id: transaction._id }, readOpts) ?? transaction;
281
+ await this.dispatch(createEvent(REVENUE_EVENTS.WEBHOOK_PROCESSED, {
282
+ webhookType: webhookEvent.type,
283
+ provider: providerName,
284
+ event: webhookEvent,
285
+ transaction: updated
286
+ }, ctx, {
287
+ resource: "transaction",
288
+ resourceId: updated?.publicId
289
+ }), ctx);
290
+ return updated;
291
+ }
292
+ /** Places hold on verified transaction. Returns the updated doc. */
293
+ async hold(transactionId, options = {}, ctx = {}) {
294
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
295
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
296
+ if (transaction.status !== TRANSACTION_STATUS.VERIFIED) throw new ValidationError("Can only hold verified transactions", { status: transaction.status });
297
+ const holdAmount = options.amount ?? transaction.amount;
298
+ const updated = await this.update(transactionId, { hold: {
299
+ status: HOLD_STATUS.HELD,
300
+ heldAmount: holdAmount,
301
+ releasedAmount: 0,
302
+ reason: options.reason ?? "manual_hold",
303
+ heldAt: /* @__PURE__ */ new Date(),
304
+ holdUntil: options.holdUntil,
305
+ releases: [],
306
+ metadata: options.metadata
307
+ } }, this.optsFromCtx(ctx));
308
+ await this.dispatch(createEvent(REVENUE_EVENTS.ESCROW_HELD, {
309
+ transaction: updated,
310
+ heldAmount: holdAmount,
311
+ reason: options.reason
312
+ }, ctx, {
313
+ resource: "transaction",
314
+ resourceId: updated?.publicId
315
+ }), ctx);
316
+ return updated;
317
+ }
318
+ /**
319
+ * Releases held funds. Returns the updated transaction doc.
320
+ *
321
+ * The hold update and the escrow_release transaction create happen inside
322
+ * `withTransaction` — a mid-flow crash can't leave the hold marked released
323
+ * without the corresponding outflow record (or vice versa).
324
+ */
325
+ async release(transactionId, options, ctx = {}) {
326
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
327
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
328
+ 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");
329
+ const releaseAmount = options.amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
330
+ const newReleasedAmount = transaction.hold.releasedAmount + releaseAmount;
331
+ const isFullRelease = newReleasedAmount >= transaction.hold.heldAmount;
332
+ const release = {
333
+ amount: releaseAmount,
334
+ recipientId: options.recipientId,
335
+ recipientType: options.recipientType,
336
+ releasedAt: /* @__PURE__ */ new Date(),
337
+ releasedBy: options.releasedBy,
338
+ reason: options.reason,
339
+ metadata: options.metadata
340
+ };
341
+ const pendingEvents = [];
342
+ const updated = await withTransaction(this.Model.db, async (session) => {
343
+ const writeOpts = this.optsFromCtx(ctx, { session });
344
+ const result = await this.update(transactionId, { hold: {
345
+ ...transaction.hold,
346
+ status: isFullRelease ? HOLD_STATUS.RELEASED : HOLD_STATUS.PARTIALLY_RELEASED,
347
+ releasedAmount: newReleasedAmount,
348
+ releasedAt: isFullRelease ? /* @__PURE__ */ new Date() : transaction.hold.releasedAt,
349
+ releases: [...transaction.hold.releases ?? [], release]
350
+ } }, writeOpts);
351
+ if (options.createTransaction !== false) await this.create({
352
+ organizationId: transaction.organizationId,
353
+ customerId: options.recipientId,
354
+ type: "escrow_release",
355
+ flow: "outflow",
356
+ tags: ["escrow", "release"],
357
+ amount: releaseAmount,
358
+ currency: transaction.currency,
359
+ fee: 0,
360
+ tax: 0,
361
+ net: releaseAmount,
362
+ method: transaction.method,
363
+ status: TRANSACTION_STATUS.VERIFIED,
364
+ relatedTransactionId: transaction._id,
365
+ sourceId: transaction.sourceId,
366
+ sourceModel: transaction.sourceModel,
367
+ verifiedAt: /* @__PURE__ */ new Date(),
368
+ metadata: options.metadata
369
+ }, writeOpts);
370
+ const event = createEvent(REVENUE_EVENTS.ESCROW_RELEASED, {
371
+ transaction: result,
372
+ releaseAmount,
373
+ recipientId: options.recipientId,
374
+ recipientType: options.recipientType,
375
+ isFullRelease,
376
+ isPartialRelease: !isFullRelease
377
+ }, ctx, {
378
+ resource: "transaction",
379
+ resourceId: result?.publicId
380
+ });
381
+ await this.saveToOutbox(event, session);
382
+ pendingEvents.push(event);
383
+ return result;
384
+ });
385
+ for (const ev of pendingEvents) await this.publishToTransport(ev);
386
+ return updated;
387
+ }
388
+ /**
389
+ * Splits payment among recipients. Returns the updated transaction doc.
390
+ *
391
+ * N + 2 writes (one create per recipient, one update on the parent, one
392
+ * platform_revenue create) all commit atomically. Partial splits are the
393
+ * worst class of bug in a payments system — this is exactly what
394
+ * `withTransaction` is for.
395
+ */
396
+ async split(transactionId, rules, ctx = {}) {
397
+ const transaction = await this.getById(transactionId, this.optsFromCtx(ctx));
398
+ if (!transaction) throw new TransactionNotFoundError(transactionId);
399
+ const gatewayFeeRate = this.deps.commission?.gatewayFeeRate ?? 0;
400
+ const splits = calculateSplits(transaction.amount, rules, gatewayFeeRate);
401
+ const orgPayout = calculateOrganizationPayout(transaction.amount, splits);
402
+ const pendingEvents = [];
403
+ const updated = await withTransaction(this.Model.db, async (session) => {
404
+ const writeOpts = this.optsFromCtx(ctx, { session });
405
+ for (const s of splits) await this.create({
406
+ organizationId: transaction.organizationId,
407
+ customerId: s.recipientId,
408
+ type: "commission",
409
+ flow: "outflow",
410
+ tags: ["split", s.type],
411
+ amount: s.grossAmount,
412
+ currency: transaction.currency,
413
+ fee: s.gatewayFeeAmount,
414
+ tax: 0,
415
+ net: s.netAmount,
416
+ method: transaction.method,
417
+ status: TRANSACTION_STATUS.VERIFIED,
418
+ relatedTransactionId: transaction._id,
419
+ sourceId: transaction.sourceId,
420
+ sourceModel: transaction.sourceModel,
421
+ verifiedAt: /* @__PURE__ */ new Date()
422
+ }, writeOpts);
423
+ const result = await this.update(transactionId, {
424
+ splits,
425
+ metadata: {
426
+ ...transaction.metadata,
427
+ organizationPayout: orgPayout
428
+ }
429
+ }, writeOpts);
430
+ await this.create({
431
+ organizationId: transaction.organizationId,
432
+ type: "platform_revenue",
433
+ flow: "inflow",
434
+ tags: ["split", "platform"],
435
+ amount: orgPayout,
436
+ currency: transaction.currency,
437
+ fee: 0,
438
+ tax: 0,
439
+ net: orgPayout,
440
+ method: transaction.method,
441
+ status: TRANSACTION_STATUS.VERIFIED,
442
+ relatedTransactionId: transaction._id,
443
+ verifiedAt: /* @__PURE__ */ new Date()
444
+ }, writeOpts);
445
+ const event = createEvent(REVENUE_EVENTS.ESCROW_SPLIT, {
446
+ transaction: result,
447
+ splits,
448
+ organizationPayout: orgPayout
449
+ }, ctx, {
450
+ resource: "transaction",
451
+ resourceId: transaction.publicId
452
+ });
453
+ await this.saveToOutbox(event, session);
454
+ pendingEvents.push(event);
455
+ return result;
456
+ });
457
+ for (const ev of pendingEvents) await this.publishToTransport(ev);
458
+ return updated;
459
+ }
460
+ };
461
+
462
+ //#endregion
463
+ //#region src/repositories/subscription.repository.ts
464
+ /**
465
+ * SubscriptionRepository — data layer + domain verbs.
466
+ *
467
+ * CRUD inherited from mongokit. Domain verbs: activate, cancel, pause, resume.
468
+ *
469
+ * Events: each domain verb calls `this.deps.events.publish(createEvent(...))`
470
+ * with a fully-qualified `REVENUE_EVENTS.*` name. Hosts can subscribe glob-style
471
+ * via `revenue.events.subscribe('revenue:subscription.*', handler)` — the
472
+ * injected transport is arc-compatible (PACKAGE_RULES §13–§14).
473
+ */
474
+ var SubscriptionRepository = class extends Repository {
475
+ deps;
476
+ constructor(model, plugins = []) {
477
+ super(model, plugins);
478
+ }
479
+ inject(deps) {
480
+ this.deps = deps;
481
+ }
482
+ /**
483
+ * Host-owned outbox save → in-process transport publish (PACKAGE_RULES P8).
484
+ * Session-bound when `ctx.session` is present (atomic outbox row write).
485
+ */
486
+ async dispatch(event, ctx = {}) {
487
+ if (this.deps.outbox) try {
488
+ await this.deps.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
489
+ } catch (err) {
490
+ this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
491
+ }
492
+ try {
493
+ await this.deps.events.publish(event);
494
+ } catch (err) {
495
+ this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
496
+ }
497
+ }
498
+ async activate(subscriptionId, options = {}, ctx = {}) {
499
+ const sub = await this.getById(subscriptionId);
500
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
501
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.ACTIVE, subscriptionId);
502
+ const now = options.timestamp ?? /* @__PURE__ */ new Date();
503
+ const endDate = new Date(now);
504
+ if (sub.planKey === "monthly") endDate.setMonth(endDate.getMonth() + 1);
505
+ else if (sub.planKey === "quarterly") endDate.setMonth(endDate.getMonth() + 3);
506
+ else if (sub.planKey === "yearly") endDate.setFullYear(endDate.getFullYear() + 1);
507
+ else endDate.setDate(endDate.getDate() + 30);
508
+ const updated = await this.update(subscriptionId, {
509
+ status: SUBSCRIPTION_STATUS.ACTIVE,
510
+ isActive: true,
511
+ activatedAt: now,
512
+ endDate
513
+ }, { throwOnNotFound: true });
514
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
515
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_ACTIVATED, {
516
+ subscription: updated,
517
+ activatedAt: now
518
+ }, ctx, {
519
+ resource: "subscription",
520
+ resourceId: updated?.publicId
521
+ }), ctx);
522
+ return updated;
523
+ }
524
+ async cancel(subscriptionId, options = {}, ctx = {}) {
525
+ const sub = await this.getById(subscriptionId);
526
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
527
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.CANCELLED, subscriptionId);
528
+ const updates = {
529
+ status: SUBSCRIPTION_STATUS.CANCELLED,
530
+ isActive: false,
531
+ canceledAt: /* @__PURE__ */ new Date(),
532
+ cancellationReason: options.reason
533
+ };
534
+ if (!options.immediate && sub.endDate) {
535
+ updates.cancelAt = sub.endDate;
536
+ updates.status = sub.status;
537
+ updates.isActive = sub.isActive;
538
+ delete updates.canceledAt;
539
+ }
540
+ const updated = await this.update(subscriptionId, updates, { throwOnNotFound: true });
541
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
542
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
543
+ subscription: updated,
544
+ immediate: options.immediate,
545
+ reason: options.reason
546
+ }, ctx, {
547
+ resource: "subscription",
548
+ resourceId: updated?.publicId
549
+ }), ctx);
550
+ return updated;
551
+ }
552
+ async pause(subscriptionId, options = {}, ctx = {}) {
553
+ const sub = await this.getById(subscriptionId);
554
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
555
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.PAUSED, subscriptionId);
556
+ const updated = await this.update(subscriptionId, {
557
+ status: SUBSCRIPTION_STATUS.PAUSED,
558
+ isActive: false,
559
+ pausedAt: /* @__PURE__ */ new Date(),
560
+ pauseReason: options.reason
561
+ }, { throwOnNotFound: true });
562
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
563
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_PAUSED, {
564
+ subscription: updated,
565
+ reason: options.reason
566
+ }, ctx, {
567
+ resource: "subscription",
568
+ resourceId: updated?.publicId
569
+ }), ctx);
570
+ return updated;
571
+ }
572
+ async resume(subscriptionId, options = {}, ctx = {}) {
573
+ const sub = await this.getById(subscriptionId);
574
+ if (!sub) throw new SubscriptionNotFoundError(subscriptionId);
575
+ SUBSCRIPTION_STATE_MACHINE.validate(sub.status, SUBSCRIPTION_STATUS.ACTIVE, subscriptionId);
576
+ const updates = {
577
+ status: SUBSCRIPTION_STATUS.ACTIVE,
578
+ isActive: true
579
+ };
580
+ if (options.extendPeriod && sub.pausedAt && sub.endDate) {
581
+ const pauseDuration = Date.now() - sub.pausedAt.getTime();
582
+ updates.endDate = new Date(sub.endDate.getTime() + pauseDuration);
583
+ }
584
+ const updated = await this.update(subscriptionId, updates, { throwOnNotFound: true });
585
+ if (!updated) throw new SubscriptionNotFoundError(subscriptionId);
586
+ await this.dispatch(createEvent(REVENUE_EVENTS.SUBSCRIPTION_RESUMED, {
587
+ subscription: updated,
588
+ extendPeriod: options.extendPeriod
589
+ }, ctx, {
590
+ resource: "subscription",
591
+ resourceId: updated?.publicId
592
+ }), ctx);
593
+ return updated;
594
+ }
595
+ };
596
+
597
+ //#endregion
598
+ //#region src/repositories/settlement.repository.ts
599
+ /**
600
+ * SettlementRepository — data layer + domain verbs.
601
+ *
602
+ * CRUD inherited from mongokit. Domain verbs: schedule, processPending, complete, fail.
603
+ *
604
+ * Events are published via the injected `events` transport (arc-compatible).
605
+ * Hosts subscribe glob-style via `revenue.events.subscribe('revenue:settlement.*', h)`.
606
+ * See PACKAGE_RULES §13–§14.
607
+ */
608
+ var SettlementRepository = class extends Repository {
609
+ deps;
610
+ constructor(model, plugins = []) {
611
+ super(model, plugins);
612
+ }
613
+ inject(deps) {
614
+ this.deps = deps;
615
+ }
616
+ /**
617
+ * Host-owned outbox save → in-process transport publish (PACKAGE_RULES P8).
618
+ * Session-bound when `ctx.session` is present (atomic outbox row write).
619
+ */
620
+ async dispatch(event, ctx = {}) {
621
+ if (this.deps.outbox) try {
622
+ await this.deps.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
623
+ } catch (err) {
624
+ this.deps.logger?.error("[revenue] outbox.save failed for", event.type, err);
625
+ }
626
+ try {
627
+ await this.deps.events.publish(event);
628
+ } catch (err) {
629
+ this.deps.logger?.error("[revenue] events.publish failed for", event.type, err);
630
+ }
631
+ }
632
+ async schedule(params, ctx = {}) {
633
+ const settlement = await this.create({
634
+ ...params,
635
+ status: SETTLEMENT_STATUS.PENDING,
636
+ scheduledAt: params.scheduledAt ?? /* @__PURE__ */ new Date(),
637
+ retryCount: 0
638
+ });
639
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_SCHEDULED, {
640
+ settlement,
641
+ scheduledAt: settlement.scheduledAt
642
+ }, ctx, {
643
+ resource: "settlement",
644
+ resourceId: settlement.publicId
645
+ }), ctx);
646
+ return settlement;
647
+ }
648
+ async processPending(options = {}, ctx = {}) {
649
+ const query = {
650
+ status: "pending",
651
+ scheduledAt: { $lte: /* @__PURE__ */ new Date() }
652
+ };
653
+ if (options.organizationId) query.organizationId = options.organizationId;
654
+ if (options.payoutMethod) query.payoutMethod = options.payoutMethod;
655
+ const pending = (await this.getAll({
656
+ filters: query,
657
+ limit: options.limit ?? 50,
658
+ sort: { scheduledAt: 1 }
659
+ })).docs ?? [];
660
+ if (options.dryRun) return {
661
+ processed: pending.length,
662
+ succeeded: 0,
663
+ failed: 0,
664
+ settlements: pending,
665
+ errors: []
666
+ };
667
+ const results = {
668
+ processed: 0,
669
+ succeeded: 0,
670
+ failed: 0,
671
+ settlements: [],
672
+ errors: []
673
+ };
674
+ for (const settlement of pending) {
675
+ try {
676
+ SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.PROCESSING, String(settlement._id));
677
+ await this.update(settlement._id, {
678
+ status: SETTLEMENT_STATUS.PROCESSING,
679
+ processedAt: /* @__PURE__ */ new Date()
680
+ });
681
+ results.succeeded++;
682
+ results.settlements.push(settlement);
683
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_PROCESSING, {
684
+ settlement,
685
+ processedAt: /* @__PURE__ */ new Date()
686
+ }, ctx, {
687
+ resource: "settlement",
688
+ resourceId: settlement.publicId
689
+ }), ctx);
690
+ } catch (err) {
691
+ results.failed++;
692
+ results.errors.push({
693
+ settlementId: settlement._id,
694
+ error: err
695
+ });
696
+ }
697
+ results.processed++;
698
+ }
699
+ return results;
700
+ }
701
+ async complete(settlementId, details = {}, ctx = {}) {
702
+ const settlement = await this.getById(settlementId);
703
+ if (!settlement) throw new SettlementNotFoundError(settlementId);
704
+ SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.COMPLETED, settlementId);
705
+ const updates = {
706
+ status: SETTLEMENT_STATUS.COMPLETED,
707
+ completedAt: /* @__PURE__ */ new Date(),
708
+ notes: details.notes,
709
+ metadata: {
710
+ ...settlement.metadata,
711
+ ...details.metadata
712
+ }
713
+ };
714
+ if (details.transferReference) updates.bankTransferDetails = {
715
+ ...settlement.bankTransferDetails,
716
+ transferReference: details.transferReference,
717
+ transferredAt: details.transferredAt ?? /* @__PURE__ */ new Date()
718
+ };
719
+ if (details.transactionHash) updates.cryptoDetails = {
720
+ ...settlement.cryptoDetails,
721
+ transactionHash: details.transactionHash,
722
+ transferredAt: details.transferredAt ?? /* @__PURE__ */ new Date()
723
+ };
724
+ const updated = await this.update(settlementId, updates, { throwOnNotFound: true });
725
+ if (!updated) throw new SettlementNotFoundError(settlementId);
726
+ await this.deps.bridges.ledger?.onSettlementCompleted?.(updated, ctx);
727
+ await this.deps.bridges.notification?.onSettlementCompleted?.(updated, ctx);
728
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_COMPLETED, {
729
+ settlement: updated,
730
+ completedAt: /* @__PURE__ */ new Date()
731
+ }, ctx, {
732
+ resource: "settlement",
733
+ resourceId: updated?.publicId
734
+ }), ctx);
735
+ return updated;
736
+ }
737
+ async fail(settlementId, reason, options = {}, ctx = {}) {
738
+ const settlement = await this.getById(settlementId);
739
+ if (!settlement) throw new SettlementNotFoundError(settlementId);
740
+ if (options.retry) await this.update(settlementId, {
741
+ status: SETTLEMENT_STATUS.PENDING,
742
+ retryCount: (settlement.retryCount ?? 0) + 1,
743
+ failureReason: reason,
744
+ failureCode: options.code,
745
+ scheduledAt: new Date(Date.now() + 36e5)
746
+ });
747
+ else {
748
+ SETTLEMENT_STATE_MACHINE.validate(settlement.status, SETTLEMENT_STATUS.FAILED, settlementId);
749
+ await this.update(settlementId, {
750
+ status: SETTLEMENT_STATUS.FAILED,
751
+ failedAt: /* @__PURE__ */ new Date(),
752
+ failureReason: reason,
753
+ failureCode: options.code
754
+ });
755
+ }
756
+ const updated = await this.getById(settlementId);
757
+ await this.dispatch(createEvent(REVENUE_EVENTS.SETTLEMENT_FAILED, {
758
+ settlement: updated,
759
+ reason,
760
+ code: options.code,
761
+ retry: options.retry
762
+ }, ctx, {
763
+ resource: "settlement",
764
+ resourceId: updated?.publicId
765
+ }), ctx);
766
+ return updated;
767
+ }
768
+ };
769
+
770
+ //#endregion
771
+ export { SubscriptionRepository as n, TransactionRepository as r, SettlementRepository as t };