@umituz/react-native-subscription 2.37.105 → 2.37.107
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/revenuecat/core/types/RevenueCatConfig.ts +12 -36
- package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +18 -47
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +144 -66
- package/src/domains/subscription/application/SubscriptionSyncUtils.ts +4 -0
- package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +7 -35
- package/src/domains/subscription/core/SubscriptionEvents.ts +42 -0
- package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +2 -5
- package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +17 -20
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +4 -2
- package/src/domains/subscription/application/SubscriptionSyncService.ts +0 -68
- package/src/domains/subscription/application/statusChangeHandlers.ts +0 -55
- package/src/domains/subscription/application/syncEventEmitter.ts +0 -5
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.107",
|
|
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",
|
|
@@ -1,43 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
PurchaseCompletedEvent,
|
|
3
|
+
RenewalDetectedEvent,
|
|
4
|
+
PremiumStatusChangedEvent,
|
|
5
|
+
PlanChangedEvent,
|
|
6
|
+
RestoreCompletedEvent,
|
|
7
|
+
} from "../../../subscription/core/SubscriptionEvents";
|
|
3
8
|
|
|
4
9
|
export interface RevenueCatConfig {
|
|
5
10
|
apiKey?: string;
|
|
6
11
|
entitlementIdentifier: string;
|
|
7
12
|
consumableProductIdentifiers?: string[];
|
|
8
|
-
onPremiumStatusChanged?: (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
willRenew?: boolean,
|
|
14
|
-
periodType?: string,
|
|
15
|
-
originalTransactionId?: string
|
|
16
|
-
) => Promise<void> | void;
|
|
17
|
-
onPurchaseCompleted?: (
|
|
18
|
-
userId: string,
|
|
19
|
-
productId: string,
|
|
20
|
-
customerInfo: CustomerInfo,
|
|
21
|
-
source?: string,
|
|
22
|
-
packageType?: PackageType | null
|
|
23
|
-
) => Promise<void> | void;
|
|
24
|
-
onRestoreCompleted?: (
|
|
25
|
-
userId: string,
|
|
26
|
-
isPremium: boolean,
|
|
27
|
-
customerInfo: CustomerInfo
|
|
28
|
-
) => Promise<void> | void;
|
|
29
|
-
onRenewalDetected?: (
|
|
30
|
-
userId: string,
|
|
31
|
-
productId: string,
|
|
32
|
-
newExpirationDate: string,
|
|
33
|
-
customerInfo: CustomerInfo
|
|
34
|
-
) => Promise<void> | void;
|
|
35
|
-
onPlanChanged?: (
|
|
36
|
-
userId: string,
|
|
37
|
-
newProductId: string,
|
|
38
|
-
previousProductId: string,
|
|
39
|
-
isUpgrade: boolean,
|
|
40
|
-
customerInfo: CustomerInfo
|
|
41
|
-
) => Promise<void> | void;
|
|
13
|
+
onPremiumStatusChanged?: (event: PremiumStatusChangedEvent) => Promise<void> | void;
|
|
14
|
+
onPurchaseCompleted?: (event: PurchaseCompletedEvent) => Promise<void> | void;
|
|
15
|
+
onRestoreCompleted?: (event: RestoreCompletedEvent) => Promise<void> | void;
|
|
16
|
+
onRenewalDetected?: (event: RenewalDetectedEvent) => Promise<void> | void;
|
|
17
|
+
onPlanChanged?: (event: PlanChangedEvent) => Promise<void> | void;
|
|
42
18
|
onCreditsUpdated?: (userId: string) => void;
|
|
43
19
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import Purchases, { type CustomerInfo, type PurchasesOfferings } from "react-native-purchases";
|
|
2
|
-
import { doc, setDoc } from "firebase/firestore";
|
|
3
2
|
import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
4
3
|
import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
5
4
|
import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
|
|
6
5
|
import { UserSwitchMutex } from "./UserSwitchMutex";
|
|
7
6
|
import { getPremiumEntitlement } from "../../core/types";
|
|
8
7
|
import { ANONYMOUS_CACHE_KEY, type PeriodType } from "../../../subscription/core/SubscriptionConstants";
|
|
9
|
-
import { requireFirestore } from "../../../../shared/infrastructure/firestore";
|
|
10
8
|
|
|
11
9
|
declare const __DEV__: boolean;
|
|
12
10
|
|
|
@@ -39,17 +37,6 @@ function isAnonymousId(userId: string): boolean {
|
|
|
39
37
|
return userId.startsWith('$RCAnonymous') || userId.startsWith('device_');
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
async function syncRevenueCatIdToProfile(firebaseUserId: string, revenueCatUserId: string): Promise<void> {
|
|
43
|
-
try {
|
|
44
|
-
const db = requireFirestore();
|
|
45
|
-
const userRef = doc(db, "users", firebaseUserId);
|
|
46
|
-
await setDoc(userRef, { revenueCatUserId }, { merge: true });
|
|
47
|
-
} catch (error) {
|
|
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));
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
40
|
export async function handleUserSwitch(
|
|
54
41
|
deps: InitializerDeps,
|
|
55
42
|
userId: string
|
|
@@ -100,7 +87,7 @@ async function performUserSwitch(
|
|
|
100
87
|
const result = await Purchases.logIn(normalizedUserId!);
|
|
101
88
|
customerInfo = result.customerInfo;
|
|
102
89
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
103
|
-
console.log('[UserSwitchHandler]
|
|
90
|
+
console.log('[UserSwitchHandler] Purchases.logIn() successful, created:', result.created);
|
|
104
91
|
}
|
|
105
92
|
} else {
|
|
106
93
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
@@ -119,13 +106,8 @@ async function performUserSwitch(
|
|
|
119
106
|
deps.setCurrentUserId(normalizedUserId || undefined);
|
|
120
107
|
const offerings = await fetchOfferingsSafe();
|
|
121
108
|
|
|
122
|
-
if (normalizedUserId) {
|
|
123
|
-
const rcId = await Purchases.getAppUserID();
|
|
124
|
-
void syncRevenueCatIdToProfile(normalizedUserId, rcId);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
109
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
128
|
-
console.log('[UserSwitchHandler]
|
|
110
|
+
console.log('[UserSwitchHandler] User switch completed successfully');
|
|
129
111
|
}
|
|
130
112
|
|
|
131
113
|
return buildSuccessResult(deps, customerInfo, offerings);
|
|
@@ -174,7 +156,7 @@ export async function handleInitialConfiguration(
|
|
|
174
156
|
deps.setCurrentUserId(normalizedUserId || undefined);
|
|
175
157
|
|
|
176
158
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
177
|
-
console.log('[UserSwitchHandler]
|
|
159
|
+
console.log('[UserSwitchHandler] Purchases.configure() successful');
|
|
178
160
|
}
|
|
179
161
|
|
|
180
162
|
// Fetch customer info (critical) and offerings (non-fatal) separately.
|
|
@@ -184,14 +166,9 @@ export async function handleInitialConfiguration(
|
|
|
184
166
|
fetchOfferingsSafe(),
|
|
185
167
|
]);
|
|
186
168
|
|
|
187
|
-
const currentUserId = await Purchases.getAppUserID();
|
|
188
|
-
|
|
189
|
-
if (normalizedUserId) {
|
|
190
|
-
void syncRevenueCatIdToProfile(normalizedUserId, currentUserId);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
169
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
194
|
-
|
|
170
|
+
const currentUserId = await Purchases.getAppUserID();
|
|
171
|
+
console.log('[UserSwitchHandler] Initial configuration completed:', {
|
|
195
172
|
revenueCatUserId: currentUserId,
|
|
196
173
|
activeEntitlements: Object.keys(customerInfo.entitlements.active),
|
|
197
174
|
offeringsCount: offerings?.all ? Object.keys(offerings.all).length : 0,
|
|
@@ -211,27 +188,21 @@ export async function handleInitialConfiguration(
|
|
|
211
188
|
|
|
212
189
|
if (premiumEntitlement) {
|
|
213
190
|
const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
|
|
214
|
-
const originalTransactionId = subscription?.storeTransactionId ?? undefined;
|
|
215
191
|
|
|
216
|
-
await deps.config.onPremiumStatusChanged(
|
|
217
|
-
normalizedUserId,
|
|
218
|
-
true,
|
|
219
|
-
premiumEntitlement.productIdentifier,
|
|
220
|
-
premiumEntitlement.expirationDate ?? undefined,
|
|
221
|
-
premiumEntitlement.willRenew,
|
|
222
|
-
premiumEntitlement.periodType as PeriodType | undefined,
|
|
223
|
-
originalTransactionId
|
|
224
|
-
);
|
|
192
|
+
await deps.config.onPremiumStatusChanged({
|
|
193
|
+
userId: normalizedUserId,
|
|
194
|
+
isPremium: true,
|
|
195
|
+
productId: premiumEntitlement.productIdentifier,
|
|
196
|
+
expiresAt: premiumEntitlement.expirationDate ?? undefined,
|
|
197
|
+
willRenew: premiumEntitlement.willRenew,
|
|
198
|
+
periodType: premiumEntitlement.periodType as PeriodType | undefined,
|
|
199
|
+
originalTransactionId: subscription?.storeTransactionId ?? undefined,
|
|
200
|
+
});
|
|
225
201
|
} else {
|
|
226
|
-
await deps.config.onPremiumStatusChanged(
|
|
227
|
-
normalizedUserId,
|
|
228
|
-
false,
|
|
229
|
-
|
|
230
|
-
undefined,
|
|
231
|
-
undefined,
|
|
232
|
-
undefined,
|
|
233
|
-
undefined
|
|
234
|
-
);
|
|
202
|
+
await deps.config.onPremiumStatusChanged({
|
|
203
|
+
userId: normalizedUserId,
|
|
204
|
+
isPremium: false,
|
|
205
|
+
});
|
|
235
206
|
}
|
|
236
207
|
} catch (error) {
|
|
237
208
|
// Log error but don't fail initialization
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import type { CustomerInfo } from "react-native-purchases";
|
|
2
1
|
import Purchases from "react-native-purchases";
|
|
3
|
-
import type { PeriodType, PurchaseSource } from "../core/SubscriptionConstants";
|
|
4
2
|
import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
3
|
+
import type { PremiumStatusChangedEvent, PurchaseCompletedEvent, RenewalDetectedEvent } from "../core/SubscriptionEvents";
|
|
5
4
|
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
6
5
|
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
7
|
-
import { emitCreditsUpdated } from "./syncEventEmitter";
|
|
8
6
|
import { generatePurchaseId, generateRenewalId } from "./syncIdGenerators";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Central processor for all subscription sync operations.
|
|
11
|
+
* Handles purchases, renewals, and status changes with credit allocation.
|
|
12
|
+
*
|
|
13
|
+
* Responsibilities:
|
|
14
|
+
* - Purchase: allocate initial credits via atomic Firestore transaction
|
|
15
|
+
* - Renewal: allocate renewal credits
|
|
16
|
+
* - Status change: sync metadata (no credit allocation) or mark expired
|
|
17
|
+
* - Recovery: create missing credits document for premium users
|
|
18
|
+
*/
|
|
12
19
|
export class SubscriptionSyncProcessor {
|
|
13
20
|
private purchaseInProgress = false;
|
|
14
21
|
|
|
@@ -17,6 +24,62 @@ export class SubscriptionSyncProcessor {
|
|
|
17
24
|
private getAnonymousUserId: () => Promise<string>
|
|
18
25
|
) {}
|
|
19
26
|
|
|
27
|
+
// ─── Public API (replaces SubscriptionSyncService) ────────────────
|
|
28
|
+
|
|
29
|
+
async handlePurchase(event: PurchaseCompletedEvent): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
await this.processPurchase(event);
|
|
32
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PURCHASE_COMPLETED, {
|
|
33
|
+
userId: event.userId,
|
|
34
|
+
productId: event.productId,
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('[SubscriptionSyncProcessor] Purchase processing failed', {
|
|
38
|
+
userId: event.userId,
|
|
39
|
+
productId: event.productId,
|
|
40
|
+
error: error instanceof Error ? error.message : String(error),
|
|
41
|
+
});
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async handleRenewal(event: RenewalDetectedEvent): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
await this.processRenewal(event);
|
|
49
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.RENEWAL_DETECTED, {
|
|
50
|
+
userId: event.userId,
|
|
51
|
+
productId: event.productId,
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('[SubscriptionSyncProcessor] Renewal processing failed', {
|
|
55
|
+
userId: event.userId,
|
|
56
|
+
productId: event.productId,
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
});
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async handlePremiumStatusChanged(event: PremiumStatusChangedEvent): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
await this.processStatusChange(event);
|
|
66
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, {
|
|
67
|
+
userId: event.userId,
|
|
68
|
+
isPremium: event.isPremium,
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error('[SubscriptionSyncProcessor] Status change processing failed', {
|
|
72
|
+
userId: event.userId,
|
|
73
|
+
isPremium: event.isPremium,
|
|
74
|
+
productId: event.productId,
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
});
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Internal Processing ──────────────────────────────────────────
|
|
82
|
+
|
|
20
83
|
private async getRevenueCatAppUserId(): Promise<string | null> {
|
|
21
84
|
try {
|
|
22
85
|
return await Purchases.getAppUserID();
|
|
@@ -40,22 +103,22 @@ export class SubscriptionSyncProcessor {
|
|
|
40
103
|
return trimmedAnonymous;
|
|
41
104
|
}
|
|
42
105
|
|
|
43
|
-
async processPurchase(
|
|
106
|
+
private async processPurchase(event: PurchaseCompletedEvent): Promise<void> {
|
|
44
107
|
this.purchaseInProgress = true;
|
|
45
108
|
try {
|
|
46
|
-
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
47
|
-
revenueCatData.packageType = packageType ?? null;
|
|
109
|
+
const revenueCatData = extractRevenueCatData(event.customerInfo, this.entitlementId);
|
|
110
|
+
revenueCatData.packageType = event.packageType ?? null;
|
|
48
111
|
const revenueCatAppUserId = await this.getRevenueCatAppUserId();
|
|
49
|
-
revenueCatData.revenueCatUserId = revenueCatAppUserId ?? userId;
|
|
50
|
-
const purchaseId = generatePurchaseId(revenueCatData.originalTransactionId, productId);
|
|
112
|
+
revenueCatData.revenueCatUserId = revenueCatAppUserId ?? event.userId;
|
|
113
|
+
const purchaseId = generatePurchaseId(revenueCatData.originalTransactionId, event.productId);
|
|
51
114
|
|
|
52
|
-
const creditsUserId = await this.getCreditsUserId(userId);
|
|
115
|
+
const creditsUserId = await this.getCreditsUserId(event.userId);
|
|
53
116
|
|
|
54
117
|
const result = await getCreditsRepository().initializeCredits(
|
|
55
118
|
creditsUserId,
|
|
56
119
|
purchaseId,
|
|
57
|
-
productId,
|
|
58
|
-
source ?? PURCHASE_SOURCE.SETTINGS,
|
|
120
|
+
event.productId,
|
|
121
|
+
event.source ?? PURCHASE_SOURCE.SETTINGS,
|
|
59
122
|
revenueCatData,
|
|
60
123
|
PURCHASE_TYPE.INITIAL
|
|
61
124
|
);
|
|
@@ -64,27 +127,27 @@ export class SubscriptionSyncProcessor {
|
|
|
64
127
|
throw new Error(`[SubscriptionSyncProcessor] Credit initialization failed for purchase: ${result.error?.message ?? 'unknown'}`);
|
|
65
128
|
}
|
|
66
129
|
|
|
67
|
-
emitCreditsUpdated(creditsUserId);
|
|
130
|
+
this.emitCreditsUpdated(creditsUserId);
|
|
68
131
|
} finally {
|
|
69
132
|
this.purchaseInProgress = false;
|
|
70
133
|
}
|
|
71
134
|
}
|
|
72
135
|
|
|
73
|
-
async processRenewal(
|
|
136
|
+
private async processRenewal(event: RenewalDetectedEvent): Promise<void> {
|
|
74
137
|
this.purchaseInProgress = true;
|
|
75
138
|
try {
|
|
76
|
-
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
77
|
-
revenueCatData.expirationDate = newExpirationDate ?? revenueCatData.expirationDate;
|
|
139
|
+
const revenueCatData = extractRevenueCatData(event.customerInfo, this.entitlementId);
|
|
140
|
+
revenueCatData.expirationDate = event.newExpirationDate ?? revenueCatData.expirationDate;
|
|
78
141
|
const revenueCatAppUserId = await this.getRevenueCatAppUserId();
|
|
79
|
-
revenueCatData.revenueCatUserId = revenueCatAppUserId ?? userId;
|
|
80
|
-
const purchaseId = generateRenewalId(revenueCatData.originalTransactionId, productId, newExpirationDate);
|
|
142
|
+
revenueCatData.revenueCatUserId = revenueCatAppUserId ?? event.userId;
|
|
143
|
+
const purchaseId = generateRenewalId(revenueCatData.originalTransactionId, event.productId, event.newExpirationDate);
|
|
81
144
|
|
|
82
|
-
const creditsUserId = await this.getCreditsUserId(userId);
|
|
145
|
+
const creditsUserId = await this.getCreditsUserId(event.userId);
|
|
83
146
|
|
|
84
147
|
const result = await getCreditsRepository().initializeCredits(
|
|
85
148
|
creditsUserId,
|
|
86
149
|
purchaseId,
|
|
87
|
-
productId,
|
|
150
|
+
event.productId,
|
|
88
151
|
PURCHASE_SOURCE.RENEWAL,
|
|
89
152
|
revenueCatData,
|
|
90
153
|
PURCHASE_TYPE.RENEWAL
|
|
@@ -94,21 +157,13 @@ export class SubscriptionSyncProcessor {
|
|
|
94
157
|
throw new Error(`[SubscriptionSyncProcessor] Credit initialization failed for renewal: ${result.error?.message ?? 'unknown'}`);
|
|
95
158
|
}
|
|
96
159
|
|
|
97
|
-
emitCreditsUpdated(creditsUserId);
|
|
160
|
+
this.emitCreditsUpdated(creditsUserId);
|
|
98
161
|
} finally {
|
|
99
162
|
this.purchaseInProgress = false;
|
|
100
163
|
}
|
|
101
164
|
}
|
|
102
165
|
|
|
103
|
-
async processStatusChange(
|
|
104
|
-
userId: string,
|
|
105
|
-
isPremium: boolean,
|
|
106
|
-
productId?: string,
|
|
107
|
-
expiresAt?: string,
|
|
108
|
-
willRenew?: boolean,
|
|
109
|
-
periodType?: PeriodType,
|
|
110
|
-
originalTransactionId?: string
|
|
111
|
-
) {
|
|
166
|
+
private async processStatusChange(event: PremiumStatusChangedEvent): Promise<void> {
|
|
112
167
|
// If a purchase is in progress, skip metadata sync (purchase handler does it)
|
|
113
168
|
// but still allow recovery to run — the purchase handler's credit initialization
|
|
114
169
|
// might have failed, and this is the safety net.
|
|
@@ -116,59 +171,82 @@ export class SubscriptionSyncProcessor {
|
|
|
116
171
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
117
172
|
console.log("[SubscriptionSyncProcessor] Purchase in progress - running recovery only");
|
|
118
173
|
}
|
|
119
|
-
if (isPremium && productId) {
|
|
120
|
-
const creditsUserId = await this.getCreditsUserId(userId);
|
|
121
|
-
await
|
|
122
|
-
creditsUserId,
|
|
123
|
-
isPremium,
|
|
124
|
-
productId,
|
|
125
|
-
expiresAt ?? null,
|
|
126
|
-
willRenew ?? false,
|
|
127
|
-
periodType ?? null,
|
|
128
|
-
undefined,
|
|
129
|
-
undefined,
|
|
130
|
-
undefined,
|
|
131
|
-
undefined,
|
|
132
|
-
originalTransactionId
|
|
133
|
-
);
|
|
174
|
+
if (event.isPremium && event.productId) {
|
|
175
|
+
const creditsUserId = await this.getCreditsUserId(event.userId);
|
|
176
|
+
await this.syncPremiumStatus(creditsUserId, event);
|
|
134
177
|
}
|
|
135
178
|
return;
|
|
136
179
|
}
|
|
137
180
|
|
|
138
|
-
const creditsUserId = await this.getCreditsUserId(userId);
|
|
181
|
+
const creditsUserId = await this.getCreditsUserId(event.userId);
|
|
139
182
|
|
|
140
|
-
if (!isPremium && productId) {
|
|
141
|
-
await
|
|
183
|
+
if (!event.isPremium && event.productId) {
|
|
184
|
+
await this.expireSubscription(creditsUserId);
|
|
142
185
|
return;
|
|
143
186
|
}
|
|
144
187
|
|
|
145
|
-
if (!isPremium && !productId) {
|
|
188
|
+
if (!event.isPremium && !event.productId) {
|
|
146
189
|
// No entitlement and no productId — could be:
|
|
147
190
|
// 1. Free user who never purchased (no credits doc) → skip
|
|
148
191
|
// 2. Previously premium user whose entitlement was removed → expire
|
|
149
192
|
const hasDoc = await getCreditsRepository().creditsDocumentExists(creditsUserId);
|
|
150
193
|
if (hasDoc) {
|
|
151
|
-
await
|
|
194
|
+
await this.expireSubscription(creditsUserId);
|
|
152
195
|
}
|
|
153
196
|
return;
|
|
154
197
|
}
|
|
155
198
|
|
|
156
|
-
if (!productId) {
|
|
199
|
+
if (!event.productId) {
|
|
157
200
|
return;
|
|
158
201
|
}
|
|
159
202
|
|
|
160
|
-
await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
await this.syncPremiumStatus(creditsUserId, event);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Credit Document Operations (replaces statusChangeHandlers) ───
|
|
207
|
+
|
|
208
|
+
private async expireSubscription(userId: string): Promise<void> {
|
|
209
|
+
await getCreditsRepository().syncExpiredStatus(userId);
|
|
210
|
+
this.emitCreditsUpdated(userId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async syncPremiumStatus(userId: string, event: PremiumStatusChangedEvent): Promise<void> {
|
|
214
|
+
const repo = getCreditsRepository();
|
|
215
|
+
|
|
216
|
+
// Recovery: if premium user has no credits document, create one.
|
|
217
|
+
// Handles edge cases like test store, reinstalls, or failed purchase initialization.
|
|
218
|
+
if (event.isPremium) {
|
|
219
|
+
const created = await repo.ensurePremiumCreditsExist(
|
|
220
|
+
userId,
|
|
221
|
+
event.productId!,
|
|
222
|
+
event.willRenew ?? false,
|
|
223
|
+
event.expiresAt ?? null,
|
|
224
|
+
event.periodType ?? null,
|
|
225
|
+
event.originalTransactionId,
|
|
226
|
+
);
|
|
227
|
+
if (__DEV__ && created) {
|
|
228
|
+
console.log('[SubscriptionSyncProcessor] Recovery: created missing credits document for premium user', {
|
|
229
|
+
userId,
|
|
230
|
+
productId: event.productId,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await repo.syncPremiumMetadata(userId, {
|
|
236
|
+
isPremium: event.isPremium,
|
|
237
|
+
willRenew: event.willRenew ?? false,
|
|
238
|
+
expirationDate: event.expiresAt ?? null,
|
|
239
|
+
productId: event.productId!,
|
|
240
|
+
periodType: event.periodType ?? null,
|
|
241
|
+
unsubscribeDetectedAt: null,
|
|
242
|
+
billingIssueDetectedAt: null,
|
|
243
|
+
store: null,
|
|
244
|
+
ownershipType: null,
|
|
245
|
+
});
|
|
246
|
+
this.emitCreditsUpdated(userId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private emitCreditsUpdated(userId: string): void {
|
|
250
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
173
251
|
}
|
|
174
252
|
}
|
|
@@ -39,6 +39,10 @@ export const extractRevenueCatData = (customerInfo: CustomerInfo, entitlementId:
|
|
|
39
39
|
return {
|
|
40
40
|
expirationDate: entitlement.expirationDate ?? null,
|
|
41
41
|
willRenew: entitlement.willRenew ?? null,
|
|
42
|
+
// Maps SDK's `storeTransactionId` (current transaction ID from Apple/Google)
|
|
43
|
+
// to our domain's `originalTransactionId`. Used as dedup key in processedTransactions.
|
|
44
|
+
// Note: The SDK does not expose `original_store_transaction_id` (the first purchase's
|
|
45
|
+
// ID that stays constant across renewals) — only available via server-side webhooks.
|
|
42
46
|
originalTransactionId: subscription?.storeTransactionId ?? null,
|
|
43
47
|
periodType: validatePeriodType(entitlement.periodType),
|
|
44
48
|
packageType: null,
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { configureCreditsRepository } from "../../../credits/infrastructure/CreditsRepositoryManager";
|
|
2
2
|
import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionManager";
|
|
3
3
|
import { configureAuthProvider } from "../../presentation/useAuthAwarePurchase";
|
|
4
|
-
import {
|
|
4
|
+
import { SubscriptionSyncProcessor } from "../SubscriptionSyncProcessor";
|
|
5
5
|
import type { SubscriptionInitConfig } from "../SubscriptionInitializerTypes";
|
|
6
|
-
import type { CustomerInfo } from "react-native-purchases";
|
|
7
|
-
import type { PackageType } from "../../../revenuecat/core/types/RevenueCatTypes";
|
|
8
|
-
import { PURCHASE_SOURCE, PERIOD_TYPE, type PurchaseSource, type PeriodType } from "../../core/SubscriptionConstants";
|
|
9
6
|
|
|
10
|
-
export function configureServices(config: SubscriptionInitConfig, apiKey: string):
|
|
7
|
+
export function configureServices(config: SubscriptionInitConfig, apiKey: string): SubscriptionSyncProcessor {
|
|
11
8
|
const { entitlementId, credits, creditPackages, getFirebaseAuth, showAuthModal, onCreditsUpdated, getAnonymousUserId } = config;
|
|
12
9
|
|
|
13
10
|
if (!creditPackages) {
|
|
@@ -19,41 +16,16 @@ export function configureServices(config: SubscriptionInitConfig, apiKey: string
|
|
|
19
16
|
creditPackageAmounts: creditPackages.amounts
|
|
20
17
|
});
|
|
21
18
|
|
|
22
|
-
const
|
|
19
|
+
const syncProcessor = new SubscriptionSyncProcessor(entitlementId, getAnonymousUserId);
|
|
23
20
|
|
|
24
21
|
SubscriptionManager.configure({
|
|
25
22
|
config: {
|
|
26
23
|
apiKey,
|
|
27
24
|
entitlementIdentifier: entitlementId,
|
|
28
25
|
consumableProductIdentifiers: [creditPackages.identifierPattern],
|
|
29
|
-
onPurchaseCompleted: (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
c: CustomerInfo,
|
|
33
|
-
s?: string,
|
|
34
|
-
pkgType?: PackageType | null
|
|
35
|
-
) => {
|
|
36
|
-
const validSource = s && Object.values(PURCHASE_SOURCE).includes(s as PurchaseSource) ? s as PurchaseSource : undefined;
|
|
37
|
-
return syncService.handlePurchase(u, p, c, validSource, pkgType);
|
|
38
|
-
},
|
|
39
|
-
onRenewalDetected: (
|
|
40
|
-
u: string,
|
|
41
|
-
p: string,
|
|
42
|
-
expires: string,
|
|
43
|
-
c: CustomerInfo
|
|
44
|
-
) => syncService.handleRenewal(u, p, expires, c),
|
|
45
|
-
onPremiumStatusChanged: (
|
|
46
|
-
u: string,
|
|
47
|
-
isP: boolean,
|
|
48
|
-
pId?: string,
|
|
49
|
-
exp?: string,
|
|
50
|
-
willR?: boolean,
|
|
51
|
-
pt?: string,
|
|
52
|
-
txnId?: string
|
|
53
|
-
) => {
|
|
54
|
-
const validPeriodType = pt && Object.values(PERIOD_TYPE).includes(pt as PeriodType) ? pt as PeriodType : undefined;
|
|
55
|
-
return syncService.handlePremiumStatusChanged(u, isP, pId, exp, willR, validPeriodType, txnId);
|
|
56
|
-
},
|
|
26
|
+
onPurchaseCompleted: (event) => syncProcessor.handlePurchase(event),
|
|
27
|
+
onRenewalDetected: (event) => syncProcessor.handleRenewal(event),
|
|
28
|
+
onPremiumStatusChanged: (event) => syncProcessor.handlePremiumStatusChanged(event),
|
|
57
29
|
onCreditsUpdated,
|
|
58
30
|
},
|
|
59
31
|
apiKey,
|
|
@@ -67,5 +39,5 @@ export function configureServices(config: SubscriptionInitConfig, apiKey: string
|
|
|
67
39
|
showAuthModal,
|
|
68
40
|
});
|
|
69
41
|
|
|
70
|
-
return
|
|
42
|
+
return syncProcessor;
|
|
71
43
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
+
import type { PeriodType, PurchaseSource } from "./SubscriptionConstants";
|
|
3
|
+
import type { PackageType } from "../../revenuecat/core/types/RevenueCatTypes";
|
|
4
|
+
|
|
5
|
+
export interface PurchaseCompletedEvent {
|
|
6
|
+
userId: string;
|
|
7
|
+
productId: string;
|
|
8
|
+
customerInfo: CustomerInfo;
|
|
9
|
+
source?: PurchaseSource;
|
|
10
|
+
packageType?: PackageType | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RenewalDetectedEvent {
|
|
14
|
+
userId: string;
|
|
15
|
+
productId: string;
|
|
16
|
+
newExpirationDate: string;
|
|
17
|
+
customerInfo: CustomerInfo;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PremiumStatusChangedEvent {
|
|
21
|
+
userId: string;
|
|
22
|
+
isPremium: boolean;
|
|
23
|
+
productId?: string;
|
|
24
|
+
expiresAt?: string;
|
|
25
|
+
willRenew?: boolean;
|
|
26
|
+
periodType?: PeriodType;
|
|
27
|
+
originalTransactionId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PlanChangedEvent {
|
|
31
|
+
userId: string;
|
|
32
|
+
newProductId: string;
|
|
33
|
+
previousProductId: string;
|
|
34
|
+
isUpgrade: boolean;
|
|
35
|
+
customerInfo: CustomerInfo;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RestoreCompletedEvent {
|
|
39
|
+
userId: string;
|
|
40
|
+
isPremium: boolean;
|
|
41
|
+
customerInfo: CustomerInfo;
|
|
42
|
+
}
|
|
@@ -13,9 +13,8 @@ async function handleRenewal(
|
|
|
13
13
|
if (!onRenewalDetected) return;
|
|
14
14
|
|
|
15
15
|
try {
|
|
16
|
-
await onRenewalDetected(userId, productId, expirationDate, customerInfo);
|
|
16
|
+
await onRenewalDetected({ userId, productId, newExpirationDate: expirationDate, customerInfo });
|
|
17
17
|
} catch (error) {
|
|
18
|
-
// Callback errors should not break customer info processing
|
|
19
18
|
console.error('[CustomerInfoHandler] Renewal callback failed:', {
|
|
20
19
|
userId,
|
|
21
20
|
productId,
|
|
@@ -35,9 +34,8 @@ async function handlePlanChange(
|
|
|
35
34
|
if (!onPlanChanged) return;
|
|
36
35
|
|
|
37
36
|
try {
|
|
38
|
-
await onPlanChanged(userId, newProductId, previousProductId, isUpgrade, customerInfo);
|
|
37
|
+
await onPlanChanged({ userId, newProductId, previousProductId, isUpgrade, customerInfo });
|
|
39
38
|
} catch (error) {
|
|
40
|
-
// Callback errors should not break customer info processing
|
|
41
39
|
console.error('[CustomerInfoHandler] Plan change callback failed:', {
|
|
42
40
|
userId,
|
|
43
41
|
newProductId,
|
|
@@ -56,7 +54,6 @@ async function handlePremiumStatusSync(
|
|
|
56
54
|
try {
|
|
57
55
|
await syncPremiumStatus(config, userId, customerInfo);
|
|
58
56
|
} catch (error) {
|
|
59
|
-
// Sync errors are logged by PremiumStatusSyncer, don't break processing
|
|
60
57
|
console.error('[CustomerInfoHandler] Premium status sync failed:', {
|
|
61
58
|
userId,
|
|
62
59
|
error: error instanceof Error ? error.message : String(error)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
-
import type { RevenueCatConfig
|
|
3
|
-
import type { PurchaseSource } from "
|
|
2
|
+
import type { RevenueCatConfig } from "../../../revenuecat/core/types";
|
|
3
|
+
import type { PurchaseSource } from "../../core/SubscriptionConstants";
|
|
4
|
+
import type { PackageType } from "../../../revenuecat/core/types";
|
|
4
5
|
import { getPremiumEntitlement } from "../../../revenuecat/core/types";
|
|
6
|
+
import type { PeriodType } from "../../core/SubscriptionConstants";
|
|
5
7
|
|
|
6
8
|
export async function syncPremiumStatus(
|
|
7
9
|
config: RevenueCatConfig,
|
|
@@ -29,19 +31,18 @@ export async function syncPremiumStatus(
|
|
|
29
31
|
try {
|
|
30
32
|
if (premiumEntitlement) {
|
|
31
33
|
const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
|
|
32
|
-
const originalTransactionId = subscription?.storeTransactionId ?? undefined;
|
|
33
34
|
|
|
34
|
-
await config.onPremiumStatusChanged(
|
|
35
|
+
await config.onPremiumStatusChanged({
|
|
35
36
|
userId,
|
|
36
|
-
true,
|
|
37
|
-
premiumEntitlement.productIdentifier,
|
|
38
|
-
premiumEntitlement.expirationDate ?? undefined,
|
|
39
|
-
premiumEntitlement.willRenew,
|
|
40
|
-
premiumEntitlement.periodType as
|
|
41
|
-
originalTransactionId
|
|
42
|
-
);
|
|
37
|
+
isPremium: true,
|
|
38
|
+
productId: premiumEntitlement.productIdentifier,
|
|
39
|
+
expiresAt: premiumEntitlement.expirationDate ?? undefined,
|
|
40
|
+
willRenew: premiumEntitlement.willRenew,
|
|
41
|
+
periodType: premiumEntitlement.periodType as PeriodType | undefined,
|
|
42
|
+
originalTransactionId: subscription?.storeTransactionId ?? undefined,
|
|
43
|
+
});
|
|
43
44
|
} else {
|
|
44
|
-
await config.onPremiumStatusChanged(userId, false
|
|
45
|
+
await config.onPremiumStatusChanged({ userId, isPremium: false });
|
|
45
46
|
}
|
|
46
47
|
return { success: true };
|
|
47
48
|
} catch (error) {
|
|
@@ -65,11 +66,9 @@ export async function notifyPurchaseCompleted(
|
|
|
65
66
|
source?: PurchaseSource,
|
|
66
67
|
packageType?: PackageType | null
|
|
67
68
|
): Promise<void> {
|
|
68
|
-
if (!config.onPurchaseCompleted)
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
69
|
+
if (!config.onPurchaseCompleted) return;
|
|
71
70
|
|
|
72
|
-
await config.onPurchaseCompleted(userId, productId, customerInfo, source, packageType);
|
|
71
|
+
await config.onPurchaseCompleted({ userId, productId, customerInfo, source, packageType });
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
export async function notifyRestoreCompleted(
|
|
@@ -78,12 +77,10 @@ export async function notifyRestoreCompleted(
|
|
|
78
77
|
isPremium: boolean,
|
|
79
78
|
customerInfo: CustomerInfo
|
|
80
79
|
): Promise<void> {
|
|
81
|
-
if (!config.onRestoreCompleted)
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
80
|
+
if (!config.onRestoreCompleted) return;
|
|
84
81
|
|
|
85
82
|
try {
|
|
86
|
-
await config.onRestoreCompleted(userId, isPremium, customerInfo);
|
|
83
|
+
await config.onRestoreCompleted({ userId, isPremium, customerInfo });
|
|
87
84
|
} catch (error) {
|
|
88
85
|
console.error('[PremiumStatusSyncer] Restore callback failed:', error instanceof Error ? error.message : String(error));
|
|
89
86
|
}
|
|
@@ -110,10 +110,12 @@ const DevTestPanel: React.FC<{ statusType: string }> = ({ statusType }) => {
|
|
|
110
110
|
|
|
111
111
|
const handleCancel = useCallback(() => run("Cancel", async () => {
|
|
112
112
|
const { useAuthStore, selectUserId } = require("@umituz/react-native-auth");
|
|
113
|
-
const {
|
|
113
|
+
const { getCreditsRepository } = require("../../../credits/infrastructure/CreditsRepositoryManager");
|
|
114
|
+
const { subscriptionEventBus, SUBSCRIPTION_EVENTS } = require("../../../../shared/infrastructure/SubscriptionEventBus");
|
|
114
115
|
const userId = selectUserId(useAuthStore.getState());
|
|
115
116
|
if (!userId) throw new Error("No userId found");
|
|
116
|
-
await
|
|
117
|
+
await getCreditsRepository().syncExpiredStatus(userId);
|
|
118
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
117
119
|
}), [run]);
|
|
118
120
|
|
|
119
121
|
const handleRestore = useCallback(() => run("Restore", async () => {
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
-
import { type PeriodType, type PurchaseSource } from "../core/SubscriptionConstants";
|
|
3
|
-
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
4
|
-
import { SubscriptionSyncProcessor } from "./SubscriptionSyncProcessor";
|
|
5
|
-
import type { PackageType } from "../../revenuecat/core/types";
|
|
6
|
-
|
|
7
|
-
export class SubscriptionSyncService {
|
|
8
|
-
private processor: SubscriptionSyncProcessor;
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
entitlementId: string,
|
|
12
|
-
getAnonymousUserId: () => Promise<string>
|
|
13
|
-
) {
|
|
14
|
-
this.processor = new SubscriptionSyncProcessor(entitlementId, getAnonymousUserId);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async handlePurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource, packageType?: PackageType | null) {
|
|
18
|
-
try {
|
|
19
|
-
await this.processor.processPurchase(userId, productId, customerInfo, source, packageType);
|
|
20
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PURCHASE_COMPLETED, { userId, productId });
|
|
21
|
-
} catch (error) {
|
|
22
|
-
console.error('[SubscriptionSyncService] Purchase processing failed', {
|
|
23
|
-
userId,
|
|
24
|
-
productId,
|
|
25
|
-
error: error instanceof Error ? error.message : String(error)
|
|
26
|
-
});
|
|
27
|
-
throw error;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async handleRenewal(userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) {
|
|
32
|
-
try {
|
|
33
|
-
await this.processor.processRenewal(userId, productId, newExpirationDate, customerInfo);
|
|
34
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.RENEWAL_DETECTED, { userId, productId });
|
|
35
|
-
} catch (error) {
|
|
36
|
-
console.error('[SubscriptionSyncService] Renewal processing failed', {
|
|
37
|
-
userId,
|
|
38
|
-
productId,
|
|
39
|
-
newExpirationDate,
|
|
40
|
-
error: error instanceof Error ? error.message : String(error)
|
|
41
|
-
});
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async handlePremiumStatusChanged(
|
|
47
|
-
userId: string,
|
|
48
|
-
isPremium: boolean,
|
|
49
|
-
productId?: string,
|
|
50
|
-
expiresAt?: string,
|
|
51
|
-
willRenew?: boolean,
|
|
52
|
-
periodType?: PeriodType,
|
|
53
|
-
originalTransactionId?: string
|
|
54
|
-
) {
|
|
55
|
-
try {
|
|
56
|
-
await this.processor.processStatusChange(userId, isPremium, productId, expiresAt, willRenew, periodType, originalTransactionId);
|
|
57
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, { userId, isPremium });
|
|
58
|
-
} catch (error) {
|
|
59
|
-
console.error('[SubscriptionSyncService] Status change processing failed', {
|
|
60
|
-
userId,
|
|
61
|
-
isPremium,
|
|
62
|
-
productId,
|
|
63
|
-
error: error instanceof Error ? error.message : String(error)
|
|
64
|
-
});
|
|
65
|
-
throw error;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { PeriodType } from "../core/SubscriptionConstants";
|
|
2
|
-
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
3
|
-
import { emitCreditsUpdated } from "./syncEventEmitter";
|
|
4
|
-
|
|
5
|
-
export const handleExpiredSubscription = async (userId: string): Promise<void> => {
|
|
6
|
-
await getCreditsRepository().syncExpiredStatus(userId);
|
|
7
|
-
emitCreditsUpdated(userId);
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export const handlePremiumStatusSync = async (
|
|
11
|
-
userId: string,
|
|
12
|
-
isPremium: boolean,
|
|
13
|
-
productId: string,
|
|
14
|
-
expiresAt: string | null,
|
|
15
|
-
willRenew: boolean,
|
|
16
|
-
periodType: PeriodType | null,
|
|
17
|
-
unsubscribeDetectedAt?: string | null,
|
|
18
|
-
billingIssueDetectedAt?: string | null,
|
|
19
|
-
store?: string | null,
|
|
20
|
-
ownershipType?: string | null,
|
|
21
|
-
originalTransactionId?: string
|
|
22
|
-
): Promise<void> => {
|
|
23
|
-
const repo = getCreditsRepository();
|
|
24
|
-
|
|
25
|
-
// Recovery: if premium user has no credits document, create one.
|
|
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.
|
|
29
|
-
if (isPremium) {
|
|
30
|
-
const created = await repo.ensurePremiumCreditsExist(
|
|
31
|
-
userId,
|
|
32
|
-
productId,
|
|
33
|
-
willRenew,
|
|
34
|
-
expiresAt,
|
|
35
|
-
periodType,
|
|
36
|
-
originalTransactionId,
|
|
37
|
-
);
|
|
38
|
-
if (__DEV__ && created) {
|
|
39
|
-
console.log('[handlePremiumStatusSync] Recovery: created missing credits document for premium user', { userId, productId });
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
await repo.syncPremiumMetadata(userId, {
|
|
44
|
-
isPremium,
|
|
45
|
-
willRenew,
|
|
46
|
-
expirationDate: expiresAt,
|
|
47
|
-
productId,
|
|
48
|
-
periodType,
|
|
49
|
-
unsubscribeDetectedAt: unsubscribeDetectedAt ?? null,
|
|
50
|
-
billingIssueDetectedAt: billingIssueDetectedAt ?? null,
|
|
51
|
-
store: store ?? null,
|
|
52
|
-
ownershipType: ownershipType ?? null,
|
|
53
|
-
});
|
|
54
|
-
emitCreditsUpdated(userId);
|
|
55
|
-
};
|