@umituz/react-native-subscription 1.0.6 → 1.1.0

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.
Files changed (83) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +10 -0
  3. package/lib/application/ports/ISubscriptionRepository.d.ts +25 -0
  4. package/lib/application/ports/ISubscriptionRepository.d.ts.map +1 -0
  5. package/lib/application/ports/ISubscriptionRepository.js +9 -0
  6. package/lib/application/ports/ISubscriptionRepository.js.map +1 -0
  7. package/lib/application/ports/ISubscriptionService.d.ts +28 -0
  8. package/lib/application/ports/ISubscriptionService.d.ts.map +1 -0
  9. package/lib/application/ports/ISubscriptionService.js +6 -0
  10. package/lib/application/ports/ISubscriptionService.js.map +1 -0
  11. package/lib/domain/entities/SubscriptionStatus.d.ts +31 -0
  12. package/lib/domain/entities/SubscriptionStatus.d.ts.map +1 -0
  13. package/lib/domain/entities/SubscriptionStatus.js +39 -0
  14. package/lib/domain/entities/SubscriptionStatus.js.map +1 -0
  15. package/lib/domain/errors/SubscriptionError.d.ts +18 -0
  16. package/lib/domain/errors/SubscriptionError.d.ts.map +1 -0
  17. package/lib/domain/errors/SubscriptionError.js +30 -0
  18. package/lib/domain/errors/SubscriptionError.js.map +1 -0
  19. package/lib/domain/value-objects/SubscriptionConfig.d.ts +15 -0
  20. package/lib/domain/value-objects/SubscriptionConfig.d.ts.map +1 -0
  21. package/lib/domain/value-objects/SubscriptionConfig.js +6 -0
  22. package/lib/domain/value-objects/SubscriptionConfig.js.map +1 -0
  23. package/lib/index.d.ts +33 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +43 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/infrastructure/services/ActivationHandler.d.ts +20 -0
  28. package/lib/infrastructure/services/ActivationHandler.d.ts.map +1 -0
  29. package/lib/infrastructure/services/ActivationHandler.js +71 -0
  30. package/lib/infrastructure/services/ActivationHandler.js.map +1 -0
  31. package/lib/infrastructure/services/SubscriptionService.d.ts +22 -0
  32. package/lib/infrastructure/services/SubscriptionService.d.ts.map +1 -0
  33. package/lib/infrastructure/services/SubscriptionService.js +110 -0
  34. package/lib/infrastructure/services/SubscriptionService.js.map +1 -0
  35. package/lib/presentation/hooks/useSubscription.d.ts +33 -0
  36. package/lib/presentation/hooks/useSubscription.d.ts.map +1 -0
  37. package/lib/presentation/hooks/useSubscription.js +129 -0
  38. package/lib/presentation/hooks/useSubscription.js.map +1 -0
  39. package/lib/utils/dateUtils.d.ts +39 -0
  40. package/lib/utils/dateUtils.d.ts.map +1 -0
  41. package/lib/utils/dateUtils.js +117 -0
  42. package/lib/utils/dateUtils.js.map +1 -0
  43. package/lib/utils/dateValidationUtils.d.ts +20 -0
  44. package/lib/utils/dateValidationUtils.d.ts.map +1 -0
  45. package/lib/utils/dateValidationUtils.js +39 -0
  46. package/lib/utils/dateValidationUtils.js.map +1 -0
  47. package/lib/utils/periodUtils.d.ts +38 -0
  48. package/lib/utils/periodUtils.d.ts.map +1 -0
  49. package/lib/utils/periodUtils.js +70 -0
  50. package/lib/utils/periodUtils.js.map +1 -0
  51. package/lib/utils/planDetectionUtils.d.ts +17 -0
  52. package/lib/utils/planDetectionUtils.d.ts.map +1 -0
  53. package/lib/utils/planDetectionUtils.js +31 -0
  54. package/lib/utils/planDetectionUtils.js.map +1 -0
  55. package/lib/utils/priceUtils.d.ts +23 -0
  56. package/lib/utils/priceUtils.d.ts.map +1 -0
  57. package/lib/utils/priceUtils.js +29 -0
  58. package/lib/utils/priceUtils.js.map +1 -0
  59. package/lib/utils/subscriptionConstants.d.ts +62 -0
  60. package/lib/utils/subscriptionConstants.d.ts.map +1 -0
  61. package/lib/utils/subscriptionConstants.js +61 -0
  62. package/lib/utils/subscriptionConstants.js.map +1 -0
  63. package/package.json +13 -3
  64. package/src/application/ports/ISubscriptionRepository.ts +10 -0
  65. package/src/application/ports/ISubscriptionService.ts +10 -0
  66. package/src/domain/entities/SubscriptionStatus.test.ts +106 -0
  67. package/src/domain/entities/SubscriptionStatus.ts +10 -0
  68. package/src/domain/errors/SubscriptionError.ts +10 -0
  69. package/src/domain/value-objects/SubscriptionConfig.ts +0 -0
  70. package/src/index.ts +9 -2
  71. package/src/infrastructure/services/ActivationHandler.ts +108 -0
  72. package/src/infrastructure/services/SubscriptionService.ts +58 -177
  73. package/src/presentation/hooks/useSubscription.ts +22 -2
  74. package/src/utils/dateUtils.test.ts +116 -0
  75. package/src/utils/dateUtils.ts +12 -76
  76. package/src/utils/dateValidationUtils.test.ts +142 -0
  77. package/src/utils/dateValidationUtils.ts +53 -0
  78. package/src/utils/periodUtils.ts +0 -0
  79. package/src/utils/planDetectionUtils.test.ts +47 -0
  80. package/src/utils/planDetectionUtils.ts +40 -0
  81. package/src/utils/priceUtils.test.ts +35 -0
  82. package/src/utils/priceUtils.ts +0 -0
  83. package/src/utils/subscriptionConstants.ts +0 -0
@@ -1,183 +1,98 @@
1
1
  /**
2
2
  * Subscription Service Implementation
3
- * Secure subscription management with database-first approach
4
- *
5
- * SECURITY: Database-first approach ensures:
6
- * - 10-50x faster subscription checks
7
- * - Works offline (database cache)
8
- * - More reliable than SDK-dependent checks
9
- * - Server-side validation always enforced
3
+ * Database-first subscription management
10
4
  */
11
5
 
12
- import type { ISubscriptionService } from '../../application/ports/ISubscriptionService';
13
- import type { ISubscriptionRepository } from '../../application/ports/ISubscriptionRepository';
14
- import type { SubscriptionStatus } from '../../domain/entities/SubscriptionStatus';
15
- import {
16
- createDefaultSubscriptionStatus,
17
- isSubscriptionValid,
18
- } from '../../domain/entities/SubscriptionStatus';
6
+ import type { ISubscriptionService } from "../../application/ports/ISubscriptionService";
7
+ import type { ISubscriptionRepository } from "../../application/ports/ISubscriptionRepository";
8
+ import type { SubscriptionStatus } from "../../domain/entities/SubscriptionStatus";
9
+ import { createDefaultSubscriptionStatus } from "../../domain/entities/SubscriptionStatus";
19
10
  import {
20
11
  SubscriptionRepositoryError,
21
12
  SubscriptionValidationError,
22
- } from '../../domain/errors/SubscriptionError';
23
- import type { SubscriptionConfig } from '../../domain/value-objects/SubscriptionConfig';
13
+ } from "../../domain/errors/SubscriptionError";
14
+ import type { SubscriptionConfig } from "../../domain/value-objects/SubscriptionConfig";
15
+ import {
16
+ activateSubscription,
17
+ deactivateSubscription,
18
+ type ActivationHandlerConfig,
19
+ } from "./ActivationHandler";
24
20
 
25
21
  export class SubscriptionService implements ISubscriptionService {
26
22
  private repository: ISubscriptionRepository;
27
- private onStatusChanged?: (
28
- userId: string,
29
- status: SubscriptionStatus,
30
- ) => Promise<void> | void;
31
- private onError?: (error: Error, context: string) => Promise<void> | void;
23
+ private handlerConfig: ActivationHandlerConfig;
32
24
 
33
25
  constructor(config: SubscriptionConfig) {
34
26
  if (!config.repository) {
35
- throw new SubscriptionValidationError(
36
- 'Repository is required for SubscriptionService',
37
- );
27
+ throw new SubscriptionValidationError("Repository is required");
38
28
  }
39
29
 
40
30
  this.repository = config.repository;
41
- this.onStatusChanged = config.onStatusChanged;
42
- this.onError = config.onError;
31
+ this.handlerConfig = {
32
+ repository: config.repository,
33
+ onStatusChanged: config.onStatusChanged,
34
+ onError: config.onError,
35
+ };
43
36
  }
44
37
 
45
- /**
46
- * Get subscription status for a user
47
- * Returns default (free) status if user not found
48
- */
49
38
  async getSubscriptionStatus(userId: string): Promise<SubscriptionStatus> {
50
39
  try {
51
40
  const status = await this.repository.getSubscriptionStatus(userId);
52
41
  if (!status) {
42
+ if (typeof globalThis !== 'undefined' && (globalThis as any).__DEV__) {
43
+ console.log("[Subscription] No status found for user, returning default");
44
+ }
53
45
  return createDefaultSubscriptionStatus();
54
46
  }
55
47
 
56
- // Validate subscription status (check expiration)
57
48
  const isValid = this.repository.isSubscriptionValid(status);
58
49
  if (!isValid && status.isPremium) {
59
- // Subscription expired, update status
60
- const updatedStatus = await this.deactivateSubscription(userId);
61
- return updatedStatus;
50
+ if (typeof globalThis !== 'undefined' && (globalThis as any).__DEV__) {
51
+ console.log("[Subscription] Expired subscription found, deactivating");
52
+ }
53
+ return await this.deactivateSubscription(userId);
62
54
  }
63
55
 
64
56
  return status;
65
57
  } catch (error) {
66
- await this.handleError(
67
- error instanceof Error
68
- ? error
69
- : new Error('Error getting subscription status'),
70
- 'SubscriptionService.getSubscriptionStatus',
71
- );
58
+ await this.handleError(error, "getSubscriptionStatus");
72
59
  return createDefaultSubscriptionStatus();
73
60
  }
74
61
  }
75
62
 
76
- /**
77
- * Check if user has active subscription
78
- */
79
63
  async isPremium(userId: string): Promise<boolean> {
80
64
  const status = await this.getSubscriptionStatus(userId);
81
65
  return this.repository.isSubscriptionValid(status);
82
66
  }
83
67
 
84
- /**
85
- * Activate subscription
86
- */
87
68
  async activateSubscription(
88
69
  userId: string,
89
70
  productId: string,
90
- expiresAt: string | null,
71
+ expiresAt: string | null
91
72
  ): Promise<SubscriptionStatus> {
92
- try {
93
- const updatedStatus = await this.repository.updateSubscriptionStatus(
94
- userId,
95
- {
96
- isPremium: true,
97
- productId,
98
- expiresAt,
99
- purchasedAt: new Date().toISOString(),
100
- syncedAt: new Date().toISOString(),
101
- },
102
- );
103
-
104
- // Call callback if provided
105
- if (this.onStatusChanged) {
106
- try {
107
- await this.onStatusChanged(userId, updatedStatus);
108
- } catch (error) {
109
- // Don't fail activation if callback fails
110
- await this.handleError(
111
- error instanceof Error ? error : new Error('Callback failed'),
112
- 'SubscriptionService.activateSubscription.onStatusChanged',
113
- );
114
- }
115
- }
116
-
117
- return updatedStatus;
118
- } catch (error) {
119
- await this.handleError(
120
- error instanceof Error
121
- ? error
122
- : new Error('Error activating subscription'),
123
- 'SubscriptionService.activateSubscription',
124
- );
125
- throw new SubscriptionRepositoryError(
126
- 'Failed to activate subscription',
127
- );
73
+ if (typeof globalThis !== 'undefined' && (globalThis as any).__DEV__) {
74
+ console.log("[Subscription] Activating subscription", { userId, productId, expiresAt });
128
75
  }
76
+ return activateSubscription(
77
+ this.handlerConfig,
78
+ userId,
79
+ productId,
80
+ expiresAt
81
+ );
129
82
  }
130
83
 
131
- /**
132
- * Deactivate subscription
133
- */
134
84
  async deactivateSubscription(userId: string): Promise<SubscriptionStatus> {
135
- try {
136
- const updatedStatus = await this.repository.updateSubscriptionStatus(
137
- userId,
138
- {
139
- isPremium: false,
140
- expiresAt: null,
141
- productId: null,
142
- },
143
- );
144
-
145
- // Call callback if provided
146
- if (this.onStatusChanged) {
147
- try {
148
- await this.onStatusChanged(userId, updatedStatus);
149
- } catch (error) {
150
- // Don't fail deactivation if callback fails
151
- await this.handleError(
152
- error instanceof Error ? error : new Error('Callback failed'),
153
- 'SubscriptionService.deactivateSubscription.onStatusChanged',
154
- );
155
- }
156
- }
157
-
158
- return updatedStatus;
159
- } catch (error) {
160
- await this.handleError(
161
- error instanceof Error
162
- ? error
163
- : new Error('Error deactivating subscription'),
164
- 'SubscriptionService.deactivateSubscription',
165
- );
166
- throw new SubscriptionRepositoryError(
167
- 'Failed to deactivate subscription',
168
- );
85
+ if (typeof globalThis !== 'undefined' && (globalThis as any).__DEV__) {
86
+ console.log("[Subscription] Deactivating subscription", { userId });
169
87
  }
88
+ return deactivateSubscription(this.handlerConfig, userId);
170
89
  }
171
90
 
172
- /**
173
- * Update subscription status
174
- */
175
91
  async updateSubscriptionStatus(
176
92
  userId: string,
177
- updates: Partial<SubscriptionStatus>,
93
+ updates: Partial<SubscriptionStatus>
178
94
  ): Promise<SubscriptionStatus> {
179
95
  try {
180
- // Add syncedAt timestamp
181
96
  const updatesWithSync = {
182
97
  ...updates,
183
98
  syncedAt: new Date().toISOString(),
@@ -185,61 +100,40 @@ export class SubscriptionService implements ISubscriptionService {
185
100
 
186
101
  const updatedStatus = await this.repository.updateSubscriptionStatus(
187
102
  userId,
188
- updatesWithSync,
103
+ updatesWithSync
189
104
  );
190
105
 
191
- // Call callback if provided
192
- if (this.onStatusChanged) {
106
+ if (this.handlerConfig.onStatusChanged) {
193
107
  try {
194
- await this.onStatusChanged(userId, updatedStatus);
108
+ await this.handlerConfig.onStatusChanged(userId, updatedStatus);
195
109
  } catch (error) {
196
- // Don't fail update if callback fails
197
- await this.handleError(
198
- error instanceof Error ? error : new Error('Callback failed'),
199
- 'SubscriptionService.updateSubscriptionStatus.onStatusChanged',
200
- );
110
+ await this.handleError(error, "updateSubscriptionStatus.callback");
201
111
  }
202
112
  }
203
113
 
204
114
  return updatedStatus;
205
115
  } catch (error) {
206
- await this.handleError(
207
- error instanceof Error
208
- ? error
209
- : new Error('Error updating subscription status'),
210
- 'SubscriptionService.updateSubscriptionStatus',
211
- );
212
- throw new SubscriptionRepositoryError(
213
- 'Failed to update subscription status',
214
- );
116
+ await this.handleError(error, "updateSubscriptionStatus");
117
+ throw new SubscriptionRepositoryError("Failed to update subscription");
215
118
  }
216
119
  }
217
120
 
218
- /**
219
- * Handle errors with optional callback
220
- */
221
- private async handleError(error: Error, context: string): Promise<void> {
222
- if (this.onError) {
223
- try {
224
- await this.onError(error, context);
225
- } catch {
226
- // Ignore callback errors
227
- }
121
+ private async handleError(error: unknown, context: string): Promise<void> {
122
+ if (!this.handlerConfig.onError) return;
123
+
124
+ try {
125
+ const err = error instanceof Error ? error : new Error("Unknown error");
126
+ await this.handlerConfig.onError(err, `SubscriptionService.${context}`);
127
+ } catch {
128
+ // Ignore callback errors
228
129
  }
229
130
  }
230
131
  }
231
132
 
232
- /**
233
- * Singleton instance
234
- * Apps should use initializeSubscriptionService() to set up with their config
235
- */
236
133
  let subscriptionServiceInstance: SubscriptionService | null = null;
237
134
 
238
- /**
239
- * Initialize Subscription service with configuration
240
- */
241
135
  export function initializeSubscriptionService(
242
- config: SubscriptionConfig,
136
+ config: SubscriptionConfig
243
137
  ): SubscriptionService {
244
138
  if (!subscriptionServiceInstance) {
245
139
  subscriptionServiceInstance = new SubscriptionService(config);
@@ -247,27 +141,14 @@ export function initializeSubscriptionService(
247
141
  return subscriptionServiceInstance;
248
142
  }
249
143
 
250
- /**
251
- * Get Subscription service instance
252
- * Returns null if service is not initialized (graceful degradation)
253
- */
254
144
  export function getSubscriptionService(): SubscriptionService | null {
255
- if (!subscriptionServiceInstance) {
256
- /* eslint-disable-next-line no-console */
257
- if (__DEV__) {
258
- console.warn(
259
- 'Subscription service is not initialized. Call initializeSubscriptionService() first.',
260
- );
261
- }
262
- return null;
145
+ if (!subscriptionServiceInstance && typeof globalThis !== 'undefined' && (globalThis as any).__DEV__) {
146
+ // eslint-disable-next-line no-console
147
+ console.warn("[Subscription] Service not initialized");
263
148
  }
264
149
  return subscriptionServiceInstance;
265
150
  }
266
151
 
267
- /**
268
- * Reset Subscription service (useful for testing)
269
- */
270
152
  export function resetSubscriptionService(): void {
271
153
  subscriptionServiceInstance = null;
272
154
  }
273
-
@@ -3,7 +3,7 @@
3
3
  * React hook for subscription management
4
4
  */
5
5
 
6
- import { useState, useCallback, useEffect } from 'react';
6
+ import { useState, useCallback } from 'react';
7
7
  import { getSubscriptionService } from '../../infrastructure/services/SubscriptionService';
8
8
  import type { SubscriptionStatus } from '../../domain/entities/SubscriptionStatus';
9
9
 
@@ -44,6 +44,11 @@ export function useSubscription(): UseSubscriptionResult {
44
44
  const [error, setError] = useState<string | null>(null);
45
45
 
46
46
  const loadStatus = useCallback(async (userId: string) => {
47
+ if (!userId) {
48
+ setError('User ID is required');
49
+ return;
50
+ }
51
+
47
52
  const service = getSubscriptionService();
48
53
  if (!service) {
49
54
  setError('Subscription service is not initialized');
@@ -66,6 +71,11 @@ export function useSubscription(): UseSubscriptionResult {
66
71
  }, []);
67
72
 
68
73
  const refreshStatus = useCallback(async (userId: string) => {
74
+ if (!userId) {
75
+ setError('User ID is required');
76
+ return;
77
+ }
78
+
69
79
  const service = getSubscriptionService();
70
80
  if (!service) {
71
81
  setError('Subscription service is not initialized');
@@ -89,6 +99,11 @@ export function useSubscription(): UseSubscriptionResult {
89
99
 
90
100
  const activateSubscription = useCallback(
91
101
  async (userId: string, productId: string, expiresAt: string | null) => {
102
+ if (!userId || !productId) {
103
+ setError('User ID and Product ID are required');
104
+ return;
105
+ }
106
+
92
107
  const service = getSubscriptionService();
93
108
  if (!service) {
94
109
  setError('Subscription service is not initialized');
@@ -118,6 +133,11 @@ export function useSubscription(): UseSubscriptionResult {
118
133
  );
119
134
 
120
135
  const deactivateSubscription = useCallback(async (userId: string) => {
136
+ if (!userId) {
137
+ setError('User ID is required');
138
+ return;
139
+ }
140
+
121
141
  const service = getSubscriptionService();
122
142
  if (!service) {
123
143
  setError('Subscription service is not initialized');
@@ -140,7 +160,7 @@ export function useSubscription(): UseSubscriptionResult {
140
160
  }
141
161
  }, []);
142
162
 
143
- const isPremium = status ? status.isPremium && (status.expiresAt === null || new Date(status.expiresAt) > new Date()) : false;
163
+ const isPremium = status?.isPremium && (status.expiresAt === null || new Date(status.expiresAt).getTime() > Date.now()) || false;
144
164
 
145
165
  return {
146
166
  status,
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tests for Date Utilities
3
+ */
4
+
5
+ import {
6
+ formatExpirationDate,
7
+ calculateExpirationDate,
8
+ } from '../utils/dateUtils';
9
+ import { SUBSCRIPTION_PLAN_TYPES } from '../utils/subscriptionConstants';
10
+
11
+ describe('Date Utils', () => {
12
+ describe('formatExpirationDate', () => {
13
+ it('should return null for null expiration', () => {
14
+ expect(formatExpirationDate(null)).toBeNull();
15
+ });
16
+
17
+ it('should format date correctly', () => {
18
+ const date = '2024-12-25T00:00:00.000Z';
19
+ const formatted = formatExpirationDate(date);
20
+
21
+ expect(formatted).toMatch(/December 25, 2024/);
22
+ });
23
+
24
+ it('should use custom locale', () => {
25
+ const date = '2024-12-25T00:00:00.000Z';
26
+ const formatted = formatExpirationDate(date, 'tr-TR');
27
+
28
+ expect(formatted).toBeTruthy();
29
+ expect(typeof formatted).toBe('string');
30
+ });
31
+
32
+ it('should return null for invalid date', () => {
33
+ const formatted = formatExpirationDate('invalid-date');
34
+ expect(formatted).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('calculateExpirationDate', () => {
39
+ beforeEach(() => {
40
+ // Mock current date for consistent testing
41
+ jest.useFakeTimers();
42
+ jest.setSystemTime(new Date('2024-01-15T00:00:00.000Z'));
43
+ });
44
+
45
+ afterEach(() => {
46
+ jest.useRealTimers();
47
+ });
48
+
49
+ it('should return null for null productId', () => {
50
+ expect(calculateExpirationDate(null)).toBeNull();
51
+ expect(calculateExpirationDate('')).toBeNull();
52
+ expect(calculateExpirationDate(undefined)).toBeNull();
53
+ });
54
+
55
+ it('should calculate weekly expiration', () => {
56
+ const result = calculateExpirationDate('com.app.weekly');
57
+ const expectedDate = new Date('2024-01-22T00:00:00.000Z');
58
+
59
+ expect(result).toBe(expectedDate.toISOString());
60
+ });
61
+
62
+ it('should calculate monthly expiration', () => {
63
+ const result = calculateExpirationDate('com.app.monthly');
64
+ const expectedDate = new Date('2024-02-15T00:00:00.000Z');
65
+
66
+ expect(result).toBe(expectedDate.toISOString());
67
+ });
68
+
69
+ it('should calculate yearly expiration', () => {
70
+ const result = calculateExpirationDate('com.app.yearly');
71
+ const expectedDate = new Date('2025-01-15T00:00:00.000Z');
72
+
73
+ expect(result).toBe(expectedDate.toISOString());
74
+ });
75
+
76
+ it('should default to monthly for unknown plan', () => {
77
+ const result = calculateExpirationDate('com.app.unknown');
78
+ const expectedDate = new Date('2024-02-15T00:00:00.000Z');
79
+
80
+ expect(result).toBe(expectedDate.toISOString());
81
+ });
82
+
83
+ it('should trust valid RevenueCat date', () => {
84
+ const futureDate = new Date('2024-02-20T00:00:00.000Z');
85
+ const result = calculateExpirationDate('com.app.monthly', futureDate.toISOString());
86
+
87
+ expect(result).toBe(futureDate.toISOString());
88
+ });
89
+
90
+ it('should ignore RevenueCat date if too short (sandbox)', () => {
91
+ const nearFutureDate = new Date('2024-01-16T00:00:00.000Z'); // Only 1 day
92
+ const result = calculateExpirationDate('com.app.monthly', nearFutureDate.toISOString());
93
+
94
+ // Should calculate manually instead of using RevenueCat date
95
+ const expectedDate = new Date('2024-02-15T00:00:00.000Z');
96
+ expect(result).toBe(expectedDate.toISOString());
97
+ });
98
+
99
+ it('should ignore past RevenueCat date', () => {
100
+ const pastDate = new Date('2024-01-10T00:00:00.000Z');
101
+ const result = calculateExpirationDate('com.app.monthly', pastDate.toISOString());
102
+
103
+ // Should calculate manually instead of using RevenueCat date
104
+ const expectedDate = new Date('2024-02-15T00:00:00.000Z');
105
+ expect(result).toBe(expectedDate.toISOString());
106
+ });
107
+
108
+ it('should handle invalid RevenueCat date', () => {
109
+ const result = calculateExpirationDate('com.app.monthly', 'invalid-date');
110
+
111
+ // Should calculate manually instead of using RevenueCat date
112
+ const expectedDate = new Date('2024-02-15T00:00:00.000Z');
113
+ expect(result).toBe(expectedDate.toISOString());
114
+ });
115
+ });
116
+ });
@@ -3,88 +3,20 @@
3
3
  * Subscription date-related helper functions
4
4
  *
5
5
  * Following SOLID, DRY, KISS principles:
6
- * - Single Responsibility: Only date-related operations
6
+ * - Single Responsibility: Only date formatting and calculation
7
7
  * - DRY: No code duplication
8
8
  * - KISS: Simple, clear implementations
9
9
  */
10
10
 
11
- import type { SubscriptionStatus } from '../domain/entities/SubscriptionStatus';
11
+ import { DATE_CONSTANTS } from './subscriptionConstants';
12
+ import { extractPlanFromProductId } from './planDetectionUtils';
12
13
  import {
13
14
  SUBSCRIPTION_PLAN_TYPES,
14
15
  MIN_SUBSCRIPTION_DURATIONS_DAYS,
15
16
  SUBSCRIPTION_PERIOD_DAYS,
16
- DATE_CONSTANTS,
17
- PRODUCT_ID_KEYWORDS,
18
17
  type SubscriptionPlanType,
19
18
  } from './subscriptionConstants';
20
19
 
21
- /**
22
- * Extract subscription plan type from product ID
23
- * Example: "com.umituz.app.weekly" → "weekly"
24
- * @internal
25
- */
26
- function extractPlanFromProductId(
27
- productId: string | null | undefined,
28
- ): SubscriptionPlanType {
29
- if (!productId) return SUBSCRIPTION_PLAN_TYPES.UNKNOWN;
30
-
31
- const lower = productId.toLowerCase();
32
-
33
- if (PRODUCT_ID_KEYWORDS.WEEKLY.some((keyword) => lower.includes(keyword))) {
34
- return SUBSCRIPTION_PLAN_TYPES.WEEKLY;
35
- }
36
- if (PRODUCT_ID_KEYWORDS.MONTHLY.some((keyword) => lower.includes(keyword))) {
37
- return SUBSCRIPTION_PLAN_TYPES.MONTHLY;
38
- }
39
- if (PRODUCT_ID_KEYWORDS.YEARLY.some((keyword) => lower.includes(keyword))) {
40
- return SUBSCRIPTION_PLAN_TYPES.YEARLY;
41
- }
42
-
43
- return SUBSCRIPTION_PLAN_TYPES.UNKNOWN;
44
- }
45
-
46
- /**
47
- * Check if subscription is expired
48
- */
49
- export function isSubscriptionExpired(
50
- status: SubscriptionStatus | null,
51
- ): boolean {
52
- if (!status || !status.isPremium) {
53
- return true;
54
- }
55
-
56
- if (!status.expiresAt) {
57
- // Lifetime subscription (no expiration)
58
- return false;
59
- }
60
-
61
- const expirationDate = new Date(status.expiresAt);
62
- const now = new Date();
63
-
64
- return expirationDate.getTime() <= now.getTime();
65
- }
66
-
67
- /**
68
- * Get days until subscription expires
69
- * Returns null for lifetime subscriptions
70
- */
71
- export function getDaysUntilExpiration(
72
- status: SubscriptionStatus | null,
73
- ): number | null {
74
- if (!status || !status.expiresAt) {
75
- return null;
76
- }
77
-
78
- const expirationDate = new Date(status.expiresAt);
79
- const now = new Date();
80
- const diffMs = expirationDate.getTime() - now.getTime();
81
- const diffDays = Math.ceil(
82
- diffMs / DATE_CONSTANTS.MILLISECONDS_PER_DAY,
83
- );
84
-
85
- return diffDays > 0 ? diffDays : 0;
86
- }
87
-
88
20
  /**
89
21
  * Format expiration date for display
90
22
  */
@@ -98,6 +30,9 @@ export function formatExpirationDate(
98
30
 
99
31
  try {
100
32
  const date = new Date(expiresAt);
33
+ if (isNaN(date.getTime())) {
34
+ return null;
35
+ }
101
36
  return date.toLocaleDateString(locale, {
102
37
  year: 'numeric',
103
38
  month: 'long',
@@ -118,24 +53,26 @@ export function formatExpirationDate(
118
53
  * - Yearly subscriptions: Same day next year (e.g., Nov 10, 2024 → Nov 10, 2025)
119
54
  * - Weekly subscriptions: +7 days
120
55
  *
121
- * @param productId - Product identifier (e.g., "com.umituz.app.monthly")
56
+ * @param productId - Product identifier (e.g., "com.company.app.monthly")
122
57
  * @param revenueCatExpiresAt - Optional expiration date from RevenueCat API
123
58
  * @returns ISO date string for expiration, or null if invalid
124
59
  *
125
60
  * @example
126
61
  * // Monthly subscription purchased on Nov 10, 2024
127
- * calculateExpirationDate('com.umituz.app.monthly', null)
62
+ * calculateExpirationDate('com.company.app.monthly', null)
128
63
  * // Returns: '2024-12-10T...' (Dec 10, 2024)
129
64
  *
130
65
  * @example
131
66
  * // Yearly subscription purchased on Nov 10, 2024
132
- * calculateExpirationDate('com.umituz.app.yearly', null)
67
+ * calculateExpirationDate('com.company.app.yearly', null)
133
68
  * // Returns: '2025-11-10T...' (Nov 10, 2025)
134
69
  */
135
70
  export function calculateExpirationDate(
136
71
  productId: string | null | undefined,
137
72
  revenueCatExpiresAt?: string | null,
138
73
  ): string | null {
74
+ if (!productId) return null;
75
+
139
76
  const plan = extractPlanFromProductId(productId);
140
77
  const now = new Date();
141
78
 
@@ -207,5 +144,4 @@ export function calculateExpirationDate(
207
144
  }
208
145
 
209
146
  return calculatedDate.toISOString();
210
- }
211
-
147
+ }