@umituz/react-native-subscription 2.37.116 → 2.37.117
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 +1 -1
- package/src/domains/credits/application/creditOperationUtils.ts +7 -4
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +9 -0
- package/src/domains/credits/presentation/deduct-credit/types.ts +0 -1
- package/src/domains/credits/presentation/deduct-credit/useDeductCredit.ts +0 -5
- package/src/domains/credits/utils/creditCalculations.ts +171 -0
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +17 -23
- package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +2 -3
- package/src/utils/dateUtils.core.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.37.
|
|
3
|
+
"version": "2.37.117",
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
42
|
-
storeTransactionId: subscription?.storeTransactionId ??
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|