@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.
- package/README.md +131 -19
- package/core/builder.js +39 -0
- package/dist/types/core/builder.d.ts +87 -0
- package/dist/types/core/container.d.ts +57 -0
- package/dist/types/core/errors.d.ts +122 -0
- package/dist/types/enums/escrow.enums.d.ts +24 -0
- package/dist/types/enums/index.d.ts +69 -0
- package/dist/types/enums/monetization.enums.d.ts +6 -0
- package/dist/types/enums/payment.enums.d.ts +16 -0
- package/dist/types/enums/split.enums.d.ts +25 -0
- package/dist/types/enums/subscription.enums.d.ts +15 -0
- package/dist/types/enums/transaction.enums.d.ts +24 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/providers/base.d.ts +126 -0
- package/dist/types/schemas/escrow/hold.schema.d.ts +54 -0
- package/dist/types/schemas/escrow/index.d.ts +6 -0
- package/dist/types/schemas/index.d.ts +506 -0
- package/dist/types/schemas/split/index.d.ts +8 -0
- package/dist/types/schemas/split/split.schema.d.ts +142 -0
- package/dist/types/schemas/subscription/index.d.ts +152 -0
- package/dist/types/schemas/subscription/info.schema.d.ts +128 -0
- package/dist/types/schemas/subscription/plan.schema.d.ts +39 -0
- package/dist/types/schemas/transaction/common.schema.d.ts +12 -0
- package/dist/types/schemas/transaction/gateway.schema.d.ts +86 -0
- package/dist/types/schemas/transaction/index.d.ts +202 -0
- package/dist/types/schemas/transaction/payment.schema.d.ts +145 -0
- package/dist/types/services/escrow.service.d.ts +51 -0
- package/dist/types/services/payment.service.d.ts +80 -0
- package/dist/types/services/subscription.service.d.ts +138 -0
- package/dist/types/services/transaction.service.d.ts +40 -0
- package/dist/types/utils/category-resolver.d.ts +46 -0
- package/dist/types/utils/commission-split.d.ts +56 -0
- package/dist/types/utils/commission.d.ts +29 -0
- package/dist/types/utils/hooks.d.ts +17 -0
- package/dist/types/utils/index.d.ts +6 -0
- package/dist/types/utils/logger.d.ts +12 -0
- package/dist/types/utils/subscription/actions.d.ts +28 -0
- package/dist/types/utils/subscription/index.d.ts +2 -0
- package/dist/types/utils/subscription/period.d.ts +47 -0
- package/dist/types/utils/transaction-type.d.ts +102 -0
- package/enums/escrow.enums.js +36 -0
- package/enums/index.js +36 -0
- package/enums/split.enums.js +37 -0
- package/index.js +6 -0
- package/package.json +91 -74
- package/schemas/escrow/hold.schema.js +62 -0
- package/schemas/escrow/index.js +15 -0
- package/schemas/index.js +6 -0
- package/schemas/split/index.js +16 -0
- package/schemas/split/split.schema.js +86 -0
- package/services/escrow.service.js +353 -0
- package/services/payment.service.js +54 -3
- package/services/subscription.service.js +15 -9
- package/utils/commission-split.js +180 -0
- package/utils/index.js +6 -0
- 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:
|
|
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(
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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';
|