@umituz/react-native-subscription 2.37.104 → 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/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 +3 -0
- package/src/domains/revenuecat/core/types/RevenueCatConfig.ts +12 -36
- package/src/domains/revenuecat/infrastructure/services/UserSwitchMutex.ts +4 -2
- package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +21 -28
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +146 -68
- package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +7 -35
- package/src/domains/subscription/core/SubscriptionConstants.ts +2 -0
- package/src/domains/subscription/core/SubscriptionEvents.ts +42 -0
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +1 -2
- 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/presentation/screens/components/SubscriptionHeader.types.ts +4 -0
- package/src/domains/subscription/presentation/screens/components/SubscriptionHeaderContent.tsx +3 -3
- 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,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;
|
|
@@ -12,6 +12,9 @@ export const PURCHASE_ID_PREFIXES = {
|
|
|
12
12
|
|
|
13
13
|
export const PROCESSED_PURCHASES_WINDOW = 50;
|
|
14
14
|
|
|
15
|
+
/** Maximum credits that can be deducted in a single operation. */
|
|
16
|
+
export const MAX_SINGLE_DEDUCTION = 10000;
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* Global Firestore collection for cross-user transaction deduplication.
|
|
17
20
|
* Prevents the same Apple/Google transaction from allocating credits
|
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -101,7 +100,7 @@ async function performUserSwitch(
|
|
|
101
100
|
const result = await Purchases.logIn(normalizedUserId!);
|
|
102
101
|
customerInfo = result.customerInfo;
|
|
103
102
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
104
|
-
console.log('[UserSwitchHandler]
|
|
103
|
+
console.log('[UserSwitchHandler] Purchases.logIn() successful, created:', result.created);
|
|
105
104
|
}
|
|
106
105
|
} else {
|
|
107
106
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
@@ -126,7 +125,7 @@ async function performUserSwitch(
|
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
129
|
-
console.log('[UserSwitchHandler]
|
|
128
|
+
console.log('[UserSwitchHandler] User switch completed successfully');
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
return buildSuccessResult(deps, customerInfo, offerings);
|
|
@@ -175,7 +174,7 @@ export async function handleInitialConfiguration(
|
|
|
175
174
|
deps.setCurrentUserId(normalizedUserId || undefined);
|
|
176
175
|
|
|
177
176
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
178
|
-
console.log('[UserSwitchHandler]
|
|
177
|
+
console.log('[UserSwitchHandler] Purchases.configure() successful');
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
// Fetch customer info (critical) and offerings (non-fatal) separately.
|
|
@@ -192,7 +191,7 @@ export async function handleInitialConfiguration(
|
|
|
192
191
|
}
|
|
193
192
|
|
|
194
193
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
195
|
-
console.log('[UserSwitchHandler]
|
|
194
|
+
console.log('[UserSwitchHandler] Initial configuration completed:', {
|
|
196
195
|
revenueCatUserId: currentUserId,
|
|
197
196
|
activeEntitlements: Object.keys(customerInfo.entitlements.active),
|
|
198
197
|
offeringsCount: offerings?.all ? Object.keys(offerings.all).length : 0,
|
|
@@ -212,27 +211,21 @@ export async function handleInitialConfiguration(
|
|
|
212
211
|
|
|
213
212
|
if (premiumEntitlement) {
|
|
214
213
|
const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
premiumEntitlement.
|
|
221
|
-
premiumEntitlement.
|
|
222
|
-
premiumEntitlement.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
);
|
|
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
|
+
});
|
|
226
224
|
} else {
|
|
227
|
-
await deps.config.onPremiumStatusChanged(
|
|
228
|
-
normalizedUserId,
|
|
229
|
-
false,
|
|
230
|
-
|
|
231
|
-
undefined,
|
|
232
|
-
undefined,
|
|
233
|
-
undefined,
|
|
234
|
-
undefined
|
|
235
|
-
);
|
|
225
|
+
await deps.config.onPremiumStatusChanged({
|
|
226
|
+
userId: normalizedUserId,
|
|
227
|
+
isPremium: false,
|
|
228
|
+
});
|
|
236
229
|
}
|
|
237
230
|
} catch (error) {
|
|
238
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();
|
|
@@ -27,35 +90,35 @@ export class SubscriptionSyncProcessor {
|
|
|
27
90
|
|
|
28
91
|
private async getCreditsUserId(revenueCatUserId: string | null | undefined): Promise<string> {
|
|
29
92
|
const trimmed = revenueCatUserId?.trim();
|
|
30
|
-
if (trimmed && trimmed.length > 0) {
|
|
93
|
+
if (trimmed && trimmed.length > 0 && trimmed !== 'undefined' && trimmed !== 'null') {
|
|
31
94
|
return trimmed;
|
|
32
95
|
}
|
|
33
96
|
|
|
34
97
|
console.warn("[SubscriptionSyncProcessor] revenueCatUserId is empty/null, falling back to anonymousUserId");
|
|
35
98
|
const anonymousId = await this.getAnonymousUserId();
|
|
36
99
|
const trimmedAnonymous = anonymousId?.trim();
|
|
37
|
-
if (!trimmedAnonymous || trimmedAnonymous.length === 0) {
|
|
100
|
+
if (!trimmedAnonymous || trimmedAnonymous.length === 0 || trimmedAnonymous === 'undefined' || trimmedAnonymous === 'null') {
|
|
38
101
|
throw new Error("[SubscriptionSyncProcessor] Cannot resolve credits userId: both revenueCatUserId and anonymousUserId are empty");
|
|
39
102
|
}
|
|
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
|
+
}
|
|
@@ -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;
|
|
@@ -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 () => {
|
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}
|
|
@@ -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
|
-
};
|