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