@classytic/revenue 0.0.21 → 0.0.23

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,400 +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
-
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 TransactionNotFoundError(paymentIntentId);
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 ProviderNotFoundError(gatewayType, Object.keys(this.providers));
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 RefundError(paymentId, 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 ProviderNotFoundError(providerName, Object.keys(this.providers));
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 ProviderError(providerName, `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 TransactionNotFoundError(webhookEvent.data.paymentIntentId);
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 TransactionNotFoundError(transactionId);
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 ProviderNotFoundError(providerName, Object.keys(this.providers));
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;
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;