@umituz/react-native-subscription 2.37.105 → 2.37.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.37.105",
3
+ "version": "2.37.107",
4
4
  "description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,43 +1,19 @@
1
- import type { 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
  }
@@ -1,12 +1,10 @@
1
1
  import Purchases, { type CustomerInfo, type PurchasesOfferings } from "react-native-purchases";
2
- import { doc, setDoc } from "firebase/firestore";
3
2
  import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
4
3
  import type { InitializerDeps } from "./RevenueCatInitializer.types";
5
4
  import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
6
5
  import { UserSwitchMutex } from "./UserSwitchMutex";
7
6
  import { getPremiumEntitlement } from "../../core/types";
8
7
  import { ANONYMOUS_CACHE_KEY, type PeriodType } from "../../../subscription/core/SubscriptionConstants";
9
- import { requireFirestore } from "../../../../shared/infrastructure/firestore";
10
8
 
11
9
  declare const __DEV__: boolean;
12
10
 
@@ -39,17 +37,6 @@ function isAnonymousId(userId: string): boolean {
39
37
  return userId.startsWith('$RCAnonymous') || userId.startsWith('device_');
40
38
  }
41
39
 
42
- async function syncRevenueCatIdToProfile(firebaseUserId: string, revenueCatUserId: string): Promise<void> {
43
- try {
44
- const db = requireFirestore();
45
- const userRef = doc(db, "users", firebaseUserId);
46
- await setDoc(userRef, { revenueCatUserId }, { merge: true });
47
- } catch (error) {
48
- // Non-fatal: profile update failure should not block SDK initialization
49
- console.warn('[UserSwitchHandler] Failed to sync RevenueCat ID to profile:', error instanceof Error ? error.message : String(error));
50
- }
51
- }
52
-
53
40
  export async function handleUserSwitch(
54
41
  deps: InitializerDeps,
55
42
  userId: string
@@ -100,7 +87,7 @@ async function performUserSwitch(
100
87
  const result = await Purchases.logIn(normalizedUserId!);
101
88
  customerInfo = result.customerInfo;
102
89
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
103
- console.log('[UserSwitchHandler] Purchases.logIn() successful, created:', result.created);
90
+ console.log('[UserSwitchHandler] Purchases.logIn() successful, created:', result.created);
104
91
  }
105
92
  } else {
106
93
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
@@ -119,13 +106,8 @@ async function performUserSwitch(
119
106
  deps.setCurrentUserId(normalizedUserId || undefined);
120
107
  const offerings = await fetchOfferingsSafe();
121
108
 
122
- if (normalizedUserId) {
123
- const rcId = await Purchases.getAppUserID();
124
- void syncRevenueCatIdToProfile(normalizedUserId, rcId);
125
- }
126
-
127
109
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
128
- console.log('[UserSwitchHandler] User switch completed successfully');
110
+ console.log('[UserSwitchHandler] User switch completed successfully');
129
111
  }
130
112
 
131
113
  return buildSuccessResult(deps, customerInfo, offerings);
@@ -174,7 +156,7 @@ export async function handleInitialConfiguration(
174
156
  deps.setCurrentUserId(normalizedUserId || undefined);
175
157
 
176
158
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
177
- console.log('[UserSwitchHandler] Purchases.configure() successful');
159
+ console.log('[UserSwitchHandler] Purchases.configure() successful');
178
160
  }
179
161
 
180
162
  // Fetch customer info (critical) and offerings (non-fatal) separately.
@@ -184,14 +166,9 @@ export async function handleInitialConfiguration(
184
166
  fetchOfferingsSafe(),
185
167
  ]);
186
168
 
187
- const currentUserId = await Purchases.getAppUserID();
188
-
189
- if (normalizedUserId) {
190
- void syncRevenueCatIdToProfile(normalizedUserId, currentUserId);
191
- }
192
-
193
169
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
194
- console.log('[UserSwitchHandler] Initial configuration completed:', {
170
+ const currentUserId = await Purchases.getAppUserID();
171
+ console.log('[UserSwitchHandler] Initial configuration completed:', {
195
172
  revenueCatUserId: currentUserId,
196
173
  activeEntitlements: Object.keys(customerInfo.entitlements.active),
197
174
  offeringsCount: offerings?.all ? Object.keys(offerings.all).length : 0,
@@ -211,27 +188,21 @@ export async function handleInitialConfiguration(
211
188
 
212
189
  if (premiumEntitlement) {
213
190
  const subscription = customerInfo.subscriptionsByProductIdentifier?.[premiumEntitlement.productIdentifier];
214
- const originalTransactionId = subscription?.storeTransactionId ?? undefined;
215
191
 
216
- await deps.config.onPremiumStatusChanged(
217
- normalizedUserId,
218
- true,
219
- premiumEntitlement.productIdentifier,
220
- premiumEntitlement.expirationDate ?? undefined,
221
- premiumEntitlement.willRenew,
222
- premiumEntitlement.periodType as PeriodType | undefined,
223
- originalTransactionId
224
- );
192
+ await deps.config.onPremiumStatusChanged({
193
+ userId: normalizedUserId,
194
+ isPremium: true,
195
+ productId: premiumEntitlement.productIdentifier,
196
+ expiresAt: premiumEntitlement.expirationDate ?? undefined,
197
+ willRenew: premiumEntitlement.willRenew,
198
+ periodType: premiumEntitlement.periodType as PeriodType | undefined,
199
+ originalTransactionId: subscription?.storeTransactionId ?? undefined,
200
+ });
225
201
  } else {
226
- await deps.config.onPremiumStatusChanged(
227
- normalizedUserId,
228
- false,
229
- undefined,
230
- undefined,
231
- undefined,
232
- undefined,
233
- undefined
234
- );
202
+ await deps.config.onPremiumStatusChanged({
203
+ userId: normalizedUserId,
204
+ isPremium: false,
205
+ });
235
206
  }
236
207
  } catch (error) {
237
208
  // Log error but don't fail initialization
@@ -1,14 +1,21 @@
1
- import type { CustomerInfo } from "react-native-purchases";
2
1
  import Purchases from "react-native-purchases";
3
- import type { PeriodType, PurchaseSource } from "../core/SubscriptionConstants";
4
2
  import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
3
+ import type { PremiumStatusChangedEvent, PurchaseCompletedEvent, RenewalDetectedEvent } from "../core/SubscriptionEvents";
5
4
  import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
6
5
  import { extractRevenueCatData } from "./SubscriptionSyncUtils";
7
- import { emitCreditsUpdated } from "./syncEventEmitter";
8
6
  import { generatePurchaseId, generateRenewalId } from "./syncIdGenerators";
9
- import { 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();
@@ -40,22 +103,22 @@ export class SubscriptionSyncProcessor {
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
  }
@@ -39,6 +39,10 @@ export const extractRevenueCatData = (customerInfo: CustomerInfo, entitlementId:
39
39
  return {
40
40
  expirationDate: entitlement.expirationDate ?? null,
41
41
  willRenew: entitlement.willRenew ?? null,
42
+ // Maps SDK's `storeTransactionId` (current transaction ID from Apple/Google)
43
+ // to our domain's `originalTransactionId`. Used as dedup key in processedTransactions.
44
+ // Note: The SDK does not expose `original_store_transaction_id` (the first purchase's
45
+ // ID that stays constant across renewals) — only available via server-side webhooks.
42
46
  originalTransactionId: subscription?.storeTransactionId ?? null,
43
47
  periodType: validatePeriodType(entitlement.periodType),
44
48
  packageType: null,
@@ -1,13 +1,10 @@
1
1
  import { configureCreditsRepository } from "../../../credits/infrastructure/CreditsRepositoryManager";
2
2
  import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionManager";
3
3
  import { configureAuthProvider } from "../../presentation/useAuthAwarePurchase";
4
- import { 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
  }
@@ -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, 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 () => {
@@ -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
- };