@classytic/revenue 0.2.3 → 1.0.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 +498 -499
- package/dist/actions-CwG-b7fR.d.ts +519 -0
- package/dist/core/index.d.ts +884 -0
- package/dist/core/index.js +2941 -0
- package/dist/core/index.js.map +1 -0
- package/dist/enums/index.d.ts +130 -0
- package/dist/enums/index.js +167 -0
- package/dist/enums/index.js.map +1 -0
- package/dist/index-BnJWVXuw.d.ts +378 -0
- package/dist/index-ChVD3P9k.d.ts +504 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +4353 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/index.d.ts +132 -0
- package/dist/providers/index.js +122 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/retry-80lBCmSe.d.ts +234 -0
- package/dist/schemas/index.d.ts +894 -0
- package/dist/schemas/index.js +524 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/validation.d.ts +309 -0
- package/dist/schemas/validation.js +249 -0
- package/dist/schemas/validation.js.map +1 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +1632 -0
- package/dist/services/index.js.map +1 -0
- package/dist/split.enums-DHdM1YAV.d.ts +255 -0
- package/dist/split.schema-BPdFZMbU.d.ts +958 -0
- package/dist/utils/index.d.ts +24 -0
- package/dist/utils/index.js +1067 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +48 -32
- package/core/builder.js +0 -219
- package/core/container.js +0 -119
- package/core/errors.js +0 -262
- package/dist/types/core/builder.d.ts +0 -97
- package/dist/types/core/container.d.ts +0 -57
- package/dist/types/core/errors.d.ts +0 -122
- package/dist/types/enums/escrow.enums.d.ts +0 -24
- package/dist/types/enums/index.d.ts +0 -69
- package/dist/types/enums/monetization.enums.d.ts +0 -6
- package/dist/types/enums/payment.enums.d.ts +0 -16
- package/dist/types/enums/split.enums.d.ts +0 -25
- package/dist/types/enums/subscription.enums.d.ts +0 -15
- package/dist/types/enums/transaction.enums.d.ts +0 -24
- package/dist/types/index.d.ts +0 -22
- package/dist/types/providers/base.d.ts +0 -126
- package/dist/types/schemas/escrow/hold.schema.d.ts +0 -54
- package/dist/types/schemas/escrow/index.d.ts +0 -6
- package/dist/types/schemas/index.d.ts +0 -506
- package/dist/types/schemas/split/index.d.ts +0 -8
- package/dist/types/schemas/split/split.schema.d.ts +0 -142
- package/dist/types/schemas/subscription/index.d.ts +0 -152
- package/dist/types/schemas/subscription/info.schema.d.ts +0 -128
- package/dist/types/schemas/subscription/plan.schema.d.ts +0 -39
- package/dist/types/schemas/transaction/common.schema.d.ts +0 -12
- package/dist/types/schemas/transaction/gateway.schema.d.ts +0 -86
- package/dist/types/schemas/transaction/index.d.ts +0 -202
- package/dist/types/schemas/transaction/payment.schema.d.ts +0 -145
- package/dist/types/services/escrow.service.d.ts +0 -51
- package/dist/types/services/monetization.service.d.ts +0 -193
- package/dist/types/services/payment.service.d.ts +0 -112
- package/dist/types/services/transaction.service.d.ts +0 -40
- package/dist/types/utils/category-resolver.d.ts +0 -46
- package/dist/types/utils/commission-split.d.ts +0 -56
- package/dist/types/utils/commission.d.ts +0 -29
- package/dist/types/utils/hooks.d.ts +0 -17
- package/dist/types/utils/index.d.ts +0 -6
- package/dist/types/utils/logger.d.ts +0 -12
- package/dist/types/utils/subscription/actions.d.ts +0 -28
- package/dist/types/utils/subscription/index.d.ts +0 -2
- package/dist/types/utils/subscription/period.d.ts +0 -47
- package/dist/types/utils/transaction-type.d.ts +0 -102
- package/enums/escrow.enums.js +0 -36
- package/enums/index.d.ts +0 -116
- package/enums/index.js +0 -110
- package/enums/monetization.enums.js +0 -15
- package/enums/payment.enums.js +0 -64
- package/enums/split.enums.js +0 -37
- package/enums/subscription.enums.js +0 -33
- package/enums/transaction.enums.js +0 -69
- package/index.js +0 -76
- package/providers/base.js +0 -162
- package/schemas/escrow/hold.schema.js +0 -62
- package/schemas/escrow/index.js +0 -15
- package/schemas/index.d.ts +0 -33
- package/schemas/index.js +0 -27
- package/schemas/split/index.js +0 -16
- package/schemas/split/split.schema.js +0 -86
- package/schemas/subscription/index.js +0 -17
- package/schemas/subscription/info.schema.js +0 -115
- package/schemas/subscription/plan.schema.js +0 -48
- package/schemas/transaction/common.schema.js +0 -22
- package/schemas/transaction/gateway.schema.js +0 -69
- package/schemas/transaction/index.js +0 -20
- package/schemas/transaction/payment.schema.js +0 -110
- package/services/escrow.service.js +0 -353
- package/services/monetization.service.js +0 -671
- package/services/payment.service.js +0 -517
- package/services/transaction.service.js +0 -142
- package/utils/category-resolver.js +0 -74
- package/utils/commission-split.js +0 -180
- package/utils/commission.js +0 -83
- package/utils/hooks.js +0 -44
- package/utils/index.d.ts +0 -164
- package/utils/index.js +0 -16
- package/utils/logger.js +0 -36
- package/utils/subscription/actions.js +0 -68
- package/utils/subscription/index.js +0 -20
- package/utils/subscription/period.js +0 -123
- 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
|