@umituz/react-native-subscription 1.0.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 +22 -0
- package/README.md +206 -0
- package/package.json +55 -0
- package/src/application/ports/ISubscriptionRepository.ts +32 -0
- package/src/application/ports/ISubscriptionService.ts +41 -0
- package/src/domain/entities/SubscriptionStatus.ts +64 -0
- package/src/domain/errors/SubscriptionError.ts +33 -0
- package/src/domain/value-objects/SubscriptionConfig.ts +22 -0
- package/src/index.ts +72 -0
- package/src/infrastructure/services/SubscriptionService.ts +273 -0
- package/src/presentation/hooks/useSubscription.ts +156 -0
- package/src/utils/subscriptionUtils.ts +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Ümit UZ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @umituz/react-native-subscription
|
|
2
|
+
|
|
3
|
+
Subscription management system for React Native apps - Database-first approach with secure validation.
|
|
4
|
+
|
|
5
|
+
Built with **SOLID**, **DRY**, and **KISS** principles.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @umituz/react-native-subscription
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Peer Dependencies
|
|
14
|
+
|
|
15
|
+
- `react` >= 18.2.0
|
|
16
|
+
- `react-native` >= 0.74.0
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- ✅ Domain-Driven Design (DDD) architecture
|
|
21
|
+
- ✅ SOLID principles (Single Responsibility, Open/Closed, etc.)
|
|
22
|
+
- ✅ DRY (Don't Repeat Yourself)
|
|
23
|
+
- ✅ KISS (Keep It Simple, Stupid)
|
|
24
|
+
- ✅ **Security**: Database-first approach - Always validate server-side
|
|
25
|
+
- ✅ Type-safe operations
|
|
26
|
+
- ✅ React hooks for easy integration
|
|
27
|
+
- ✅ Works with any database (Firebase, Supabase, etc.)
|
|
28
|
+
|
|
29
|
+
## Important: Database-First Approach
|
|
30
|
+
|
|
31
|
+
**This package follows a database-first approach:**
|
|
32
|
+
|
|
33
|
+
- Subscription status is ALWAYS checked from your database
|
|
34
|
+
- This ensures 10-50x faster subscription checks
|
|
35
|
+
- Works offline (database cache)
|
|
36
|
+
- More reliable than SDK-dependent checks
|
|
37
|
+
- **SECURITY**: Server-side validation always enforced
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### 1. Implement Repository Interface
|
|
42
|
+
|
|
43
|
+
First, implement the `ISubscriptionRepository` interface with your database:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import type { ISubscriptionRepository } from '@umituz/react-native-subscription';
|
|
47
|
+
import type { SubscriptionStatus } from '@umituz/react-native-subscription';
|
|
48
|
+
|
|
49
|
+
class MySubscriptionRepository implements ISubscriptionRepository {
|
|
50
|
+
async getSubscriptionStatus(userId: string): Promise<SubscriptionStatus | null> {
|
|
51
|
+
// Fetch from your database (Firebase, Supabase, etc.)
|
|
52
|
+
const doc = await db.collection('users').doc(userId).get();
|
|
53
|
+
return doc.data()?.subscription || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async updateSubscriptionStatus(
|
|
57
|
+
userId: string,
|
|
58
|
+
status: Partial<SubscriptionStatus>,
|
|
59
|
+
): Promise<SubscriptionStatus> {
|
|
60
|
+
// Update in your database
|
|
61
|
+
await db.collection('users').doc(userId).update({
|
|
62
|
+
subscription: status,
|
|
63
|
+
updatedAt: new Date(),
|
|
64
|
+
});
|
|
65
|
+
return await this.getSubscriptionStatus(userId) || createDefaultSubscriptionStatus();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isSubscriptionValid(status: SubscriptionStatus): boolean {
|
|
69
|
+
if (!status.isPremium) return false;
|
|
70
|
+
if (!status.expiresAt) return true; // Lifetime subscription
|
|
71
|
+
return new Date(status.expiresAt) > new Date();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Initialize Subscription Service
|
|
77
|
+
|
|
78
|
+
Initialize the service early in your app (e.g., in `App.tsx`):
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { initializeSubscriptionService } from '@umituz/react-native-subscription';
|
|
82
|
+
import { MySubscriptionRepository } from './repositories/MySubscriptionRepository';
|
|
83
|
+
|
|
84
|
+
// Initialize Subscription service
|
|
85
|
+
initializeSubscriptionService({
|
|
86
|
+
repository: new MySubscriptionRepository(),
|
|
87
|
+
onStatusChanged: async (userId, status) => {
|
|
88
|
+
// Optional: Sync to analytics, send notifications, etc.
|
|
89
|
+
await analytics.logEvent('subscription_changed', {
|
|
90
|
+
userId,
|
|
91
|
+
isPremium: status.isPremium,
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
onError: async (error, context) => {
|
|
95
|
+
// Optional: Log errors to crash reporting
|
|
96
|
+
await crashlytics.logError(error, context);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. Use Subscription Hook in Components
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { useSubscription } from '@umituz/react-native-subscription';
|
|
105
|
+
import { useAuth } from '@umituz/react-native-auth';
|
|
106
|
+
|
|
107
|
+
function PremiumFeature() {
|
|
108
|
+
const { user } = useAuth();
|
|
109
|
+
const { status, isPremium, loading, loadStatus } = useSubscription();
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (user?.uid) {
|
|
113
|
+
loadStatus(user.uid);
|
|
114
|
+
}
|
|
115
|
+
}, [user?.uid, loadStatus]);
|
|
116
|
+
|
|
117
|
+
if (loading) {
|
|
118
|
+
return <LoadingSpinner />;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!isPremium) {
|
|
122
|
+
return <UpgradePrompt />;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return <PremiumContent />;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 4. Activate/Deactivate Subscription
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { getSubscriptionService } from '@umituz/react-native-subscription';
|
|
133
|
+
|
|
134
|
+
const service = getSubscriptionService();
|
|
135
|
+
|
|
136
|
+
// Activate subscription (e.g., after purchase)
|
|
137
|
+
await service.activateSubscription(
|
|
138
|
+
userId,
|
|
139
|
+
'premium_monthly',
|
|
140
|
+
'2024-12-31T23:59:59Z', // or null for lifetime
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Deactivate subscription
|
|
144
|
+
await service.deactivateSubscription(userId);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## API
|
|
148
|
+
|
|
149
|
+
### Functions
|
|
150
|
+
|
|
151
|
+
- `initializeSubscriptionService(config)`: Initialize Subscription service with configuration
|
|
152
|
+
- `getSubscriptionService()`: Get Subscription service instance (throws if not initialized)
|
|
153
|
+
- `resetSubscriptionService()`: Reset service instance (useful for testing)
|
|
154
|
+
|
|
155
|
+
### Hook
|
|
156
|
+
|
|
157
|
+
- `useSubscription()`: React hook for subscription operations
|
|
158
|
+
|
|
159
|
+
### Types
|
|
160
|
+
|
|
161
|
+
- `SubscriptionStatus`: Subscription status entity
|
|
162
|
+
- `SubscriptionConfig`: Configuration interface
|
|
163
|
+
- `ISubscriptionRepository`: Repository interface (must be implemented)
|
|
164
|
+
- `UseSubscriptionResult`: Hook return type
|
|
165
|
+
|
|
166
|
+
### Errors
|
|
167
|
+
|
|
168
|
+
- `SubscriptionError`: Base error class
|
|
169
|
+
- `SubscriptionRepositoryError`: Repository errors
|
|
170
|
+
- `SubscriptionValidationError`: Validation errors
|
|
171
|
+
- `SubscriptionConfigurationError`: Configuration errors
|
|
172
|
+
|
|
173
|
+
## Security Best Practices
|
|
174
|
+
|
|
175
|
+
1. **Database-First**: Always check subscription status from your database, not SDK
|
|
176
|
+
2. **Server-Side Validation**: Always validate subscription expiration server-side
|
|
177
|
+
3. **Error Handling**: Always handle errors gracefully
|
|
178
|
+
4. **Repository Pattern**: Implement repository interface with your database
|
|
179
|
+
5. **Callbacks**: Use callbacks to sync subscription changes to analytics/notifications
|
|
180
|
+
|
|
181
|
+
## Integration with RevenueCat
|
|
182
|
+
|
|
183
|
+
This package works seamlessly with `@umituz/react-native-revenuecat`:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { initializeRevenueCatService } from '@umituz/react-native-revenuecat';
|
|
187
|
+
import { getSubscriptionService } from '@umituz/react-native-subscription';
|
|
188
|
+
|
|
189
|
+
initializeRevenueCatService({
|
|
190
|
+
onPremiumStatusChanged: async (userId, isPremium, productId, expiresAt) => {
|
|
191
|
+
const subscriptionService = getSubscriptionService();
|
|
192
|
+
if (subscriptionService) {
|
|
193
|
+
if (isPremium && productId) {
|
|
194
|
+
await subscriptionService.activateSubscription(userId, productId, expiresAt || null);
|
|
195
|
+
} else {
|
|
196
|
+
await subscriptionService.deactivateSubscription(userId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
206
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@umituz/react-native-subscription",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Subscription management system for React Native apps - Database-first approach with secure validation",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"lint": "tsc --noEmit",
|
|
10
|
+
"version:patch": "npm version patch -m 'chore: release v%s'",
|
|
11
|
+
"version:minor": "npm version minor -m 'chore: release v%s'",
|
|
12
|
+
"version:major": "npm version major -m 'chore: release v%s'"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"react-native",
|
|
16
|
+
"subscription",
|
|
17
|
+
"premium",
|
|
18
|
+
"in-app-purchase",
|
|
19
|
+
"iap",
|
|
20
|
+
"security",
|
|
21
|
+
"ddd",
|
|
22
|
+
"domain-driven-design",
|
|
23
|
+
"type-safe",
|
|
24
|
+
"solid",
|
|
25
|
+
"dry",
|
|
26
|
+
"kiss",
|
|
27
|
+
"database-first"
|
|
28
|
+
],
|
|
29
|
+
"author": "Ümit UZ <umit@umituz.com>",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/umituz/react-native-subscription.git"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=18.2.0",
|
|
37
|
+
"react-native": ">=0.74.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/react": "^18.2.45",
|
|
41
|
+
"@types/react-native": "^0.73.0",
|
|
42
|
+
"react": "^18.2.0",
|
|
43
|
+
"react-native": "^0.74.0",
|
|
44
|
+
"typescript": "^5.3.3"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"src",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Repository Interface
|
|
3
|
+
* Port for database operations
|
|
4
|
+
*
|
|
5
|
+
* SECURITY: Apps must implement this interface with their database.
|
|
6
|
+
* Never expose database credentials or allow direct database access.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SubscriptionStatus } from '../../domain/entities/SubscriptionStatus';
|
|
10
|
+
|
|
11
|
+
export interface ISubscriptionRepository {
|
|
12
|
+
/**
|
|
13
|
+
* Get subscription status for a user
|
|
14
|
+
* Returns null if user not found
|
|
15
|
+
*/
|
|
16
|
+
getSubscriptionStatus(userId: string): Promise<SubscriptionStatus | null>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Update subscription status for a user
|
|
20
|
+
*/
|
|
21
|
+
updateSubscriptionStatus(
|
|
22
|
+
userId: string,
|
|
23
|
+
status: Partial<SubscriptionStatus>,
|
|
24
|
+
): Promise<SubscriptionStatus>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if subscription is valid (not expired)
|
|
28
|
+
* SECURITY: Always validate expiration server-side
|
|
29
|
+
*/
|
|
30
|
+
isSubscriptionValid(status: SubscriptionStatus): boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Service Interface
|
|
3
|
+
* Port for subscription operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SubscriptionStatus } from '../../domain/entities/SubscriptionStatus';
|
|
7
|
+
|
|
8
|
+
export interface ISubscriptionService {
|
|
9
|
+
/**
|
|
10
|
+
* Get subscription status for a user
|
|
11
|
+
*/
|
|
12
|
+
getSubscriptionStatus(userId: string): Promise<SubscriptionStatus>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if user has active subscription
|
|
16
|
+
*/
|
|
17
|
+
isPremium(userId: string): Promise<boolean>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Activate subscription
|
|
21
|
+
*/
|
|
22
|
+
activateSubscription(
|
|
23
|
+
userId: string,
|
|
24
|
+
productId: string,
|
|
25
|
+
expiresAt: string | null,
|
|
26
|
+
): Promise<SubscriptionStatus>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Deactivate subscription
|
|
30
|
+
*/
|
|
31
|
+
deactivateSubscription(userId: string): Promise<SubscriptionStatus>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Update subscription status
|
|
35
|
+
*/
|
|
36
|
+
updateSubscriptionStatus(
|
|
37
|
+
userId: string,
|
|
38
|
+
updates: Partial<SubscriptionStatus>,
|
|
39
|
+
): Promise<SubscriptionStatus>;
|
|
40
|
+
}
|
|
41
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Status Entity
|
|
3
|
+
* Represents subscription status for a user
|
|
4
|
+
*
|
|
5
|
+
* SECURITY: This is a read-only entity from database.
|
|
6
|
+
* Never trust client-side subscription status - always validate server-side.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SubscriptionStatus {
|
|
10
|
+
/** Whether user has active subscription */
|
|
11
|
+
isPremium: boolean;
|
|
12
|
+
|
|
13
|
+
/** Subscription expiration date (ISO string) */
|
|
14
|
+
expiresAt: string | null;
|
|
15
|
+
|
|
16
|
+
/** Product ID of the subscription */
|
|
17
|
+
productId: string | null;
|
|
18
|
+
|
|
19
|
+
/** When subscription was purchased (ISO string) */
|
|
20
|
+
purchasedAt: string | null;
|
|
21
|
+
|
|
22
|
+
/** External service customer ID (e.g., RevenueCat customer ID) */
|
|
23
|
+
customerId: string | null;
|
|
24
|
+
|
|
25
|
+
/** Last sync time with external service (ISO string) */
|
|
26
|
+
syncedAt: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create default subscription status (free user)
|
|
31
|
+
*/
|
|
32
|
+
export function createDefaultSubscriptionStatus(): SubscriptionStatus {
|
|
33
|
+
return {
|
|
34
|
+
isPremium: false,
|
|
35
|
+
expiresAt: null,
|
|
36
|
+
productId: null,
|
|
37
|
+
purchasedAt: null,
|
|
38
|
+
customerId: null,
|
|
39
|
+
syncedAt: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if subscription status is valid (not expired)
|
|
45
|
+
* SECURITY: Always validate expiration server-side
|
|
46
|
+
*/
|
|
47
|
+
export function isSubscriptionValid(status: SubscriptionStatus | null): boolean {
|
|
48
|
+
if (!status || !status.isPremium) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!status.expiresAt) {
|
|
53
|
+
// Lifetime subscription (no expiration)
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const expirationDate = new Date(status.expiresAt);
|
|
58
|
+
const now = new Date();
|
|
59
|
+
|
|
60
|
+
// Add 1 day buffer for clock skew and timezone issues
|
|
61
|
+
const bufferMs = 24 * 60 * 60 * 1000;
|
|
62
|
+
return expirationDate.getTime() > now.getTime() - bufferMs;
|
|
63
|
+
}
|
|
64
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Errors
|
|
3
|
+
* Domain-specific errors for subscription operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class SubscriptionError extends Error {
|
|
7
|
+
constructor(message: string, public readonly code: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'SubscriptionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SubscriptionRepositoryError extends SubscriptionError {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message, 'REPOSITORY_ERROR');
|
|
16
|
+
this.name = 'SubscriptionRepositoryError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SubscriptionValidationError extends SubscriptionError {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message, 'VALIDATION_ERROR');
|
|
23
|
+
this.name = 'SubscriptionValidationError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SubscriptionConfigurationError extends SubscriptionError {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message, 'CONFIGURATION_ERROR');
|
|
30
|
+
this.name = 'SubscriptionConfigurationError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Configuration Value Object
|
|
3
|
+
* Configuration for subscription service
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SubscriptionStatus } from '../entities/SubscriptionStatus';
|
|
7
|
+
import type { ISubscriptionRepository } from '../../application/ports/ISubscriptionRepository';
|
|
8
|
+
|
|
9
|
+
export interface SubscriptionConfig {
|
|
10
|
+
/** Repository implementation for database operations */
|
|
11
|
+
repository: ISubscriptionRepository;
|
|
12
|
+
|
|
13
|
+
/** Optional callback when subscription status changes */
|
|
14
|
+
onStatusChanged?: (
|
|
15
|
+
userId: string,
|
|
16
|
+
status: SubscriptionStatus,
|
|
17
|
+
) => Promise<void> | void;
|
|
18
|
+
|
|
19
|
+
/** Optional callback for error logging */
|
|
20
|
+
onError?: (error: Error, context: string) => Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native Subscription - Public API
|
|
3
|
+
*
|
|
4
|
+
* Domain-Driven Design (DDD) Architecture
|
|
5
|
+
*
|
|
6
|
+
* This is the SINGLE SOURCE OF TRUTH for all subscription operations.
|
|
7
|
+
* ALL imports from the Subscription package MUST go through this file.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* - domain: Entities, value objects, errors (business logic)
|
|
11
|
+
* - application: Ports (interfaces)
|
|
12
|
+
* - infrastructure: Subscription service implementation
|
|
13
|
+
* - presentation: Hooks (React integration)
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { initializeSubscriptionService, useSubscription } from '@umituz/react-native-subscription';
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// DOMAIN LAYER - Business Logic
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
SubscriptionError,
|
|
25
|
+
SubscriptionRepositoryError,
|
|
26
|
+
SubscriptionValidationError,
|
|
27
|
+
SubscriptionConfigurationError,
|
|
28
|
+
} from './domain/errors/SubscriptionError';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
createDefaultSubscriptionStatus,
|
|
32
|
+
isSubscriptionValid,
|
|
33
|
+
} from './domain/entities/SubscriptionStatus';
|
|
34
|
+
export type { SubscriptionStatus } from './domain/entities/SubscriptionStatus';
|
|
35
|
+
|
|
36
|
+
export type { SubscriptionConfig } from './domain/value-objects/SubscriptionConfig';
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// APPLICATION LAYER - Ports
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
export type { ISubscriptionRepository } from './application/ports/ISubscriptionRepository';
|
|
43
|
+
export type { ISubscriptionService } from './application/ports/ISubscriptionService';
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// INFRASTRUCTURE LAYER - Implementation
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
SubscriptionService,
|
|
51
|
+
initializeSubscriptionService,
|
|
52
|
+
getSubscriptionService,
|
|
53
|
+
resetSubscriptionService,
|
|
54
|
+
} from './infrastructure/services/SubscriptionService';
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// PRESENTATION LAYER - Hooks
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
export { useSubscription } from './presentation/hooks/useSubscription';
|
|
61
|
+
export type { UseSubscriptionResult } from './presentation/hooks/useSubscription';
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// UTILS
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
isSubscriptionExpired,
|
|
69
|
+
getDaysUntilExpiration,
|
|
70
|
+
formatExpirationDate,
|
|
71
|
+
} from './utils/subscriptionUtils';
|
|
72
|
+
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
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
|
|
10
|
+
*/
|
|
11
|
+
|
|
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';
|
|
19
|
+
import {
|
|
20
|
+
SubscriptionRepositoryError,
|
|
21
|
+
SubscriptionValidationError,
|
|
22
|
+
} from '../domain/errors/SubscriptionError';
|
|
23
|
+
import type { SubscriptionConfig } from '../domain/value-objects/SubscriptionConfig';
|
|
24
|
+
|
|
25
|
+
export class SubscriptionService implements ISubscriptionService {
|
|
26
|
+
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;
|
|
32
|
+
|
|
33
|
+
constructor(config: SubscriptionConfig) {
|
|
34
|
+
if (!config.repository) {
|
|
35
|
+
throw new SubscriptionValidationError(
|
|
36
|
+
'Repository is required for SubscriptionService',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.repository = config.repository;
|
|
41
|
+
this.onStatusChanged = config.onStatusChanged;
|
|
42
|
+
this.onError = config.onError;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get subscription status for a user
|
|
47
|
+
* Returns default (free) status if user not found
|
|
48
|
+
*/
|
|
49
|
+
async getSubscriptionStatus(userId: string): Promise<SubscriptionStatus> {
|
|
50
|
+
try {
|
|
51
|
+
const status = await this.repository.getSubscriptionStatus(userId);
|
|
52
|
+
if (!status) {
|
|
53
|
+
return createDefaultSubscriptionStatus();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Validate subscription status (check expiration)
|
|
57
|
+
const isValid = this.repository.isSubscriptionValid(status);
|
|
58
|
+
if (!isValid && status.isPremium) {
|
|
59
|
+
// Subscription expired, update status
|
|
60
|
+
const updatedStatus = await this.deactivateSubscription(userId);
|
|
61
|
+
return updatedStatus;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return status;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await this.handleError(
|
|
67
|
+
error instanceof Error
|
|
68
|
+
? error
|
|
69
|
+
: new Error('Error getting subscription status'),
|
|
70
|
+
'SubscriptionService.getSubscriptionStatus',
|
|
71
|
+
);
|
|
72
|
+
return createDefaultSubscriptionStatus();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if user has active subscription
|
|
78
|
+
*/
|
|
79
|
+
async isPremium(userId: string): Promise<boolean> {
|
|
80
|
+
const status = await this.getSubscriptionStatus(userId);
|
|
81
|
+
return this.repository.isSubscriptionValid(status);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Activate subscription
|
|
86
|
+
*/
|
|
87
|
+
async activateSubscription(
|
|
88
|
+
userId: string,
|
|
89
|
+
productId: string,
|
|
90
|
+
expiresAt: string | null,
|
|
91
|
+
): 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
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Deactivate subscription
|
|
133
|
+
*/
|
|
134
|
+
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
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Update subscription status
|
|
174
|
+
*/
|
|
175
|
+
async updateSubscriptionStatus(
|
|
176
|
+
userId: string,
|
|
177
|
+
updates: Partial<SubscriptionStatus>,
|
|
178
|
+
): Promise<SubscriptionStatus> {
|
|
179
|
+
try {
|
|
180
|
+
// Add syncedAt timestamp
|
|
181
|
+
const updatesWithSync = {
|
|
182
|
+
...updates,
|
|
183
|
+
syncedAt: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const updatedStatus = await this.repository.updateSubscriptionStatus(
|
|
187
|
+
userId,
|
|
188
|
+
updatesWithSync,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Call callback if provided
|
|
192
|
+
if (this.onStatusChanged) {
|
|
193
|
+
try {
|
|
194
|
+
await this.onStatusChanged(userId, updatedStatus);
|
|
195
|
+
} 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
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return updatedStatus;
|
|
205
|
+
} 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
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
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
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Singleton instance
|
|
234
|
+
* Apps should use initializeSubscriptionService() to set up with their config
|
|
235
|
+
*/
|
|
236
|
+
let subscriptionServiceInstance: SubscriptionService | null = null;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Initialize Subscription service with configuration
|
|
240
|
+
*/
|
|
241
|
+
export function initializeSubscriptionService(
|
|
242
|
+
config: SubscriptionConfig,
|
|
243
|
+
): SubscriptionService {
|
|
244
|
+
if (!subscriptionServiceInstance) {
|
|
245
|
+
subscriptionServiceInstance = new SubscriptionService(config);
|
|
246
|
+
}
|
|
247
|
+
return subscriptionServiceInstance;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get Subscription service instance
|
|
252
|
+
* Returns null if service is not initialized (graceful degradation)
|
|
253
|
+
*/
|
|
254
|
+
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;
|
|
263
|
+
}
|
|
264
|
+
return subscriptionServiceInstance;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Reset Subscription service (useful for testing)
|
|
269
|
+
*/
|
|
270
|
+
export function resetSubscriptionService(): void {
|
|
271
|
+
subscriptionServiceInstance = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSubscription Hook
|
|
3
|
+
* React hook for subscription management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
7
|
+
import { getSubscriptionService } from '../infrastructure/services/SubscriptionService';
|
|
8
|
+
import type { SubscriptionStatus } from '../domain/entities/SubscriptionStatus';
|
|
9
|
+
|
|
10
|
+
export interface UseSubscriptionResult {
|
|
11
|
+
/** Current subscription status */
|
|
12
|
+
status: SubscriptionStatus | null;
|
|
13
|
+
/** Whether subscription is loading */
|
|
14
|
+
loading: boolean;
|
|
15
|
+
/** Error if any */
|
|
16
|
+
error: string | null;
|
|
17
|
+
/** Whether user has active subscription */
|
|
18
|
+
isPremium: boolean;
|
|
19
|
+
/** Load subscription status */
|
|
20
|
+
loadStatus: (userId: string) => Promise<void>;
|
|
21
|
+
/** Refresh subscription status */
|
|
22
|
+
refreshStatus: (userId: string) => Promise<void>;
|
|
23
|
+
/** Activate subscription */
|
|
24
|
+
activateSubscription: (
|
|
25
|
+
userId: string,
|
|
26
|
+
productId: string,
|
|
27
|
+
expiresAt: string | null,
|
|
28
|
+
) => Promise<void>;
|
|
29
|
+
/** Deactivate subscription */
|
|
30
|
+
deactivateSubscription: (userId: string) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hook for subscription operations
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* const { status, isPremium, loadStatus } = useSubscription();
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function useSubscription(): UseSubscriptionResult {
|
|
42
|
+
const [status, setStatus] = useState<SubscriptionStatus | null>(null);
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
const loadStatus = useCallback(async (userId: string) => {
|
|
47
|
+
const service = getSubscriptionService();
|
|
48
|
+
if (!service) {
|
|
49
|
+
setError('Subscription service is not initialized');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const subscriptionStatus = await service.getSubscriptionStatus(userId);
|
|
58
|
+
setStatus(subscriptionStatus);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const errorMessage =
|
|
61
|
+
err instanceof Error ? err.message : 'Failed to load subscription status';
|
|
62
|
+
setError(errorMessage);
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const refreshStatus = useCallback(async (userId: string) => {
|
|
69
|
+
const service = getSubscriptionService();
|
|
70
|
+
if (!service) {
|
|
71
|
+
setError('Subscription service is not initialized');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setLoading(true);
|
|
76
|
+
setError(null);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const subscriptionStatus = await service.getSubscriptionStatus(userId);
|
|
80
|
+
setStatus(subscriptionStatus);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const errorMessage =
|
|
83
|
+
err instanceof Error ? err.message : 'Failed to refresh subscription status';
|
|
84
|
+
setError(errorMessage);
|
|
85
|
+
} finally {
|
|
86
|
+
setLoading(false);
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const activateSubscription = useCallback(
|
|
91
|
+
async (userId: string, productId: string, expiresAt: string | null) => {
|
|
92
|
+
const service = getSubscriptionService();
|
|
93
|
+
if (!service) {
|
|
94
|
+
setError('Subscription service is not initialized');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setLoading(true);
|
|
99
|
+
setError(null);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const updatedStatus = await service.activateSubscription(
|
|
103
|
+
userId,
|
|
104
|
+
productId,
|
|
105
|
+
expiresAt,
|
|
106
|
+
);
|
|
107
|
+
setStatus(updatedStatus);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const errorMessage =
|
|
110
|
+
err instanceof Error ? err.message : 'Failed to activate subscription';
|
|
111
|
+
setError(errorMessage);
|
|
112
|
+
throw err;
|
|
113
|
+
} finally {
|
|
114
|
+
setLoading(false);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const deactivateSubscription = useCallback(async (userId: string) => {
|
|
121
|
+
const service = getSubscriptionService();
|
|
122
|
+
if (!service) {
|
|
123
|
+
setError('Subscription service is not initialized');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setLoading(true);
|
|
128
|
+
setError(null);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const updatedStatus = await service.deactivateSubscription(userId);
|
|
132
|
+
setStatus(updatedStatus);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const errorMessage =
|
|
135
|
+
err instanceof Error ? err.message : 'Failed to deactivate subscription';
|
|
136
|
+
setError(errorMessage);
|
|
137
|
+
throw err;
|
|
138
|
+
} finally {
|
|
139
|
+
setLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
const isPremium = status ? status.isPremium && (status.expiresAt === null || new Date(status.expiresAt) > new Date()) : false;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
status,
|
|
147
|
+
loading,
|
|
148
|
+
error,
|
|
149
|
+
isPremium,
|
|
150
|
+
loadStatus,
|
|
151
|
+
refreshStatus,
|
|
152
|
+
activateSubscription,
|
|
153
|
+
deactivateSubscription,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Utilities
|
|
3
|
+
* Helper functions for subscription operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SubscriptionStatus } from '../domain/entities/SubscriptionStatus';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if subscription is expired
|
|
10
|
+
*/
|
|
11
|
+
export function isSubscriptionExpired(status: SubscriptionStatus | null): boolean {
|
|
12
|
+
if (!status || !status.isPremium) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!status.expiresAt) {
|
|
17
|
+
// Lifetime subscription (no expiration)
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const expirationDate = new Date(status.expiresAt);
|
|
22
|
+
const now = new Date();
|
|
23
|
+
|
|
24
|
+
return expirationDate.getTime() <= now.getTime();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get days until subscription expires
|
|
29
|
+
* Returns null for lifetime subscriptions
|
|
30
|
+
*/
|
|
31
|
+
export function getDaysUntilExpiration(
|
|
32
|
+
status: SubscriptionStatus | null,
|
|
33
|
+
): number | null {
|
|
34
|
+
if (!status || !status.expiresAt) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expirationDate = new Date(status.expiresAt);
|
|
39
|
+
const now = new Date();
|
|
40
|
+
const diffMs = expirationDate.getTime() - now.getTime();
|
|
41
|
+
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
42
|
+
|
|
43
|
+
return diffDays > 0 ? diffDays : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format expiration date for display
|
|
48
|
+
*/
|
|
49
|
+
export function formatExpirationDate(
|
|
50
|
+
expiresAt: string | null,
|
|
51
|
+
locale: string = 'en-US',
|
|
52
|
+
): string | null {
|
|
53
|
+
if (!expiresAt) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const date = new Date(expiresAt);
|
|
59
|
+
return date.toLocaleDateString(locale, {
|
|
60
|
+
year: 'numeric',
|
|
61
|
+
month: 'long',
|
|
62
|
+
day: 'numeric',
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|