@umituz/react-native-subscription 2.37.105 → 2.37.106
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 -24
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +144 -66
- 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.106",
|
|
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
|
}
|
|
@@ -100,7 +100,7 @@ async function performUserSwitch(
|
|
|
100
100
|
const result = await Purchases.logIn(normalizedUserId!);
|
|
101
101
|
customerInfo = result.customerInfo;
|
|
102
102
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
103
|
-
console.log('[UserSwitchHandler]
|
|
103
|
+
console.log('[UserSwitchHandler] Purchases.logIn() successful, created:', result.created);
|
|
104
104
|
}
|
|
105
105
|
} else {
|
|
106
106
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
@@ -125,7 +125,7 @@ async function performUserSwitch(
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
128
|
-
console.log('[UserSwitchHandler]
|
|
128
|
+
console.log('[UserSwitchHandler] User switch completed successfully');
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
return buildSuccessResult(deps, customerInfo, offerings);
|
|
@@ -174,7 +174,7 @@ export async function handleInitialConfiguration(
|
|
|
174
174
|
deps.setCurrentUserId(normalizedUserId || undefined);
|
|
175
175
|
|
|
176
176
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
177
|
-
console.log('[UserSwitchHandler]
|
|
177
|
+
console.log('[UserSwitchHandler] Purchases.configure() successful');
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
// Fetch customer info (critical) and offerings (non-fatal) separately.
|
|
@@ -191,7 +191,7 @@ export async function handleInitialConfiguration(
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
194
|
-
console.log('[UserSwitchHandler]
|
|
194
|
+
console.log('[UserSwitchHandler] Initial configuration completed:', {
|
|
195
195
|
revenueCatUserId: currentUserId,
|
|
196
196
|
activeEntitlements: Object.keys(customerInfo.entitlements.active),
|
|
197
197
|
offeringsCount: offerings?.all ? Object.keys(offerings.all).length : 0,
|
|
@@ -211,27 +211,21 @@ export async function handleInitialConfiguration(
|
|
|
211
211
|
|
|
212
212
|
if (premiumEntitlement) {
|
|
213
213
|
const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
premiumEntitlement.
|
|
220
|
-
premiumEntitlement.
|
|
221
|
-
premiumEntitlement.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
214
|
+
|
|
215
|
+
await deps.config.onPremiumStatusChanged({
|
|
216
|
+
userId: normalizedUserId,
|
|
217
|
+
isPremium: true,
|
|
218
|
+
productId: premiumEntitlement.productIdentifier,
|
|
219
|
+
expiresAt: premiumEntitlement.expirationDate ?? undefined,
|
|
220
|
+
willRenew: premiumEntitlement.willRenew,
|
|
221
|
+
periodType: premiumEntitlement.periodType as PeriodType | undefined,
|
|
222
|
+
originalTransactionId: subscription?.storeTransactionId ?? undefined,
|
|
223
|
+
});
|
|
225
224
|
} else {
|
|
226
|
-
await deps.config.onPremiumStatusChanged(
|
|
227
|
-
normalizedUserId,
|
|
228
|
-
false,
|
|
229
|
-
|
|
230
|
-
undefined,
|
|
231
|
-
undefined,
|
|
232
|
-
undefined,
|
|
233
|
-
undefined
|
|
234
|
-
);
|
|
225
|
+
await deps.config.onPremiumStatusChanged({
|
|
226
|
+
userId: normalizedUserId,
|
|
227
|
+
isPremium: false,
|
|
228
|
+
});
|
|
235
229
|
}
|
|
236
230
|
} catch (error) {
|
|
237
231
|
// 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
|
}
|
|
@@ -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
|
-
};
|