@umituz/react-native-subscription 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Subscription management system for React Native apps - Database-first approach with secure validation",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/index.ts CHANGED
@@ -68,5 +68,18 @@ export {
68
68
  isSubscriptionExpired,
69
69
  getDaysUntilExpiration,
70
70
  formatExpirationDate,
71
+ calculateExpirationDate,
72
+ formatPrice,
73
+ getPeriodText,
71
74
  } from './utils/subscriptionUtils';
72
75
 
76
+ export {
77
+ SUBSCRIPTION_PLAN_TYPES,
78
+ MIN_SUBSCRIPTION_DURATIONS_DAYS,
79
+ SUBSCRIPTION_PERIOD_DAYS,
80
+ DATE_CONSTANTS,
81
+ SUBSCRIPTION_PERIOD_UNITS,
82
+ PRODUCT_ID_KEYWORDS,
83
+ type SubscriptionPlanType,
84
+ } from './utils/subscriptionConstants';
85
+
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Subscription Constants
3
+ * Centralized constants for subscription operations
4
+ *
5
+ * Following SOLID, DRY, KISS principles:
6
+ * - Single Responsibility: Only constants, no logic
7
+ * - DRY: All constants in one place
8
+ * - KISS: Simple, clear constant definitions
9
+ */
10
+
11
+ /**
12
+ * Subscription plan types
13
+ */
14
+ export const SUBSCRIPTION_PLAN_TYPES = {
15
+ WEEKLY: 'weekly',
16
+ MONTHLY: 'monthly',
17
+ YEARLY: 'yearly',
18
+ UNKNOWN: 'unknown',
19
+ } as const;
20
+
21
+ export type SubscriptionPlanType =
22
+ (typeof SUBSCRIPTION_PLAN_TYPES)[keyof typeof SUBSCRIPTION_PLAN_TYPES];
23
+
24
+ /**
25
+ * Minimum expected subscription durations in days
26
+ * Used to detect sandbox accelerated timers
27
+ * Includes 1 day tolerance for clock skew
28
+ */
29
+ export const MIN_SUBSCRIPTION_DURATIONS_DAYS = {
30
+ WEEKLY: 6,
31
+ MONTHLY: 28,
32
+ YEARLY: 360,
33
+ UNKNOWN: 28,
34
+ } as const;
35
+
36
+ /**
37
+ * Subscription period multipliers
38
+ * Days to add for each subscription type
39
+ */
40
+ export const SUBSCRIPTION_PERIOD_DAYS = {
41
+ WEEKLY: 7,
42
+ } as const;
43
+
44
+ /**
45
+ * Date calculation constants
46
+ */
47
+ export const DATE_CONSTANTS = {
48
+ MILLISECONDS_PER_DAY: 1000 * 60 * 60 * 24,
49
+ DEFAULT_LOCALE: 'en-US',
50
+ } as const;
51
+
52
+ /**
53
+ * Subscription period unit mappings
54
+ * Maps RevenueCat period units to our internal types
55
+ */
56
+ export const SUBSCRIPTION_PERIOD_UNITS = {
57
+ WEEK: 'WEEK',
58
+ MONTH: 'MONTH',
59
+ YEAR: 'YEAR',
60
+ } as const;
61
+
62
+ /**
63
+ * Product ID keywords for plan detection
64
+ */
65
+ export const PRODUCT_ID_KEYWORDS = {
66
+ WEEKLY: ['weekly', 'week'],
67
+ MONTHLY: ['monthly', 'month'],
68
+ YEARLY: ['yearly', 'year', 'annual'],
69
+ } as const;
70
+
@@ -1,9 +1,48 @@
1
1
  /**
2
2
  * Subscription Utilities
3
3
  * Helper functions for subscription operations
4
+ *
5
+ * Following SOLID, DRY, KISS principles:
6
+ * - Single Responsibility: Each function does ONE thing
7
+ * - DRY: No code duplication
8
+ * - KISS: Simple, clear implementations
4
9
  */
5
10
 
6
11
  import type { SubscriptionStatus } from '../domain/entities/SubscriptionStatus';
12
+ import {
13
+ SUBSCRIPTION_PLAN_TYPES,
14
+ MIN_SUBSCRIPTION_DURATIONS_DAYS,
15
+ SUBSCRIPTION_PERIOD_DAYS,
16
+ DATE_CONSTANTS,
17
+ SUBSCRIPTION_PERIOD_UNITS,
18
+ PRODUCT_ID_KEYWORDS,
19
+ type SubscriptionPlanType,
20
+ } from './subscriptionConstants';
21
+
22
+ /**
23
+ * Extract subscription plan type from product ID
24
+ * Example: "com.umituz.app.weekly" → "weekly"
25
+ * @internal
26
+ */
27
+ function extractPlanFromProductId(
28
+ productId: string | null | undefined,
29
+ ): SubscriptionPlanType {
30
+ if (!productId) return SUBSCRIPTION_PLAN_TYPES.UNKNOWN;
31
+
32
+ const lower = productId.toLowerCase();
33
+
34
+ if (PRODUCT_ID_KEYWORDS.WEEKLY.some((keyword) => lower.includes(keyword))) {
35
+ return SUBSCRIPTION_PLAN_TYPES.WEEKLY;
36
+ }
37
+ if (PRODUCT_ID_KEYWORDS.MONTHLY.some((keyword) => lower.includes(keyword))) {
38
+ return SUBSCRIPTION_PLAN_TYPES.MONTHLY;
39
+ }
40
+ if (PRODUCT_ID_KEYWORDS.YEARLY.some((keyword) => lower.includes(keyword))) {
41
+ return SUBSCRIPTION_PLAN_TYPES.YEARLY;
42
+ }
43
+
44
+ return SUBSCRIPTION_PLAN_TYPES.UNKNOWN;
45
+ }
7
46
 
8
47
  /**
9
48
  * Check if subscription is expired
@@ -38,7 +77,9 @@ export function getDaysUntilExpiration(
38
77
  const expirationDate = new Date(status.expiresAt);
39
78
  const now = new Date();
40
79
  const diffMs = expirationDate.getTime() - now.getTime();
41
- const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
80
+ const diffDays = Math.ceil(
81
+ diffMs / DATE_CONSTANTS.MILLISECONDS_PER_DAY,
82
+ );
42
83
 
43
84
  return diffDays > 0 ? diffDays : 0;
44
85
  }
@@ -48,7 +89,7 @@ export function getDaysUntilExpiration(
48
89
  */
49
90
  export function formatExpirationDate(
50
91
  expiresAt: string | null,
51
- locale: string = 'en-US',
92
+ locale: string = DATE_CONSTANTS.DEFAULT_LOCALE,
52
93
  ): string | null {
53
94
  if (!expiresAt) {
54
95
  return null;
@@ -66,3 +107,209 @@ export function formatExpirationDate(
66
107
  }
67
108
  }
68
109
 
110
+ /**
111
+ * Calculate expiration date based on subscription plan
112
+ *
113
+ * This function handles:
114
+ * - RevenueCat sandbox accelerated timers (detects and recalculates)
115
+ * - Production dates (trusts RevenueCat's date if valid)
116
+ * - Monthly subscriptions: Same day next month (e.g., Nov 10 → Dec 10)
117
+ * - Yearly subscriptions: Same day next year (e.g., Nov 10, 2024 → Nov 10, 2025)
118
+ * - Weekly subscriptions: +7 days
119
+ *
120
+ * @param productId - Product identifier (e.g., "com.umituz.app.monthly")
121
+ * @param revenueCatExpiresAt - Optional expiration date from RevenueCat API
122
+ * @returns ISO date string for expiration, or null if invalid
123
+ *
124
+ * @example
125
+ * // Monthly subscription purchased on Nov 10, 2024
126
+ * calculateExpirationDate('com.umituz.app.monthly', null)
127
+ * // Returns: '2024-12-10T...' (Dec 10, 2024)
128
+ *
129
+ * @example
130
+ * // Yearly subscription purchased on Nov 10, 2024
131
+ * calculateExpirationDate('com.umituz.app.yearly', null)
132
+ * // Returns: '2025-11-10T...' (Nov 10, 2025)
133
+ */
134
+ export function calculateExpirationDate(
135
+ productId: string | null | undefined,
136
+ revenueCatExpiresAt?: string | null,
137
+ ): string | null {
138
+ const plan = extractPlanFromProductId(productId);
139
+ const now = new Date();
140
+
141
+ // Check if RevenueCat's date is valid and not sandbox accelerated
142
+ if (revenueCatExpiresAt) {
143
+ try {
144
+ const rcDate = new Date(revenueCatExpiresAt);
145
+
146
+ // Only trust if date is in the future
147
+ if (rcDate > now) {
148
+ // Detect sandbox accelerated timers by checking duration
149
+ const durationMs = rcDate.getTime() - now.getTime();
150
+ const durationDays =
151
+ durationMs / DATE_CONSTANTS.MILLISECONDS_PER_DAY;
152
+
153
+ // Get minimum expected duration for this plan type
154
+ const minDurationMap: Record<SubscriptionPlanType, number> = {
155
+ [SUBSCRIPTION_PLAN_TYPES.WEEKLY]:
156
+ MIN_SUBSCRIPTION_DURATIONS_DAYS.WEEKLY,
157
+ [SUBSCRIPTION_PLAN_TYPES.MONTHLY]:
158
+ MIN_SUBSCRIPTION_DURATIONS_DAYS.MONTHLY,
159
+ [SUBSCRIPTION_PLAN_TYPES.YEARLY]:
160
+ MIN_SUBSCRIPTION_DURATIONS_DAYS.YEARLY,
161
+ [SUBSCRIPTION_PLAN_TYPES.UNKNOWN]:
162
+ MIN_SUBSCRIPTION_DURATIONS_DAYS.UNKNOWN,
163
+ };
164
+
165
+ const minDuration = minDurationMap[plan];
166
+
167
+ // If duration is reasonable, trust RevenueCat's date (production)
168
+ if (durationDays >= minDuration) {
169
+ return rcDate.toISOString();
170
+ }
171
+ // Otherwise, fall through to manual calculation (sandbox accelerated)
172
+ }
173
+ } catch {
174
+ // Invalid date, fall through to calculation
175
+ }
176
+ }
177
+
178
+ // Calculate production-equivalent expiration date
179
+ // Use current date as base to preserve the day of month/year
180
+ const calculatedDate = new Date(now);
181
+
182
+ switch (plan) {
183
+ case SUBSCRIPTION_PLAN_TYPES.WEEKLY:
184
+ // Weekly: +7 days
185
+ calculatedDate.setDate(
186
+ calculatedDate.getDate() + SUBSCRIPTION_PERIOD_DAYS.WEEKLY,
187
+ );
188
+ break;
189
+
190
+ case SUBSCRIPTION_PLAN_TYPES.MONTHLY:
191
+ // Monthly: Same day next month
192
+ // This handles edge cases like Jan 31 → Feb 28/29 correctly
193
+ calculatedDate.setMonth(calculatedDate.getMonth() + 1);
194
+ break;
195
+
196
+ case SUBSCRIPTION_PLAN_TYPES.YEARLY:
197
+ // Yearly: Same day next year
198
+ // This handles leap years correctly (Feb 29 → Feb 28/29)
199
+ calculatedDate.setFullYear(calculatedDate.getFullYear() + 1);
200
+ break;
201
+
202
+ default:
203
+ // Unknown plan type - default to 1 month
204
+ calculatedDate.setMonth(calculatedDate.getMonth() + 1);
205
+ break;
206
+ }
207
+
208
+ return calculatedDate.toISOString();
209
+ }
210
+
211
+ /**
212
+ * Format price for display
213
+ * Formats a price value with currency code using Intl.NumberFormat
214
+ *
215
+ * @param price - Price value (e.g., 9.99)
216
+ * @param currencyCode - ISO 4217 currency code (e.g., "USD", "EUR", "TRY")
217
+ * @returns Formatted price string (e.g., "$9.99", "€9.99", "₺9.99")
218
+ *
219
+ * @example
220
+ * formatPrice(9.99, "USD") // Returns: "$9.99"
221
+ * formatPrice(229.99, "TRY") // Returns: "₺229.99"
222
+ */
223
+ export function formatPrice(price: number, currencyCode: string): string {
224
+ return new Intl.NumberFormat(DATE_CONSTANTS.DEFAULT_LOCALE, {
225
+ style: 'currency',
226
+ currency: currencyCode,
227
+ }).format(price);
228
+ }
229
+
230
+ /**
231
+ * Get subscription period text from RevenueCat package
232
+ * Extracts readable period text from PurchasesPackage or subscription period object
233
+ *
234
+ * @param input - RevenueCat PurchasesPackage or subscription period object
235
+ * @returns Human-readable period text (e.g., "month", "year", "2 months")
236
+ *
237
+ * @example
238
+ * // From PurchasesPackage
239
+ * getPeriodText(pkg) // Returns: "month"
240
+ *
241
+ * @example
242
+ * // From subscription period object
243
+ * getPeriodText({ unit: "MONTH", numberOfUnits: 1 }) // Returns: "month"
244
+ * getPeriodText({ unit: "YEAR", numberOfUnits: 1 }) // Returns: "year"
245
+ * getPeriodText({ unit: "MONTH", numberOfUnits: 3 }) // Returns: "3 months"
246
+ */
247
+ export function getPeriodText(
248
+ input:
249
+ | {
250
+ product?: {
251
+ subscriptionPeriod?: {
252
+ unit: string;
253
+ numberOfUnits: number;
254
+ } | null;
255
+ };
256
+ }
257
+ | {
258
+ unit: string;
259
+ numberOfUnits: number;
260
+ }
261
+ | null
262
+ | undefined,
263
+ ): string {
264
+ if (!input) return '';
265
+
266
+ // Extract subscription period from PurchasesPackage or use directly
267
+ let subscriptionPeriod:
268
+ | {
269
+ unit: string;
270
+ numberOfUnits: number;
271
+ }
272
+ | null
273
+ | undefined;
274
+
275
+ // Check if input is PurchasesPackage (has product property)
276
+ if ('product' in input && input.product?.subscriptionPeriod) {
277
+ const period = input.product.subscriptionPeriod;
278
+ // Type guard: check if period is an object with unit and numberOfUnits
279
+ if (
280
+ typeof period === 'object' &&
281
+ 'unit' in period &&
282
+ 'numberOfUnits' in period
283
+ ) {
284
+ subscriptionPeriod = {
285
+ unit: period.unit as string,
286
+ numberOfUnits: period.numberOfUnits as number,
287
+ };
288
+ }
289
+ } else if ('unit' in input && 'numberOfUnits' in input) {
290
+ // Input is already a subscription period object
291
+ subscriptionPeriod = {
292
+ unit: input.unit,
293
+ numberOfUnits: input.numberOfUnits,
294
+ };
295
+ }
296
+
297
+ if (!subscriptionPeriod) return '';
298
+
299
+ const { unit, numberOfUnits } = subscriptionPeriod;
300
+
301
+ if (unit === SUBSCRIPTION_PERIOD_UNITS.MONTH) {
302
+ return numberOfUnits === 1 ? 'month' : `${numberOfUnits} months`;
303
+ }
304
+
305
+ if (unit === SUBSCRIPTION_PERIOD_UNITS.YEAR) {
306
+ return numberOfUnits === 1 ? 'year' : `${numberOfUnits} years`;
307
+ }
308
+
309
+ if (unit === SUBSCRIPTION_PERIOD_UNITS.WEEK) {
310
+ return numberOfUnits === 1 ? 'week' : `${numberOfUnits} weeks`;
311
+ }
312
+
313
+ return '';
314
+ }
315
+