@classytic/revenue 0.0.24 → 0.2.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 +184 -24
- package/core/builder.js +51 -4
- package/dist/types/core/builder.d.ts +95 -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 +139 -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/payment.enums.js +26 -5
- package/enums/split.enums.js +37 -0
- package/index.js +8 -2
- 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 +64 -3
- package/services/subscription.service.js +36 -16
- package/utils/commission-split.js +180 -0
- package/utils/index.js +6 -0
- package/revenue.d.ts +0 -350
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split Payment Schema
|
|
3
|
+
* @classytic/revenue
|
|
4
|
+
*
|
|
5
|
+
* Schema for multi-party commission splits
|
|
6
|
+
* Spread into transaction schema when needed
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SPLIT_TYPE, SPLIT_TYPE_VALUES, SPLIT_STATUS, SPLIT_STATUS_VALUES, PAYOUT_METHOD, PAYOUT_METHOD_VALUES } from '../../enums/split.enums.js';
|
|
10
|
+
|
|
11
|
+
export const splitItemSchema = {
|
|
12
|
+
type: {
|
|
13
|
+
type: String,
|
|
14
|
+
enum: SPLIT_TYPE_VALUES,
|
|
15
|
+
required: true,
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
recipientId: {
|
|
19
|
+
type: String,
|
|
20
|
+
required: true,
|
|
21
|
+
index: true,
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
recipientType: {
|
|
25
|
+
type: String,
|
|
26
|
+
required: true,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
rate: {
|
|
30
|
+
type: Number,
|
|
31
|
+
required: true,
|
|
32
|
+
min: 0,
|
|
33
|
+
max: 1,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
grossAmount: {
|
|
37
|
+
type: Number,
|
|
38
|
+
required: true,
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
gatewayFeeRate: {
|
|
42
|
+
type: Number,
|
|
43
|
+
default: 0,
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
gatewayFeeAmount: {
|
|
47
|
+
type: Number,
|
|
48
|
+
default: 0,
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
netAmount: {
|
|
52
|
+
type: Number,
|
|
53
|
+
required: true,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
status: {
|
|
57
|
+
type: String,
|
|
58
|
+
enum: SPLIT_STATUS_VALUES,
|
|
59
|
+
default: SPLIT_STATUS.PENDING,
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
dueDate: Date,
|
|
63
|
+
paidDate: Date,
|
|
64
|
+
|
|
65
|
+
payoutMethod: {
|
|
66
|
+
type: String,
|
|
67
|
+
enum: PAYOUT_METHOD_VALUES,
|
|
68
|
+
required: false,
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
payoutTransactionId: String,
|
|
72
|
+
|
|
73
|
+
metadata: {
|
|
74
|
+
type: Object,
|
|
75
|
+
default: {},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const splitsSchema = {
|
|
80
|
+
splits: {
|
|
81
|
+
type: [splitItemSchema],
|
|
82
|
+
default: [],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default splitsSchema;
|
|
@@ -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';
|
|
@@ -81,15 +83,40 @@ export class PaymentService {
|
|
|
81
83
|
|
|
82
84
|
// Update transaction as failed
|
|
83
85
|
transaction.status = 'failed';
|
|
86
|
+
transaction.failureReason = error.message;
|
|
84
87
|
transaction.metadata = {
|
|
85
88
|
...transaction.metadata,
|
|
86
89
|
verificationError: error.message,
|
|
90
|
+
failedAt: new Date().toISOString(),
|
|
87
91
|
};
|
|
88
92
|
await transaction.save();
|
|
89
93
|
|
|
94
|
+
// Trigger payment.failed hook
|
|
95
|
+
this._triggerHook('payment.failed', {
|
|
96
|
+
transaction,
|
|
97
|
+
error: error.message,
|
|
98
|
+
provider: gatewayType,
|
|
99
|
+
paymentIntentId,
|
|
100
|
+
});
|
|
101
|
+
|
|
90
102
|
throw new PaymentVerificationError(paymentIntentId, error.message);
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
// Validate amount and currency match
|
|
106
|
+
if (paymentResult.amount && paymentResult.amount !== transaction.amount) {
|
|
107
|
+
throw new ValidationError(
|
|
108
|
+
`Amount mismatch: expected ${transaction.amount}, got ${paymentResult.amount}`,
|
|
109
|
+
{ expected: transaction.amount, actual: paymentResult.amount }
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (paymentResult.currency && paymentResult.currency.toUpperCase() !== transaction.currency.toUpperCase()) {
|
|
114
|
+
throw new ValidationError(
|
|
115
|
+
`Currency mismatch: expected ${transaction.currency}, got ${paymentResult.currency}`,
|
|
116
|
+
{ expected: transaction.currency, actual: paymentResult.currency }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
93
120
|
// Update transaction based on verification result
|
|
94
121
|
transaction.status = paymentResult.status === 'succeeded' ? 'verified' : paymentResult.status;
|
|
95
122
|
transaction.verifiedAt = paymentResult.paidAt || new Date();
|
|
@@ -111,7 +138,7 @@ export class PaymentService {
|
|
|
111
138
|
return {
|
|
112
139
|
transaction,
|
|
113
140
|
paymentResult,
|
|
114
|
-
status:
|
|
141
|
+
status: transaction.status,
|
|
115
142
|
};
|
|
116
143
|
}
|
|
117
144
|
|
|
@@ -212,8 +239,24 @@ export class PaymentService {
|
|
|
212
239
|
throw new RefundNotSupportedError(gatewayType);
|
|
213
240
|
}
|
|
214
241
|
|
|
242
|
+
// Calculate refundable amount
|
|
243
|
+
const refundedSoFar = transaction.refundedAmount || 0;
|
|
244
|
+
const refundableAmount = transaction.amount - refundedSoFar;
|
|
245
|
+
const refundAmount = amount || refundableAmount;
|
|
246
|
+
|
|
247
|
+
// Validate refund amount
|
|
248
|
+
if (refundAmount <= 0) {
|
|
249
|
+
throw new ValidationError(`Refund amount must be positive, got ${refundAmount}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (refundAmount > refundableAmount) {
|
|
253
|
+
throw new ValidationError(
|
|
254
|
+
`Refund amount (${refundAmount}) exceeds refundable balance (${refundableAmount})`,
|
|
255
|
+
{ refundAmount, refundableAmount, alreadyRefunded: refundedSoFar }
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
215
259
|
// Refund via provider
|
|
216
|
-
const refundAmount = amount || transaction.amount;
|
|
217
260
|
let refundResult = null;
|
|
218
261
|
|
|
219
262
|
try {
|
|
@@ -306,13 +349,31 @@ export class PaymentService {
|
|
|
306
349
|
throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
|
|
307
350
|
}
|
|
308
351
|
|
|
352
|
+
// Check if provider supports webhooks
|
|
353
|
+
const capabilities = provider.getCapabilities();
|
|
354
|
+
if (!capabilities.supportsWebhooks) {
|
|
355
|
+
throw new ProviderCapabilityError(providerName, 'webhooks');
|
|
356
|
+
}
|
|
357
|
+
|
|
309
358
|
// Process webhook via provider
|
|
310
359
|
let webhookEvent = null;
|
|
311
360
|
try {
|
|
312
361
|
webhookEvent = await provider.handleWebhook(payload, headers);
|
|
313
362
|
} catch (error) {
|
|
314
363
|
this.logger.error('Webhook processing failed:', error);
|
|
315
|
-
throw new ProviderError(
|
|
364
|
+
throw new ProviderError(
|
|
365
|
+
`Webhook processing failed for ${providerName}: ${error.message}`,
|
|
366
|
+
'WEBHOOK_PROCESSING_FAILED',
|
|
367
|
+
{ retryable: false }
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Validate webhook event structure
|
|
372
|
+
if (!webhookEvent?.data?.paymentIntentId) {
|
|
373
|
+
throw new ValidationError(
|
|
374
|
+
`Invalid webhook event structure from ${providerName}: missing paymentIntentId`,
|
|
375
|
+
{ provider: providerName, eventType: webhookEvent?.type }
|
|
376
|
+
);
|
|
316
377
|
}
|
|
317
378
|
|
|
318
379
|
// Find transaction by payment intent ID from webhook
|
|
@@ -45,14 +45,14 @@ export class SubscriptionService {
|
|
|
45
45
|
* @param {String} params.planKey - Plan key ('monthly', 'quarterly', 'yearly')
|
|
46
46
|
* @param {Number} params.amount - Subscription amount
|
|
47
47
|
* @param {String} params.currency - Currency code (default: 'BDT')
|
|
48
|
-
* @param {String} params.gateway - Payment gateway
|
|
48
|
+
* @param {String} params.gateway - Payment gateway name (default: 'manual') - Use ANY registered provider name: 'manual', 'bkash', 'nagad', 'stripe', etc.
|
|
49
49
|
* @param {String} params.entity - Logical entity identifier (e.g., 'Order', 'PlatformSubscription', 'Membership')
|
|
50
50
|
* NOTE: This is NOT a database model name - it's just a logical identifier for categoryMappings
|
|
51
51
|
* @param {String} params.monetizationType - Monetization type ('free', 'subscription', 'purchase')
|
|
52
52
|
* @param {Object} params.paymentData - Payment method details
|
|
53
53
|
* @param {Object} params.metadata - Additional metadata
|
|
54
54
|
* @param {String} params.idempotencyKey - Idempotency key for duplicate prevention
|
|
55
|
-
*
|
|
55
|
+
*
|
|
56
56
|
* @example
|
|
57
57
|
* // With polymorphic reference (recommended)
|
|
58
58
|
* await revenue.subscriptions.create({
|
|
@@ -62,10 +62,11 @@ export class SubscriptionService {
|
|
|
62
62
|
* referenceId: subscription._id, // Links to entity
|
|
63
63
|
* referenceModel: 'Subscription', // Model name
|
|
64
64
|
* },
|
|
65
|
+
* gateway: 'bkash', // Any registered provider
|
|
65
66
|
* amount: 1500,
|
|
66
67
|
* // ...
|
|
67
68
|
* });
|
|
68
|
-
*
|
|
69
|
+
*
|
|
69
70
|
* @returns {Promise<Object>} { subscription, transaction, paymentIntent }
|
|
70
71
|
*/
|
|
71
72
|
async create(params) {
|
|
@@ -204,13 +205,26 @@ export class SubscriptionService {
|
|
|
204
205
|
subscription = await SubscriptionModel.create(subscriptionData);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
// Trigger
|
|
208
|
-
|
|
208
|
+
// Trigger hooks - emit specific event based on monetization type
|
|
209
|
+
const eventData = {
|
|
209
210
|
subscription,
|
|
210
211
|
transaction,
|
|
211
212
|
paymentIntent,
|
|
212
213
|
isFree,
|
|
213
|
-
|
|
214
|
+
monetizationType,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Emit specific monetization event
|
|
218
|
+
if (monetizationType === MONETIZATION_TYPES.PURCHASE) {
|
|
219
|
+
this._triggerHook('purchase.created', eventData);
|
|
220
|
+
} else if (monetizationType === MONETIZATION_TYPES.SUBSCRIPTION) {
|
|
221
|
+
this._triggerHook('subscription.created', eventData);
|
|
222
|
+
} else if (monetizationType === MONETIZATION_TYPES.FREE) {
|
|
223
|
+
this._triggerHook('free.created', eventData);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Also emit generic event for backward compatibility
|
|
227
|
+
this._triggerHook('monetization.created', eventData);
|
|
214
228
|
|
|
215
229
|
return {
|
|
216
230
|
subscription,
|
|
@@ -271,7 +285,7 @@ export class SubscriptionService {
|
|
|
271
285
|
*
|
|
272
286
|
* @param {String} subscriptionId - Subscription ID
|
|
273
287
|
* @param {Object} params - Renewal parameters
|
|
274
|
-
* @param {String} params.gateway - Payment gateway
|
|
288
|
+
* @param {String} params.gateway - Payment gateway name (default: 'manual') - Use ANY registered provider name: 'manual', 'bkash', 'nagad', 'stripe', etc.
|
|
275
289
|
* @param {String} params.entity - Logical entity identifier (optional, inherits from subscription)
|
|
276
290
|
* @param {Object} params.paymentData - Payment method details
|
|
277
291
|
* @param {Object} params.metadata - Additional metadata
|
|
@@ -309,15 +323,21 @@ export class SubscriptionService {
|
|
|
309
323
|
}
|
|
310
324
|
|
|
311
325
|
// Create payment intent
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
326
|
+
let paymentIntent = null;
|
|
327
|
+
try {
|
|
328
|
+
paymentIntent = await provider.createIntent({
|
|
329
|
+
amount: subscription.amount,
|
|
330
|
+
currency: subscription.currency || 'BDT',
|
|
331
|
+
metadata: {
|
|
332
|
+
...metadata,
|
|
333
|
+
type: 'subscription_renewal',
|
|
334
|
+
subscriptionId: subscription._id.toString(),
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.logger.error('Failed to create payment intent for renewal:', error);
|
|
339
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
340
|
+
}
|
|
321
341
|
|
|
322
342
|
// Resolve category - use provided entity or inherit from subscription metadata
|
|
323
343
|
const effectiveEntity = entity || subscription.metadata?.entity;
|