@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.
@@ -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;