@classytic/revenue 0.0.22 → 0.0.24

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.
@@ -1,431 +1,441 @@
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
- import { TRANSACTION_TYPE } from '../enums/transaction.enums.js';
20
-
21
- /**
22
- * Payment Service
23
- * Uses DI container for all dependencies
24
- */
25
- export class PaymentService {
26
- constructor(container) {
27
- this.container = container;
28
- this.models = container.get('models');
29
- this.providers = container.get('providers');
30
- this.config = container.get('config');
31
- this.hooks = container.get('hooks');
32
- this.logger = container.get('logger');
33
- }
34
-
35
- /**
36
- * Verify a payment
37
- *
38
- * @param {String} paymentIntentId - Payment intent ID or transaction ID
39
- * @param {Object} options - Verification options
40
- * @param {String} options.verifiedBy - User ID who verified (for manual verification)
41
- * @returns {Promise<Object>} { transaction, status }
42
- */
43
- async verify(paymentIntentId, options = {}) {
44
- const { verifiedBy = null } = options;
45
-
46
- const TransactionModel = this.models.Transaction;
47
-
48
- // Find transaction by payment intent ID or transaction ID
49
- let transaction = await TransactionModel.findOne({
50
- 'gateway.paymentIntentId': paymentIntentId,
51
- });
52
-
53
- if (!transaction) {
54
- // Try finding by transaction ID directly
55
- transaction = await TransactionModel.findById(paymentIntentId);
56
- }
57
-
58
- if (!transaction) {
59
- throw new TransactionNotFoundError(paymentIntentId);
60
- }
61
-
62
- if (transaction.status === 'verified' || transaction.status === 'completed') {
63
- throw new AlreadyVerifiedError(transaction._id);
64
- }
65
-
66
- // Get provider for verification
67
- const gatewayType = transaction.gateway?.type || 'manual';
68
- const provider = this.providers[gatewayType];
69
-
70
- if (!provider) {
71
- throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
72
- }
73
-
74
- // Verify payment with provider
75
- let paymentResult = null;
76
- try {
77
- paymentResult = await provider.verifyPayment(paymentIntentId);
78
- } catch (error) {
79
- this.logger.error('Payment verification failed:', error);
80
-
81
- // Update transaction as failed
82
- transaction.status = 'failed';
83
- transaction.metadata = {
84
- ...transaction.metadata,
85
- verificationError: error.message,
86
- };
87
- await transaction.save();
88
-
89
- throw new PaymentVerificationError(paymentIntentId, error.message);
90
- }
91
-
92
- // Update transaction based on verification result
93
- transaction.status = paymentResult.status === 'succeeded' ? 'verified' : paymentResult.status;
94
- transaction.verifiedAt = paymentResult.paidAt || new Date();
95
- transaction.verifiedBy = verifiedBy;
96
- transaction.gateway = {
97
- ...transaction.gateway,
98
- verificationData: paymentResult.metadata,
99
- };
100
-
101
- await transaction.save();
102
-
103
- // Trigger hook
104
- this._triggerHook('payment.verified', {
105
- transaction,
106
- paymentResult,
107
- verifiedBy,
108
- });
109
-
110
- return {
111
- transaction,
112
- paymentResult,
113
- status: 'verified',
114
- };
115
- }
116
-
117
- /**
118
- * Get payment status
119
- *
120
- * @param {String} paymentIntentId - Payment intent ID or transaction ID
121
- * @returns {Promise<Object>} { transaction, status }
122
- */
123
- async getStatus(paymentIntentId) {
124
- const TransactionModel = this.models.Transaction;
125
-
126
- // Find transaction
127
- let transaction = await TransactionModel.findOne({
128
- 'gateway.paymentIntentId': paymentIntentId,
129
- });
130
-
131
- if (!transaction) {
132
- transaction = await TransactionModel.findById(paymentIntentId);
133
- }
134
-
135
- if (!transaction) {
136
- throw new TransactionNotFoundError(paymentIntentId);
137
- }
138
-
139
- // Get provider
140
- const gatewayType = transaction.gateway?.type || 'manual';
141
- const provider = this.providers[gatewayType];
142
-
143
- if (!provider) {
144
- throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
145
- }
146
-
147
- // Get status from provider
148
- let paymentResult = null;
149
- try {
150
- paymentResult = await provider.getStatus(paymentIntentId);
151
- } catch (error) {
152
- this.logger.warn('Failed to get payment status from provider:', error);
153
- // Return transaction status as fallback
154
- return {
155
- transaction,
156
- status: transaction.status,
157
- provider: gatewayType,
158
- };
159
- }
160
-
161
- return {
162
- transaction,
163
- paymentResult,
164
- status: paymentResult.status,
165
- provider: gatewayType,
166
- };
167
- }
168
-
169
- /**
170
- * Refund a payment
171
- *
172
- * @param {String} paymentId - Payment intent ID or transaction ID
173
- * @param {Number} amount - Amount to refund (optional, full refund if not provided)
174
- * @param {Object} options - Refund options
175
- * @param {String} options.reason - Refund reason
176
- * @returns {Promise<Object>} { transaction, refundResult }
177
- */
178
- async refund(paymentId, amount = null, options = {}) {
179
- const { reason = null } = options;
180
-
181
- const TransactionModel = this.models.Transaction;
182
-
183
- // Find transaction
184
- let transaction = await TransactionModel.findOne({
185
- 'gateway.paymentIntentId': paymentId,
186
- });
187
-
188
- if (!transaction) {
189
- transaction = await TransactionModel.findById(paymentId);
190
- }
191
-
192
- if (!transaction) {
193
- throw new TransactionNotFoundError(paymentId);
194
- }
195
-
196
- if (transaction.status !== 'verified' && transaction.status !== 'completed') {
197
- throw new RefundError(transaction._id, 'Only verified/completed transactions can be refunded');
198
- }
199
-
200
- // Get provider
201
- const gatewayType = transaction.gateway?.type || 'manual';
202
- const provider = this.providers[gatewayType];
203
-
204
- if (!provider) {
205
- throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
206
- }
207
-
208
- // Check if provider supports refunds
209
- const capabilities = provider.getCapabilities();
210
- if (!capabilities.supportsRefunds) {
211
- throw new RefundNotSupportedError(gatewayType);
212
- }
213
-
214
- // Refund via provider
215
- const refundAmount = amount || transaction.amount;
216
- let refundResult = null;
217
-
218
- try {
219
- refundResult = await provider.refund(paymentId, refundAmount, { reason });
220
- } catch (error) {
221
- this.logger.error('Refund failed:', error);
222
- throw new RefundError(paymentId, error.message);
223
- }
224
-
225
- // Create separate refund transaction (EXPENSE) for proper accounting
226
- const refundTransactionType = this.config.transactionTypeMapping?.refund || TRANSACTION_TYPE.EXPENSE;
227
-
228
- const refundTransaction = await TransactionModel.create({
229
- organizationId: transaction.organizationId,
230
- customerId: transaction.customerId,
231
- amount: refundAmount,
232
- currency: transaction.currency,
233
- category: transaction.category,
234
- type: refundTransactionType, // EXPENSE - money going out
235
- method: transaction.method || 'manual',
236
- status: 'completed',
237
- gateway: {
238
- type: transaction.gateway?.type || 'manual',
239
- paymentIntentId: refundResult.id,
240
- provider: refundResult.provider,
241
- },
242
- paymentDetails: transaction.paymentDetails,
243
- metadata: {
244
- ...transaction.metadata,
245
- isRefund: true,
246
- originalTransactionId: transaction._id.toString(),
247
- refundReason: reason,
248
- refundResult: refundResult.metadata,
249
- },
250
- idempotencyKey: `refund_${transaction._id}_${Date.now()}`,
251
- });
252
-
253
- // Update original transaction status
254
- const isPartialRefund = refundAmount < transaction.amount;
255
- transaction.status = isPartialRefund ? 'partially_refunded' : 'refunded';
256
- transaction.refundedAmount = (transaction.refundedAmount || 0) + refundAmount;
257
- transaction.refundedAt = refundResult.refundedAt || new Date();
258
- transaction.metadata = {
259
- ...transaction.metadata,
260
- refundTransactionId: refundTransaction._id.toString(),
261
- refundReason: reason,
262
- };
263
-
264
- await transaction.save();
265
-
266
- // Trigger hook
267
- this._triggerHook('payment.refunded', {
268
- transaction,
269
- refundTransaction,
270
- refundResult,
271
- refundAmount,
272
- reason,
273
- isPartialRefund,
274
- });
275
-
276
- return {
277
- transaction,
278
- refundTransaction,
279
- refundResult,
280
- status: transaction.status,
281
- };
282
- }
283
-
284
- /**
285
- * Handle webhook from payment provider
286
- *
287
- * @param {String} provider - Provider name
288
- * @param {Object} payload - Webhook payload
289
- * @param {Object} headers - Request headers
290
- * @returns {Promise<Object>} { event, transaction }
291
- */
292
- async handleWebhook(providerName, payload, headers = {}) {
293
- const provider = this.providers[providerName];
294
-
295
- if (!provider) {
296
- throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
297
- }
298
-
299
- // Process webhook via provider
300
- let webhookEvent = null;
301
- try {
302
- webhookEvent = await provider.handleWebhook(payload, headers);
303
- } catch (error) {
304
- this.logger.error('Webhook processing failed:', error);
305
- throw new ProviderError(providerName, `Webhook processing failed: ${error.message}`);
306
- }
307
-
308
- // Find transaction by payment intent ID from webhook
309
- const TransactionModel = this.models.Transaction;
310
- const transaction = await TransactionModel.findOne({
311
- 'gateway.paymentIntentId': webhookEvent.data.paymentIntentId,
312
- });
313
-
314
- if (!transaction) {
315
- this.logger.warn('Transaction not found for webhook event', {
316
- provider: providerName,
317
- eventId: webhookEvent.id,
318
- paymentIntentId: webhookEvent.data.paymentIntentId,
319
- });
320
- throw new TransactionNotFoundError(webhookEvent.data.paymentIntentId);
321
- }
322
-
323
- // Check for duplicate webhook processing (idempotency)
324
- if (transaction.webhook?.eventId === webhookEvent.id && transaction.webhook?.processedAt) {
325
- this.logger.warn('Webhook already processed', {
326
- transactionId: transaction._id,
327
- eventId: webhookEvent.id,
328
- });
329
- return {
330
- event: webhookEvent,
331
- transaction,
332
- status: 'already_processed',
333
- };
334
- }
335
-
336
- // Update transaction based on webhook event
337
- transaction.webhook = {
338
- eventId: webhookEvent.id,
339
- eventType: webhookEvent.type,
340
- receivedAt: new Date(),
341
- processedAt: new Date(),
342
- data: webhookEvent.data,
343
- };
344
-
345
- // Update status based on webhook type
346
- if (webhookEvent.type === 'payment.succeeded') {
347
- transaction.status = 'verified';
348
- transaction.verifiedAt = webhookEvent.createdAt;
349
- } else if (webhookEvent.type === 'payment.failed') {
350
- transaction.status = 'failed';
351
- } else if (webhookEvent.type === 'refund.succeeded') {
352
- transaction.status = 'refunded';
353
- transaction.refundedAt = webhookEvent.createdAt;
354
- }
355
-
356
- await transaction.save();
357
-
358
- // Trigger hook
359
- this._triggerHook(`payment.webhook.${webhookEvent.type}`, {
360
- event: webhookEvent,
361
- transaction,
362
- });
363
-
364
- return {
365
- event: webhookEvent,
366
- transaction,
367
- status: 'processed',
368
- };
369
- }
370
-
371
- /**
372
- * List payments/transactions with filters
373
- *
374
- * @param {Object} filters - Query filters
375
- * @param {Object} options - Query options (limit, skip, sort)
376
- * @returns {Promise<Array>} Transactions
377
- */
378
- async list(filters = {}, options = {}) {
379
- const TransactionModel = this.models.Transaction;
380
- const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
381
-
382
- const transactions = await TransactionModel
383
- .find(filters)
384
- .limit(limit)
385
- .skip(skip)
386
- .sort(sort);
387
-
388
- return transactions;
389
- }
390
-
391
- /**
392
- * Get payment/transaction by ID
393
- *
394
- * @param {String} transactionId - Transaction ID
395
- * @returns {Promise<Object>} Transaction
396
- */
397
- async get(transactionId) {
398
- const TransactionModel = this.models.Transaction;
399
- const transaction = await TransactionModel.findById(transactionId);
400
-
401
- if (!transaction) {
402
- throw new TransactionNotFoundError(transactionId);
403
- }
404
-
405
- return transaction;
406
- }
407
-
408
- /**
409
- * Get provider instance
410
- *
411
- * @param {String} providerName - Provider name
412
- * @returns {Object} Provider instance
413
- */
414
- getProvider(providerName) {
415
- const provider = this.providers[providerName];
416
- if (!provider) {
417
- throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
418
- }
419
- return provider;
420
- }
421
-
422
- /**
423
- * Trigger event hook (fire-and-forget, non-blocking)
424
- * @private
425
- */
426
- _triggerHook(event, data) {
427
- triggerHook(this.hooks, event, data, this.logger);
428
- }
429
- }
430
-
431
- export default PaymentService;
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
+ import { reverseCommission } from '../utils/commission.js';
20
+ import { TRANSACTION_TYPE } from '../enums/transaction.enums.js';
21
+
22
+ /**
23
+ * Payment Service
24
+ * Uses DI container for all dependencies
25
+ */
26
+ export class PaymentService {
27
+ constructor(container) {
28
+ this.container = container;
29
+ this.models = container.get('models');
30
+ this.providers = container.get('providers');
31
+ this.config = container.get('config');
32
+ this.hooks = container.get('hooks');
33
+ this.logger = container.get('logger');
34
+ }
35
+
36
+ /**
37
+ * Verify a payment
38
+ *
39
+ * @param {String} paymentIntentId - Payment intent ID or transaction ID
40
+ * @param {Object} options - Verification options
41
+ * @param {String} options.verifiedBy - User ID who verified (for manual verification)
42
+ * @returns {Promise<Object>} { transaction, status }
43
+ */
44
+ async verify(paymentIntentId, options = {}) {
45
+ const { verifiedBy = null } = options;
46
+
47
+ const TransactionModel = this.models.Transaction;
48
+
49
+ // Find transaction by payment intent ID or transaction ID
50
+ let transaction = await TransactionModel.findOne({
51
+ 'gateway.paymentIntentId': paymentIntentId,
52
+ });
53
+
54
+ if (!transaction) {
55
+ // Try finding by transaction ID directly
56
+ transaction = await TransactionModel.findById(paymentIntentId);
57
+ }
58
+
59
+ if (!transaction) {
60
+ throw new TransactionNotFoundError(paymentIntentId);
61
+ }
62
+
63
+ if (transaction.status === 'verified' || transaction.status === 'completed') {
64
+ throw new AlreadyVerifiedError(transaction._id);
65
+ }
66
+
67
+ // Get provider for verification
68
+ const gatewayType = transaction.gateway?.type || 'manual';
69
+ const provider = this.providers[gatewayType];
70
+
71
+ if (!provider) {
72
+ throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
73
+ }
74
+
75
+ // Verify payment with provider
76
+ let paymentResult = null;
77
+ try {
78
+ paymentResult = await provider.verifyPayment(paymentIntentId);
79
+ } catch (error) {
80
+ this.logger.error('Payment verification failed:', error);
81
+
82
+ // Update transaction as failed
83
+ transaction.status = 'failed';
84
+ transaction.metadata = {
85
+ ...transaction.metadata,
86
+ verificationError: error.message,
87
+ };
88
+ await transaction.save();
89
+
90
+ throw new PaymentVerificationError(paymentIntentId, error.message);
91
+ }
92
+
93
+ // Update transaction based on verification result
94
+ transaction.status = paymentResult.status === 'succeeded' ? 'verified' : paymentResult.status;
95
+ transaction.verifiedAt = paymentResult.paidAt || new Date();
96
+ transaction.verifiedBy = verifiedBy;
97
+ transaction.gateway = {
98
+ ...transaction.gateway,
99
+ verificationData: paymentResult.metadata,
100
+ };
101
+
102
+ await transaction.save();
103
+
104
+ // Trigger hook
105
+ this._triggerHook('payment.verified', {
106
+ transaction,
107
+ paymentResult,
108
+ verifiedBy,
109
+ });
110
+
111
+ return {
112
+ transaction,
113
+ paymentResult,
114
+ status: 'verified',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Get payment status
120
+ *
121
+ * @param {String} paymentIntentId - Payment intent ID or transaction ID
122
+ * @returns {Promise<Object>} { transaction, status }
123
+ */
124
+ async getStatus(paymentIntentId) {
125
+ const TransactionModel = this.models.Transaction;
126
+
127
+ // Find transaction
128
+ let transaction = await TransactionModel.findOne({
129
+ 'gateway.paymentIntentId': paymentIntentId,
130
+ });
131
+
132
+ if (!transaction) {
133
+ transaction = await TransactionModel.findById(paymentIntentId);
134
+ }
135
+
136
+ if (!transaction) {
137
+ throw new TransactionNotFoundError(paymentIntentId);
138
+ }
139
+
140
+ // Get provider
141
+ const gatewayType = transaction.gateway?.type || 'manual';
142
+ const provider = this.providers[gatewayType];
143
+
144
+ if (!provider) {
145
+ throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
146
+ }
147
+
148
+ // Get status from provider
149
+ let paymentResult = null;
150
+ try {
151
+ paymentResult = await provider.getStatus(paymentIntentId);
152
+ } catch (error) {
153
+ this.logger.warn('Failed to get payment status from provider:', error);
154
+ // Return transaction status as fallback
155
+ return {
156
+ transaction,
157
+ status: transaction.status,
158
+ provider: gatewayType,
159
+ };
160
+ }
161
+
162
+ return {
163
+ transaction,
164
+ paymentResult,
165
+ status: paymentResult.status,
166
+ provider: gatewayType,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Refund a payment
172
+ *
173
+ * @param {String} paymentId - Payment intent ID or transaction ID
174
+ * @param {Number} amount - Amount to refund (optional, full refund if not provided)
175
+ * @param {Object} options - Refund options
176
+ * @param {String} options.reason - Refund reason
177
+ * @returns {Promise<Object>} { transaction, refundResult }
178
+ */
179
+ async refund(paymentId, amount = null, options = {}) {
180
+ const { reason = null } = options;
181
+
182
+ const TransactionModel = this.models.Transaction;
183
+
184
+ // Find transaction
185
+ let transaction = await TransactionModel.findOne({
186
+ 'gateway.paymentIntentId': paymentId,
187
+ });
188
+
189
+ if (!transaction) {
190
+ transaction = await TransactionModel.findById(paymentId);
191
+ }
192
+
193
+ if (!transaction) {
194
+ throw new TransactionNotFoundError(paymentId);
195
+ }
196
+
197
+ if (transaction.status !== 'verified' && transaction.status !== 'completed') {
198
+ throw new RefundError(transaction._id, 'Only verified/completed transactions can be refunded');
199
+ }
200
+
201
+ // Get provider
202
+ const gatewayType = transaction.gateway?.type || 'manual';
203
+ const provider = this.providers[gatewayType];
204
+
205
+ if (!provider) {
206
+ throw new ProviderNotFoundError(gatewayType, Object.keys(this.providers));
207
+ }
208
+
209
+ // Check if provider supports refunds
210
+ const capabilities = provider.getCapabilities();
211
+ if (!capabilities.supportsRefunds) {
212
+ throw new RefundNotSupportedError(gatewayType);
213
+ }
214
+
215
+ // Refund via provider
216
+ const refundAmount = amount || transaction.amount;
217
+ let refundResult = null;
218
+
219
+ try {
220
+ refundResult = await provider.refund(paymentId, refundAmount, { reason });
221
+ } catch (error) {
222
+ this.logger.error('Refund failed:', error);
223
+ throw new RefundError(paymentId, error.message);
224
+ }
225
+
226
+ // Create separate refund transaction (EXPENSE) for proper accounting
227
+ const refundTransactionType = this.config.transactionTypeMapping?.refund || TRANSACTION_TYPE.EXPENSE;
228
+
229
+ // Reverse commission proportionally for refund
230
+ const refundCommission = transaction.commission
231
+ ? reverseCommission(transaction.commission, transaction.amount, refundAmount)
232
+ : null;
233
+
234
+ const refundTransaction = await TransactionModel.create({
235
+ organizationId: transaction.organizationId,
236
+ customerId: transaction.customerId,
237
+ amount: refundAmount,
238
+ currency: transaction.currency,
239
+ category: transaction.category,
240
+ type: refundTransactionType, // EXPENSE - money going out
241
+ method: transaction.method || 'manual',
242
+ status: 'completed',
243
+ gateway: {
244
+ type: transaction.gateway?.type || 'manual',
245
+ paymentIntentId: refundResult.id,
246
+ provider: refundResult.provider,
247
+ },
248
+ paymentDetails: transaction.paymentDetails,
249
+ ...(refundCommission && { commission: refundCommission }), // Reversed commission
250
+ // Polymorphic reference (copy from original transaction)
251
+ ...(transaction.referenceId && { referenceId: transaction.referenceId }),
252
+ ...(transaction.referenceModel && { referenceModel: transaction.referenceModel }),
253
+ metadata: {
254
+ ...transaction.metadata,
255
+ isRefund: true,
256
+ originalTransactionId: transaction._id.toString(),
257
+ refundReason: reason,
258
+ refundResult: refundResult.metadata,
259
+ },
260
+ idempotencyKey: `refund_${transaction._id}_${Date.now()}`,
261
+ });
262
+
263
+ // Update original transaction status
264
+ const isPartialRefund = refundAmount < transaction.amount;
265
+ transaction.status = isPartialRefund ? 'partially_refunded' : 'refunded';
266
+ transaction.refundedAmount = (transaction.refundedAmount || 0) + refundAmount;
267
+ transaction.refundedAt = refundResult.refundedAt || new Date();
268
+ transaction.metadata = {
269
+ ...transaction.metadata,
270
+ refundTransactionId: refundTransaction._id.toString(),
271
+ refundReason: reason,
272
+ };
273
+
274
+ await transaction.save();
275
+
276
+ // Trigger hook
277
+ this._triggerHook('payment.refunded', {
278
+ transaction,
279
+ refundTransaction,
280
+ refundResult,
281
+ refundAmount,
282
+ reason,
283
+ isPartialRefund,
284
+ });
285
+
286
+ return {
287
+ transaction,
288
+ refundTransaction,
289
+ refundResult,
290
+ status: transaction.status,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Handle webhook from payment provider
296
+ *
297
+ * @param {String} provider - Provider name
298
+ * @param {Object} payload - Webhook payload
299
+ * @param {Object} headers - Request headers
300
+ * @returns {Promise<Object>} { event, transaction }
301
+ */
302
+ async handleWebhook(providerName, payload, headers = {}) {
303
+ const provider = this.providers[providerName];
304
+
305
+ if (!provider) {
306
+ throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
307
+ }
308
+
309
+ // Process webhook via provider
310
+ let webhookEvent = null;
311
+ try {
312
+ webhookEvent = await provider.handleWebhook(payload, headers);
313
+ } catch (error) {
314
+ this.logger.error('Webhook processing failed:', error);
315
+ throw new ProviderError(providerName, `Webhook processing failed: ${error.message}`);
316
+ }
317
+
318
+ // Find transaction by payment intent ID from webhook
319
+ const TransactionModel = this.models.Transaction;
320
+ const transaction = await TransactionModel.findOne({
321
+ 'gateway.paymentIntentId': webhookEvent.data.paymentIntentId,
322
+ });
323
+
324
+ if (!transaction) {
325
+ this.logger.warn('Transaction not found for webhook event', {
326
+ provider: providerName,
327
+ eventId: webhookEvent.id,
328
+ paymentIntentId: webhookEvent.data.paymentIntentId,
329
+ });
330
+ throw new TransactionNotFoundError(webhookEvent.data.paymentIntentId);
331
+ }
332
+
333
+ // Check for duplicate webhook processing (idempotency)
334
+ if (transaction.webhook?.eventId === webhookEvent.id && transaction.webhook?.processedAt) {
335
+ this.logger.warn('Webhook already processed', {
336
+ transactionId: transaction._id,
337
+ eventId: webhookEvent.id,
338
+ });
339
+ return {
340
+ event: webhookEvent,
341
+ transaction,
342
+ status: 'already_processed',
343
+ };
344
+ }
345
+
346
+ // Update transaction based on webhook event
347
+ transaction.webhook = {
348
+ eventId: webhookEvent.id,
349
+ eventType: webhookEvent.type,
350
+ receivedAt: new Date(),
351
+ processedAt: new Date(),
352
+ data: webhookEvent.data,
353
+ };
354
+
355
+ // Update status based on webhook type
356
+ if (webhookEvent.type === 'payment.succeeded') {
357
+ transaction.status = 'verified';
358
+ transaction.verifiedAt = webhookEvent.createdAt;
359
+ } else if (webhookEvent.type === 'payment.failed') {
360
+ transaction.status = 'failed';
361
+ } else if (webhookEvent.type === 'refund.succeeded') {
362
+ transaction.status = 'refunded';
363
+ transaction.refundedAt = webhookEvent.createdAt;
364
+ }
365
+
366
+ await transaction.save();
367
+
368
+ // Trigger hook
369
+ this._triggerHook(`payment.webhook.${webhookEvent.type}`, {
370
+ event: webhookEvent,
371
+ transaction,
372
+ });
373
+
374
+ return {
375
+ event: webhookEvent,
376
+ transaction,
377
+ status: 'processed',
378
+ };
379
+ }
380
+
381
+ /**
382
+ * List payments/transactions with filters
383
+ *
384
+ * @param {Object} filters - Query filters
385
+ * @param {Object} options - Query options (limit, skip, sort)
386
+ * @returns {Promise<Array>} Transactions
387
+ */
388
+ async list(filters = {}, options = {}) {
389
+ const TransactionModel = this.models.Transaction;
390
+ const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
391
+
392
+ const transactions = await TransactionModel
393
+ .find(filters)
394
+ .limit(limit)
395
+ .skip(skip)
396
+ .sort(sort);
397
+
398
+ return transactions;
399
+ }
400
+
401
+ /**
402
+ * Get payment/transaction by ID
403
+ *
404
+ * @param {String} transactionId - Transaction ID
405
+ * @returns {Promise<Object>} Transaction
406
+ */
407
+ async get(transactionId) {
408
+ const TransactionModel = this.models.Transaction;
409
+ const transaction = await TransactionModel.findById(transactionId);
410
+
411
+ if (!transaction) {
412
+ throw new TransactionNotFoundError(transactionId);
413
+ }
414
+
415
+ return transaction;
416
+ }
417
+
418
+ /**
419
+ * Get provider instance
420
+ *
421
+ * @param {String} providerName - Provider name
422
+ * @returns {Object} Provider instance
423
+ */
424
+ getProvider(providerName) {
425
+ const provider = this.providers[providerName];
426
+ if (!provider) {
427
+ throw new ProviderNotFoundError(providerName, Object.keys(this.providers));
428
+ }
429
+ return provider;
430
+ }
431
+
432
+ /**
433
+ * Trigger event hook (fire-and-forget, non-blocking)
434
+ * @private
435
+ */
436
+ _triggerHook(event, data) {
437
+ triggerHook(this.hooks, event, data, this.logger);
438
+ }
439
+ }
440
+
441
+ export default PaymentService;