@classytic/revenue 0.0.24 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +131 -19
  2. package/core/builder.js +39 -0
  3. package/dist/types/core/builder.d.ts +87 -0
  4. package/dist/types/core/container.d.ts +57 -0
  5. package/dist/types/core/errors.d.ts +122 -0
  6. package/dist/types/enums/escrow.enums.d.ts +24 -0
  7. package/dist/types/enums/index.d.ts +69 -0
  8. package/dist/types/enums/monetization.enums.d.ts +6 -0
  9. package/dist/types/enums/payment.enums.d.ts +16 -0
  10. package/dist/types/enums/split.enums.d.ts +25 -0
  11. package/dist/types/enums/subscription.enums.d.ts +15 -0
  12. package/dist/types/enums/transaction.enums.d.ts +24 -0
  13. package/dist/types/index.d.ts +22 -0
  14. package/dist/types/providers/base.d.ts +126 -0
  15. package/dist/types/schemas/escrow/hold.schema.d.ts +54 -0
  16. package/dist/types/schemas/escrow/index.d.ts +6 -0
  17. package/dist/types/schemas/index.d.ts +506 -0
  18. package/dist/types/schemas/split/index.d.ts +8 -0
  19. package/dist/types/schemas/split/split.schema.d.ts +142 -0
  20. package/dist/types/schemas/subscription/index.d.ts +152 -0
  21. package/dist/types/schemas/subscription/info.schema.d.ts +128 -0
  22. package/dist/types/schemas/subscription/plan.schema.d.ts +39 -0
  23. package/dist/types/schemas/transaction/common.schema.d.ts +12 -0
  24. package/dist/types/schemas/transaction/gateway.schema.d.ts +86 -0
  25. package/dist/types/schemas/transaction/index.d.ts +202 -0
  26. package/dist/types/schemas/transaction/payment.schema.d.ts +145 -0
  27. package/dist/types/services/escrow.service.d.ts +51 -0
  28. package/dist/types/services/payment.service.d.ts +80 -0
  29. package/dist/types/services/subscription.service.d.ts +138 -0
  30. package/dist/types/services/transaction.service.d.ts +40 -0
  31. package/dist/types/utils/category-resolver.d.ts +46 -0
  32. package/dist/types/utils/commission-split.d.ts +56 -0
  33. package/dist/types/utils/commission.d.ts +29 -0
  34. package/dist/types/utils/hooks.d.ts +17 -0
  35. package/dist/types/utils/index.d.ts +6 -0
  36. package/dist/types/utils/logger.d.ts +12 -0
  37. package/dist/types/utils/subscription/actions.d.ts +28 -0
  38. package/dist/types/utils/subscription/index.d.ts +2 -0
  39. package/dist/types/utils/subscription/period.d.ts +47 -0
  40. package/dist/types/utils/transaction-type.d.ts +102 -0
  41. package/enums/escrow.enums.js +36 -0
  42. package/enums/index.js +36 -0
  43. package/enums/split.enums.js +37 -0
  44. package/index.js +6 -0
  45. package/package.json +91 -74
  46. package/schemas/escrow/hold.schema.js +62 -0
  47. package/schemas/escrow/index.js +15 -0
  48. package/schemas/index.js +6 -0
  49. package/schemas/split/index.js +16 -0
  50. package/schemas/split/split.schema.js +86 -0
  51. package/services/escrow.service.js +353 -0
  52. package/services/payment.service.js +54 -3
  53. package/services/subscription.service.js +15 -9
  54. package/utils/commission-split.js +180 -0
  55. package/utils/index.js +6 -0
  56. package/revenue.d.ts +0 -350
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Escrow Service
3
+ * @classytic/revenue
4
+ *
5
+ * Platform-as-intermediary payment flow
6
+ * Hold funds → Verify → Split/Deduct → Release to organization
7
+ */
8
+
9
+ import { TransactionNotFoundError } from '../core/errors.js';
10
+ import { HOLD_STATUS, RELEASE_REASON, HOLD_REASON } from '../enums/escrow.enums.js';
11
+ import { TRANSACTION_TYPE, TRANSACTION_STATUS } from '../enums/transaction.enums.js';
12
+ import { SPLIT_STATUS } from '../enums/split.enums.js';
13
+ import { triggerHook } from '../utils/hooks.js';
14
+ import { calculateSplits, calculateOrganizationPayout } from '../utils/commission-split.js';
15
+
16
+ export class EscrowService {
17
+ constructor(container) {
18
+ this.container = container;
19
+ this.models = container.get('models');
20
+ this.providers = container.get('providers');
21
+ this.config = container.get('config');
22
+ this.hooks = container.get('hooks');
23
+ this.logger = container.get('logger');
24
+ }
25
+
26
+ /**
27
+ * Hold funds in escrow
28
+ *
29
+ * @param {String} transactionId - Transaction to hold
30
+ * @param {Object} options - Hold options
31
+ * @returns {Promise<Object>} Updated transaction
32
+ */
33
+ async hold(transactionId, options = {}) {
34
+ const {
35
+ reason = HOLD_REASON.PAYMENT_VERIFICATION,
36
+ holdUntil = null,
37
+ metadata = {},
38
+ } = options;
39
+
40
+ const TransactionModel = this.models.Transaction;
41
+ const transaction = await TransactionModel.findById(transactionId);
42
+
43
+ if (!transaction) {
44
+ throw new TransactionNotFoundError(transactionId);
45
+ }
46
+
47
+ if (transaction.status !== TRANSACTION_STATUS.VERIFIED) {
48
+ throw new Error(`Cannot hold transaction with status: ${transaction.status}. Must be verified.`);
49
+ }
50
+
51
+ transaction.hold = {
52
+ status: HOLD_STATUS.HELD,
53
+ heldAmount: transaction.amount,
54
+ releasedAmount: 0,
55
+ reason,
56
+ heldAt: new Date(),
57
+ ...(holdUntil && { holdUntil }),
58
+ releases: [],
59
+ metadata,
60
+ };
61
+
62
+ await transaction.save();
63
+
64
+ this._triggerHook('escrow.held', {
65
+ transaction,
66
+ heldAmount: transaction.amount,
67
+ reason,
68
+ });
69
+
70
+ return transaction;
71
+ }
72
+
73
+ /**
74
+ * Release funds from escrow to recipient
75
+ *
76
+ * @param {String} transactionId - Transaction to release
77
+ * @param {Object} options - Release options
78
+ * @returns {Promise<Object>} { transaction, releaseTransaction }
79
+ */
80
+ async release(transactionId, options = {}) {
81
+ const {
82
+ amount = null,
83
+ recipientId,
84
+ recipientType = 'organization',
85
+ reason = RELEASE_REASON.PAYMENT_VERIFIED,
86
+ releasedBy = null,
87
+ createTransaction = true,
88
+ metadata = {},
89
+ } = options;
90
+
91
+ const TransactionModel = this.models.Transaction;
92
+ const transaction = await TransactionModel.findById(transactionId);
93
+
94
+ if (!transaction) {
95
+ throw new TransactionNotFoundError(transactionId);
96
+ }
97
+
98
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
99
+ throw new Error(`Transaction is not in held status. Current: ${transaction.hold?.status || 'none'}`);
100
+ }
101
+
102
+ if (!recipientId) {
103
+ throw new Error('recipientId is required for release');
104
+ }
105
+
106
+ const releaseAmount = amount || (transaction.hold.heldAmount - transaction.hold.releasedAmount);
107
+ const availableAmount = transaction.hold.heldAmount - transaction.hold.releasedAmount;
108
+
109
+ if (releaseAmount > availableAmount) {
110
+ throw new Error(`Release amount (${releaseAmount}) exceeds available held amount (${availableAmount})`);
111
+ }
112
+
113
+ const releaseRecord = {
114
+ amount: releaseAmount,
115
+ recipientId,
116
+ recipientType,
117
+ releasedAt: new Date(),
118
+ releasedBy,
119
+ reason,
120
+ metadata,
121
+ };
122
+
123
+ transaction.hold.releases.push(releaseRecord);
124
+ transaction.hold.releasedAmount += releaseAmount;
125
+
126
+ const isFullRelease = transaction.hold.releasedAmount >= transaction.hold.heldAmount;
127
+ const isPartialRelease = transaction.hold.releasedAmount > 0 && transaction.hold.releasedAmount < transaction.hold.heldAmount;
128
+
129
+ if (isFullRelease) {
130
+ transaction.hold.status = HOLD_STATUS.RELEASED;
131
+ transaction.hold.releasedAt = new Date();
132
+ transaction.status = TRANSACTION_STATUS.COMPLETED;
133
+ } else if (isPartialRelease) {
134
+ transaction.hold.status = HOLD_STATUS.PARTIALLY_RELEASED;
135
+ }
136
+
137
+ await transaction.save();
138
+
139
+ let releaseTransaction = null;
140
+ if (createTransaction) {
141
+ releaseTransaction = await TransactionModel.create({
142
+ organizationId: transaction.organizationId,
143
+ customerId: recipientId,
144
+ amount: releaseAmount,
145
+ currency: transaction.currency,
146
+ category: transaction.category,
147
+ type: TRANSACTION_TYPE.INCOME,
148
+ method: transaction.method,
149
+ status: TRANSACTION_STATUS.COMPLETED,
150
+ gateway: transaction.gateway,
151
+ referenceId: transaction.referenceId,
152
+ referenceModel: transaction.referenceModel,
153
+ metadata: {
154
+ ...metadata,
155
+ isRelease: true,
156
+ heldTransactionId: transaction._id.toString(),
157
+ releaseReason: reason,
158
+ recipientType,
159
+ },
160
+ idempotencyKey: `release_${transaction._id}_${Date.now()}`,
161
+ });
162
+ }
163
+
164
+ this._triggerHook('escrow.released', {
165
+ transaction,
166
+ releaseTransaction,
167
+ releaseAmount,
168
+ recipientId,
169
+ recipientType,
170
+ reason,
171
+ isFullRelease,
172
+ isPartialRelease,
173
+ });
174
+
175
+ return {
176
+ transaction,
177
+ releaseTransaction,
178
+ releaseAmount,
179
+ isFullRelease,
180
+ isPartialRelease,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Cancel hold and release back to customer
186
+ *
187
+ * @param {String} transactionId - Transaction to cancel hold
188
+ * @param {Object} options - Cancel options
189
+ * @returns {Promise<Object>} Updated transaction
190
+ */
191
+ async cancel(transactionId, options = {}) {
192
+ const { reason = 'Hold cancelled', metadata = {} } = options;
193
+
194
+ const TransactionModel = this.models.Transaction;
195
+ const transaction = await TransactionModel.findById(transactionId);
196
+
197
+ if (!transaction) {
198
+ throw new TransactionNotFoundError(transactionId);
199
+ }
200
+
201
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
202
+ throw new Error(`Transaction is not in held status. Current: ${transaction.hold?.status || 'none'}`);
203
+ }
204
+
205
+ transaction.hold.status = HOLD_STATUS.CANCELLED;
206
+ transaction.hold.cancelledAt = new Date();
207
+ transaction.hold.metadata = {
208
+ ...transaction.hold.metadata,
209
+ ...metadata,
210
+ cancelReason: reason,
211
+ };
212
+
213
+ transaction.status = TRANSACTION_STATUS.CANCELLED;
214
+
215
+ await transaction.save();
216
+
217
+ this._triggerHook('escrow.cancelled', {
218
+ transaction,
219
+ reason,
220
+ });
221
+
222
+ return transaction;
223
+ }
224
+
225
+ /**
226
+ * Split payment to multiple recipients
227
+ * Deducts splits from held amount and releases remainder to organization
228
+ *
229
+ * @param {String} transactionId - Transaction to split
230
+ * @param {Array} splitRules - Split configuration
231
+ * @returns {Promise<Object>} { transaction, splitTransactions, organizationTransaction }
232
+ */
233
+ async split(transactionId, splitRules = []) {
234
+ const TransactionModel = this.models.Transaction;
235
+ const transaction = await TransactionModel.findById(transactionId);
236
+
237
+ if (!transaction) {
238
+ throw new TransactionNotFoundError(transactionId);
239
+ }
240
+
241
+ if (!transaction.hold || transaction.hold.status !== HOLD_STATUS.HELD) {
242
+ throw new Error(`Transaction must be held before splitting. Current: ${transaction.hold?.status || 'none'}`);
243
+ }
244
+
245
+ if (!splitRules || splitRules.length === 0) {
246
+ throw new Error('splitRules cannot be empty');
247
+ }
248
+
249
+ const splits = calculateSplits(
250
+ transaction.amount,
251
+ splitRules,
252
+ transaction.commission?.gatewayFeeRate || 0
253
+ );
254
+
255
+ transaction.splits = splits;
256
+ await transaction.save();
257
+
258
+ const splitTransactions = [];
259
+
260
+ for (const split of splits) {
261
+ const splitTransaction = await TransactionModel.create({
262
+ organizationId: transaction.organizationId,
263
+ customerId: split.recipientId,
264
+ amount: split.netAmount,
265
+ currency: transaction.currency,
266
+ category: split.type,
267
+ type: TRANSACTION_TYPE.EXPENSE,
268
+ method: transaction.method,
269
+ status: TRANSACTION_STATUS.COMPLETED,
270
+ gateway: transaction.gateway,
271
+ referenceId: transaction.referenceId,
272
+ referenceModel: transaction.referenceModel,
273
+ metadata: {
274
+ isSplit: true,
275
+ splitType: split.type,
276
+ recipientType: split.recipientType,
277
+ originalTransactionId: transaction._id.toString(),
278
+ grossAmount: split.grossAmount,
279
+ gatewayFeeAmount: split.gatewayFeeAmount,
280
+ },
281
+ idempotencyKey: `split_${transaction._id}_${split.recipientId}_${Date.now()}`,
282
+ });
283
+
284
+ split.payoutTransactionId = splitTransaction._id.toString();
285
+ split.status = SPLIT_STATUS.PAID;
286
+ split.paidDate = new Date();
287
+
288
+ splitTransactions.push(splitTransaction);
289
+ }
290
+
291
+ await transaction.save();
292
+
293
+ const organizationPayout = calculateOrganizationPayout(transaction.amount, splits);
294
+
295
+ const organizationTransaction = await this.release(transactionId, {
296
+ amount: organizationPayout,
297
+ recipientId: transaction.organizationId,
298
+ recipientType: 'organization',
299
+ reason: RELEASE_REASON.PAYMENT_VERIFIED,
300
+ createTransaction: true,
301
+ metadata: {
302
+ afterSplits: true,
303
+ totalSplits: splits.length,
304
+ totalSplitAmount: transaction.amount - organizationPayout,
305
+ },
306
+ });
307
+
308
+ this._triggerHook('escrow.split', {
309
+ transaction,
310
+ splits,
311
+ splitTransactions,
312
+ organizationTransaction: organizationTransaction.releaseTransaction,
313
+ organizationPayout,
314
+ });
315
+
316
+ return {
317
+ transaction,
318
+ splits,
319
+ splitTransactions,
320
+ organizationTransaction: organizationTransaction.releaseTransaction,
321
+ organizationPayout,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Get escrow status
327
+ *
328
+ * @param {String} transactionId - Transaction ID
329
+ * @returns {Promise<Object>} Escrow status
330
+ */
331
+ async getStatus(transactionId) {
332
+ const TransactionModel = this.models.Transaction;
333
+ const transaction = await TransactionModel.findById(transactionId);
334
+
335
+ if (!transaction) {
336
+ throw new TransactionNotFoundError(transactionId);
337
+ }
338
+
339
+ return {
340
+ transaction,
341
+ hold: transaction.hold || null,
342
+ splits: transaction.splits || [],
343
+ hasHold: !!transaction.hold,
344
+ hasSplits: transaction.splits && transaction.splits.length > 0,
345
+ };
346
+ }
347
+
348
+ _triggerHook(event, data) {
349
+ triggerHook(this.hooks, event, data, this.logger);
350
+ }
351
+ }
352
+
353
+ export default EscrowService;
@@ -9,11 +9,13 @@
9
9
  import {
10
10
  TransactionNotFoundError,
11
11
  ProviderNotFoundError,
12
+ ProviderError,
12
13
  AlreadyVerifiedError,
13
14
  PaymentVerificationError,
14
15
  RefundNotSupportedError,
15
16
  RefundError,
16
17
  ProviderCapabilityError,
18
+ ValidationError,
17
19
  } from '../core/errors.js';
18
20
  import { triggerHook } from '../utils/hooks.js';
19
21
  import { reverseCommission } from '../utils/commission.js';
@@ -90,6 +92,21 @@ export class PaymentService {
90
92
  throw new PaymentVerificationError(paymentIntentId, error.message);
91
93
  }
92
94
 
95
+ // Validate amount and currency match
96
+ if (paymentResult.amount && paymentResult.amount !== transaction.amount) {
97
+ throw new ValidationError(
98
+ `Amount mismatch: expected ${transaction.amount}, got ${paymentResult.amount}`,
99
+ { expected: transaction.amount, actual: paymentResult.amount }
100
+ );
101
+ }
102
+
103
+ if (paymentResult.currency && paymentResult.currency.toUpperCase() !== transaction.currency.toUpperCase()) {
104
+ throw new ValidationError(
105
+ `Currency mismatch: expected ${transaction.currency}, got ${paymentResult.currency}`,
106
+ { expected: transaction.currency, actual: paymentResult.currency }
107
+ );
108
+ }
109
+
93
110
  // Update transaction based on verification result
94
111
  transaction.status = paymentResult.status === 'succeeded' ? 'verified' : paymentResult.status;
95
112
  transaction.verifiedAt = paymentResult.paidAt || new Date();
@@ -111,7 +128,7 @@ export class PaymentService {
111
128
  return {
112
129
  transaction,
113
130
  paymentResult,
114
- status: 'verified',
131
+ status: transaction.status,
115
132
  };
116
133
  }
117
134
 
@@ -212,8 +229,24 @@ export class PaymentService {
212
229
  throw new RefundNotSupportedError(gatewayType);
213
230
  }
214
231
 
232
+ // Calculate refundable amount
233
+ const refundedSoFar = transaction.refundedAmount || 0;
234
+ const refundableAmount = transaction.amount - refundedSoFar;
235
+ const refundAmount = amount || refundableAmount;
236
+
237
+ // Validate refund amount
238
+ if (refundAmount <= 0) {
239
+ throw new ValidationError(`Refund amount must be positive, got ${refundAmount}`);
240
+ }
241
+
242
+ if (refundAmount > refundableAmount) {
243
+ throw new ValidationError(
244
+ `Refund amount (${refundAmount}) exceeds refundable balance (${refundableAmount})`,
245
+ { refundAmount, refundableAmount, alreadyRefunded: refundedSoFar }
246
+ );
247
+ }
248
+
215
249
  // Refund via provider
216
- const refundAmount = amount || transaction.amount;
217
250
  let refundResult = null;
218
251
 
219
252
  try {
@@ -306,13 +339,31 @@ export class PaymentService {
306
339
  throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
307
340
  }
308
341
 
342
+ // Check if provider supports webhooks
343
+ const capabilities = provider.getCapabilities();
344
+ if (!capabilities.supportsWebhooks) {
345
+ throw new ProviderCapabilityError(providerName, 'webhooks');
346
+ }
347
+
309
348
  // Process webhook via provider
310
349
  let webhookEvent = null;
311
350
  try {
312
351
  webhookEvent = await provider.handleWebhook(payload, headers);
313
352
  } catch (error) {
314
353
  this.logger.error('Webhook processing failed:', error);
315
- throw new ProviderError(providerName, `Webhook processing failed: ${error.message}`);
354
+ throw new ProviderError(
355
+ `Webhook processing failed for ${providerName}: ${error.message}`,
356
+ 'WEBHOOK_PROCESSING_FAILED',
357
+ { retryable: false }
358
+ );
359
+ }
360
+
361
+ // Validate webhook event structure
362
+ if (!webhookEvent?.data?.paymentIntentId) {
363
+ throw new ValidationError(
364
+ `Invalid webhook event structure from ${providerName}: missing paymentIntentId`,
365
+ { provider: providerName, eventType: webhookEvent?.type }
366
+ );
316
367
  }
317
368
 
318
369
  // Find transaction by payment intent ID from webhook
@@ -309,15 +309,21 @@ export class SubscriptionService {
309
309
  }
310
310
 
311
311
  // Create payment intent
312
- const paymentIntent = await provider.createIntent({
313
- amount: subscription.amount,
314
- currency: subscription.currency || 'BDT',
315
- metadata: {
316
- ...metadata,
317
- type: 'subscription_renewal',
318
- subscriptionId: subscription._id.toString(),
319
- },
320
- });
312
+ let paymentIntent = null;
313
+ try {
314
+ paymentIntent = await provider.createIntent({
315
+ amount: subscription.amount,
316
+ currency: subscription.currency || 'BDT',
317
+ metadata: {
318
+ ...metadata,
319
+ type: 'subscription_renewal',
320
+ subscriptionId: subscription._id.toString(),
321
+ },
322
+ });
323
+ } catch (error) {
324
+ this.logger.error('Failed to create payment intent for renewal:', error);
325
+ throw new PaymentIntentCreationError(gateway, error);
326
+ }
321
327
 
322
328
  // Resolve category - use provided entity or inherit from subscription metadata
323
329
  const effectiveEntity = entity || subscription.metadata?.entity;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Commission Split Utilities
3
+ * @classytic/revenue
4
+ *
5
+ * Multi-party commission split calculation for affiliate/referral systems
6
+ */
7
+
8
+ import { SPLIT_TYPE, SPLIT_STATUS } from '../enums/split.enums.js';
9
+
10
+ /**
11
+ * Calculate multi-party commission splits
12
+ *
13
+ * @param {Number} amount - Transaction amount
14
+ * @param {Array} splitRules - Split configuration
15
+ * @param {Number} gatewayFeeRate - Gateway fee rate (optional)
16
+ * @returns {Array} Split objects
17
+ *
18
+ * @example
19
+ * calculateSplits(1000, [
20
+ * { type: 'platform_commission', recipientId: 'platform', recipientType: 'platform', rate: 0.10 },
21
+ * { type: 'affiliate_commission', recipientId: 'affiliate-123', recipientType: 'user', rate: 0.02 },
22
+ * ], 0.018);
23
+ *
24
+ * Returns:
25
+ * [
26
+ * { type: 'platform_commission', recipientId: 'platform', grossAmount: 100, gatewayFeeAmount: 18, netAmount: 82, ... },
27
+ * { type: 'affiliate_commission', recipientId: 'affiliate-123', grossAmount: 20, gatewayFeeAmount: 0, netAmount: 20, ... },
28
+ * ]
29
+ */
30
+ export function calculateSplits(amount, splitRules = [], gatewayFeeRate = 0) {
31
+ if (!splitRules || splitRules.length === 0) {
32
+ return [];
33
+ }
34
+
35
+ if (amount < 0) {
36
+ throw new Error('Transaction amount cannot be negative');
37
+ }
38
+
39
+ if (gatewayFeeRate < 0 || gatewayFeeRate > 1) {
40
+ throw new Error('Gateway fee rate must be between 0 and 1');
41
+ }
42
+
43
+ const totalRate = splitRules.reduce((sum, rule) => sum + rule.rate, 0);
44
+ if (totalRate > 1) {
45
+ throw new Error(`Total split rate (${totalRate}) cannot exceed 1.0`);
46
+ }
47
+
48
+ return splitRules.map((rule, index) => {
49
+ if (rule.rate < 0 || rule.rate > 1) {
50
+ throw new Error(`Split rate must be between 0 and 1 for split ${index}`);
51
+ }
52
+
53
+ const grossAmount = Math.round(amount * rule.rate * 100) / 100;
54
+
55
+ const gatewayFeeAmount = index === 0 && gatewayFeeRate > 0
56
+ ? Math.round(amount * gatewayFeeRate * 100) / 100
57
+ : 0;
58
+
59
+ const netAmount = Math.max(0, Math.round((grossAmount - gatewayFeeAmount) * 100) / 100);
60
+
61
+ return {
62
+ type: rule.type || SPLIT_TYPE.CUSTOM,
63
+ recipientId: rule.recipientId,
64
+ recipientType: rule.recipientType,
65
+ rate: rule.rate,
66
+ grossAmount,
67
+ gatewayFeeRate: gatewayFeeAmount > 0 ? gatewayFeeRate : 0,
68
+ gatewayFeeAmount,
69
+ netAmount,
70
+ status: SPLIT_STATUS.PENDING,
71
+ dueDate: rule.dueDate || null,
72
+ metadata: rule.metadata || {},
73
+ };
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Calculate organization payout after splits
79
+ *
80
+ * @param {Number} amount - Total transaction amount
81
+ * @param {Array} splits - Calculated splits
82
+ * @returns {Number} Amount organization receives
83
+ */
84
+ export function calculateOrganizationPayout(amount, splits = []) {
85
+ const totalSplitAmount = splits.reduce((sum, split) => sum + split.grossAmount, 0);
86
+ return Math.max(0, Math.round((amount - totalSplitAmount) * 100) / 100);
87
+ }
88
+
89
+ /**
90
+ * Reverse splits proportionally on refund
91
+ *
92
+ * @param {Array} originalSplits - Original split objects
93
+ * @param {Number} originalAmount - Original transaction amount
94
+ * @param {Number} refundAmount - Amount being refunded
95
+ * @returns {Array} Reversed splits
96
+ */
97
+ export function reverseSplits(originalSplits, originalAmount, refundAmount) {
98
+ if (!originalSplits || originalSplits.length === 0) {
99
+ return [];
100
+ }
101
+
102
+ const refundRatio = refundAmount / originalAmount;
103
+
104
+ return originalSplits.map(split => ({
105
+ ...split,
106
+ grossAmount: Math.round(split.grossAmount * refundRatio * 100) / 100,
107
+ gatewayFeeAmount: Math.round(split.gatewayFeeAmount * refundRatio * 100) / 100,
108
+ netAmount: Math.round(split.netAmount * refundRatio * 100) / 100,
109
+ status: SPLIT_STATUS.WAIVED,
110
+ }));
111
+ }
112
+
113
+ /**
114
+ * Build commission object with splits support
115
+ * Backward compatible with existing calculateCommission
116
+ *
117
+ * @param {Number} amount - Transaction amount
118
+ * @param {Number} commissionRate - Platform commission rate
119
+ * @param {Number} gatewayFeeRate - Gateway fee rate
120
+ * @param {Object} options - Additional options
121
+ * @returns {Object} Commission with optional splits
122
+ */
123
+ export function calculateCommissionWithSplits(amount, commissionRate, gatewayFeeRate = 0, options = {}) {
124
+ const { affiliateRate = 0, affiliateId = null, affiliateType = 'user' } = options;
125
+
126
+ if (commissionRate <= 0 && affiliateRate <= 0) {
127
+ return null;
128
+ }
129
+
130
+ const splitRules = [];
131
+
132
+ if (commissionRate > 0) {
133
+ splitRules.push({
134
+ type: SPLIT_TYPE.PLATFORM_COMMISSION,
135
+ recipientId: 'platform',
136
+ recipientType: 'platform',
137
+ rate: commissionRate,
138
+ });
139
+ }
140
+
141
+ if (affiliateRate > 0 && affiliateId) {
142
+ splitRules.push({
143
+ type: SPLIT_TYPE.AFFILIATE_COMMISSION,
144
+ recipientId: affiliateId,
145
+ recipientType: affiliateType,
146
+ rate: affiliateRate,
147
+ });
148
+ }
149
+
150
+ const splits = calculateSplits(amount, splitRules, gatewayFeeRate);
151
+
152
+ const platformSplit = splits.find(s => s.type === SPLIT_TYPE.PLATFORM_COMMISSION);
153
+ const affiliateSplit = splits.find(s => s.type === SPLIT_TYPE.AFFILIATE_COMMISSION);
154
+
155
+ return {
156
+ rate: commissionRate,
157
+ grossAmount: platformSplit?.grossAmount || 0,
158
+ gatewayFeeRate: platformSplit?.gatewayFeeRate || 0,
159
+ gatewayFeeAmount: platformSplit?.gatewayFeeAmount || 0,
160
+ netAmount: platformSplit?.netAmount || 0,
161
+ status: SPLIT_STATUS.PENDING,
162
+ ...(splits.length > 0 && { splits }),
163
+ ...(affiliateSplit && {
164
+ affiliate: {
165
+ recipientId: affiliateSplit.recipientId,
166
+ recipientType: affiliateSplit.recipientType,
167
+ rate: affiliateSplit.rate,
168
+ grossAmount: affiliateSplit.grossAmount,
169
+ netAmount: affiliateSplit.netAmount,
170
+ },
171
+ }),
172
+ };
173
+ }
174
+
175
+ export default {
176
+ calculateSplits,
177
+ calculateOrganizationPayout,
178
+ reverseSplits,
179
+ calculateCommissionWithSplits,
180
+ };
package/utils/index.js CHANGED
@@ -7,4 +7,10 @@ export * from './transaction-type.js';
7
7
  export { default as logger, setLogger } from './logger.js';
8
8
  export { triggerHook } from './hooks.js';
9
9
  export { calculateCommission, reverseCommission } from './commission.js';
10
+ export {
11
+ calculateSplits,
12
+ calculateOrganizationPayout,
13
+ reverseSplits,
14
+ calculateCommissionWithSplits,
15
+ } from './commission-split.js';
10
16
  export * from './subscription/index.js';