@classytic/revenue 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/core/builder.js +170 -0
- package/core/container.js +119 -0
- package/core/errors.js +262 -0
- package/enums/index.js +70 -0
- package/enums/monetization.enums.js +15 -0
- package/enums/payment.enums.js +43 -0
- package/enums/subscription.enums.js +33 -0
- package/enums/transaction.enums.js +53 -0
- package/index.js +58 -0
- package/package.json +62 -0
- package/providers/base.js +162 -0
- package/providers/manual.js +171 -0
- package/revenue.d.ts +290 -0
- package/schemas/index.js +21 -0
- package/schemas/subscription/index.js +17 -0
- package/schemas/subscription/info.schema.js +115 -0
- package/schemas/subscription/plan.schema.js +48 -0
- package/schemas/transaction/common.schema.js +22 -0
- package/schemas/transaction/gateway.schema.js +69 -0
- package/schemas/transaction/index.js +20 -0
- package/schemas/transaction/payment.schema.js +110 -0
- package/services/payment.service.js +400 -0
- package/services/subscription.service.js +537 -0
- package/services/transaction.service.js +142 -0
- package/utils/hooks.js +44 -0
- package/utils/index.js +8 -0
- package/utils/logger.js +36 -0
- package/utils/transaction-type.js +254 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment Service
|
|
3
|
+
* @classytic/revenue
|
|
4
|
+
*
|
|
5
|
+
* Framework-agnostic payment verification and management service with DI
|
|
6
|
+
* Handles payment verification, refunds, and status updates
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
TransactionNotFoundError,
|
|
11
|
+
ProviderNotFoundError,
|
|
12
|
+
AlreadyVerifiedError,
|
|
13
|
+
PaymentVerificationError,
|
|
14
|
+
RefundNotSupportedError,
|
|
15
|
+
RefundError,
|
|
16
|
+
ProviderCapabilityError,
|
|
17
|
+
} from '../core/errors.js';
|
|
18
|
+
import { triggerHook } from '../utils/hooks.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Payment Service
|
|
22
|
+
* Uses DI container for all dependencies
|
|
23
|
+
*/
|
|
24
|
+
export class PaymentService {
|
|
25
|
+
constructor(container) {
|
|
26
|
+
this.container = container;
|
|
27
|
+
this.models = container.get('models');
|
|
28
|
+
this.providers = container.get('providers');
|
|
29
|
+
this.config = container.get('config');
|
|
30
|
+
this.hooks = container.get('hooks');
|
|
31
|
+
this.logger = container.get('logger');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Verify a payment
|
|
36
|
+
*
|
|
37
|
+
* @param {String} paymentIntentId - Payment intent ID or transaction ID
|
|
38
|
+
* @param {Object} options - Verification options
|
|
39
|
+
* @param {String} options.verifiedBy - User ID who verified (for manual verification)
|
|
40
|
+
* @returns {Promise<Object>} { transaction, status }
|
|
41
|
+
*/
|
|
42
|
+
async verify(paymentIntentId, options = {}) {
|
|
43
|
+
const { verifiedBy = null } = options;
|
|
44
|
+
|
|
45
|
+
const TransactionModel = this.models.Transaction;
|
|
46
|
+
|
|
47
|
+
// Find transaction by payment intent ID or transaction ID
|
|
48
|
+
let transaction = await TransactionModel.findOne({
|
|
49
|
+
'gateway.paymentIntentId': paymentIntentId,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!transaction) {
|
|
53
|
+
// Try finding by transaction ID directly
|
|
54
|
+
transaction = await TransactionModel.findById(paymentIntentId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!transaction) {
|
|
58
|
+
throw new TransactionNotFoundError(paymentIntentId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (transaction.status === 'verified' || transaction.status === 'completed') {
|
|
62
|
+
throw new AlreadyVerifiedError(transaction._id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Get provider for verification
|
|
66
|
+
const gatewayType = transaction.gateway?.type || 'manual';
|
|
67
|
+
const provider = this.providers[gatewayType];
|
|
68
|
+
|
|
69
|
+
if (!provider) {
|
|
70
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Verify payment with provider
|
|
74
|
+
let paymentResult = null;
|
|
75
|
+
try {
|
|
76
|
+
paymentResult = await provider.verifyPayment(paymentIntentId);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.logger.error('Payment verification failed:', error);
|
|
79
|
+
|
|
80
|
+
// Update transaction as failed
|
|
81
|
+
transaction.status = 'failed';
|
|
82
|
+
transaction.metadata = {
|
|
83
|
+
...transaction.metadata,
|
|
84
|
+
verificationError: error.message,
|
|
85
|
+
};
|
|
86
|
+
await transaction.save();
|
|
87
|
+
|
|
88
|
+
throw new PaymentVerificationError(paymentIntentId, error.message);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Update transaction based on verification result
|
|
92
|
+
transaction.status = paymentResult.status === 'succeeded' ? 'verified' : paymentResult.status;
|
|
93
|
+
transaction.verifiedAt = paymentResult.paidAt || new Date();
|
|
94
|
+
transaction.verifiedBy = verifiedBy;
|
|
95
|
+
transaction.gateway = {
|
|
96
|
+
...transaction.gateway,
|
|
97
|
+
verificationData: paymentResult.metadata,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await transaction.save();
|
|
101
|
+
|
|
102
|
+
// Trigger hook
|
|
103
|
+
this._triggerHook('payment.verified', {
|
|
104
|
+
transaction,
|
|
105
|
+
paymentResult,
|
|
106
|
+
verifiedBy,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
transaction,
|
|
111
|
+
paymentResult,
|
|
112
|
+
status: 'verified',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get payment status
|
|
118
|
+
*
|
|
119
|
+
* @param {String} paymentIntentId - Payment intent ID or transaction ID
|
|
120
|
+
* @returns {Promise<Object>} { transaction, status }
|
|
121
|
+
*/
|
|
122
|
+
async getStatus(paymentIntentId) {
|
|
123
|
+
const TransactionModel = this.models.Transaction;
|
|
124
|
+
|
|
125
|
+
// Find transaction
|
|
126
|
+
let transaction = await TransactionModel.findOne({
|
|
127
|
+
'gateway.paymentIntentId': paymentIntentId,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!transaction) {
|
|
131
|
+
transaction = await TransactionModel.findById(paymentIntentId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!transaction) {
|
|
135
|
+
throw new Error('Transaction not found');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Get provider
|
|
139
|
+
const gatewayType = transaction.gateway?.type || 'manual';
|
|
140
|
+
const provider = this.providers[gatewayType];
|
|
141
|
+
|
|
142
|
+
if (!provider) {
|
|
143
|
+
throw new Error(`Payment provider "${gatewayType}" not found`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Get status from provider
|
|
147
|
+
let paymentResult = null;
|
|
148
|
+
try {
|
|
149
|
+
paymentResult = await provider.getStatus(paymentIntentId);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
this.logger.warn('Failed to get payment status from provider:', error);
|
|
152
|
+
// Return transaction status as fallback
|
|
153
|
+
return {
|
|
154
|
+
transaction,
|
|
155
|
+
status: transaction.status,
|
|
156
|
+
provider: gatewayType,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
transaction,
|
|
162
|
+
paymentResult,
|
|
163
|
+
status: paymentResult.status,
|
|
164
|
+
provider: gatewayType,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Refund a payment
|
|
170
|
+
*
|
|
171
|
+
* @param {String} paymentId - Payment intent ID or transaction ID
|
|
172
|
+
* @param {Number} amount - Amount to refund (optional, full refund if not provided)
|
|
173
|
+
* @param {Object} options - Refund options
|
|
174
|
+
* @param {String} options.reason - Refund reason
|
|
175
|
+
* @returns {Promise<Object>} { transaction, refundResult }
|
|
176
|
+
*/
|
|
177
|
+
async refund(paymentId, amount = null, options = {}) {
|
|
178
|
+
const { reason = null } = options;
|
|
179
|
+
|
|
180
|
+
const TransactionModel = this.models.Transaction;
|
|
181
|
+
|
|
182
|
+
// Find transaction
|
|
183
|
+
let transaction = await TransactionModel.findOne({
|
|
184
|
+
'gateway.paymentIntentId': paymentId,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!transaction) {
|
|
188
|
+
transaction = await TransactionModel.findById(paymentId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!transaction) {
|
|
192
|
+
throw new TransactionNotFoundError(paymentId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (transaction.status !== 'verified' && transaction.status !== 'completed') {
|
|
196
|
+
throw new RefundError(transaction._id, 'Only verified/completed transactions can be refunded');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Get provider
|
|
200
|
+
const gatewayType = transaction.gateway?.type || 'manual';
|
|
201
|
+
const provider = this.providers[gatewayType];
|
|
202
|
+
|
|
203
|
+
if (!provider) {
|
|
204
|
+
throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check if provider supports refunds
|
|
208
|
+
const capabilities = provider.getCapabilities();
|
|
209
|
+
if (!capabilities.supportsRefunds) {
|
|
210
|
+
throw new RefundNotSupportedError(gatewayType);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Refund via provider
|
|
214
|
+
const refundAmount = amount || transaction.amount;
|
|
215
|
+
let refundResult = null;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
refundResult = await provider.refund(paymentId, refundAmount, { reason });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.logger.error('Refund failed:', error);
|
|
221
|
+
throw new Error(`Refund failed: ${error.message}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Update transaction
|
|
225
|
+
const isPartialRefund = refundAmount < transaction.amount;
|
|
226
|
+
transaction.status = isPartialRefund ? 'partially_refunded' : 'refunded';
|
|
227
|
+
transaction.refundedAmount = (transaction.refundedAmount || 0) + refundAmount;
|
|
228
|
+
transaction.refundedAt = refundResult.refundedAt || new Date();
|
|
229
|
+
transaction.metadata = {
|
|
230
|
+
...transaction.metadata,
|
|
231
|
+
refundReason: reason,
|
|
232
|
+
refundResult: refundResult.metadata,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await transaction.save();
|
|
236
|
+
|
|
237
|
+
// Trigger hook
|
|
238
|
+
this._triggerHook('payment.refunded', {
|
|
239
|
+
transaction,
|
|
240
|
+
refundResult,
|
|
241
|
+
refundAmount,
|
|
242
|
+
reason,
|
|
243
|
+
isPartialRefund,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
transaction,
|
|
248
|
+
refundResult,
|
|
249
|
+
status: transaction.status,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Handle webhook from payment provider
|
|
255
|
+
*
|
|
256
|
+
* @param {String} provider - Provider name
|
|
257
|
+
* @param {Object} payload - Webhook payload
|
|
258
|
+
* @param {Object} headers - Request headers
|
|
259
|
+
* @returns {Promise<Object>} { event, transaction }
|
|
260
|
+
*/
|
|
261
|
+
async handleWebhook(providerName, payload, headers = {}) {
|
|
262
|
+
const provider = this.providers[providerName];
|
|
263
|
+
|
|
264
|
+
if (!provider) {
|
|
265
|
+
throw new Error(`Payment provider "${providerName}" not found`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Process webhook via provider
|
|
269
|
+
let webhookEvent = null;
|
|
270
|
+
try {
|
|
271
|
+
webhookEvent = await provider.handleWebhook(payload, headers);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.logger.error('Webhook processing failed:', error);
|
|
274
|
+
throw new Error(`Webhook processing failed: ${error.message}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Find transaction by payment intent ID from webhook
|
|
278
|
+
const TransactionModel = this.models.Transaction;
|
|
279
|
+
const transaction = await TransactionModel.findOne({
|
|
280
|
+
'gateway.paymentIntentId': webhookEvent.data.paymentIntentId,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (!transaction) {
|
|
284
|
+
this.logger.warn('Transaction not found for webhook event', {
|
|
285
|
+
provider: providerName,
|
|
286
|
+
eventId: webhookEvent.id,
|
|
287
|
+
paymentIntentId: webhookEvent.data.paymentIntentId,
|
|
288
|
+
});
|
|
289
|
+
throw new Error('Transaction not found for payment intent');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Check for duplicate webhook processing (idempotency)
|
|
293
|
+
if (transaction.webhook?.eventId === webhookEvent.id && transaction.webhook?.processedAt) {
|
|
294
|
+
this.logger.warn('Webhook already processed', {
|
|
295
|
+
transactionId: transaction._id,
|
|
296
|
+
eventId: webhookEvent.id,
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
event: webhookEvent,
|
|
300
|
+
transaction,
|
|
301
|
+
status: 'already_processed',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Update transaction based on webhook event
|
|
306
|
+
transaction.webhook = {
|
|
307
|
+
eventId: webhookEvent.id,
|
|
308
|
+
eventType: webhookEvent.type,
|
|
309
|
+
receivedAt: new Date(),
|
|
310
|
+
processedAt: new Date(),
|
|
311
|
+
data: webhookEvent.data,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Update status based on webhook type
|
|
315
|
+
if (webhookEvent.type === 'payment.succeeded') {
|
|
316
|
+
transaction.status = 'verified';
|
|
317
|
+
transaction.verifiedAt = webhookEvent.createdAt;
|
|
318
|
+
} else if (webhookEvent.type === 'payment.failed') {
|
|
319
|
+
transaction.status = 'failed';
|
|
320
|
+
} else if (webhookEvent.type === 'refund.succeeded') {
|
|
321
|
+
transaction.status = 'refunded';
|
|
322
|
+
transaction.refundedAt = webhookEvent.createdAt;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await transaction.save();
|
|
326
|
+
|
|
327
|
+
// Trigger hook
|
|
328
|
+
this._triggerHook(`payment.webhook.${webhookEvent.type}`, {
|
|
329
|
+
event: webhookEvent,
|
|
330
|
+
transaction,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
event: webhookEvent,
|
|
335
|
+
transaction,
|
|
336
|
+
status: 'processed',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* List payments/transactions with filters
|
|
342
|
+
*
|
|
343
|
+
* @param {Object} filters - Query filters
|
|
344
|
+
* @param {Object} options - Query options (limit, skip, sort)
|
|
345
|
+
* @returns {Promise<Array>} Transactions
|
|
346
|
+
*/
|
|
347
|
+
async list(filters = {}, options = {}) {
|
|
348
|
+
const TransactionModel = this.models.Transaction;
|
|
349
|
+
const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
|
|
350
|
+
|
|
351
|
+
const transactions = await TransactionModel
|
|
352
|
+
.find(filters)
|
|
353
|
+
.limit(limit)
|
|
354
|
+
.skip(skip)
|
|
355
|
+
.sort(sort);
|
|
356
|
+
|
|
357
|
+
return transactions;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get payment/transaction by ID
|
|
362
|
+
*
|
|
363
|
+
* @param {String} transactionId - Transaction ID
|
|
364
|
+
* @returns {Promise<Object>} Transaction
|
|
365
|
+
*/
|
|
366
|
+
async get(transactionId) {
|
|
367
|
+
const TransactionModel = this.models.Transaction;
|
|
368
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
369
|
+
|
|
370
|
+
if (!transaction) {
|
|
371
|
+
throw new Error('Transaction not found');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return transaction;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Get provider instance
|
|
379
|
+
*
|
|
380
|
+
* @param {String} providerName - Provider name
|
|
381
|
+
* @returns {Object} Provider instance
|
|
382
|
+
*/
|
|
383
|
+
getProvider(providerName) {
|
|
384
|
+
const provider = this.providers[providerName];
|
|
385
|
+
if (!provider) {
|
|
386
|
+
throw new Error(`Payment provider "${providerName}" not found. Available: ${Object.keys(this.providers).join(', ')}`);
|
|
387
|
+
}
|
|
388
|
+
return provider;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Trigger event hook (fire-and-forget, non-blocking)
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
_triggerHook(event, data) {
|
|
396
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export default PaymentService;
|