@umituz/react-native-subscription 2.37.116 → 2.37.118

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": "2.37.116",
3
+ "version": "2.37.118",
4
4
  "description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,6 +10,12 @@ import {
10
10
  } from "./creditOperationUtils.types";
11
11
  import { PURCHASE_ID_PREFIXES, PROCESSED_PURCHASES_WINDOW } from "../core/CreditsConstants";
12
12
 
13
+ // Helper to check if purchaseId is a purchase or renewal (used for setting lastPurchaseAt)
14
+ function isPurchaseOrRenewal(purchaseId: string): boolean {
15
+ return purchaseId.startsWith(PURCHASE_ID_PREFIXES.PURCHASE) ||
16
+ purchaseId.startsWith(PURCHASE_ID_PREFIXES.RENEWAL);
17
+ }
18
+
13
19
 
14
20
  export function calculateNewCredits({ metadata, existingData, creditLimit }: CalculateCreditsParams): number {
15
21
  const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
@@ -48,9 +54,6 @@ export function buildCreditsData({
48
54
  periodType: metadata.periodType ?? undefined,
49
55
  });
50
56
 
51
- const isPurchaseOrRenewal = purchaseId.startsWith(PURCHASE_ID_PREFIXES.PURCHASE) ||
52
- purchaseId.startsWith(PURCHASE_ID_PREFIXES.RENEWAL);
53
-
54
57
  const expirationTimestamp = metadata.expirationDate ? toTimestamp(metadata.expirationDate) : null;
55
58
  const canceledAtTimestamp = metadata.unsubscribeDetectedAt ? toTimestamp(metadata.unsubscribeDetectedAt) : null;
56
59
  const billingIssueTimestamp = metadata.billingIssueDetectedAt ? toTimestamp(metadata.billingIssueDetectedAt) : null;
@@ -65,7 +68,7 @@ export function buildCreditsData({
65
68
  productId,
66
69
  platform,
67
70
  ...(purchaseHistory.length > 0 && { purchaseHistory }),
68
- ...(isPurchaseOrRenewal && { lastPurchaseAt: serverTimestamp() }),
71
+ ...(isPurchaseOrRenewal(purchaseId) && { lastPurchaseAt: serverTimestamp() }),
69
72
  ...(expirationTimestamp && { expirationDate: expirationTimestamp }),
70
73
  ...(metadata.willRenew !== undefined && { willRenew: metadata.willRenew }),
71
74
  ...(metadata.storeTransactionId && { storeTransactionId: metadata.storeTransactionId }),
@@ -70,6 +70,15 @@ export async function syncPremiumMetadata(
70
70
  * Cross-user guard: if storeTransactionId is provided and already registered
71
71
  * to a different user in the global processedTransactions collection, the recovery
72
72
  * document is NOT created (the subscription belongs to another UID).
73
+ *
74
+ * NOTE: This uses non-atomic check-then-act (getDoc + setDoc). In theory, two concurrent
75
+ * calls could both see no document and create duplicates. However, this is extremely rare
76
+ * in practice because: (1) createRecoveryCreditsDocument is called after a successful
77
+ * purchase which is already serialized, (2) the global transaction check (below) prevents
78
+ * duplicates across users, (3) even if two recovery docs are created, the credits document
79
+ * logic is idempotent (same purchaseId processed twice is no-op). Making this atomic
80
+ * would require a transaction spanning both the credits doc and global processedTransactions,
81
+ * which adds complexity without meaningful benefit given the safeguards above.
73
82
  */
74
83
  export async function createRecoveryCreditsDocument(
75
84
  ref: DocumentReference,
@@ -6,7 +6,6 @@ export interface UseDeductCreditParams {
6
6
  export interface UseDeductCreditResult {
7
7
  checkCredits: (cost?: number) => Promise<boolean>;
8
8
  deductCredit: (cost?: number) => Promise<boolean>;
9
- deductCredits: (cost: number) => Promise<boolean>;
10
9
  refundCredits: (amount: number) => Promise<boolean>;
11
10
  isDeducting: boolean;
12
11
  }
@@ -45,10 +45,6 @@ export const useDeductCredit = ({
45
45
  }
46
46
  }, [onCreditsExhausted, userId]);
47
47
 
48
- const deductCredits = useCallback(async (cost: number): Promise<boolean> => {
49
- return await deductCredit(cost);
50
- }, [deductCredit]);
51
-
52
48
  const checkCredits = useCallback(async (cost: number = 1): Promise<boolean> => {
53
49
  if (!userId) return false;
54
50
  return repository.hasCredits(userId, cost);
@@ -77,7 +73,6 @@ export const useDeductCredit = ({
77
73
  return {
78
74
  checkCredits,
79
75
  deductCredit,
80
- deductCredits,
81
76
  refundCredits,
82
77
  isDeducting: mutation.isPending
83
78
  };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Credit calculation utilities
3
+ * All credit-related calculations consolidated in one place
4
+ */
5
+
6
+ import type { CreditsConfig } from "../core/Credits";
7
+ import { isValidNumber, isNonNegativeNumber } from "../../../shared/utils/validators";
8
+ import { detectPackageType } from "../../../utils/packageTypeDetector";
9
+ import { getCreditAllocation } from "../../../utils/creditMapper";
10
+ import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
11
+ import { isPast } from "../../../utils/dateUtils";
12
+ import type { CalculateCreditsParams } from "../application/creditOperationUtils.types";
13
+
14
+ // ============================================================================
15
+ // VALIDATION HELPERS
16
+ // ============================================================================
17
+
18
+ const isValidBalance = (balance: number | null | undefined): balance is number => {
19
+ return isValidNumber(balance) && isNonNegativeNumber(balance);
20
+ };
21
+
22
+ const isValidCost = (cost: number): boolean => {
23
+ return isValidNumber(cost) && isNonNegativeNumber(cost);
24
+ };
25
+
26
+ const isValidMaxCredits = (max: number): boolean => {
27
+ return isValidNumber(max) && max > 0;
28
+ };
29
+
30
+ // ============================================================================
31
+ // CREDIT AMOUNT CALCULATIONS
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Calculate remaining credits after a deduction
36
+ * Ensures result is never negative (minimum 0)
37
+ */
38
+ export function calculateRemaining(current: number, cost: number): number {
39
+ return Math.max(0, current - cost);
40
+ }
41
+
42
+ /**
43
+ * Check if a balance can afford a cost
44
+ * Returns false if balance is invalid or insufficient
45
+ */
46
+ export function canAffordAmount(
47
+ balance: number | null | undefined,
48
+ cost: number
49
+ ): boolean {
50
+ if (!isValidBalance(balance) || !isValidCost(cost)) return false;
51
+ return balance >= cost;
52
+ }
53
+
54
+ /**
55
+ * Calculate percentage of current vs max credits
56
+ * Returns 0 for invalid inputs, clamped between 0-100
57
+ */
58
+ export function calculateSafePercentage(
59
+ current: number | null | undefined,
60
+ max: number
61
+ ): number {
62
+ if (!isValidNumber(current) || !isValidMaxCredits(max)) return 0;
63
+ const percentage = (current / max) * 100;
64
+ return Math.min(Math.max(percentage, 0), 100);
65
+ }
66
+
67
+ /**
68
+ * Calculate credit limit for a product ID
69
+ * Throws if product ID is missing or limit cannot be determined
70
+ */
71
+ export function calculateCreditLimit(
72
+ productId: string | undefined,
73
+ config: CreditsConfig
74
+ ): number {
75
+ if (!productId) {
76
+ throw new Error(
77
+ "[CreditCalculations] Cannot calculate credit limit without productId"
78
+ );
79
+ }
80
+
81
+ // Check for explicit amount override
82
+ const explicitAmount = config.creditPackageAmounts?.[productId];
83
+ if (
84
+ explicitAmount !== undefined &&
85
+ explicitAmount !== null &&
86
+ typeof explicitAmount === "number"
87
+ ) {
88
+ return explicitAmount;
89
+ }
90
+
91
+ // Calculate from package type allocations
92
+ const packageType = detectPackageType(productId);
93
+ const dynamicLimit = getCreditAllocation(packageType, config.packageAllocations);
94
+
95
+ if (dynamicLimit === null || dynamicLimit === undefined) {
96
+ throw new Error(
97
+ `[CreditCalculations] Cannot determine credit limit for productId: ${productId}, packageType: ${packageType}`
98
+ );
99
+ }
100
+
101
+ return dynamicLimit;
102
+ }
103
+
104
+ /**
105
+ * Calculate new credit balance for a purchase/subscription
106
+ * Takes into account: current balance, subscription status, package type, credit limit
107
+ */
108
+ export function calculateNewCredits(params: CalculateCreditsParams): number {
109
+ const { metadata, existingData, creditLimit } = params;
110
+
111
+ // Determine if subscription is expired
112
+ const isExpired = metadata.expirationDate
113
+ ? isPast(metadata.expirationDate)
114
+ : false;
115
+ const isPremium = metadata.isPremium;
116
+
117
+ // Resolve subscription status
118
+ const status = resolveSubscriptionStatus({
119
+ isPremium,
120
+ willRenew: metadata.willRenew ?? false,
121
+ isExpired,
122
+ periodType: metadata.periodType ?? undefined,
123
+ });
124
+
125
+ // Import orchestrator lazily to avoid circular dependency
126
+ const { creditAllocationOrchestrator } = require("../application/credit-strategies/CreditAllocationOrchestrator");
127
+
128
+ return creditAllocationOrchestrator.allocate({
129
+ status,
130
+ existingData,
131
+ creditLimit,
132
+ isSubscriptionActive: isPremium && !isExpired,
133
+ productId: metadata.productId ?? null,
134
+ });
135
+ }
136
+
137
+ // ============================================================================
138
+ // LOAD STATUS CALCULATIONS
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Calculate credits load status from query state
143
+ */
144
+ export function deriveCreditsLoadStatus(
145
+ queryStatus: "pending" | "error" | "success",
146
+ queryEnabled: boolean
147
+ ): "idle" | "loading" | "error" | "ready" {
148
+ if (!queryEnabled) return "idle";
149
+ if (queryStatus === "pending") return "loading";
150
+ if (queryStatus === "error") return "error";
151
+ return "ready";
152
+ }
153
+
154
+ /**
155
+ * Derive credit-related computed values
156
+ */
157
+ export interface DerivedCreditValues {
158
+ hasCredits: boolean;
159
+ creditsPercent: number;
160
+ }
161
+
162
+ export function deriveCreditValues(
163
+ credits: { credits?: number | null } | null,
164
+ creditLimit: number
165
+ ): DerivedCreditValues {
166
+ const creditAmount = credits?.credits ?? 0;
167
+ const has = creditAmount > 0;
168
+ const percent = calculateSafePercentage(creditAmount, creditLimit);
169
+
170
+ return { hasCredits: has, creditsPercent: percent };
171
+ }
@@ -24,24 +24,24 @@ export async function handleAlreadyPurchasedError(
24
24
  ): Promise<PurchaseResult> {
25
25
  try {
26
26
  const restoreResult = await handleRestore(deps, userId);
27
- if (restoreResult.success && restoreResult.customerInfo) {
28
- if (restoreResult.isPremium) {
29
- await notifyPurchaseCompleted(
30
- deps.config,
31
- userId,
32
- pkg.product.identifier,
33
- restoreResult.customerInfo,
34
- getSavedPurchase()?.source
35
- );
36
- }
37
- clearSavedPurchase();
38
- return {
39
- success: true,
40
- isPremium: restoreResult.isPremium ?? false,
41
- customerInfo: restoreResult.customerInfo,
42
- productId: restoreResult.productId || pkg.product.identifier,
43
- };
27
+ // restoreResult.success is always true here (handleRestore throws on error)
28
+ // and restoreResult.customerInfo is always present (RevenueCat guarantees it)
29
+ if (restoreResult.isPremium) {
30
+ await notifyPurchaseCompleted(
31
+ deps.config,
32
+ userId,
33
+ pkg.product.identifier,
34
+ restoreResult.customerInfo,
35
+ getSavedPurchase()?.source
36
+ );
44
37
  }
38
+ clearSavedPurchase();
39
+ return {
40
+ success: true,
41
+ isPremium: restoreResult.isPremium ?? false,
42
+ customerInfo: restoreResult.customerInfo,
43
+ productId: restoreResult.productId ?? pkg.product.identifier,
44
+ };
45
45
  } catch (_restoreError) {
46
46
  throw new RevenueCatPurchaseError(
47
47
  "You already own this subscription, but restore failed. Please try restoring purchases manually.",
@@ -49,12 +49,6 @@ export async function handleAlreadyPurchasedError(
49
49
  error instanceof Error ? error : undefined
50
50
  );
51
51
  }
52
-
53
- throw new RevenueCatPurchaseError(
54
- "You already own this subscription, but it could not be activated.",
55
- pkg.product.identifier,
56
- error instanceof Error ? error : undefined
57
- );
58
52
  }
59
53
 
60
54
  export function handlePurchaseError(
@@ -3,7 +3,6 @@ import type { RevenueCatConfig } from "../../../revenuecat/core/types";
3
3
  import type { PurchaseSource } from "../../core/SubscriptionConstants";
4
4
  import type { PackageType } from "../../../revenuecat/core/types";
5
5
  import { getPremiumEntitlement } from "../../../revenuecat/core/types";
6
- import type { PeriodType } from "../../core/SubscriptionConstants";
7
6
 
8
7
  export async function syncPremiumStatus(
9
8
  config: RevenueCatConfig,
@@ -38,8 +37,8 @@ export async function syncPremiumStatus(
38
37
  productId: premiumEntitlement.productIdentifier,
39
38
  expirationDate: premiumEntitlement.expirationDate ?? null,
40
39
  willRenew: premiumEntitlement.willRenew,
41
- periodType: premiumEntitlement.periodType as PeriodType | undefined,
42
- storeTransactionId: subscription?.storeTransactionId ?? undefined,
40
+ periodType: premiumEntitlement.periodType,
41
+ storeTransactionId: subscription?.storeTransactionId ?? null,
43
42
  unsubscribeDetectedAt: premiumEntitlement.unsubscribeDetectedAt ?? null,
44
43
  billingIssueDetectedAt: premiumEntitlement.billingIssueDetectedAt ?? null,
45
44
  store: premiumEntitlement.store ?? null,
@@ -2,12 +2,18 @@ export type DateLike = Date | string | number;
2
2
 
3
3
  export function isPast(date: DateLike): boolean {
4
4
  const d = new Date(date);
5
- return d.getTime() < Date.now();
5
+ const time = d.getTime();
6
+ // Invalid dates (NaN) are not considered past - they're invalid
7
+ if (isNaN(time)) return false;
8
+ return time < Date.now();
6
9
  }
7
10
 
8
11
  export function isFuture(date: DateLike): boolean {
9
12
  const d = new Date(date);
10
- return d.getTime() > Date.now();
13
+ const time = d.getTime();
14
+ // Invalid dates (NaN) are not considered future - they're invalid
15
+ if (isNaN(time)) return false;
16
+ return time > Date.now();
11
17
  }
12
18
 
13
19
  export function isNow(date: DateLike, marginMs: number = 1000): boolean {