@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.
Files changed (21) hide show
  1. package/package.json +1 -1
  2. package/src/domains/credits/application/DeductCreditsCommand.ts +1 -2
  3. package/src/domains/credits/application/PurchaseMetadataGenerator.ts +4 -1
  4. package/src/domains/credits/application/creditDocumentHelpers.ts +10 -1
  5. package/src/domains/credits/core/CreditsConstants.ts +3 -0
  6. package/src/domains/revenuecat/core/types/RevenueCatConfig.ts +12 -36
  7. package/src/domains/revenuecat/infrastructure/services/UserSwitchMutex.ts +4 -2
  8. package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +21 -28
  9. package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +146 -68
  10. package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +7 -35
  11. package/src/domains/subscription/core/SubscriptionConstants.ts +2 -0
  12. package/src/domains/subscription/core/SubscriptionEvents.ts +42 -0
  13. package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +1 -2
  14. package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +2 -5
  15. package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +17 -20
  16. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +4 -2
  17. package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.types.ts +4 -0
  18. package/src/domains/subscription/presentation/screens/components/SubscriptionHeaderContent.tsx +3 -3
  19. package/src/domains/subscription/application/SubscriptionSyncService.ts +0 -68
  20. package/src/domains/subscription/application/statusChangeHandlers.ts +0 -55
  21. 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.104",
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
- if (packageType !== PACKAGE_TYPE.UNKNOWN && creditLimit > existingData.creditLimit) {
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
- return creditsDoc.data() as UserCreditsDocumentRead;
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 { CustomerInfo } from "react-native-purchases";
2
- import type { PackageType } from "./RevenueCatTypes";
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
- userId: string,
10
- isPremium: boolean,
11
- productId?: string,
12
- expiresAt?: string,
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 (_ignored) {
22
- // Intentional: waiting for active switch to complete without failing
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 { PeriodType } from "../../../subscription/core/SubscriptionConstants";
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] Purchases.logIn() successful, created:', result.created);
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] User switch completed successfully');
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] Purchases.configure() successful');
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] Initial configuration completed:', {
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
- const originalTransactionId = subscription?.storeTransactionId ?? undefined;
216
-
217
- await deps.config.onPremiumStatusChanged(
218
- normalizedUserId,
219
- true,
220
- premiumEntitlement.productIdentifier,
221
- premiumEntitlement.expirationDate ?? undefined,
222
- premiumEntitlement.willRenew,
223
- premiumEntitlement.periodType as PeriodType | undefined,
224
- originalTransactionId
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
- undefined,
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 { handleExpiredSubscription, handlePremiumStatusSync } from "./statusChangeHandlers";
10
- import type { PackageType } from "../../revenuecat/core/types";
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(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource, packageType?: PackageType | null) {
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(userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) {
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 handlePremiumStatusSync(
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 handleExpiredSubscription(creditsUserId);
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 handleExpiredSubscription(creditsUserId);
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 handlePremiumStatusSync(
161
- creditsUserId,
162
- isPremium,
163
- productId,
164
- expiresAt ?? null,
165
- willRenew ?? false,
166
- periodType ?? null,
167
- undefined,
168
- undefined,
169
- undefined,
170
- undefined,
171
- originalTransactionId
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 { SubscriptionSyncService } from "../SubscriptionSyncService";
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): SubscriptionSyncService {
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 syncService = new SubscriptionSyncService(entitlementId, getAnonymousUserId);
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
- u: string,
31
- p: string,
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 syncService;
42
+ return syncProcessor;
71
43
  }
@@ -57,3 +57,5 @@ export const PURCHASE_TYPE = {
57
57
  } as const;
58
58
 
59
59
  export type PurchaseType = (typeof PURCHASE_TYPE)[keyof typeof PURCHASE_TYPE];
60
+
61
+ export const ANONYMOUS_CACHE_KEY = '__anonymous__';
@@ -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, PackageType } from "../../../revenuecat/core/types";
3
- import type { PurchaseSource } from "../../../subscription/core/SubscriptionConstants";
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 "NORMAL" | "INTRO" | undefined,
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, undefined, undefined, undefined, undefined, undefined);
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 { handleExpiredSubscription } = require("../../application/statusChangeHandlers");
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 handleExpiredSubscription(userId);
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 () => {
@@ -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;
@@ -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
- };
@@ -1,5 +0,0 @@
1
- import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
2
-
3
- export const emitCreditsUpdated = (userId: string): void => {
4
- subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
5
- };