@umituz/react-native-subscription 2.37.103 → 2.37.105
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/CreditsInitializer.ts +36 -3
- package/src/domains/credits/application/DeductCreditsCommand.ts +1 -2
- package/src/domains/credits/application/PurchaseMetadataGenerator.ts +4 -1
- package/src/domains/credits/application/creditDocumentHelpers.ts +10 -1
- package/src/domains/credits/core/CreditsConstants.ts +10 -0
- package/src/domains/credits/infrastructure/CreditsRepository.ts +4 -0
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +3 -2
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +33 -4
- package/src/domains/revenuecat/core/types/RevenueCatConfig.ts +2 -1
- package/src/domains/revenuecat/infrastructure/services/UserSwitchMutex.ts +4 -2
- package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +9 -5
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +16 -5
- package/src/domains/subscription/application/SubscriptionSyncService.ts +3 -2
- package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +3 -2
- package/src/domains/subscription/application/statusChangeHandlers.ts +5 -1
- package/src/domains/subscription/core/SubscriptionConstants.ts +2 -0
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +1 -2
- package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +6 -2
- package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.types.ts +4 -0
- package/src/domains/subscription/presentation/screens/components/SubscriptionHeaderContent.tsx +3 -3
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.105",
|
|
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",
|
|
@@ -2,19 +2,21 @@ import type { CreditsConfig } from "../core/Credits";
|
|
|
2
2
|
import { getAppVersion, validatePlatform } from "../../../utils/appUtils";
|
|
3
3
|
|
|
4
4
|
import type { InitializeCreditsMetadata, InitializationResult } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
5
|
-
import { runTransaction, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
5
|
+
import { runTransaction, serverTimestamp, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
6
|
+
import { doc } from "firebase/firestore";
|
|
6
7
|
import { getCreditDocumentOrDefault } from "./creditDocumentHelpers";
|
|
7
8
|
import { calculateNewCredits, buildCreditsData } from "./creditOperationUtils";
|
|
8
9
|
import { calculateCreditLimit } from "./CreditLimitCalculator";
|
|
9
10
|
import { generatePurchaseMetadata } from "./PurchaseMetadataGenerator";
|
|
10
|
-
import { PURCHASE_ID_PREFIXES } from "../core/CreditsConstants";
|
|
11
|
+
import { PURCHASE_ID_PREFIXES, GLOBAL_TRANSACTION_COLLECTION } from "../core/CreditsConstants";
|
|
11
12
|
|
|
12
13
|
export async function initializeCreditsTransaction(
|
|
13
14
|
_db: Firestore,
|
|
14
15
|
creditsRef: DocumentReference,
|
|
15
16
|
config: CreditsConfig,
|
|
16
17
|
purchaseId: string,
|
|
17
|
-
metadata: InitializeCreditsMetadata
|
|
18
|
+
metadata: InitializeCreditsMetadata,
|
|
19
|
+
userId: string
|
|
18
20
|
): Promise<InitializationResult> {
|
|
19
21
|
|
|
20
22
|
if (!purchaseId.startsWith(PURCHASE_ID_PREFIXES.PURCHASE) && !purchaseId.startsWith(PURCHASE_ID_PREFIXES.RENEWAL)) {
|
|
@@ -37,6 +39,26 @@ export async function initializeCreditsTransaction(
|
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// Global cross-user deduplication: prevent the same Apple/Google transaction
|
|
43
|
+
// from allocating credits under multiple Firebase UIDs.
|
|
44
|
+
if (metadata.originalTransactionId) {
|
|
45
|
+
const globalRef = doc(_db, GLOBAL_TRANSACTION_COLLECTION, metadata.originalTransactionId);
|
|
46
|
+
const globalDoc = await transaction.get(globalRef);
|
|
47
|
+
if (globalDoc.exists()) {
|
|
48
|
+
const globalData = globalDoc.data();
|
|
49
|
+
if (globalData?.ownerUserId && globalData.ownerUserId !== userId) {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[CreditsInitializer] Transaction ${metadata.originalTransactionId} already processed by user ${globalData.ownerUserId}, skipping for ${userId}`
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
credits: existingData.credits,
|
|
55
|
+
alreadyProcessed: true,
|
|
56
|
+
finalData: existingData
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
const creditLimit = calculateCreditLimit(metadata.productId, config);
|
|
41
63
|
const { purchaseHistory } = generatePurchaseMetadata({
|
|
42
64
|
productId: metadata.productId,
|
|
@@ -66,6 +88,17 @@ export async function initializeCreditsTransaction(
|
|
|
66
88
|
|
|
67
89
|
transaction.set(creditsRef, creditsData, { merge: true });
|
|
68
90
|
|
|
91
|
+
// Register transaction globally so other UIDs cannot claim the same purchase.
|
|
92
|
+
if (metadata.originalTransactionId) {
|
|
93
|
+
const globalRef = doc(_db, GLOBAL_TRANSACTION_COLLECTION, metadata.originalTransactionId);
|
|
94
|
+
transaction.set(globalRef, {
|
|
95
|
+
ownerUserId: userId,
|
|
96
|
+
purchaseId,
|
|
97
|
+
productId: metadata.productId,
|
|
98
|
+
createdAt: serverTimestamp(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
return {
|
|
70
103
|
credits: newCredits,
|
|
71
104
|
alreadyProcessed: false,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runTransaction, serverTimestamp, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
2
2
|
import type { DeductCreditsResult } from "../core/Credits";
|
|
3
|
-
import { CREDIT_ERROR_CODES } from "../core/CreditsConstants";
|
|
3
|
+
import { CREDIT_ERROR_CODES, MAX_SINGLE_DEDUCTION } from "../core/CreditsConstants";
|
|
4
4
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
5
5
|
|
|
6
6
|
export async function deductCreditsOperation(
|
|
@@ -20,7 +20,6 @@ export async function deductCreditsOperation(
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const MAX_SINGLE_DEDUCTION = 10000;
|
|
24
23
|
if (cost <= 0 || !Number.isFinite(cost) || cost > MAX_SINGLE_DEDUCTION) {
|
|
25
24
|
return {
|
|
26
25
|
success: false,
|
|
@@ -26,7 +26,10 @@ export function generatePurchaseMetadata(
|
|
|
26
26
|
const packageType = detectPackageType(productId);
|
|
27
27
|
let purchaseType: PurchaseType = type;
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const existingLimit = typeof existingData.creditLimit === 'number' && Number.isFinite(existingData.creditLimit)
|
|
30
|
+
? existingData.creditLimit
|
|
31
|
+
: 0;
|
|
32
|
+
if (packageType !== PACKAGE_TYPE.UNKNOWN && creditLimit > existingLimit) {
|
|
30
33
|
purchaseType = PURCHASE_TYPE.UPGRADE;
|
|
31
34
|
}
|
|
32
35
|
|
|
@@ -7,7 +7,16 @@ export function getCreditDocumentOrDefault(
|
|
|
7
7
|
platform: Platform
|
|
8
8
|
): UserCreditsDocumentRead {
|
|
9
9
|
if (creditsDoc.exists()) {
|
|
10
|
-
|
|
10
|
+
const raw = creditsDoc.data() as Record<string, unknown>;
|
|
11
|
+
// Ensure critical fields have safe defaults to prevent NaN/undefined propagation
|
|
12
|
+
return {
|
|
13
|
+
...raw,
|
|
14
|
+
credits: typeof raw.credits === 'number' && Number.isFinite(raw.credits) ? raw.credits : 0,
|
|
15
|
+
creditLimit: typeof raw.creditLimit === 'number' && Number.isFinite(raw.creditLimit) ? raw.creditLimit : 0,
|
|
16
|
+
processedPurchases: Array.isArray(raw.processedPurchases) ? raw.processedPurchases : [],
|
|
17
|
+
purchaseHistory: Array.isArray(raw.purchaseHistory) ? raw.purchaseHistory : [],
|
|
18
|
+
isPremium: typeof raw.isPremium === 'boolean' ? raw.isPremium : false,
|
|
19
|
+
} as UserCreditsDocumentRead;
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
const now = serverTimestamp() as unknown as FirestoreTimestamp;
|
|
@@ -11,3 +11,13 @@ export const PURCHASE_ID_PREFIXES = {
|
|
|
11
11
|
} as const;
|
|
12
12
|
|
|
13
13
|
export const PROCESSED_PURCHASES_WINDOW = 50;
|
|
14
|
+
|
|
15
|
+
/** Maximum credits that can be deducted in a single operation. */
|
|
16
|
+
export const MAX_SINGLE_DEDUCTION = 10000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Global Firestore collection for cross-user transaction deduplication.
|
|
20
|
+
* Prevents the same Apple/Google transaction from allocating credits
|
|
21
|
+
* under multiple Firebase UIDs (e.g., anonymous + converted accounts).
|
|
22
|
+
*/
|
|
23
|
+
export const GLOBAL_TRANSACTION_COLLECTION = 'processedTransactions';
|
|
@@ -92,6 +92,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
92
92
|
willRenew: boolean,
|
|
93
93
|
expirationDate: string | null,
|
|
94
94
|
periodType: string | null,
|
|
95
|
+
originalTransactionId?: string | null,
|
|
95
96
|
): Promise<boolean> {
|
|
96
97
|
const db = requireFirestore();
|
|
97
98
|
const creditLimit = calculateCreditLimit(productId, this.config);
|
|
@@ -102,6 +103,9 @@ export class CreditsRepository extends BaseRepository {
|
|
|
102
103
|
willRenew,
|
|
103
104
|
expirationDate,
|
|
104
105
|
periodType,
|
|
106
|
+
db,
|
|
107
|
+
userId,
|
|
108
|
+
originalTransactionId,
|
|
105
109
|
);
|
|
106
110
|
}
|
|
107
111
|
}
|
|
@@ -43,7 +43,7 @@ function isTransientError(error: unknown): boolean {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export async function initializeCreditsWithRetry(params: InitializeCreditsParams): Promise<CreditsResult> {
|
|
46
|
-
const { db, ref, config, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
|
|
46
|
+
const { db, ref, config, userId, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
|
|
47
47
|
|
|
48
48
|
const creditLimit = calculateCreditLimit(productId, config);
|
|
49
49
|
const cfg = { ...config, creditLimit };
|
|
@@ -72,7 +72,8 @@ export async function initializeCreditsWithRetry(params: InitializeCreditsParams
|
|
|
72
72
|
ownershipType: revenueCatData.ownershipType,
|
|
73
73
|
revenueCatUserId: revenueCatData.revenueCatUserId,
|
|
74
74
|
type,
|
|
75
|
-
}
|
|
75
|
+
},
|
|
76
|
+
userId
|
|
76
77
|
);
|
|
77
78
|
|
|
78
79
|
return {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { DocumentReference, Transaction } from "@umituz/react-native-firebase";
|
|
1
|
+
import type { DocumentReference, Transaction, Firestore } from "@umituz/react-native-firebase";
|
|
2
2
|
import { runTransaction, serverTimestamp } from "@umituz/react-native-firebase";
|
|
3
|
-
import { getDoc, setDoc } from "firebase/firestore";
|
|
3
|
+
import { doc, getDoc, setDoc } from "firebase/firestore";
|
|
4
4
|
import { SUBSCRIPTION_STATUS } from "../../../subscription/core/SubscriptionConstants";
|
|
5
5
|
import { resolveSubscriptionStatus } from "../../../subscription/core/SubscriptionStatus";
|
|
6
6
|
import { toTimestamp } from "../../../../shared/utils/dateConverter";
|
|
7
7
|
import { isPast } from "../../../../utils/dateUtils";
|
|
8
8
|
import { getAppVersion, validatePlatform } from "../../../../utils/appUtils";
|
|
9
|
+
import { GLOBAL_TRANSACTION_COLLECTION } from "../../core/CreditsConstants";
|
|
9
10
|
|
|
10
11
|
// Fix: was getDoc+setDoc (non-atomic) — now uses runTransaction so concurrent
|
|
11
12
|
// initializeCreditsTransaction and deductCreditsOperation no longer see stale
|
|
@@ -76,6 +77,10 @@ export async function syncPremiumMetadata(
|
|
|
76
77
|
* Recovery: creates a credits document for premium users who don't have one.
|
|
77
78
|
* This handles edge cases like test store purchases, reinstalls, or failed initializations.
|
|
78
79
|
* Returns true if a new document was created, false if one already existed.
|
|
80
|
+
*
|
|
81
|
+
* Cross-user guard: if originalTransactionId is provided and already registered
|
|
82
|
+
* to a different user in the global processedTransactions collection, the recovery
|
|
83
|
+
* document is NOT created (the subscription belongs to another UID).
|
|
79
84
|
*/
|
|
80
85
|
export async function createRecoveryCreditsDocument(
|
|
81
86
|
ref: DocumentReference,
|
|
@@ -84,9 +89,33 @@ export async function createRecoveryCreditsDocument(
|
|
|
84
89
|
willRenew: boolean,
|
|
85
90
|
expirationDate: string | null,
|
|
86
91
|
periodType: string | null,
|
|
92
|
+
db?: Firestore,
|
|
93
|
+
userId?: string,
|
|
94
|
+
originalTransactionId?: string | null,
|
|
87
95
|
): Promise<boolean> {
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
96
|
+
const existingDoc = await getDoc(ref);
|
|
97
|
+
if (existingDoc.exists()) return false;
|
|
98
|
+
|
|
99
|
+
// Cross-user deduplication: if this transaction was already processed by another
|
|
100
|
+
// user, skip recovery to prevent double credit allocation across UIDs.
|
|
101
|
+
if (db && userId && originalTransactionId) {
|
|
102
|
+
try {
|
|
103
|
+
const globalRef = doc(db, GLOBAL_TRANSACTION_COLLECTION, originalTransactionId);
|
|
104
|
+
const globalDoc = await getDoc(globalRef);
|
|
105
|
+
if (globalDoc.exists()) {
|
|
106
|
+
const globalData = globalDoc.data();
|
|
107
|
+
if (globalData?.ownerUserId && globalData.ownerUserId !== userId) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`[CreditsWriter] Recovery skipped: transaction ${originalTransactionId} belongs to user ${globalData.ownerUserId}, not ${userId}`
|
|
110
|
+
);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Non-fatal: if global check fails, still create recovery doc as safety net
|
|
116
|
+
console.warn('[CreditsWriter] Global transaction check failed during recovery:', error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
90
119
|
|
|
91
120
|
const platform = validatePlatform();
|
|
92
121
|
const appVersion = getAppVersion();
|
|
@@ -11,7 +11,8 @@ export interface RevenueCatConfig {
|
|
|
11
11
|
productId?: string,
|
|
12
12
|
expiresAt?: string,
|
|
13
13
|
willRenew?: boolean,
|
|
14
|
-
periodType?: string
|
|
14
|
+
periodType?: string,
|
|
15
|
+
originalTransactionId?: string
|
|
15
16
|
) => Promise<void> | void;
|
|
16
17
|
onPurchaseCompleted?: (
|
|
17
18
|
userId: string,
|
|
@@ -18,8 +18,10 @@ class UserSwitchMutexImpl {
|
|
|
18
18
|
}
|
|
19
19
|
try {
|
|
20
20
|
await this.activeSwitchPromise;
|
|
21
|
-
} catch (
|
|
22
|
-
//
|
|
21
|
+
} catch (error) {
|
|
22
|
+
// Previous switch failed — this is non-fatal for the current switch,
|
|
23
|
+
// but worth logging so the failure is visible in diagnostics.
|
|
24
|
+
console.warn('[UserSwitchMutex] Previous user switch failed:', error instanceof Error ? error.message : String(error));
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const timeSinceLastSwitch = Date.now() - this.lastSwitchTime;
|
|
@@ -5,11 +5,9 @@ import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
|
5
5
|
import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
|
|
6
6
|
import { UserSwitchMutex } from "./UserSwitchMutex";
|
|
7
7
|
import { getPremiumEntitlement } from "../../core/types";
|
|
8
|
-
import type
|
|
8
|
+
import { ANONYMOUS_CACHE_KEY, type PeriodType } from "../../../subscription/core/SubscriptionConstants";
|
|
9
9
|
import { requireFirestore } from "../../../../shared/infrastructure/firestore";
|
|
10
10
|
|
|
11
|
-
const ANONYMOUS_CACHE_KEY = '__anonymous__';
|
|
12
|
-
|
|
13
11
|
declare const __DEV__: boolean;
|
|
14
12
|
|
|
15
13
|
function buildSuccessResult(deps: InitializerDeps, customerInfo: CustomerInfo, offerings: PurchasesOfferings | null): InitializeResult {
|
|
@@ -46,8 +44,9 @@ async function syncRevenueCatIdToProfile(firebaseUserId: string, revenueCatUserI
|
|
|
46
44
|
const db = requireFirestore();
|
|
47
45
|
const userRef = doc(db, "users", firebaseUserId);
|
|
48
46
|
await setDoc(userRef, { revenueCatUserId }, { merge: true });
|
|
49
|
-
} catch {
|
|
47
|
+
} catch (error) {
|
|
50
48
|
// Non-fatal: profile update failure should not block SDK initialization
|
|
49
|
+
console.warn('[UserSwitchHandler] Failed to sync RevenueCat ID to profile:', error instanceof Error ? error.message : String(error));
|
|
51
50
|
}
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -211,13 +210,17 @@ export async function handleInitialConfiguration(
|
|
|
211
210
|
);
|
|
212
211
|
|
|
213
212
|
if (premiumEntitlement) {
|
|
213
|
+
const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
|
|
214
|
+
const originalTransactionId = subscription?.storeTransactionId ?? undefined;
|
|
215
|
+
|
|
214
216
|
await deps.config.onPremiumStatusChanged(
|
|
215
217
|
normalizedUserId,
|
|
216
218
|
true,
|
|
217
219
|
premiumEntitlement.productIdentifier,
|
|
218
220
|
premiumEntitlement.expirationDate ?? undefined,
|
|
219
221
|
premiumEntitlement.willRenew,
|
|
220
|
-
premiumEntitlement.periodType as PeriodType | undefined
|
|
222
|
+
premiumEntitlement.periodType as PeriodType | undefined,
|
|
223
|
+
originalTransactionId
|
|
221
224
|
);
|
|
222
225
|
} else {
|
|
223
226
|
await deps.config.onPremiumStatusChanged(
|
|
@@ -226,6 +229,7 @@ export async function handleInitialConfiguration(
|
|
|
226
229
|
undefined,
|
|
227
230
|
undefined,
|
|
228
231
|
undefined,
|
|
232
|
+
undefined,
|
|
229
233
|
undefined
|
|
230
234
|
);
|
|
231
235
|
}
|
|
@@ -27,14 +27,14 @@ export class SubscriptionSyncProcessor {
|
|
|
27
27
|
|
|
28
28
|
private async getCreditsUserId(revenueCatUserId: string | null | undefined): Promise<string> {
|
|
29
29
|
const trimmed = revenueCatUserId?.trim();
|
|
30
|
-
if (trimmed && trimmed.length > 0) {
|
|
30
|
+
if (trimmed && trimmed.length > 0 && trimmed !== 'undefined' && trimmed !== 'null') {
|
|
31
31
|
return trimmed;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
console.warn("[SubscriptionSyncProcessor] revenueCatUserId is empty/null, falling back to anonymousUserId");
|
|
35
35
|
const anonymousId = await this.getAnonymousUserId();
|
|
36
36
|
const trimmedAnonymous = anonymousId?.trim();
|
|
37
|
-
if (!trimmedAnonymous || trimmedAnonymous.length === 0) {
|
|
37
|
+
if (!trimmedAnonymous || trimmedAnonymous.length === 0 || trimmedAnonymous === 'undefined' || trimmedAnonymous === 'null') {
|
|
38
38
|
throw new Error("[SubscriptionSyncProcessor] Cannot resolve credits userId: both revenueCatUserId and anonymousUserId are empty");
|
|
39
39
|
}
|
|
40
40
|
return trimmedAnonymous;
|
|
@@ -106,7 +106,8 @@ export class SubscriptionSyncProcessor {
|
|
|
106
106
|
productId?: string,
|
|
107
107
|
expiresAt?: string,
|
|
108
108
|
willRenew?: boolean,
|
|
109
|
-
periodType?: PeriodType
|
|
109
|
+
periodType?: PeriodType,
|
|
110
|
+
originalTransactionId?: string
|
|
110
111
|
) {
|
|
111
112
|
// If a purchase is in progress, skip metadata sync (purchase handler does it)
|
|
112
113
|
// but still allow recovery to run — the purchase handler's credit initialization
|
|
@@ -123,7 +124,12 @@ export class SubscriptionSyncProcessor {
|
|
|
123
124
|
productId,
|
|
124
125
|
expiresAt ?? null,
|
|
125
126
|
willRenew ?? false,
|
|
126
|
-
periodType ?? null
|
|
127
|
+
periodType ?? null,
|
|
128
|
+
undefined,
|
|
129
|
+
undefined,
|
|
130
|
+
undefined,
|
|
131
|
+
undefined,
|
|
132
|
+
originalTransactionId
|
|
127
133
|
);
|
|
128
134
|
}
|
|
129
135
|
return;
|
|
@@ -157,7 +163,12 @@ export class SubscriptionSyncProcessor {
|
|
|
157
163
|
productId,
|
|
158
164
|
expiresAt ?? null,
|
|
159
165
|
willRenew ?? false,
|
|
160
|
-
periodType ?? null
|
|
166
|
+
periodType ?? null,
|
|
167
|
+
undefined,
|
|
168
|
+
undefined,
|
|
169
|
+
undefined,
|
|
170
|
+
undefined,
|
|
171
|
+
originalTransactionId
|
|
161
172
|
);
|
|
162
173
|
}
|
|
163
174
|
}
|
|
@@ -49,10 +49,11 @@ export class SubscriptionSyncService {
|
|
|
49
49
|
productId?: string,
|
|
50
50
|
expiresAt?: string,
|
|
51
51
|
willRenew?: boolean,
|
|
52
|
-
periodType?: PeriodType
|
|
52
|
+
periodType?: PeriodType,
|
|
53
|
+
originalTransactionId?: string
|
|
53
54
|
) {
|
|
54
55
|
try {
|
|
55
|
-
await this.processor.processStatusChange(userId, isPremium, productId, expiresAt, willRenew, periodType);
|
|
56
|
+
await this.processor.processStatusChange(userId, isPremium, productId, expiresAt, willRenew, periodType, originalTransactionId);
|
|
56
57
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, { userId, isPremium });
|
|
57
58
|
} catch (error) {
|
|
58
59
|
console.error('[SubscriptionSyncService] Status change processing failed', {
|
|
@@ -48,10 +48,11 @@ export function configureServices(config: SubscriptionInitConfig, apiKey: string
|
|
|
48
48
|
pId?: string,
|
|
49
49
|
exp?: string,
|
|
50
50
|
willR?: boolean,
|
|
51
|
-
pt?: string
|
|
51
|
+
pt?: string,
|
|
52
|
+
txnId?: string
|
|
52
53
|
) => {
|
|
53
54
|
const validPeriodType = pt && Object.values(PERIOD_TYPE).includes(pt as PeriodType) ? pt as PeriodType : undefined;
|
|
54
|
-
return syncService.handlePremiumStatusChanged(u, isP, pId, exp, willR, validPeriodType);
|
|
55
|
+
return syncService.handlePremiumStatusChanged(u, isP, pId, exp, willR, validPeriodType, txnId);
|
|
55
56
|
},
|
|
56
57
|
onCreditsUpdated,
|
|
57
58
|
},
|
|
@@ -17,12 +17,15 @@ export const handlePremiumStatusSync = async (
|
|
|
17
17
|
unsubscribeDetectedAt?: string | null,
|
|
18
18
|
billingIssueDetectedAt?: string | null,
|
|
19
19
|
store?: string | null,
|
|
20
|
-
ownershipType?: string | null
|
|
20
|
+
ownershipType?: string | null,
|
|
21
|
+
originalTransactionId?: string
|
|
21
22
|
): Promise<void> => {
|
|
22
23
|
const repo = getCreditsRepository();
|
|
23
24
|
|
|
24
25
|
// Recovery: if premium user has no credits document, create one.
|
|
25
26
|
// Handles edge cases like test store, reinstalls, or failed purchase initialization.
|
|
27
|
+
// The originalTransactionId is passed for cross-user deduplication: if this
|
|
28
|
+
// transaction was already processed by another UID, recovery is skipped.
|
|
26
29
|
if (isPremium) {
|
|
27
30
|
const created = await repo.ensurePremiumCreditsExist(
|
|
28
31
|
userId,
|
|
@@ -30,6 +33,7 @@ export const handlePremiumStatusSync = async (
|
|
|
30
33
|
willRenew,
|
|
31
34
|
expiresAt,
|
|
32
35
|
periodType,
|
|
36
|
+
originalTransactionId,
|
|
33
37
|
);
|
|
34
38
|
if (__DEV__ && created) {
|
|
35
39
|
console.log('[handlePremiumStatusSync] Recovery: created missing credits document for premium user', { userId, productId });
|
|
@@ -9,8 +9,7 @@ import { checkPremiumStatusFromService } from "./premiumStatusChecker";
|
|
|
9
9
|
import { getPackagesOperation, purchasePackageOperation, restoreOperation } from "./managerOperations";
|
|
10
10
|
import { performServiceInitialization } from "./initializationHandler";
|
|
11
11
|
import { initializationState } from "../state/initializationState";
|
|
12
|
-
|
|
13
|
-
const ANONYMOUS_CACHE_KEY = '__anonymous__';
|
|
12
|
+
import { ANONYMOUS_CACHE_KEY } from "../../core/SubscriptionConstants";
|
|
14
13
|
|
|
15
14
|
class SubscriptionManagerImpl {
|
|
16
15
|
private managerConfig: SubscriptionManagerConfig | null = null;
|
|
@@ -28,16 +28,20 @@ export async function syncPremiumStatus(
|
|
|
28
28
|
|
|
29
29
|
try {
|
|
30
30
|
if (premiumEntitlement) {
|
|
31
|
+
const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
|
|
32
|
+
const originalTransactionId = subscription?.storeTransactionId ?? undefined;
|
|
33
|
+
|
|
31
34
|
await config.onPremiumStatusChanged(
|
|
32
35
|
userId,
|
|
33
36
|
true,
|
|
34
37
|
premiumEntitlement.productIdentifier,
|
|
35
38
|
premiumEntitlement.expirationDate ?? undefined,
|
|
36
39
|
premiumEntitlement.willRenew,
|
|
37
|
-
premiumEntitlement.periodType as "NORMAL" | "INTRO" | undefined
|
|
40
|
+
premiumEntitlement.periodType as "NORMAL" | "INTRO" | undefined,
|
|
41
|
+
originalTransactionId
|
|
38
42
|
);
|
|
39
43
|
} else {
|
|
40
|
-
await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined);
|
|
44
|
+
await config.onPremiumStatusChanged(userId, false, undefined, undefined, undefined, undefined, undefined);
|
|
41
45
|
}
|
|
42
46
|
return { success: true };
|
|
43
47
|
} catch (error) {
|
package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.types.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface SubscriptionHeaderProps {
|
|
|
25
25
|
latestPurchaseDateLabel?: string;
|
|
26
26
|
billingIssuesLabel?: string;
|
|
27
27
|
sandboxLabel?: string;
|
|
28
|
+
willRenewYes?: string;
|
|
29
|
+
willRenewNo?: string;
|
|
30
|
+
billingIssuesDetected?: string;
|
|
31
|
+
sandboxTestMode?: string;
|
|
28
32
|
};
|
|
29
33
|
// Additional RevenueCat subscription details
|
|
30
34
|
willRenew?: boolean | null;
|
package/src/domains/subscription/presentation/screens/components/SubscriptionHeaderContent.tsx
CHANGED
|
@@ -67,7 +67,7 @@ export const SubscriptionHeaderContent: React.FC<SubscriptionHeaderContentProps>
|
|
|
67
67
|
{willRenew !== null && willRenew !== undefined && translations.willRenewLabel && (
|
|
68
68
|
<DetailRow
|
|
69
69
|
label={translations.willRenewLabel}
|
|
70
|
-
value={willRenew ? "Yes" : "No"}
|
|
70
|
+
value={willRenew ? (translations.willRenewYes ?? "Yes") : (translations.willRenewNo ?? "No")}
|
|
71
71
|
highlight={!willRenew}
|
|
72
72
|
style={styles.row}
|
|
73
73
|
labelStyle={styles.label}
|
|
@@ -113,7 +113,7 @@ export const SubscriptionHeaderContent: React.FC<SubscriptionHeaderContentProps>
|
|
|
113
113
|
{billingIssuesDetected && translations.billingIssuesLabel && (
|
|
114
114
|
<DetailRow
|
|
115
115
|
label={translations.billingIssuesLabel}
|
|
116
|
-
value="Detected"
|
|
116
|
+
value={translations.billingIssuesDetected ?? "Detected"}
|
|
117
117
|
highlight={true}
|
|
118
118
|
style={styles.row}
|
|
119
119
|
labelStyle={styles.label}
|
|
@@ -123,7 +123,7 @@ export const SubscriptionHeaderContent: React.FC<SubscriptionHeaderContentProps>
|
|
|
123
123
|
{typeof __DEV__ !== 'undefined' && __DEV__ && isSandbox && translations.sandboxLabel && (
|
|
124
124
|
<DetailRow
|
|
125
125
|
label={translations.sandboxLabel}
|
|
126
|
-
value="Test Mode"
|
|
126
|
+
value={translations.sandboxTestMode ?? "Test Mode"}
|
|
127
127
|
style={styles.row}
|
|
128
128
|
labelStyle={styles.label}
|
|
129
129
|
valueStyle={styles.value}
|