@classytic/revenue 0.2.4 → 1.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 (111) hide show
  1. package/README.md +498 -501
  2. package/dist/actions-CwG-b7fR.d.ts +519 -0
  3. package/dist/core/index.d.ts +884 -0
  4. package/dist/core/index.js +2941 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/enums/index.d.ts +130 -0
  7. package/dist/enums/index.js +167 -0
  8. package/dist/enums/index.js.map +1 -0
  9. package/dist/index-BnJWVXuw.d.ts +378 -0
  10. package/dist/index-ChVD3P9k.d.ts +504 -0
  11. package/dist/index.d.ts +42 -0
  12. package/dist/index.js +4362 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/providers/index.d.ts +132 -0
  15. package/dist/providers/index.js +122 -0
  16. package/dist/providers/index.js.map +1 -0
  17. package/dist/retry-80lBCmSe.d.ts +234 -0
  18. package/dist/schemas/index.d.ts +906 -0
  19. package/dist/schemas/index.js +533 -0
  20. package/dist/schemas/index.js.map +1 -0
  21. package/dist/schemas/validation.d.ts +309 -0
  22. package/dist/schemas/validation.js +249 -0
  23. package/dist/schemas/validation.js.map +1 -0
  24. package/dist/services/index.d.ts +3 -0
  25. package/dist/services/index.js +1632 -0
  26. package/dist/services/index.js.map +1 -0
  27. package/dist/split.enums-DHdM1YAV.d.ts +255 -0
  28. package/dist/split.schema-CETjPq10.d.ts +976 -0
  29. package/dist/utils/index.d.ts +24 -0
  30. package/dist/utils/index.js +1067 -0
  31. package/dist/utils/index.js.map +1 -0
  32. package/package.json +48 -32
  33. package/core/builder.js +0 -219
  34. package/core/container.js +0 -119
  35. package/core/errors.js +0 -262
  36. package/dist/types/core/builder.d.ts +0 -97
  37. package/dist/types/core/container.d.ts +0 -57
  38. package/dist/types/core/errors.d.ts +0 -122
  39. package/dist/types/enums/escrow.enums.d.ts +0 -24
  40. package/dist/types/enums/index.d.ts +0 -69
  41. package/dist/types/enums/monetization.enums.d.ts +0 -6
  42. package/dist/types/enums/payment.enums.d.ts +0 -16
  43. package/dist/types/enums/split.enums.d.ts +0 -25
  44. package/dist/types/enums/subscription.enums.d.ts +0 -15
  45. package/dist/types/enums/transaction.enums.d.ts +0 -24
  46. package/dist/types/index.d.ts +0 -22
  47. package/dist/types/providers/base.d.ts +0 -128
  48. package/dist/types/schemas/escrow/hold.schema.d.ts +0 -54
  49. package/dist/types/schemas/escrow/index.d.ts +0 -6
  50. package/dist/types/schemas/index.d.ts +0 -506
  51. package/dist/types/schemas/split/index.d.ts +0 -8
  52. package/dist/types/schemas/split/split.schema.d.ts +0 -142
  53. package/dist/types/schemas/subscription/index.d.ts +0 -152
  54. package/dist/types/schemas/subscription/info.schema.d.ts +0 -128
  55. package/dist/types/schemas/subscription/plan.schema.d.ts +0 -39
  56. package/dist/types/schemas/transaction/common.schema.d.ts +0 -12
  57. package/dist/types/schemas/transaction/gateway.schema.d.ts +0 -86
  58. package/dist/types/schemas/transaction/index.d.ts +0 -202
  59. package/dist/types/schemas/transaction/payment.schema.d.ts +0 -145
  60. package/dist/types/services/escrow.service.d.ts +0 -51
  61. package/dist/types/services/monetization.service.d.ts +0 -193
  62. package/dist/types/services/payment.service.d.ts +0 -117
  63. package/dist/types/services/transaction.service.d.ts +0 -40
  64. package/dist/types/utils/category-resolver.d.ts +0 -46
  65. package/dist/types/utils/commission-split.d.ts +0 -56
  66. package/dist/types/utils/commission.d.ts +0 -29
  67. package/dist/types/utils/hooks.d.ts +0 -17
  68. package/dist/types/utils/index.d.ts +0 -6
  69. package/dist/types/utils/logger.d.ts +0 -12
  70. package/dist/types/utils/subscription/actions.d.ts +0 -28
  71. package/dist/types/utils/subscription/index.d.ts +0 -2
  72. package/dist/types/utils/subscription/period.d.ts +0 -47
  73. package/dist/types/utils/transaction-type.d.ts +0 -102
  74. package/enums/escrow.enums.js +0 -36
  75. package/enums/index.d.ts +0 -116
  76. package/enums/index.js +0 -110
  77. package/enums/monetization.enums.js +0 -15
  78. package/enums/payment.enums.js +0 -64
  79. package/enums/split.enums.js +0 -37
  80. package/enums/subscription.enums.js +0 -33
  81. package/enums/transaction.enums.js +0 -69
  82. package/index.js +0 -76
  83. package/providers/base.js +0 -162
  84. package/schemas/escrow/hold.schema.js +0 -62
  85. package/schemas/escrow/index.js +0 -15
  86. package/schemas/index.d.ts +0 -33
  87. package/schemas/index.js +0 -27
  88. package/schemas/split/index.js +0 -16
  89. package/schemas/split/split.schema.js +0 -86
  90. package/schemas/subscription/index.js +0 -17
  91. package/schemas/subscription/info.schema.js +0 -115
  92. package/schemas/subscription/plan.schema.js +0 -48
  93. package/schemas/transaction/common.schema.js +0 -22
  94. package/schemas/transaction/gateway.schema.js +0 -69
  95. package/schemas/transaction/index.js +0 -20
  96. package/schemas/transaction/payment.schema.js +0 -110
  97. package/services/escrow.service.js +0 -353
  98. package/services/monetization.service.js +0 -675
  99. package/services/payment.service.js +0 -535
  100. package/services/transaction.service.js +0 -142
  101. package/utils/category-resolver.js +0 -74
  102. package/utils/commission-split.js +0 -180
  103. package/utils/commission.js +0 -83
  104. package/utils/hooks.js +0 -44
  105. package/utils/index.d.ts +0 -164
  106. package/utils/index.js +0 -16
  107. package/utils/logger.js +0 -36
  108. package/utils/subscription/actions.js +0 -68
  109. package/utils/subscription/index.js +0 -20
  110. package/utils/subscription/period.js +0 -123
  111. package/utils/transaction-type.js +0 -254
@@ -0,0 +1,1632 @@
1
+ import { nanoid } from 'nanoid';
2
+
3
+ // @classytic/revenue - Enterprise Revenue Management System
4
+
5
+
6
+ // src/core/errors.ts
7
+ var RevenueError = class extends Error {
8
+ code;
9
+ retryable;
10
+ metadata;
11
+ constructor(message, code, options = {}) {
12
+ super(message);
13
+ this.name = this.constructor.name;
14
+ this.code = code;
15
+ this.retryable = options.retryable ?? false;
16
+ this.metadata = options.metadata ?? {};
17
+ Error.captureStackTrace(this, this.constructor);
18
+ }
19
+ toJSON() {
20
+ return {
21
+ name: this.name,
22
+ message: this.message,
23
+ code: this.code,
24
+ retryable: this.retryable,
25
+ metadata: this.metadata
26
+ };
27
+ }
28
+ };
29
+ var ConfigurationError = class extends RevenueError {
30
+ constructor(message, metadata = {}) {
31
+ super(message, "CONFIGURATION_ERROR", { retryable: false, metadata });
32
+ }
33
+ };
34
+ var ModelNotRegisteredError = class extends ConfigurationError {
35
+ constructor(modelName) {
36
+ super(
37
+ `Model "${modelName}" is not registered. Register it via createRevenue({ models: { ${modelName}: ... } })`,
38
+ { modelName }
39
+ );
40
+ }
41
+ };
42
+ var ProviderError = class extends RevenueError {
43
+ constructor(message, code, options = {}) {
44
+ super(message, code, options);
45
+ }
46
+ };
47
+ var ProviderNotFoundError = class extends ProviderError {
48
+ constructor(providerName, availableProviders = []) {
49
+ super(
50
+ `Payment provider "${providerName}" not found. Available: ${availableProviders.join(", ")}`,
51
+ "PROVIDER_NOT_FOUND",
52
+ { retryable: false, metadata: { providerName, availableProviders } }
53
+ );
54
+ }
55
+ };
56
+ var ProviderCapabilityError = class extends ProviderError {
57
+ constructor(providerName, capability) {
58
+ super(
59
+ `Provider "${providerName}" does not support ${capability}`,
60
+ "PROVIDER_CAPABILITY_NOT_SUPPORTED",
61
+ { retryable: false, metadata: { providerName, capability } }
62
+ );
63
+ }
64
+ };
65
+ var PaymentIntentCreationError = class extends ProviderError {
66
+ constructor(providerName, originalError) {
67
+ super(
68
+ `Failed to create payment intent with provider "${providerName}": ${originalError.message}`,
69
+ "PAYMENT_INTENT_CREATION_FAILED",
70
+ { retryable: true, metadata: { providerName, originalError: originalError.message } }
71
+ );
72
+ }
73
+ };
74
+ var PaymentVerificationError = class extends ProviderError {
75
+ constructor(paymentIntentId, reason) {
76
+ super(
77
+ `Payment verification failed for intent "${paymentIntentId}": ${reason}`,
78
+ "PAYMENT_VERIFICATION_FAILED",
79
+ { retryable: true, metadata: { paymentIntentId, reason } }
80
+ );
81
+ }
82
+ };
83
+ var NotFoundError = class extends RevenueError {
84
+ constructor(message, code, metadata = {}) {
85
+ super(message, code, { retryable: false, metadata });
86
+ }
87
+ };
88
+ var SubscriptionNotFoundError = class extends NotFoundError {
89
+ constructor(subscriptionId) {
90
+ super(
91
+ `Subscription not found: ${subscriptionId}`,
92
+ "SUBSCRIPTION_NOT_FOUND",
93
+ { subscriptionId }
94
+ );
95
+ }
96
+ };
97
+ var TransactionNotFoundError = class extends NotFoundError {
98
+ constructor(transactionId) {
99
+ super(
100
+ `Transaction not found: ${transactionId}`,
101
+ "TRANSACTION_NOT_FOUND",
102
+ { transactionId }
103
+ );
104
+ }
105
+ };
106
+ var ValidationError = class extends RevenueError {
107
+ constructor(message, metadata = {}) {
108
+ super(message, "VALIDATION_ERROR", { retryable: false, metadata });
109
+ }
110
+ };
111
+ var InvalidAmountError = class extends ValidationError {
112
+ constructor(amount, message) {
113
+ super(
114
+ message ?? `Invalid amount: ${amount}. Amount must be non-negative`,
115
+ { amount }
116
+ );
117
+ }
118
+ };
119
+ var MissingRequiredFieldError = class extends ValidationError {
120
+ constructor(fieldName) {
121
+ super(`Missing required field: ${fieldName}`, { fieldName });
122
+ }
123
+ };
124
+ var StateError = class extends RevenueError {
125
+ constructor(message, code, metadata = {}) {
126
+ super(message, code, { retryable: false, metadata });
127
+ }
128
+ };
129
+ var AlreadyVerifiedError = class extends StateError {
130
+ constructor(transactionId) {
131
+ super(
132
+ `Transaction ${transactionId} is already verified`,
133
+ "ALREADY_VERIFIED",
134
+ { transactionId }
135
+ );
136
+ }
137
+ };
138
+ var InvalidStateTransitionError = class extends StateError {
139
+ constructor(resourceType, resourceId, fromState, toState) {
140
+ super(
141
+ `Invalid state transition for ${resourceType} ${resourceId}: ${fromState} \u2192 ${toState}`,
142
+ "INVALID_STATE_TRANSITION",
143
+ { resourceType, resourceId, fromState, toState }
144
+ );
145
+ }
146
+ };
147
+ var SubscriptionNotActiveError = class extends StateError {
148
+ constructor(subscriptionId, message) {
149
+ super(
150
+ message ?? `Subscription ${subscriptionId} is not active`,
151
+ "SUBSCRIPTION_NOT_ACTIVE",
152
+ { subscriptionId }
153
+ );
154
+ }
155
+ };
156
+ var OperationError = class extends RevenueError {
157
+ constructor(message, code, options = {}) {
158
+ super(message, code, options);
159
+ }
160
+ };
161
+ var RefundNotSupportedError = class extends OperationError {
162
+ constructor(providerName) {
163
+ super(
164
+ `Refunds are not supported by provider "${providerName}"`,
165
+ "REFUND_NOT_SUPPORTED",
166
+ { retryable: false, metadata: { providerName } }
167
+ );
168
+ }
169
+ };
170
+ var RefundError = class extends OperationError {
171
+ constructor(transactionId, reason) {
172
+ super(
173
+ `Refund failed for transaction ${transactionId}: ${reason}`,
174
+ "REFUND_FAILED",
175
+ { retryable: true, metadata: { transactionId, reason } }
176
+ );
177
+ }
178
+ };
179
+
180
+ // src/utils/hooks.ts
181
+ function triggerHook(hooks, event, data, logger) {
182
+ const handlers = hooks[event] ?? [];
183
+ if (handlers.length === 0) {
184
+ return;
185
+ }
186
+ Promise.all(
187
+ handlers.map(
188
+ (handler) => Promise.resolve(handler(data)).catch((error) => {
189
+ logger.error(`Hook "${event}" failed:`, {
190
+ error: error.message,
191
+ stack: error.stack,
192
+ event,
193
+ // Don't log full data (could be huge)
194
+ dataKeys: Object.keys(data)
195
+ });
196
+ })
197
+ )
198
+ ).catch(() => {
199
+ });
200
+ }
201
+
202
+ // src/enums/transaction.enums.ts
203
+ var TRANSACTION_TYPE = {
204
+ INCOME: "income",
205
+ EXPENSE: "expense"
206
+ };
207
+ var TRANSACTION_STATUS = {
208
+ VERIFIED: "verified",
209
+ COMPLETED: "completed",
210
+ CANCELLED: "cancelled"};
211
+ var LIBRARY_CATEGORIES = {
212
+ SUBSCRIPTION: "subscription",
213
+ PURCHASE: "purchase"
214
+ };
215
+
216
+ // src/utils/category-resolver.ts
217
+ function resolveCategory(entity, monetizationType, categoryMappings = {}) {
218
+ if (entity && categoryMappings[entity]) {
219
+ return categoryMappings[entity];
220
+ }
221
+ switch (monetizationType) {
222
+ case "subscription":
223
+ return LIBRARY_CATEGORIES.SUBSCRIPTION;
224
+ // 'subscription'
225
+ case "purchase":
226
+ return LIBRARY_CATEGORIES.PURCHASE;
227
+ // 'purchase'
228
+ default:
229
+ return LIBRARY_CATEGORIES.SUBSCRIPTION;
230
+ }
231
+ }
232
+
233
+ // src/utils/commission.ts
234
+ function calculateCommission(amount, commissionRate, gatewayFeeRate = 0) {
235
+ if (!commissionRate || commissionRate <= 0) {
236
+ return null;
237
+ }
238
+ if (amount < 0) {
239
+ throw new Error("Transaction amount cannot be negative");
240
+ }
241
+ if (commissionRate < 0 || commissionRate > 1) {
242
+ throw new Error("Commission rate must be between 0 and 1");
243
+ }
244
+ if (gatewayFeeRate < 0 || gatewayFeeRate > 1) {
245
+ throw new Error("Gateway fee rate must be between 0 and 1");
246
+ }
247
+ const grossAmount = Math.round(amount * commissionRate * 100) / 100;
248
+ const gatewayFeeAmount = Math.round(amount * gatewayFeeRate * 100) / 100;
249
+ const netAmount = Math.max(0, Math.round((grossAmount - gatewayFeeAmount) * 100) / 100);
250
+ return {
251
+ rate: commissionRate,
252
+ grossAmount,
253
+ gatewayFeeRate,
254
+ gatewayFeeAmount,
255
+ netAmount,
256
+ status: "pending"
257
+ };
258
+ }
259
+ function reverseCommission(originalCommission, originalAmount, refundAmount) {
260
+ if (!originalCommission?.netAmount) {
261
+ return null;
262
+ }
263
+ const refundRatio = refundAmount / originalAmount;
264
+ const reversedNetAmount = Math.round(originalCommission.netAmount * refundRatio * 100) / 100;
265
+ const reversedGrossAmount = Math.round(originalCommission.grossAmount * refundRatio * 100) / 100;
266
+ const reversedGatewayFee = Math.round(originalCommission.gatewayFeeAmount * refundRatio * 100) / 100;
267
+ return {
268
+ rate: originalCommission.rate,
269
+ grossAmount: reversedGrossAmount,
270
+ gatewayFeeRate: originalCommission.gatewayFeeRate,
271
+ gatewayFeeAmount: reversedGatewayFee,
272
+ netAmount: reversedNetAmount,
273
+ status: "waived"
274
+ // Commission waived due to refund
275
+ };
276
+ }
277
+
278
+ // src/enums/monetization.enums.ts
279
+ var MONETIZATION_TYPES = {
280
+ FREE: "free",
281
+ PURCHASE: "purchase",
282
+ SUBSCRIPTION: "subscription"
283
+ };
284
+
285
+ // src/services/monetization.service.ts
286
+ var MonetizationService = class {
287
+ models;
288
+ providers;
289
+ config;
290
+ hooks;
291
+ logger;
292
+ constructor(container) {
293
+ this.models = container.get("models");
294
+ this.providers = container.get("providers");
295
+ this.config = container.get("config");
296
+ this.hooks = container.get("hooks");
297
+ this.logger = container.get("logger");
298
+ }
299
+ /**
300
+ * Create a new monetization (purchase, subscription, or free item)
301
+ *
302
+ * @param params - Monetization parameters
303
+ *
304
+ * @example
305
+ * // One-time purchase
306
+ * await revenue.monetization.create({
307
+ * data: {
308
+ * organizationId: '...',
309
+ * customerId: '...',
310
+ * referenceId: order._id,
311
+ * referenceModel: 'Order',
312
+ * },
313
+ * planKey: 'one_time',
314
+ * monetizationType: 'purchase',
315
+ * gateway: 'bkash',
316
+ * amount: 1500,
317
+ * });
318
+ *
319
+ * // Recurring subscription
320
+ * await revenue.monetization.create({
321
+ * data: {
322
+ * organizationId: '...',
323
+ * customerId: '...',
324
+ * referenceId: subscription._id,
325
+ * referenceModel: 'Subscription',
326
+ * },
327
+ * planKey: 'monthly',
328
+ * monetizationType: 'subscription',
329
+ * gateway: 'stripe',
330
+ * amount: 2000,
331
+ * });
332
+ *
333
+ * @returns Result with subscription, transaction, and paymentIntent
334
+ */
335
+ async create(params) {
336
+ const {
337
+ data,
338
+ planKey,
339
+ amount,
340
+ currency = "BDT",
341
+ gateway = "manual",
342
+ entity = null,
343
+ monetizationType = MONETIZATION_TYPES.SUBSCRIPTION,
344
+ paymentData,
345
+ metadata = {},
346
+ idempotencyKey = null
347
+ } = params;
348
+ if (!planKey) {
349
+ throw new MissingRequiredFieldError("planKey");
350
+ }
351
+ if (amount < 0) {
352
+ throw new InvalidAmountError(amount);
353
+ }
354
+ const isFree = amount === 0;
355
+ const provider = this.providers[gateway];
356
+ if (!provider) {
357
+ throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
358
+ }
359
+ let paymentIntent = null;
360
+ let transaction = null;
361
+ if (!isFree) {
362
+ try {
363
+ paymentIntent = await provider.createIntent({
364
+ amount,
365
+ currency,
366
+ metadata: {
367
+ ...metadata,
368
+ type: "subscription",
369
+ planKey
370
+ }
371
+ });
372
+ } catch (error) {
373
+ throw new PaymentIntentCreationError(gateway, error);
374
+ }
375
+ const category = resolveCategory(entity, monetizationType, this.config.categoryMappings);
376
+ const transactionType = this.config.transactionTypeMapping?.subscription ?? this.config.transactionTypeMapping?.[monetizationType] ?? TRANSACTION_TYPE.INCOME;
377
+ const commissionRate = this.config.commissionRates?.[category] ?? 0;
378
+ const gatewayFeeRate = this.config.gatewayFeeRates?.[gateway] ?? 0;
379
+ const commission = calculateCommission(amount, commissionRate, gatewayFeeRate);
380
+ const TransactionModel = this.models.Transaction;
381
+ transaction = await TransactionModel.create({
382
+ organizationId: data.organizationId,
383
+ customerId: data.customerId ?? null,
384
+ amount,
385
+ currency,
386
+ category,
387
+ type: transactionType,
388
+ method: paymentData?.method ?? "manual",
389
+ status: paymentIntent.status === "succeeded" ? "verified" : "pending",
390
+ gateway: {
391
+ type: gateway,
392
+ sessionId: paymentIntent.sessionId,
393
+ paymentIntentId: paymentIntent.paymentIntentId,
394
+ provider: paymentIntent.provider,
395
+ metadata: paymentIntent.metadata
396
+ },
397
+ paymentDetails: {
398
+ provider: gateway,
399
+ ...paymentData
400
+ },
401
+ ...commission && { commission },
402
+ // Only include if commission exists
403
+ // Polymorphic reference (top-level, not metadata)
404
+ ...data.referenceId && { referenceId: data.referenceId },
405
+ ...data.referenceModel && { referenceModel: data.referenceModel },
406
+ metadata: {
407
+ ...metadata,
408
+ planKey,
409
+ entity,
410
+ monetizationType,
411
+ paymentIntentId: paymentIntent.id
412
+ },
413
+ idempotencyKey: idempotencyKey ?? `sub_${nanoid(16)}`
414
+ });
415
+ }
416
+ let subscription = null;
417
+ if (this.models.Subscription) {
418
+ const SubscriptionModel = this.models.Subscription;
419
+ const subscriptionData = {
420
+ organizationId: data.organizationId,
421
+ customerId: data.customerId ?? null,
422
+ planKey,
423
+ amount,
424
+ currency,
425
+ status: isFree ? "active" : "pending",
426
+ isActive: isFree,
427
+ gateway,
428
+ transactionId: transaction?._id ?? null,
429
+ paymentIntentId: paymentIntent?.id ?? null,
430
+ metadata: {
431
+ ...metadata,
432
+ isFree,
433
+ entity,
434
+ monetizationType
435
+ },
436
+ ...data
437
+ };
438
+ delete subscriptionData.referenceId;
439
+ delete subscriptionData.referenceModel;
440
+ subscription = await SubscriptionModel.create(subscriptionData);
441
+ }
442
+ const eventData = {
443
+ subscription,
444
+ transaction,
445
+ paymentIntent,
446
+ isFree,
447
+ monetizationType
448
+ };
449
+ if (monetizationType === MONETIZATION_TYPES.PURCHASE) {
450
+ this._triggerHook("purchase.created", eventData);
451
+ } else if (monetizationType === MONETIZATION_TYPES.SUBSCRIPTION) {
452
+ this._triggerHook("subscription.created", eventData);
453
+ } else if (monetizationType === MONETIZATION_TYPES.FREE) {
454
+ this._triggerHook("free.created", eventData);
455
+ }
456
+ this._triggerHook("monetization.created", eventData);
457
+ return {
458
+ subscription,
459
+ transaction,
460
+ paymentIntent
461
+ };
462
+ }
463
+ /**
464
+ * Activate subscription after payment verification
465
+ *
466
+ * @param subscriptionId - Subscription ID or transaction ID
467
+ * @param options - Activation options
468
+ * @returns Updated subscription
469
+ */
470
+ async activate(subscriptionId, options = {}) {
471
+ const { timestamp = /* @__PURE__ */ new Date() } = options;
472
+ if (!this.models.Subscription) {
473
+ throw new ModelNotRegisteredError("Subscription");
474
+ }
475
+ const SubscriptionModel = this.models.Subscription;
476
+ const subscription = await SubscriptionModel.findById(subscriptionId);
477
+ if (!subscription) {
478
+ throw new SubscriptionNotFoundError(subscriptionId);
479
+ }
480
+ if (subscription.isActive) {
481
+ this.logger.warn("Subscription already active", { subscriptionId });
482
+ return subscription;
483
+ }
484
+ const periodEnd = this._calculatePeriodEnd(subscription.planKey, timestamp);
485
+ subscription.isActive = true;
486
+ subscription.status = "active";
487
+ subscription.startDate = timestamp;
488
+ subscription.endDate = periodEnd;
489
+ subscription.activatedAt = timestamp;
490
+ await subscription.save();
491
+ this._triggerHook("subscription.activated", {
492
+ subscription,
493
+ activatedAt: timestamp
494
+ });
495
+ return subscription;
496
+ }
497
+ /**
498
+ * Renew subscription
499
+ *
500
+ * @param subscriptionId - Subscription ID
501
+ * @param params - Renewal parameters
502
+ * @returns { subscription, transaction, paymentIntent }
503
+ */
504
+ async renew(subscriptionId, params = {}) {
505
+ const {
506
+ gateway = "manual",
507
+ entity = null,
508
+ paymentData,
509
+ metadata = {},
510
+ idempotencyKey = null
511
+ } = params;
512
+ if (!this.models.Subscription) {
513
+ throw new ModelNotRegisteredError("Subscription");
514
+ }
515
+ const SubscriptionModel = this.models.Subscription;
516
+ const subscription = await SubscriptionModel.findById(subscriptionId);
517
+ if (!subscription) {
518
+ throw new SubscriptionNotFoundError(subscriptionId);
519
+ }
520
+ if (subscription.amount === 0) {
521
+ throw new InvalidAmountError(0, "Free subscriptions do not require renewal");
522
+ }
523
+ const provider = this.providers[gateway];
524
+ if (!provider) {
525
+ throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
526
+ }
527
+ let paymentIntent = null;
528
+ try {
529
+ paymentIntent = await provider.createIntent({
530
+ amount: subscription.amount,
531
+ currency: subscription.currency ?? "BDT",
532
+ metadata: {
533
+ ...metadata,
534
+ type: "subscription_renewal",
535
+ subscriptionId: subscription._id.toString()
536
+ }
537
+ });
538
+ } catch (error) {
539
+ this.logger.error("Failed to create payment intent for renewal:", error);
540
+ throw new PaymentIntentCreationError(gateway, error);
541
+ }
542
+ const effectiveEntity = entity ?? subscription.metadata?.entity;
543
+ const effectiveMonetizationType = subscription.metadata?.monetizationType ?? MONETIZATION_TYPES.SUBSCRIPTION;
544
+ const category = resolveCategory(effectiveEntity, effectiveMonetizationType, this.config.categoryMappings);
545
+ const transactionType = this.config.transactionTypeMapping?.subscription_renewal ?? this.config.transactionTypeMapping?.subscription ?? this.config.transactionTypeMapping?.[effectiveMonetizationType] ?? TRANSACTION_TYPE.INCOME;
546
+ const commissionRate = this.config.commissionRates?.[category] ?? 0;
547
+ const gatewayFeeRate = this.config.gatewayFeeRates?.[gateway] ?? 0;
548
+ const commission = calculateCommission(subscription.amount, commissionRate, gatewayFeeRate);
549
+ const TransactionModel = this.models.Transaction;
550
+ const transaction = await TransactionModel.create({
551
+ organizationId: subscription.organizationId,
552
+ customerId: subscription.customerId,
553
+ amount: subscription.amount,
554
+ currency: subscription.currency ?? "BDT",
555
+ category,
556
+ type: transactionType,
557
+ method: paymentData?.method ?? "manual",
558
+ status: paymentIntent.status === "succeeded" ? "verified" : "pending",
559
+ gateway: {
560
+ type: gateway,
561
+ sessionId: paymentIntent.sessionId,
562
+ paymentIntentId: paymentIntent.paymentIntentId,
563
+ provider: paymentIntent.provider,
564
+ metadata: paymentIntent.metadata
565
+ },
566
+ paymentDetails: {
567
+ provider: gateway,
568
+ ...paymentData
569
+ },
570
+ ...commission && { commission },
571
+ // Only include if commission exists
572
+ // Polymorphic reference to subscription
573
+ referenceId: subscription._id,
574
+ referenceModel: "Subscription",
575
+ metadata: {
576
+ ...metadata,
577
+ subscriptionId: subscription._id.toString(),
578
+ // Keep for backward compat
579
+ entity: effectiveEntity,
580
+ monetizationType: effectiveMonetizationType,
581
+ isRenewal: true,
582
+ paymentIntentId: paymentIntent.id
583
+ },
584
+ idempotencyKey: idempotencyKey ?? `renewal_${nanoid(16)}`
585
+ });
586
+ subscription.status = "pending_renewal";
587
+ subscription.renewalTransactionId = transaction._id;
588
+ subscription.renewalCount = (subscription.renewalCount ?? 0) + 1;
589
+ await subscription.save();
590
+ this._triggerHook("subscription.renewed", {
591
+ subscription,
592
+ transaction,
593
+ paymentIntent,
594
+ renewalCount: subscription.renewalCount
595
+ });
596
+ return {
597
+ subscription,
598
+ transaction,
599
+ paymentIntent
600
+ };
601
+ }
602
+ /**
603
+ * Cancel subscription
604
+ *
605
+ * @param subscriptionId - Subscription ID
606
+ * @param options - Cancellation options
607
+ * @returns Updated subscription
608
+ */
609
+ async cancel(subscriptionId, options = {}) {
610
+ const { immediate = false, reason = null } = options;
611
+ if (!this.models.Subscription) {
612
+ throw new ModelNotRegisteredError("Subscription");
613
+ }
614
+ const SubscriptionModel = this.models.Subscription;
615
+ const subscription = await SubscriptionModel.findById(subscriptionId);
616
+ if (!subscription) {
617
+ throw new SubscriptionNotFoundError(subscriptionId);
618
+ }
619
+ const now = /* @__PURE__ */ new Date();
620
+ if (immediate) {
621
+ subscription.isActive = false;
622
+ subscription.status = "cancelled";
623
+ subscription.canceledAt = now;
624
+ subscription.cancellationReason = reason;
625
+ } else {
626
+ subscription.cancelAt = subscription.endDate ?? now;
627
+ subscription.cancellationReason = reason;
628
+ }
629
+ await subscription.save();
630
+ this._triggerHook("subscription.cancelled", {
631
+ subscription,
632
+ immediate,
633
+ reason,
634
+ canceledAt: immediate ? now : subscription.cancelAt
635
+ });
636
+ return subscription;
637
+ }
638
+ /**
639
+ * Pause subscription
640
+ *
641
+ * @param subscriptionId - Subscription ID
642
+ * @param options - Pause options
643
+ * @returns Updated subscription
644
+ */
645
+ async pause(subscriptionId, options = {}) {
646
+ const { reason = null } = options;
647
+ if (!this.models.Subscription) {
648
+ throw new ModelNotRegisteredError("Subscription");
649
+ }
650
+ const SubscriptionModel = this.models.Subscription;
651
+ const subscription = await SubscriptionModel.findById(subscriptionId);
652
+ if (!subscription) {
653
+ throw new SubscriptionNotFoundError(subscriptionId);
654
+ }
655
+ if (!subscription.isActive) {
656
+ throw new SubscriptionNotActiveError(subscriptionId, "Only active subscriptions can be paused");
657
+ }
658
+ const pausedAt = /* @__PURE__ */ new Date();
659
+ subscription.isActive = false;
660
+ subscription.status = "paused";
661
+ subscription.pausedAt = pausedAt;
662
+ subscription.pauseReason = reason;
663
+ await subscription.save();
664
+ this._triggerHook("subscription.paused", {
665
+ subscription,
666
+ reason,
667
+ pausedAt
668
+ });
669
+ return subscription;
670
+ }
671
+ /**
672
+ * Resume subscription
673
+ *
674
+ * @param subscriptionId - Subscription ID
675
+ * @param options - Resume options
676
+ * @returns Updated subscription
677
+ */
678
+ async resume(subscriptionId, options = {}) {
679
+ const { extendPeriod = false } = options;
680
+ if (!this.models.Subscription) {
681
+ throw new ModelNotRegisteredError("Subscription");
682
+ }
683
+ const SubscriptionModel = this.models.Subscription;
684
+ const subscription = await SubscriptionModel.findById(subscriptionId);
685
+ if (!subscription) {
686
+ throw new SubscriptionNotFoundError(subscriptionId);
687
+ }
688
+ if (!subscription.pausedAt) {
689
+ throw new InvalidStateTransitionError(
690
+ "resume",
691
+ "paused",
692
+ subscription.status,
693
+ "Only paused subscriptions can be resumed"
694
+ );
695
+ }
696
+ const now = /* @__PURE__ */ new Date();
697
+ const pausedAt = new Date(subscription.pausedAt);
698
+ const pauseDuration = now.getTime() - pausedAt.getTime();
699
+ subscription.isActive = true;
700
+ subscription.status = "active";
701
+ subscription.pausedAt = null;
702
+ subscription.pauseReason = null;
703
+ if (extendPeriod && subscription.endDate) {
704
+ const currentEnd = new Date(subscription.endDate);
705
+ subscription.endDate = new Date(currentEnd.getTime() + pauseDuration);
706
+ }
707
+ await subscription.save();
708
+ this._triggerHook("subscription.resumed", {
709
+ subscription,
710
+ extendPeriod,
711
+ pauseDuration,
712
+ resumedAt: now
713
+ });
714
+ return subscription;
715
+ }
716
+ /**
717
+ * List subscriptions with filters
718
+ *
719
+ * @param filters - Query filters
720
+ * @param options - Query options (limit, skip, sort)
721
+ * @returns Subscriptions
722
+ */
723
+ async list(filters = {}, options = {}) {
724
+ if (!this.models.Subscription) {
725
+ throw new ModelNotRegisteredError("Subscription");
726
+ }
727
+ const SubscriptionModel = this.models.Subscription;
728
+ const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
729
+ const subscriptions = await SubscriptionModel.find(filters).limit(limit).skip(skip).sort(sort);
730
+ return subscriptions;
731
+ }
732
+ /**
733
+ * Get subscription by ID
734
+ *
735
+ * @param subscriptionId - Subscription ID
736
+ * @returns Subscription
737
+ */
738
+ async get(subscriptionId) {
739
+ if (!this.models.Subscription) {
740
+ throw new ModelNotRegisteredError("Subscription");
741
+ }
742
+ const SubscriptionModel = this.models.Subscription;
743
+ const subscription = await SubscriptionModel.findById(subscriptionId);
744
+ if (!subscription) {
745
+ throw new SubscriptionNotFoundError(subscriptionId);
746
+ }
747
+ return subscription;
748
+ }
749
+ /**
750
+ * Calculate period end date based on plan key
751
+ * @private
752
+ */
753
+ _calculatePeriodEnd(planKey, startDate = /* @__PURE__ */ new Date()) {
754
+ const start = new Date(startDate);
755
+ const end = new Date(start);
756
+ switch (planKey) {
757
+ case "monthly":
758
+ end.setMonth(end.getMonth() + 1);
759
+ break;
760
+ case "quarterly":
761
+ end.setMonth(end.getMonth() + 3);
762
+ break;
763
+ case "yearly":
764
+ end.setFullYear(end.getFullYear() + 1);
765
+ break;
766
+ default:
767
+ end.setDate(end.getDate() + 30);
768
+ }
769
+ return end;
770
+ }
771
+ /**
772
+ * Trigger event hook (fire-and-forget, non-blocking)
773
+ * @private
774
+ */
775
+ _triggerHook(event, data) {
776
+ triggerHook(this.hooks, event, data, this.logger);
777
+ }
778
+ };
779
+
780
+ // src/services/payment.service.ts
781
+ var PaymentService = class {
782
+ models;
783
+ providers;
784
+ config;
785
+ hooks;
786
+ logger;
787
+ constructor(container) {
788
+ this.models = container.get("models");
789
+ this.providers = container.get("providers");
790
+ this.config = container.get("config");
791
+ this.hooks = container.get("hooks");
792
+ this.logger = container.get("logger");
793
+ }
794
+ /**
795
+ * Verify a payment
796
+ *
797
+ * @param paymentIntentId - Payment intent ID, session ID, or transaction ID
798
+ * @param options - Verification options
799
+ * @returns { transaction, status }
800
+ */
801
+ async verify(paymentIntentId, options = {}) {
802
+ const { verifiedBy = null } = options;
803
+ const TransactionModel = this.models.Transaction;
804
+ const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
805
+ if (!transaction) {
806
+ throw new TransactionNotFoundError(paymentIntentId);
807
+ }
808
+ if (transaction.status === "verified" || transaction.status === "completed") {
809
+ throw new AlreadyVerifiedError(transaction._id.toString());
810
+ }
811
+ const gatewayType = transaction.gateway?.type ?? "manual";
812
+ const provider = this.providers[gatewayType];
813
+ if (!provider) {
814
+ throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
815
+ }
816
+ let paymentResult = null;
817
+ try {
818
+ paymentResult = await provider.verifyPayment(paymentIntentId);
819
+ } catch (error) {
820
+ this.logger.error("Payment verification failed:", error);
821
+ transaction.status = "failed";
822
+ transaction.failureReason = error.message;
823
+ transaction.metadata = {
824
+ ...transaction.metadata,
825
+ verificationError: error.message,
826
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
827
+ };
828
+ await transaction.save();
829
+ this._triggerHook("payment.failed", {
830
+ transaction,
831
+ error: error.message,
832
+ provider: gatewayType,
833
+ paymentIntentId
834
+ });
835
+ throw new PaymentVerificationError(paymentIntentId, error.message);
836
+ }
837
+ if (paymentResult.amount && paymentResult.amount !== transaction.amount) {
838
+ throw new ValidationError(
839
+ `Amount mismatch: expected ${transaction.amount}, got ${paymentResult.amount}`,
840
+ { expected: transaction.amount, actual: paymentResult.amount }
841
+ );
842
+ }
843
+ if (paymentResult.currency && paymentResult.currency.toUpperCase() !== transaction.currency.toUpperCase()) {
844
+ throw new ValidationError(
845
+ `Currency mismatch: expected ${transaction.currency}, got ${paymentResult.currency}`,
846
+ { expected: transaction.currency, actual: paymentResult.currency }
847
+ );
848
+ }
849
+ transaction.status = paymentResult.status === "succeeded" ? "verified" : paymentResult.status;
850
+ transaction.verifiedAt = paymentResult.paidAt ?? /* @__PURE__ */ new Date();
851
+ transaction.verifiedBy = verifiedBy;
852
+ transaction.gateway = {
853
+ ...transaction.gateway,
854
+ type: transaction.gateway?.type ?? "manual",
855
+ verificationData: paymentResult.metadata
856
+ };
857
+ await transaction.save();
858
+ this._triggerHook("payment.verified", {
859
+ transaction,
860
+ paymentResult,
861
+ verifiedBy
862
+ });
863
+ return {
864
+ transaction,
865
+ paymentResult,
866
+ status: transaction.status
867
+ };
868
+ }
869
+ /**
870
+ * Get payment status
871
+ *
872
+ * @param paymentIntentId - Payment intent ID, session ID, or transaction ID
873
+ * @returns { transaction, status }
874
+ */
875
+ async getStatus(paymentIntentId) {
876
+ const TransactionModel = this.models.Transaction;
877
+ const transaction = await this._findTransaction(TransactionModel, paymentIntentId);
878
+ if (!transaction) {
879
+ throw new TransactionNotFoundError(paymentIntentId);
880
+ }
881
+ const gatewayType = transaction.gateway?.type ?? "manual";
882
+ const provider = this.providers[gatewayType];
883
+ if (!provider) {
884
+ throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
885
+ }
886
+ let paymentResult = null;
887
+ try {
888
+ paymentResult = await provider.getStatus(paymentIntentId);
889
+ } catch (error) {
890
+ this.logger.warn("Failed to get payment status from provider:", error);
891
+ return {
892
+ transaction,
893
+ status: transaction.status,
894
+ provider: gatewayType
895
+ };
896
+ }
897
+ return {
898
+ transaction,
899
+ paymentResult,
900
+ status: paymentResult.status,
901
+ provider: gatewayType
902
+ };
903
+ }
904
+ /**
905
+ * Refund a payment
906
+ *
907
+ * @param paymentId - Payment intent ID, session ID, or transaction ID
908
+ * @param amount - Amount to refund (optional, full refund if not provided)
909
+ * @param options - Refund options
910
+ * @returns { transaction, refundResult }
911
+ */
912
+ async refund(paymentId, amount = null, options = {}) {
913
+ const { reason = null } = options;
914
+ const TransactionModel = this.models.Transaction;
915
+ const transaction = await this._findTransaction(TransactionModel, paymentId);
916
+ if (!transaction) {
917
+ throw new TransactionNotFoundError(paymentId);
918
+ }
919
+ if (transaction.status !== "verified" && transaction.status !== "completed") {
920
+ throw new RefundError(transaction._id.toString(), "Only verified/completed transactions can be refunded");
921
+ }
922
+ const gatewayType = transaction.gateway?.type ?? "manual";
923
+ const provider = this.providers[gatewayType];
924
+ if (!provider) {
925
+ throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
926
+ }
927
+ const capabilities = provider.getCapabilities();
928
+ if (!capabilities.supportsRefunds) {
929
+ throw new RefundNotSupportedError(gatewayType);
930
+ }
931
+ const refundedSoFar = transaction.refundedAmount ?? 0;
932
+ const refundableAmount = transaction.amount - refundedSoFar;
933
+ const refundAmount = amount ?? refundableAmount;
934
+ if (refundAmount <= 0) {
935
+ throw new ValidationError(`Refund amount must be positive, got ${refundAmount}`);
936
+ }
937
+ if (refundAmount > refundableAmount) {
938
+ throw new ValidationError(
939
+ `Refund amount (${refundAmount}) exceeds refundable balance (${refundableAmount})`,
940
+ { refundAmount, refundableAmount, alreadyRefunded: refundedSoFar }
941
+ );
942
+ }
943
+ let refundResult;
944
+ try {
945
+ refundResult = await provider.refund(paymentId, refundAmount, { reason: reason ?? void 0 });
946
+ } catch (error) {
947
+ this.logger.error("Refund failed:", error);
948
+ throw new RefundError(paymentId, error.message);
949
+ }
950
+ const refundTransactionType = this.config.transactionTypeMapping?.refund ?? TRANSACTION_TYPE.EXPENSE;
951
+ const refundCommission = transaction.commission ? reverseCommission(transaction.commission, transaction.amount, refundAmount) : null;
952
+ const refundTransaction = await TransactionModel.create({
953
+ organizationId: transaction.organizationId,
954
+ customerId: transaction.customerId,
955
+ amount: refundAmount,
956
+ currency: transaction.currency,
957
+ category: transaction.category,
958
+ type: refundTransactionType,
959
+ // EXPENSE - money going out
960
+ method: transaction.method ?? "manual",
961
+ status: "completed",
962
+ gateway: {
963
+ type: transaction.gateway?.type ?? "manual",
964
+ paymentIntentId: refundResult.id,
965
+ provider: refundResult.provider
966
+ },
967
+ paymentDetails: transaction.paymentDetails,
968
+ ...refundCommission && { commission: refundCommission },
969
+ // Reversed commission
970
+ // Polymorphic reference (copy from original transaction)
971
+ ...transaction.referenceId && { referenceId: transaction.referenceId },
972
+ ...transaction.referenceModel && { referenceModel: transaction.referenceModel },
973
+ metadata: {
974
+ ...transaction.metadata,
975
+ isRefund: true,
976
+ originalTransactionId: transaction._id.toString(),
977
+ refundReason: reason,
978
+ refundResult: refundResult.metadata
979
+ },
980
+ idempotencyKey: `refund_${transaction._id}_${Date.now()}`
981
+ });
982
+ const isPartialRefund = refundAmount < transaction.amount;
983
+ transaction.status = isPartialRefund ? "partially_refunded" : "refunded";
984
+ transaction.refundedAmount = (transaction.refundedAmount ?? 0) + refundAmount;
985
+ transaction.refundedAt = refundResult.refundedAt ?? /* @__PURE__ */ new Date();
986
+ transaction.metadata = {
987
+ ...transaction.metadata,
988
+ refundTransactionId: refundTransaction._id.toString(),
989
+ refundReason: reason
990
+ };
991
+ await transaction.save();
992
+ this._triggerHook("payment.refunded", {
993
+ transaction,
994
+ refundTransaction,
995
+ refundResult,
996
+ refundAmount,
997
+ reason,
998
+ isPartialRefund
999
+ });
1000
+ return {
1001
+ transaction,
1002
+ refundTransaction,
1003
+ refundResult,
1004
+ status: transaction.status
1005
+ };
1006
+ }
1007
+ /**
1008
+ * Handle webhook from payment provider
1009
+ *
1010
+ * @param provider - Provider name
1011
+ * @param payload - Webhook payload
1012
+ * @param headers - Request headers
1013
+ * @returns { event, transaction }
1014
+ */
1015
+ async handleWebhook(providerName, payload, headers = {}) {
1016
+ const provider = this.providers[providerName];
1017
+ if (!provider) {
1018
+ throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
1019
+ }
1020
+ const capabilities = provider.getCapabilities();
1021
+ if (!capabilities.supportsWebhooks) {
1022
+ throw new ProviderCapabilityError(providerName, "webhooks");
1023
+ }
1024
+ let webhookEvent;
1025
+ try {
1026
+ webhookEvent = await provider.handleWebhook(payload, headers);
1027
+ } catch (error) {
1028
+ this.logger.error("Webhook processing failed:", error);
1029
+ throw new ProviderError(
1030
+ `Webhook processing failed for ${providerName}: ${error.message}`,
1031
+ "WEBHOOK_PROCESSING_FAILED",
1032
+ { retryable: false }
1033
+ );
1034
+ }
1035
+ if (!webhookEvent?.data?.sessionId && !webhookEvent?.data?.paymentIntentId) {
1036
+ throw new ValidationError(
1037
+ `Invalid webhook event structure from ${providerName}: missing sessionId or paymentIntentId`,
1038
+ { provider: providerName, eventType: webhookEvent?.type }
1039
+ );
1040
+ }
1041
+ const TransactionModel = this.models.Transaction;
1042
+ let transaction = null;
1043
+ if (webhookEvent.data.sessionId) {
1044
+ transaction = await TransactionModel.findOne({
1045
+ "gateway.sessionId": webhookEvent.data.sessionId
1046
+ });
1047
+ }
1048
+ if (!transaction && webhookEvent.data.paymentIntentId) {
1049
+ transaction = await TransactionModel.findOne({
1050
+ "gateway.paymentIntentId": webhookEvent.data.paymentIntentId
1051
+ });
1052
+ }
1053
+ if (!transaction) {
1054
+ this.logger.warn("Transaction not found for webhook event", {
1055
+ provider: providerName,
1056
+ eventId: webhookEvent.id,
1057
+ sessionId: webhookEvent.data.sessionId,
1058
+ paymentIntentId: webhookEvent.data.paymentIntentId
1059
+ });
1060
+ throw new TransactionNotFoundError(
1061
+ webhookEvent.data.sessionId ?? webhookEvent.data.paymentIntentId ?? "unknown"
1062
+ );
1063
+ }
1064
+ if (webhookEvent.data.sessionId && !transaction.gateway?.sessionId) {
1065
+ transaction.gateway = {
1066
+ ...transaction.gateway,
1067
+ type: transaction.gateway?.type ?? "manual",
1068
+ sessionId: webhookEvent.data.sessionId
1069
+ };
1070
+ }
1071
+ if (webhookEvent.data.paymentIntentId && !transaction.gateway?.paymentIntentId) {
1072
+ transaction.gateway = {
1073
+ ...transaction.gateway,
1074
+ type: transaction.gateway?.type ?? "manual",
1075
+ paymentIntentId: webhookEvent.data.paymentIntentId
1076
+ };
1077
+ }
1078
+ if (transaction.webhook?.eventId === webhookEvent.id && transaction.webhook?.processedAt) {
1079
+ this.logger.warn("Webhook already processed", {
1080
+ transactionId: transaction._id,
1081
+ eventId: webhookEvent.id
1082
+ });
1083
+ return {
1084
+ event: webhookEvent,
1085
+ transaction,
1086
+ status: "already_processed"
1087
+ };
1088
+ }
1089
+ transaction.webhook = {
1090
+ eventId: webhookEvent.id,
1091
+ eventType: webhookEvent.type,
1092
+ receivedAt: /* @__PURE__ */ new Date(),
1093
+ processedAt: /* @__PURE__ */ new Date(),
1094
+ data: webhookEvent.data
1095
+ };
1096
+ if (webhookEvent.type === "payment.succeeded") {
1097
+ transaction.status = "verified";
1098
+ transaction.verifiedAt = webhookEvent.createdAt;
1099
+ } else if (webhookEvent.type === "payment.failed") {
1100
+ transaction.status = "failed";
1101
+ } else if (webhookEvent.type === "refund.succeeded") {
1102
+ transaction.status = "refunded";
1103
+ transaction.refundedAt = webhookEvent.createdAt;
1104
+ }
1105
+ await transaction.save();
1106
+ this._triggerHook(`payment.webhook.${webhookEvent.type}`, {
1107
+ event: webhookEvent,
1108
+ transaction
1109
+ });
1110
+ return {
1111
+ event: webhookEvent,
1112
+ transaction,
1113
+ status: "processed"
1114
+ };
1115
+ }
1116
+ /**
1117
+ * List payments/transactions with filters
1118
+ *
1119
+ * @param filters - Query filters
1120
+ * @param options - Query options (limit, skip, sort)
1121
+ * @returns Transactions
1122
+ */
1123
+ async list(filters = {}, options = {}) {
1124
+ const TransactionModel = this.models.Transaction;
1125
+ const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
1126
+ const transactions = await TransactionModel.find(filters).limit(limit).skip(skip).sort(sort);
1127
+ return transactions;
1128
+ }
1129
+ /**
1130
+ * Get payment/transaction by ID
1131
+ *
1132
+ * @param transactionId - Transaction ID
1133
+ * @returns Transaction
1134
+ */
1135
+ async get(transactionId) {
1136
+ const TransactionModel = this.models.Transaction;
1137
+ const transaction = await TransactionModel.findById(transactionId);
1138
+ if (!transaction) {
1139
+ throw new TransactionNotFoundError(transactionId);
1140
+ }
1141
+ return transaction;
1142
+ }
1143
+ /**
1144
+ * Get provider instance
1145
+ *
1146
+ * @param providerName - Provider name
1147
+ * @returns Provider instance
1148
+ */
1149
+ getProvider(providerName) {
1150
+ const provider = this.providers[providerName];
1151
+ if (!provider) {
1152
+ throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
1153
+ }
1154
+ return provider;
1155
+ }
1156
+ /**
1157
+ * Trigger event hook (fire-and-forget, non-blocking)
1158
+ * @private
1159
+ */
1160
+ _triggerHook(event, data) {
1161
+ triggerHook(this.hooks, event, data, this.logger);
1162
+ }
1163
+ /**
1164
+ * Find transaction by sessionId, paymentIntentId, or transaction ID
1165
+ * @private
1166
+ */
1167
+ async _findTransaction(TransactionModel, identifier) {
1168
+ let transaction = await TransactionModel.findOne({
1169
+ "gateway.sessionId": identifier
1170
+ });
1171
+ if (!transaction) {
1172
+ transaction = await TransactionModel.findOne({
1173
+ "gateway.paymentIntentId": identifier
1174
+ });
1175
+ }
1176
+ if (!transaction) {
1177
+ transaction = await TransactionModel.findById(identifier);
1178
+ }
1179
+ return transaction;
1180
+ }
1181
+ };
1182
+
1183
+ // src/services/transaction.service.ts
1184
+ var TransactionService = class {
1185
+ models;
1186
+ hooks;
1187
+ logger;
1188
+ constructor(container) {
1189
+ this.models = container.get("models");
1190
+ this.hooks = container.get("hooks");
1191
+ this.logger = container.get("logger");
1192
+ }
1193
+ /**
1194
+ * Get transaction by ID
1195
+ *
1196
+ * @param transactionId - Transaction ID
1197
+ * @returns Transaction
1198
+ */
1199
+ async get(transactionId) {
1200
+ const TransactionModel = this.models.Transaction;
1201
+ const transaction = await TransactionModel.findById(transactionId);
1202
+ if (!transaction) {
1203
+ throw new TransactionNotFoundError(transactionId);
1204
+ }
1205
+ return transaction;
1206
+ }
1207
+ /**
1208
+ * List transactions with filters
1209
+ *
1210
+ * @param filters - Query filters
1211
+ * @param options - Query options (limit, skip, sort, populate)
1212
+ * @returns { transactions, total, page, limit }
1213
+ */
1214
+ async list(filters = {}, options = {}) {
1215
+ const TransactionModel = this.models.Transaction;
1216
+ const {
1217
+ limit = 50,
1218
+ skip = 0,
1219
+ page = null,
1220
+ sort = { createdAt: -1 },
1221
+ populate = []
1222
+ } = options;
1223
+ const actualSkip = page ? (page - 1) * limit : skip;
1224
+ let query = TransactionModel.find(filters).limit(limit).skip(actualSkip).sort(sort);
1225
+ if (populate.length > 0 && typeof query.populate === "function") {
1226
+ populate.forEach((field) => {
1227
+ query = query.populate(field);
1228
+ });
1229
+ }
1230
+ const transactions = await query;
1231
+ const model = TransactionModel;
1232
+ const total = await (model.countDocuments ? model.countDocuments(filters) : model.count?.(filters)) ?? 0;
1233
+ return {
1234
+ transactions,
1235
+ total,
1236
+ page: page ?? Math.floor(actualSkip / limit) + 1,
1237
+ limit,
1238
+ pages: Math.ceil(total / limit)
1239
+ };
1240
+ }
1241
+ /**
1242
+ * Update transaction
1243
+ *
1244
+ * @param transactionId - Transaction ID
1245
+ * @param updates - Fields to update
1246
+ * @returns Updated transaction
1247
+ */
1248
+ async update(transactionId, updates) {
1249
+ const TransactionModel = this.models.Transaction;
1250
+ const model = TransactionModel;
1251
+ let transaction;
1252
+ if (typeof model.update === "function") {
1253
+ transaction = await model.update(transactionId, updates);
1254
+ } else if (typeof model.findByIdAndUpdate === "function") {
1255
+ transaction = await model.findByIdAndUpdate(
1256
+ transactionId,
1257
+ { $set: updates },
1258
+ { new: true }
1259
+ );
1260
+ } else {
1261
+ throw new Error("Transaction model does not support update operations");
1262
+ }
1263
+ if (!transaction) {
1264
+ throw new TransactionNotFoundError(transactionId);
1265
+ }
1266
+ this._triggerHook("transaction.updated", {
1267
+ transaction,
1268
+ updates
1269
+ });
1270
+ return transaction;
1271
+ }
1272
+ /**
1273
+ * Trigger event hook (fire-and-forget, non-blocking)
1274
+ * @private
1275
+ */
1276
+ _triggerHook(event, data) {
1277
+ triggerHook(this.hooks, event, data, this.logger);
1278
+ }
1279
+ };
1280
+
1281
+ // src/enums/escrow.enums.ts
1282
+ var HOLD_STATUS = {
1283
+ HELD: "held",
1284
+ RELEASED: "released",
1285
+ CANCELLED: "cancelled",
1286
+ PARTIALLY_RELEASED: "partially_released"
1287
+ };
1288
+ var RELEASE_REASON = {
1289
+ PAYMENT_VERIFIED: "payment_verified"};
1290
+ var HOLD_REASON = {
1291
+ PAYMENT_VERIFICATION: "payment_verification"};
1292
+
1293
+ // src/enums/split.enums.ts
1294
+ var SPLIT_TYPE = {
1295
+ CUSTOM: "custom"
1296
+ };
1297
+ var SPLIT_STATUS = {
1298
+ PENDING: "pending",
1299
+ PAID: "paid"};
1300
+
1301
+ // src/utils/commission-split.ts
1302
+ function calculateSplits(amount, splitRules = [], gatewayFeeRate = 0) {
1303
+ if (!splitRules || splitRules.length === 0) {
1304
+ return [];
1305
+ }
1306
+ if (amount < 0) {
1307
+ throw new Error("Transaction amount cannot be negative");
1308
+ }
1309
+ if (gatewayFeeRate < 0 || gatewayFeeRate > 1) {
1310
+ throw new Error("Gateway fee rate must be between 0 and 1");
1311
+ }
1312
+ const totalRate = splitRules.reduce((sum, rule) => sum + rule.rate, 0);
1313
+ if (totalRate > 1) {
1314
+ throw new Error(`Total split rate (${totalRate}) cannot exceed 1.0`);
1315
+ }
1316
+ return splitRules.map((rule, index) => {
1317
+ if (rule.rate < 0 || rule.rate > 1) {
1318
+ throw new Error(`Split rate must be between 0 and 1 for split ${index}`);
1319
+ }
1320
+ const grossAmount = Math.round(amount * rule.rate * 100) / 100;
1321
+ const gatewayFeeAmount = index === 0 && gatewayFeeRate > 0 ? Math.round(amount * gatewayFeeRate * 100) / 100 : 0;
1322
+ const netAmount = Math.max(0, Math.round((grossAmount - gatewayFeeAmount) * 100) / 100);
1323
+ return {
1324
+ type: rule.type ?? SPLIT_TYPE.CUSTOM,
1325
+ recipientId: rule.recipientId,
1326
+ recipientType: rule.recipientType,
1327
+ rate: rule.rate,
1328
+ grossAmount,
1329
+ gatewayFeeRate: gatewayFeeAmount > 0 ? gatewayFeeRate : 0,
1330
+ gatewayFeeAmount,
1331
+ netAmount,
1332
+ status: SPLIT_STATUS.PENDING,
1333
+ dueDate: rule.dueDate ?? null,
1334
+ metadata: rule.metadata ?? {}
1335
+ };
1336
+ });
1337
+ }
1338
+ function calculateOrganizationPayout(amount, splits = []) {
1339
+ const totalSplitAmount = splits.reduce((sum, split) => sum + split.grossAmount, 0);
1340
+ return Math.max(0, Math.round((amount - totalSplitAmount) * 100) / 100);
1341
+ }
1342
+
1343
+ // src/services/escrow.service.ts
1344
+ var EscrowService = class {
1345
+ models;
1346
+ hooks;
1347
+ logger;
1348
+ constructor(container) {
1349
+ this.models = container.get("models");
1350
+ this.hooks = container.get("hooks");
1351
+ this.logger = container.get("logger");
1352
+ }
1353
+ /**
1354
+ * Hold funds in escrow
1355
+ *
1356
+ * @param transactionId - Transaction to hold
1357
+ * @param options - Hold options
1358
+ * @returns Updated transaction
1359
+ */
1360
+ async hold(transactionId, options = {}) {
1361
+ const {
1362
+ reason = HOLD_REASON.PAYMENT_VERIFICATION,
1363
+ holdUntil = null,
1364
+ metadata = {}
1365
+ } = options;
1366
+ const TransactionModel = this.models.Transaction;
1367
+ const transaction = await TransactionModel.findById(transactionId);
1368
+ if (!transaction) {
1369
+ throw new TransactionNotFoundError(transactionId);
1370
+ }
1371
+ if (transaction.status !== TRANSACTION_STATUS.VERIFIED) {
1372
+ throw new Error(`Cannot hold transaction with status: ${transaction.status}. Must be verified.`);
1373
+ }
1374
+ transaction.hold = {
1375
+ status: HOLD_STATUS.HELD,
1376
+ heldAmount: transaction.amount,
1377
+ releasedAmount: 0,
1378
+ reason,
1379
+ heldAt: /* @__PURE__ */ new Date(),
1380
+ ...holdUntil && { holdUntil },
1381
+ releases: [],
1382
+ metadata
1383
+ };
1384
+ await transaction.save();
1385
+ this._triggerHook("escrow.held", {
1386
+ transaction,
1387
+ heldAmount: transaction.amount,
1388
+ reason
1389
+ });
1390
+ return transaction;
1391
+ }
1392
+ /**
1393
+ * Release funds from escrow to recipient
1394
+ *
1395
+ * @param transactionId - Transaction to release
1396
+ * @param options - Release options
1397
+ * @returns { transaction, releaseTransaction }
1398
+ */
1399
+ async release(transactionId, options) {
1400
+ const {
1401
+ amount = null,
1402
+ recipientId,
1403
+ recipientType = "organization",
1404
+ reason = RELEASE_REASON.PAYMENT_VERIFIED,
1405
+ releasedBy = null,
1406
+ createTransaction = true,
1407
+ metadata = {}
1408
+ } = options;
1409
+ const TransactionModel = this.models.Transaction;
1410
+ const transaction = await TransactionModel.findById(transactionId);
1411
+ if (!transaction) {
1412
+ throw new TransactionNotFoundError(transactionId);
1413
+ }
1414
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
1415
+ throw new Error(`Transaction is not in held status. Current: ${transaction.hold?.status ?? "none"}`);
1416
+ }
1417
+ if (!recipientId) {
1418
+ throw new Error("recipientId is required for release");
1419
+ }
1420
+ const releaseAmount = amount ?? transaction.hold.heldAmount - transaction.hold.releasedAmount;
1421
+ const availableAmount = transaction.hold.heldAmount - transaction.hold.releasedAmount;
1422
+ if (releaseAmount > availableAmount) {
1423
+ throw new Error(`Release amount (${releaseAmount}) exceeds available held amount (${availableAmount})`);
1424
+ }
1425
+ const releaseRecord = {
1426
+ amount: releaseAmount,
1427
+ recipientId,
1428
+ recipientType,
1429
+ releasedAt: /* @__PURE__ */ new Date(),
1430
+ releasedBy,
1431
+ reason,
1432
+ metadata
1433
+ };
1434
+ transaction.hold.releases.push(releaseRecord);
1435
+ transaction.hold.releasedAmount += releaseAmount;
1436
+ const isFullRelease = transaction.hold.releasedAmount >= transaction.hold.heldAmount;
1437
+ const isPartialRelease = transaction.hold.releasedAmount > 0 && transaction.hold.releasedAmount < transaction.hold.heldAmount;
1438
+ if (isFullRelease) {
1439
+ transaction.hold.status = HOLD_STATUS.RELEASED;
1440
+ transaction.hold.releasedAt = /* @__PURE__ */ new Date();
1441
+ transaction.status = TRANSACTION_STATUS.COMPLETED;
1442
+ } else if (isPartialRelease) {
1443
+ transaction.hold.status = HOLD_STATUS.PARTIALLY_RELEASED;
1444
+ }
1445
+ await transaction.save();
1446
+ let releaseTransaction = null;
1447
+ if (createTransaction) {
1448
+ releaseTransaction = await TransactionModel.create({
1449
+ organizationId: transaction.organizationId,
1450
+ customerId: recipientId,
1451
+ amount: releaseAmount,
1452
+ currency: transaction.currency,
1453
+ category: transaction.category,
1454
+ type: TRANSACTION_TYPE.INCOME,
1455
+ method: transaction.method,
1456
+ status: TRANSACTION_STATUS.COMPLETED,
1457
+ gateway: transaction.gateway,
1458
+ referenceId: transaction.referenceId,
1459
+ referenceModel: transaction.referenceModel,
1460
+ metadata: {
1461
+ ...metadata,
1462
+ isRelease: true,
1463
+ heldTransactionId: transaction._id.toString(),
1464
+ releaseReason: reason,
1465
+ recipientType
1466
+ },
1467
+ idempotencyKey: `release_${transaction._id}_${Date.now()}`
1468
+ });
1469
+ }
1470
+ this._triggerHook("escrow.released", {
1471
+ transaction,
1472
+ releaseTransaction,
1473
+ releaseAmount,
1474
+ recipientId,
1475
+ recipientType,
1476
+ reason,
1477
+ isFullRelease,
1478
+ isPartialRelease
1479
+ });
1480
+ return {
1481
+ transaction,
1482
+ releaseTransaction,
1483
+ releaseAmount,
1484
+ isFullRelease,
1485
+ isPartialRelease
1486
+ };
1487
+ }
1488
+ /**
1489
+ * Cancel hold and release back to customer
1490
+ *
1491
+ * @param transactionId - Transaction to cancel hold
1492
+ * @param options - Cancel options
1493
+ * @returns Updated transaction
1494
+ */
1495
+ async cancel(transactionId, options = {}) {
1496
+ const { reason = "Hold cancelled", metadata = {} } = options;
1497
+ const TransactionModel = this.models.Transaction;
1498
+ const transaction = await TransactionModel.findById(transactionId);
1499
+ if (!transaction) {
1500
+ throw new TransactionNotFoundError(transactionId);
1501
+ }
1502
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
1503
+ throw new Error(`Transaction is not in held status. Current: ${transaction.hold?.status ?? "none"}`);
1504
+ }
1505
+ transaction.hold.status = HOLD_STATUS.CANCELLED;
1506
+ transaction.hold.cancelledAt = /* @__PURE__ */ new Date();
1507
+ transaction.hold.metadata = {
1508
+ ...transaction.hold.metadata,
1509
+ ...metadata,
1510
+ cancelReason: reason
1511
+ };
1512
+ transaction.status = TRANSACTION_STATUS.CANCELLED;
1513
+ await transaction.save();
1514
+ this._triggerHook("escrow.cancelled", {
1515
+ transaction,
1516
+ reason
1517
+ });
1518
+ return transaction;
1519
+ }
1520
+ /**
1521
+ * Split payment to multiple recipients
1522
+ * Deducts splits from held amount and releases remainder to organization
1523
+ *
1524
+ * @param transactionId - Transaction to split
1525
+ * @param splitRules - Split configuration
1526
+ * @returns { transaction, splitTransactions, organizationTransaction }
1527
+ */
1528
+ async split(transactionId, splitRules = []) {
1529
+ const TransactionModel = this.models.Transaction;
1530
+ const transaction = await TransactionModel.findById(transactionId);
1531
+ if (!transaction) {
1532
+ throw new TransactionNotFoundError(transactionId);
1533
+ }
1534
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
1535
+ throw new Error(`Transaction must be held before splitting. Current: ${transaction.hold?.status ?? "none"}`);
1536
+ }
1537
+ if (!splitRules || splitRules.length === 0) {
1538
+ throw new Error("splitRules cannot be empty");
1539
+ }
1540
+ const splits = calculateSplits(
1541
+ transaction.amount,
1542
+ splitRules,
1543
+ transaction.commission?.gatewayFeeRate ?? 0
1544
+ );
1545
+ transaction.splits = splits;
1546
+ await transaction.save();
1547
+ const splitTransactions = [];
1548
+ for (const split of splits) {
1549
+ const splitTransaction = await TransactionModel.create({
1550
+ organizationId: transaction.organizationId,
1551
+ customerId: split.recipientId,
1552
+ amount: split.netAmount,
1553
+ currency: transaction.currency,
1554
+ category: split.type,
1555
+ type: TRANSACTION_TYPE.EXPENSE,
1556
+ method: transaction.method,
1557
+ status: TRANSACTION_STATUS.COMPLETED,
1558
+ gateway: transaction.gateway,
1559
+ referenceId: transaction.referenceId,
1560
+ referenceModel: transaction.referenceModel,
1561
+ metadata: {
1562
+ isSplit: true,
1563
+ splitType: split.type,
1564
+ recipientType: split.recipientType,
1565
+ originalTransactionId: transaction._id.toString(),
1566
+ grossAmount: split.grossAmount,
1567
+ gatewayFeeAmount: split.gatewayFeeAmount
1568
+ },
1569
+ idempotencyKey: `split_${transaction._id}_${split.recipientId}_${Date.now()}`
1570
+ });
1571
+ split.payoutTransactionId = splitTransaction._id.toString();
1572
+ split.status = SPLIT_STATUS.PAID;
1573
+ split.paidDate = /* @__PURE__ */ new Date();
1574
+ splitTransactions.push(splitTransaction);
1575
+ }
1576
+ await transaction.save();
1577
+ const organizationPayout = calculateOrganizationPayout(transaction.amount, splits);
1578
+ const organizationTransaction = await this.release(transactionId, {
1579
+ amount: organizationPayout,
1580
+ recipientId: transaction.organizationId?.toString() ?? "",
1581
+ recipientType: "organization",
1582
+ reason: RELEASE_REASON.PAYMENT_VERIFIED,
1583
+ createTransaction: true,
1584
+ metadata: {
1585
+ afterSplits: true,
1586
+ totalSplits: splits.length,
1587
+ totalSplitAmount: transaction.amount - organizationPayout
1588
+ }
1589
+ });
1590
+ this._triggerHook("escrow.split", {
1591
+ transaction,
1592
+ splits,
1593
+ splitTransactions,
1594
+ organizationTransaction: organizationTransaction.releaseTransaction,
1595
+ organizationPayout
1596
+ });
1597
+ return {
1598
+ transaction,
1599
+ splits,
1600
+ splitTransactions,
1601
+ organizationTransaction: organizationTransaction.releaseTransaction,
1602
+ organizationPayout
1603
+ };
1604
+ }
1605
+ /**
1606
+ * Get escrow status
1607
+ *
1608
+ * @param transactionId - Transaction ID
1609
+ * @returns Escrow status
1610
+ */
1611
+ async getStatus(transactionId) {
1612
+ const TransactionModel = this.models.Transaction;
1613
+ const transaction = await TransactionModel.findById(transactionId);
1614
+ if (!transaction) {
1615
+ throw new TransactionNotFoundError(transactionId);
1616
+ }
1617
+ return {
1618
+ transaction,
1619
+ hold: transaction.hold ?? null,
1620
+ splits: transaction.splits ?? [],
1621
+ hasHold: !!transaction.hold,
1622
+ hasSplits: transaction.splits ? transaction.splits.length > 0 : false
1623
+ };
1624
+ }
1625
+ _triggerHook(event, data) {
1626
+ triggerHook(this.hooks, event, data, this.logger);
1627
+ }
1628
+ };
1629
+
1630
+ export { EscrowService, MonetizationService, PaymentService, TransactionService };
1631
+ //# sourceMappingURL=index.js.map
1632
+ //# sourceMappingURL=index.js.map