@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.
- package/LICENSE +10 -0
- package/README.md +10 -0
- package/lib/application/ports/ISubscriptionRepository.d.ts +25 -0
- package/lib/application/ports/ISubscriptionRepository.d.ts.map +1 -0
- package/lib/application/ports/ISubscriptionRepository.js +9 -0
- package/lib/application/ports/ISubscriptionRepository.js.map +1 -0
- package/lib/application/ports/ISubscriptionService.d.ts +28 -0
- package/lib/application/ports/ISubscriptionService.d.ts.map +1 -0
- package/lib/application/ports/ISubscriptionService.js +6 -0
- package/lib/application/ports/ISubscriptionService.js.map +1 -0
- package/lib/domain/entities/SubscriptionStatus.d.ts +31 -0
- package/lib/domain/entities/SubscriptionStatus.d.ts.map +1 -0
- package/lib/domain/entities/SubscriptionStatus.js +39 -0
- package/lib/domain/entities/SubscriptionStatus.js.map +1 -0
- package/lib/domain/errors/SubscriptionError.d.ts +18 -0
- package/lib/domain/errors/SubscriptionError.d.ts.map +1 -0
- package/lib/domain/errors/SubscriptionError.js +30 -0
- package/lib/domain/errors/SubscriptionError.js.map +1 -0
- package/lib/domain/value-objects/SubscriptionConfig.d.ts +15 -0
- package/lib/domain/value-objects/SubscriptionConfig.d.ts.map +1 -0
- package/lib/domain/value-objects/SubscriptionConfig.js +6 -0
- package/lib/domain/value-objects/SubscriptionConfig.js.map +1 -0
- package/lib/index.d.ts +33 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +43 -0
- package/lib/index.js.map +1 -0
- package/lib/infrastructure/services/ActivationHandler.d.ts +20 -0
- package/lib/infrastructure/services/ActivationHandler.d.ts.map +1 -0
- package/lib/infrastructure/services/ActivationHandler.js +71 -0
- package/lib/infrastructure/services/ActivationHandler.js.map +1 -0
- package/lib/infrastructure/services/SubscriptionService.d.ts +22 -0
- package/lib/infrastructure/services/SubscriptionService.d.ts.map +1 -0
- package/lib/infrastructure/services/SubscriptionService.js +110 -0
- package/lib/infrastructure/services/SubscriptionService.js.map +1 -0
- package/lib/presentation/hooks/useSubscription.d.ts +33 -0
- package/lib/presentation/hooks/useSubscription.d.ts.map +1 -0
- package/lib/presentation/hooks/useSubscription.js +129 -0
- package/lib/presentation/hooks/useSubscription.js.map +1 -0
- package/lib/utils/dateUtils.d.ts +39 -0
- package/lib/utils/dateUtils.d.ts.map +1 -0
- package/lib/utils/dateUtils.js +117 -0
- package/lib/utils/dateUtils.js.map +1 -0
- package/lib/utils/dateValidationUtils.d.ts +20 -0
- package/lib/utils/dateValidationUtils.d.ts.map +1 -0
- package/lib/utils/dateValidationUtils.js +39 -0
- package/lib/utils/dateValidationUtils.js.map +1 -0
- package/lib/utils/periodUtils.d.ts +38 -0
- package/lib/utils/periodUtils.d.ts.map +1 -0
- package/lib/utils/periodUtils.js +70 -0
- package/lib/utils/periodUtils.js.map +1 -0
- package/lib/utils/planDetectionUtils.d.ts +17 -0
- package/lib/utils/planDetectionUtils.d.ts.map +1 -0
- package/lib/utils/planDetectionUtils.js +31 -0
- package/lib/utils/planDetectionUtils.js.map +1 -0
- package/lib/utils/priceUtils.d.ts +23 -0
- package/lib/utils/priceUtils.d.ts.map +1 -0
- package/lib/utils/priceUtils.js +29 -0
- package/lib/utils/priceUtils.js.map +1 -0
- package/lib/utils/subscriptionConstants.d.ts +62 -0
- package/lib/utils/subscriptionConstants.d.ts.map +1 -0
- package/lib/utils/subscriptionConstants.js +61 -0
- package/lib/utils/subscriptionConstants.js.map +1 -0
- package/package.json +13 -3
- package/src/application/ports/ISubscriptionRepository.ts +10 -0
- package/src/application/ports/ISubscriptionService.ts +10 -0
- package/src/domain/entities/SubscriptionStatus.test.ts +106 -0
- package/src/domain/entities/SubscriptionStatus.ts +10 -0
- package/src/domain/errors/SubscriptionError.ts +10 -0
- package/src/domain/value-objects/SubscriptionConfig.ts +0 -0
- package/src/index.ts +9 -2
- package/src/infrastructure/services/ActivationHandler.ts +108 -0
- package/src/infrastructure/services/SubscriptionService.ts +58 -177
- package/src/presentation/hooks/useSubscription.ts +22 -2
- package/src/utils/dateUtils.test.ts +116 -0
- package/src/utils/dateUtils.ts +12 -76
- package/src/utils/dateValidationUtils.test.ts +142 -0
- package/src/utils/dateValidationUtils.ts +53 -0
- package/src/utils/periodUtils.ts +0 -0
- package/src/utils/planDetectionUtils.test.ts +47 -0
- package/src/utils/planDetectionUtils.ts +40 -0
- package/src/utils/priceUtils.test.ts +35 -0
- package/src/utils/priceUtils.ts +0 -0
- package/src/utils/subscriptionConstants.ts +0 -0
|
@@ -1,183 +1,98 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subscription Service Implementation
|
|
3
|
-
*
|
|
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
|
|
13
|
-
import type { ISubscriptionRepository } from
|
|
14
|
-
import type { SubscriptionStatus } from
|
|
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
|
|
23
|
-
import type { SubscriptionConfig } from
|
|
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
|
|
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.
|
|
42
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/utils/dateUtils.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|