@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,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Service
|
|
3
|
+
* @classytic/revenue
|
|
4
|
+
*
|
|
5
|
+
* Framework-agnostic subscription management service with DI
|
|
6
|
+
* Handles complete subscription lifecycle using provider system
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { nanoid } from 'nanoid';
|
|
10
|
+
import {
|
|
11
|
+
MissingRequiredFieldError,
|
|
12
|
+
InvalidAmountError,
|
|
13
|
+
ProviderNotFoundError,
|
|
14
|
+
SubscriptionNotFoundError,
|
|
15
|
+
ModelNotRegisteredError,
|
|
16
|
+
SubscriptionNotActiveError,
|
|
17
|
+
PaymentIntentCreationError,
|
|
18
|
+
} from '../core/errors.js';
|
|
19
|
+
import { triggerHook } from '../utils/hooks.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Subscription Service
|
|
23
|
+
* Uses DI container for all dependencies
|
|
24
|
+
*/
|
|
25
|
+
export class SubscriptionService {
|
|
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
|
+
* Create a new subscription
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} params - Subscription parameters
|
|
39
|
+
* @param {Object} params.data - Subscription data (organizationId, customerId, etc.)
|
|
40
|
+
* @param {String} params.planKey - Plan key ('monthly', 'quarterly', 'yearly')
|
|
41
|
+
* @param {Number} params.amount - Subscription amount
|
|
42
|
+
* @param {String} params.currency - Currency code (default: 'BDT')
|
|
43
|
+
* @param {String} params.gateway - Payment gateway to use (default: 'manual')
|
|
44
|
+
* @param {Object} params.paymentData - Payment method details
|
|
45
|
+
* @param {Object} params.metadata - Additional metadata
|
|
46
|
+
* @param {String} params.idempotencyKey - Idempotency key for duplicate prevention
|
|
47
|
+
* @returns {Promise<Object>} { subscription, transaction, paymentIntent }
|
|
48
|
+
*/
|
|
49
|
+
async create(params) {
|
|
50
|
+
const {
|
|
51
|
+
data,
|
|
52
|
+
planKey,
|
|
53
|
+
amount,
|
|
54
|
+
currency = 'BDT',
|
|
55
|
+
gateway = 'manual',
|
|
56
|
+
paymentData,
|
|
57
|
+
metadata = {},
|
|
58
|
+
idempotencyKey = null,
|
|
59
|
+
} = params;
|
|
60
|
+
|
|
61
|
+
// Validate required fields
|
|
62
|
+
if (!data.organizationId) {
|
|
63
|
+
throw new MissingRequiredFieldError('organizationId');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!planKey) {
|
|
67
|
+
throw new MissingRequiredFieldError('planKey');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (amount < 0) {
|
|
71
|
+
throw new InvalidAmountError(amount);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isFree = amount === 0;
|
|
75
|
+
|
|
76
|
+
// Get provider
|
|
77
|
+
const provider = this.providers[gateway];
|
|
78
|
+
if (!provider) {
|
|
79
|
+
throw new ProviderNotFoundError(gateway, Object.keys(this.providers));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Create payment intent if not free
|
|
83
|
+
let paymentIntent = null;
|
|
84
|
+
let transaction = null;
|
|
85
|
+
|
|
86
|
+
if (!isFree) {
|
|
87
|
+
// Create payment intent via provider
|
|
88
|
+
try {
|
|
89
|
+
paymentIntent = await provider.createIntent({
|
|
90
|
+
amount,
|
|
91
|
+
currency,
|
|
92
|
+
metadata: {
|
|
93
|
+
...metadata,
|
|
94
|
+
type: 'subscription',
|
|
95
|
+
planKey,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new PaymentIntentCreationError(gateway, error);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Create transaction record
|
|
103
|
+
const TransactionModel = this.models.Transaction;
|
|
104
|
+
transaction = await TransactionModel.create({
|
|
105
|
+
organizationId: data.organizationId,
|
|
106
|
+
customerId: data.customerId || null,
|
|
107
|
+
amount,
|
|
108
|
+
currency,
|
|
109
|
+
category: 'platform_subscription',
|
|
110
|
+
type: 'credit',
|
|
111
|
+
status: paymentIntent.status === 'succeeded' ? 'verified' : 'pending',
|
|
112
|
+
gateway: {
|
|
113
|
+
type: gateway,
|
|
114
|
+
paymentIntentId: paymentIntent.id,
|
|
115
|
+
provider: paymentIntent.provider,
|
|
116
|
+
},
|
|
117
|
+
paymentDetails: {
|
|
118
|
+
provider: gateway,
|
|
119
|
+
...paymentData,
|
|
120
|
+
},
|
|
121
|
+
metadata: {
|
|
122
|
+
...metadata,
|
|
123
|
+
planKey,
|
|
124
|
+
paymentIntentId: paymentIntent.id,
|
|
125
|
+
},
|
|
126
|
+
idempotencyKey: idempotencyKey || `sub_${nanoid(16)}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Create subscription record (if Subscription model exists)
|
|
131
|
+
let subscription = null;
|
|
132
|
+
if (this.models.Subscription) {
|
|
133
|
+
const SubscriptionModel = this.models.Subscription;
|
|
134
|
+
|
|
135
|
+
subscription = await SubscriptionModel.create({
|
|
136
|
+
organizationId: data.organizationId,
|
|
137
|
+
customerId: data.customerId || null,
|
|
138
|
+
planKey,
|
|
139
|
+
amount,
|
|
140
|
+
currency,
|
|
141
|
+
status: isFree ? 'active' : 'pending',
|
|
142
|
+
isActive: isFree,
|
|
143
|
+
gateway,
|
|
144
|
+
transactionId: transaction?._id || null,
|
|
145
|
+
paymentIntentId: paymentIntent?.id || null,
|
|
146
|
+
metadata: {
|
|
147
|
+
...metadata,
|
|
148
|
+
isFree,
|
|
149
|
+
},
|
|
150
|
+
...data,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Trigger hook
|
|
155
|
+
this._triggerHook('subscription.created', {
|
|
156
|
+
subscription,
|
|
157
|
+
transaction,
|
|
158
|
+
paymentIntent,
|
|
159
|
+
isFree,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
subscription,
|
|
164
|
+
transaction,
|
|
165
|
+
paymentIntent,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Activate subscription after payment verification
|
|
171
|
+
*
|
|
172
|
+
* @param {String} subscriptionId - Subscription ID or transaction ID
|
|
173
|
+
* @param {Object} options - Activation options
|
|
174
|
+
* @returns {Promise<Object>} Updated subscription
|
|
175
|
+
*/
|
|
176
|
+
async activate(subscriptionId, options = {}) {
|
|
177
|
+
const { timestamp = new Date() } = options;
|
|
178
|
+
|
|
179
|
+
if (!this.models.Subscription) {
|
|
180
|
+
throw new ModelNotRegisteredError('Subscription');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const SubscriptionModel = this.models.Subscription;
|
|
184
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
185
|
+
|
|
186
|
+
if (!subscription) {
|
|
187
|
+
throw new SubscriptionNotFoundError(subscriptionId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (subscription.isActive) {
|
|
191
|
+
this.logger.warn('Subscription already active', { subscriptionId });
|
|
192
|
+
return subscription;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Calculate period dates based on plan
|
|
196
|
+
const periodEnd = this._calculatePeriodEnd(subscription.planKey, timestamp);
|
|
197
|
+
|
|
198
|
+
// Update subscription
|
|
199
|
+
subscription.isActive = true;
|
|
200
|
+
subscription.status = 'active';
|
|
201
|
+
subscription.startDate = timestamp;
|
|
202
|
+
subscription.endDate = periodEnd;
|
|
203
|
+
subscription.activatedAt = timestamp;
|
|
204
|
+
|
|
205
|
+
await subscription.save();
|
|
206
|
+
|
|
207
|
+
// Trigger hook
|
|
208
|
+
this._triggerHook('subscription.activated', {
|
|
209
|
+
subscription,
|
|
210
|
+
activatedAt: timestamp,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return subscription;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Renew subscription
|
|
218
|
+
*
|
|
219
|
+
* @param {String} subscriptionId - Subscription ID
|
|
220
|
+
* @param {Object} params - Renewal parameters
|
|
221
|
+
* @returns {Promise<Object>} { subscription, transaction, paymentIntent }
|
|
222
|
+
*/
|
|
223
|
+
async renew(subscriptionId, params = {}) {
|
|
224
|
+
const {
|
|
225
|
+
gateway = 'manual',
|
|
226
|
+
paymentData,
|
|
227
|
+
metadata = {},
|
|
228
|
+
idempotencyKey = null,
|
|
229
|
+
} = params;
|
|
230
|
+
|
|
231
|
+
if (!this.models.Subscription) {
|
|
232
|
+
throw new Error('Subscription model not registered');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const SubscriptionModel = this.models.Subscription;
|
|
236
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
237
|
+
|
|
238
|
+
if (!subscription) {
|
|
239
|
+
throw new Error('Subscription not found');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (subscription.amount === 0) {
|
|
243
|
+
throw new Error('Free subscriptions do not require renewal');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Get provider
|
|
247
|
+
const provider = this.providers[gateway];
|
|
248
|
+
if (!provider) {
|
|
249
|
+
throw new Error(`Payment provider "${gateway}" not found`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Create payment intent
|
|
253
|
+
const paymentIntent = await provider.createIntent({
|
|
254
|
+
amount: subscription.amount,
|
|
255
|
+
currency: subscription.currency || 'BDT',
|
|
256
|
+
metadata: {
|
|
257
|
+
...metadata,
|
|
258
|
+
type: 'subscription_renewal',
|
|
259
|
+
subscriptionId: subscription._id.toString(),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Create transaction
|
|
264
|
+
const TransactionModel = this.models.Transaction;
|
|
265
|
+
const transaction = await TransactionModel.create({
|
|
266
|
+
organizationId: subscription.organizationId,
|
|
267
|
+
customerId: subscription.customerId,
|
|
268
|
+
amount: subscription.amount,
|
|
269
|
+
currency: subscription.currency || 'BDT',
|
|
270
|
+
category: 'platform_subscription',
|
|
271
|
+
type: 'credit',
|
|
272
|
+
status: paymentIntent.status === 'succeeded' ? 'verified' : 'pending',
|
|
273
|
+
gateway: {
|
|
274
|
+
type: gateway,
|
|
275
|
+
paymentIntentId: paymentIntent.id,
|
|
276
|
+
provider: paymentIntent.provider,
|
|
277
|
+
},
|
|
278
|
+
paymentDetails: {
|
|
279
|
+
provider: gateway,
|
|
280
|
+
...paymentData,
|
|
281
|
+
},
|
|
282
|
+
metadata: {
|
|
283
|
+
...metadata,
|
|
284
|
+
subscriptionId: subscription._id.toString(),
|
|
285
|
+
isRenewal: true,
|
|
286
|
+
paymentIntentId: paymentIntent.id,
|
|
287
|
+
},
|
|
288
|
+
idempotencyKey: idempotencyKey || `renewal_${nanoid(16)}`,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Update subscription
|
|
292
|
+
subscription.status = 'pending_renewal';
|
|
293
|
+
subscription.renewalTransactionId = transaction._id;
|
|
294
|
+
subscription.renewalCount = (subscription.renewalCount || 0) + 1;
|
|
295
|
+
await subscription.save();
|
|
296
|
+
|
|
297
|
+
// Trigger hook
|
|
298
|
+
this._triggerHook('subscription.renewed', {
|
|
299
|
+
subscription,
|
|
300
|
+
transaction,
|
|
301
|
+
paymentIntent,
|
|
302
|
+
renewalCount: subscription.renewalCount,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
subscription,
|
|
307
|
+
transaction,
|
|
308
|
+
paymentIntent,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Cancel subscription
|
|
314
|
+
*
|
|
315
|
+
* @param {String} subscriptionId - Subscription ID
|
|
316
|
+
* @param {Object} options - Cancellation options
|
|
317
|
+
* @param {Boolean} options.immediate - Cancel immediately vs at period end
|
|
318
|
+
* @param {String} options.reason - Cancellation reason
|
|
319
|
+
* @returns {Promise<Object>} Updated subscription
|
|
320
|
+
*/
|
|
321
|
+
async cancel(subscriptionId, options = {}) {
|
|
322
|
+
const { immediate = false, reason = null } = options;
|
|
323
|
+
|
|
324
|
+
if (!this.models.Subscription) {
|
|
325
|
+
throw new Error('Subscription model not registered');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const SubscriptionModel = this.models.Subscription;
|
|
329
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
330
|
+
|
|
331
|
+
if (!subscription) {
|
|
332
|
+
throw new Error('Subscription not found');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const now = new Date();
|
|
336
|
+
|
|
337
|
+
if (immediate) {
|
|
338
|
+
subscription.isActive = false;
|
|
339
|
+
subscription.status = 'cancelled';
|
|
340
|
+
subscription.canceledAt = now;
|
|
341
|
+
subscription.cancellationReason = reason;
|
|
342
|
+
} else {
|
|
343
|
+
// Schedule cancellation at period end
|
|
344
|
+
subscription.cancelAt = subscription.endDate || now;
|
|
345
|
+
subscription.cancellationReason = reason;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await subscription.save();
|
|
349
|
+
|
|
350
|
+
// Trigger hook
|
|
351
|
+
this._triggerHook('subscription.cancelled', {
|
|
352
|
+
subscription,
|
|
353
|
+
immediate,
|
|
354
|
+
reason,
|
|
355
|
+
canceledAt: immediate ? now : subscription.cancelAt,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return subscription;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Pause subscription
|
|
363
|
+
*
|
|
364
|
+
* @param {String} subscriptionId - Subscription ID
|
|
365
|
+
* @param {Object} options - Pause options
|
|
366
|
+
* @returns {Promise<Object>} Updated subscription
|
|
367
|
+
*/
|
|
368
|
+
async pause(subscriptionId, options = {}) {
|
|
369
|
+
const { reason = null } = options;
|
|
370
|
+
|
|
371
|
+
if (!this.models.Subscription) {
|
|
372
|
+
throw new Error('Subscription model not registered');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const SubscriptionModel = this.models.Subscription;
|
|
376
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
377
|
+
|
|
378
|
+
if (!subscription) {
|
|
379
|
+
throw new Error('Subscription not found');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!subscription.isActive) {
|
|
383
|
+
throw new Error('Only active subscriptions can be paused');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const pausedAt = new Date();
|
|
387
|
+
subscription.isActive = false;
|
|
388
|
+
subscription.status = 'paused';
|
|
389
|
+
subscription.pausedAt = pausedAt;
|
|
390
|
+
subscription.pauseReason = reason;
|
|
391
|
+
|
|
392
|
+
await subscription.save();
|
|
393
|
+
|
|
394
|
+
// Trigger hook
|
|
395
|
+
this._triggerHook('subscription.paused', {
|
|
396
|
+
subscription,
|
|
397
|
+
reason,
|
|
398
|
+
pausedAt,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return subscription;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Resume subscription
|
|
406
|
+
*
|
|
407
|
+
* @param {String} subscriptionId - Subscription ID
|
|
408
|
+
* @param {Object} options - Resume options
|
|
409
|
+
* @returns {Promise<Object>} Updated subscription
|
|
410
|
+
*/
|
|
411
|
+
async resume(subscriptionId, options = {}) {
|
|
412
|
+
const { extendPeriod = false } = options;
|
|
413
|
+
|
|
414
|
+
if (!this.models.Subscription) {
|
|
415
|
+
throw new Error('Subscription model not registered');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const SubscriptionModel = this.models.Subscription;
|
|
419
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
420
|
+
|
|
421
|
+
if (!subscription) {
|
|
422
|
+
throw new Error('Subscription not found');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!subscription.pausedAt) {
|
|
426
|
+
throw new Error('Only paused subscriptions can be resumed');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const now = new Date();
|
|
430
|
+
const pausedAt = new Date(subscription.pausedAt);
|
|
431
|
+
const pauseDuration = now - pausedAt;
|
|
432
|
+
|
|
433
|
+
subscription.isActive = true;
|
|
434
|
+
subscription.status = 'active';
|
|
435
|
+
subscription.pausedAt = null;
|
|
436
|
+
subscription.pauseReason = null;
|
|
437
|
+
|
|
438
|
+
// Optionally extend period by pause duration
|
|
439
|
+
if (extendPeriod && subscription.endDate) {
|
|
440
|
+
const currentEnd = new Date(subscription.endDate);
|
|
441
|
+
subscription.endDate = new Date(currentEnd.getTime() + pauseDuration);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await subscription.save();
|
|
445
|
+
|
|
446
|
+
// Trigger hook
|
|
447
|
+
this._triggerHook('subscription.resumed', {
|
|
448
|
+
subscription,
|
|
449
|
+
extendPeriod,
|
|
450
|
+
pauseDuration,
|
|
451
|
+
resumedAt: now,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return subscription;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* List subscriptions with filters
|
|
459
|
+
*
|
|
460
|
+
* @param {Object} filters - Query filters
|
|
461
|
+
* @param {Object} options - Query options (limit, skip, sort)
|
|
462
|
+
* @returns {Promise<Array>} Subscriptions
|
|
463
|
+
*/
|
|
464
|
+
async list(filters = {}, options = {}) {
|
|
465
|
+
if (!this.models.Subscription) {
|
|
466
|
+
throw new Error('Subscription model not registered');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const SubscriptionModel = this.models.Subscription;
|
|
470
|
+
const { limit = 50, skip = 0, sort = { createdAt: -1 } } = options;
|
|
471
|
+
|
|
472
|
+
const subscriptions = await SubscriptionModel
|
|
473
|
+
.find(filters)
|
|
474
|
+
.limit(limit)
|
|
475
|
+
.skip(skip)
|
|
476
|
+
.sort(sort);
|
|
477
|
+
|
|
478
|
+
return subscriptions;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Get subscription by ID
|
|
483
|
+
*
|
|
484
|
+
* @param {String} subscriptionId - Subscription ID
|
|
485
|
+
* @returns {Promise<Object>} Subscription
|
|
486
|
+
*/
|
|
487
|
+
async get(subscriptionId) {
|
|
488
|
+
if (!this.models.Subscription) {
|
|
489
|
+
throw new Error('Subscription model not registered');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const SubscriptionModel = this.models.Subscription;
|
|
493
|
+
const subscription = await SubscriptionModel.findById(subscriptionId);
|
|
494
|
+
|
|
495
|
+
if (!subscription) {
|
|
496
|
+
throw new Error('Subscription not found');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return subscription;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Calculate period end date based on plan key
|
|
504
|
+
* @private
|
|
505
|
+
*/
|
|
506
|
+
_calculatePeriodEnd(planKey, startDate = new Date()) {
|
|
507
|
+
const start = new Date(startDate);
|
|
508
|
+
let end = new Date(start);
|
|
509
|
+
|
|
510
|
+
switch (planKey) {
|
|
511
|
+
case 'monthly':
|
|
512
|
+
end.setMonth(end.getMonth() + 1);
|
|
513
|
+
break;
|
|
514
|
+
case 'quarterly':
|
|
515
|
+
end.setMonth(end.getMonth() + 3);
|
|
516
|
+
break;
|
|
517
|
+
case 'yearly':
|
|
518
|
+
end.setFullYear(end.getFullYear() + 1);
|
|
519
|
+
break;
|
|
520
|
+
default:
|
|
521
|
+
// Default to 30 days
|
|
522
|
+
end.setDate(end.getDate() + 30);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return end;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Trigger event hook (fire-and-forget, non-blocking)
|
|
530
|
+
* @private
|
|
531
|
+
*/
|
|
532
|
+
_triggerHook(event, data) {
|
|
533
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export default SubscriptionService;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction Service
|
|
3
|
+
* @classytic/revenue
|
|
4
|
+
*
|
|
5
|
+
* Thin, focused transaction service for core operations
|
|
6
|
+
* Users handle their own analytics, exports, and complex queries
|
|
7
|
+
*
|
|
8
|
+
* Works with ANY model implementation:
|
|
9
|
+
* - Plain Mongoose models
|
|
10
|
+
* - @classytic/mongokit Repository instances
|
|
11
|
+
* - Any other abstraction with compatible interface
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { TransactionNotFoundError } from '../core/errors.js';
|
|
15
|
+
import { triggerHook } from '../utils/hooks.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Transaction Service
|
|
19
|
+
* Focused on core transaction lifecycle operations
|
|
20
|
+
*/
|
|
21
|
+
export class TransactionService {
|
|
22
|
+
constructor(container) {
|
|
23
|
+
this.container = container;
|
|
24
|
+
this.models = container.get('models');
|
|
25
|
+
this.hooks = container.get('hooks');
|
|
26
|
+
this.logger = container.get('logger');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get transaction by ID
|
|
31
|
+
*
|
|
32
|
+
* @param {String} transactionId - Transaction ID
|
|
33
|
+
* @returns {Promise<Object>} Transaction
|
|
34
|
+
*/
|
|
35
|
+
async get(transactionId) {
|
|
36
|
+
const TransactionModel = this.models.Transaction;
|
|
37
|
+
const transaction = await TransactionModel.findById(transactionId);
|
|
38
|
+
|
|
39
|
+
if (!transaction) {
|
|
40
|
+
throw new TransactionNotFoundError(transactionId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return transaction;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List transactions with filters
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} filters - Query filters
|
|
50
|
+
* @param {Object} options - Query options (limit, skip, sort, populate)
|
|
51
|
+
* @returns {Promise<Object>} { transactions, total, page, limit }
|
|
52
|
+
*/
|
|
53
|
+
async list(filters = {}, options = {}) {
|
|
54
|
+
const TransactionModel = this.models.Transaction;
|
|
55
|
+
const {
|
|
56
|
+
limit = 50,
|
|
57
|
+
skip = 0,
|
|
58
|
+
page = null,
|
|
59
|
+
sort = { createdAt: -1 },
|
|
60
|
+
populate = [],
|
|
61
|
+
} = options;
|
|
62
|
+
|
|
63
|
+
// Calculate pagination
|
|
64
|
+
const actualSkip = page ? (page - 1) * limit : skip;
|
|
65
|
+
|
|
66
|
+
// Build query
|
|
67
|
+
let query = TransactionModel.find(filters)
|
|
68
|
+
.limit(limit)
|
|
69
|
+
.skip(actualSkip)
|
|
70
|
+
.sort(sort);
|
|
71
|
+
|
|
72
|
+
// Apply population if supported
|
|
73
|
+
if (populate.length > 0 && typeof query.populate === 'function') {
|
|
74
|
+
populate.forEach(field => {
|
|
75
|
+
query = query.populate(field);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const transactions = await query;
|
|
80
|
+
|
|
81
|
+
// Count documents (works with both Mongoose and Repository)
|
|
82
|
+
const total = await (TransactionModel.countDocuments
|
|
83
|
+
? TransactionModel.countDocuments(filters)
|
|
84
|
+
: TransactionModel.count(filters));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
transactions,
|
|
88
|
+
total,
|
|
89
|
+
page: page || Math.floor(actualSkip / limit) + 1,
|
|
90
|
+
limit,
|
|
91
|
+
pages: Math.ceil(total / limit),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Update transaction
|
|
98
|
+
*
|
|
99
|
+
* @param {String} transactionId - Transaction ID
|
|
100
|
+
* @param {Object} updates - Fields to update
|
|
101
|
+
* @returns {Promise<Object>} Updated transaction
|
|
102
|
+
*/
|
|
103
|
+
async update(transactionId, updates) {
|
|
104
|
+
const TransactionModel = this.models.Transaction;
|
|
105
|
+
|
|
106
|
+
// Support both Repository pattern and Mongoose
|
|
107
|
+
let transaction;
|
|
108
|
+
if (typeof TransactionModel.update === 'function') {
|
|
109
|
+
// Repository pattern
|
|
110
|
+
transaction = await TransactionModel.update(transactionId, updates);
|
|
111
|
+
} else {
|
|
112
|
+
// Plain Mongoose
|
|
113
|
+
transaction = await TransactionModel.findByIdAndUpdate(
|
|
114
|
+
transactionId,
|
|
115
|
+
{ $set: updates },
|
|
116
|
+
{ new: true }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!transaction) {
|
|
121
|
+
throw new TransactionNotFoundError(transactionId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Trigger hook (fire-and-forget, non-blocking)
|
|
125
|
+
this._triggerHook('transaction.updated', {
|
|
126
|
+
transaction,
|
|
127
|
+
updates,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return transaction;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Trigger event hook (fire-and-forget, non-blocking)
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
_triggerHook(event, data) {
|
|
138
|
+
triggerHook(this.hooks, event, data, this.logger);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default TransactionService;
|
package/utils/hooks.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook Utilities
|
|
3
|
+
* @classytic/revenue
|
|
4
|
+
*
|
|
5
|
+
* Fire-and-forget hook execution - never blocks main flow
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Trigger hooks asynchronously without waiting
|
|
10
|
+
* Errors are logged but never thrown
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} hooks - Hooks object
|
|
13
|
+
* @param {string} event - Event name
|
|
14
|
+
* @param {Object} data - Event data
|
|
15
|
+
* @param {Object} logger - Logger instance
|
|
16
|
+
*/
|
|
17
|
+
export function triggerHook(hooks, event, data, logger) {
|
|
18
|
+
const handlers = hooks[event] || [];
|
|
19
|
+
|
|
20
|
+
if (handlers.length === 0) {
|
|
21
|
+
return; // No handlers, return immediately
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fire-and-forget: Don't await, don't block
|
|
25
|
+
Promise.all(
|
|
26
|
+
handlers.map(handler =>
|
|
27
|
+
Promise.resolve(handler(data)).catch(error => {
|
|
28
|
+
logger.error(`Hook "${event}" failed:`, {
|
|
29
|
+
error: error.message,
|
|
30
|
+
stack: error.stack,
|
|
31
|
+
event,
|
|
32
|
+
// Don't log full data (could be huge)
|
|
33
|
+
dataKeys: Object.keys(data),
|
|
34
|
+
});
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
).catch(() => {
|
|
38
|
+
// Swallow any Promise.all errors (already logged individually)
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Return immediately - hooks run in background
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default triggerHook;
|