@umituz/react-native-subscription 2.27.123 → 2.27.125
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/creditOperationUtils.ts +65 -144
- package/src/domains/credits/application/creditOperationUtils.types.ts +19 -0
- package/src/domains/credits/presentation/useCredits.ts +1 -11
- package/src/domains/paywall/components/PaywallContainer.tsx +3 -1
- package/src/domains/paywall/components/PaywallModal.tsx +19 -107
- package/src/domains/paywall/components/PaywallModal.types.ts +26 -0
- package/src/domains/paywall/components/PlanCard.tsx +45 -148
- package/src/domains/paywall/components/PlanCard.types.ts +12 -0
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +116 -0
- package/src/domains/subscription/application/SubscriptionSyncService.ts +17 -99
- package/src/domains/subscription/application/SubscriptionSyncUtils.ts +0 -2
- package/src/domains/subscription/core/SubscriptionConstants.ts +1 -13
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +7 -13
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.types.ts +15 -0
- package/src/domains/subscription/infrastructure/managers/subscriptionManagerUtils.ts +2 -1
- package/src/domains/subscription/infrastructure/services/RevenueCatInitializer.ts +20 -5
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +13 -92
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.types.ts +47 -0
- package/src/domains/subscription/presentation/screens/components/UpgradePrompt.tsx +34 -126
- package/src/domains/subscription/presentation/screens/components/UpgradePrompt.types.ts +12 -0
- package/src/domains/subscription/presentation/usePremium.ts +3 -22
- package/src/domains/subscription/presentation/usePremium.types.ts +16 -0
- package/src/domains/subscription/presentation/useSubscriptionStatus.ts +30 -22
- package/src/domains/subscription/presentation/useSubscriptionStatus.types.ts +7 -0
- package/src/domains/wallet/index.ts +6 -2
- package/src/domains/wallet/infrastructure/config/walletConfig.ts +2 -1
- package/src/domains/wallet/presentation/hooks/useProductMetadata.ts +3 -16
- package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +1 -13
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +25 -112
- package/src/domains/wallet/presentation/screens/WalletScreen.types.ts +15 -0
- package/src/shared/utils/appValidators.ts +38 -0
- package/src/shared/utils/validators.ts +4 -122
- package/src/domains/paywall/components/README.md +0 -41
- package/src/domains/subscription/presentation/screens/README.md +0 -52
|
@@ -1,160 +1,57 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plan Card
|
|
3
|
-
* Subscription plan selection card (Apple-compliant)
|
|
4
|
-
*
|
|
5
|
-
* Apple Guideline 3.1.2 Compliance:
|
|
6
|
-
* - Price is the most prominent element
|
|
7
|
-
* - Trial info is displayed in subordinate position and size
|
|
8
|
-
* - No toggle for enabling/disabling trial
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import React from "react";
|
|
12
2
|
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
13
3
|
import { AtomicText, AtomicIcon, AtomicBadge, useAppDesignTokens } from "@umituz/react-native-design-system";
|
|
14
|
-
import type { PurchasesPackage } from "react-native-purchases";
|
|
15
|
-
|
|
16
4
|
import { formatPriceWithPeriod } from '../../../utils/priceUtils';
|
|
17
|
-
|
|
18
|
-
interface PlanCardProps {
|
|
19
|
-
pkg: PurchasesPackage;
|
|
20
|
-
isSelected: boolean;
|
|
21
|
-
onSelect: () => void;
|
|
22
|
-
/** Badge text (e.g., "Best Value") - NOT for trial */
|
|
23
|
-
badge?: string;
|
|
24
|
-
/** Credit amount for this plan */
|
|
25
|
-
creditAmount?: number;
|
|
26
|
-
/** Credits label text (e.g., "credits") */
|
|
27
|
-
creditsLabel?: string;
|
|
28
|
-
/** Whether this plan has a free trial (Apple-compliant display) */
|
|
29
|
-
hasFreeTrial?: boolean;
|
|
30
|
-
/** Trial subtitle text (e.g., "7 days free, then billed") - shown as small gray text */
|
|
31
|
-
trialSubtitleText?: string;
|
|
32
|
-
}
|
|
5
|
+
import { PlanCardProps } from "./PlanCard.types";
|
|
33
6
|
|
|
34
7
|
export const PlanCard: React.FC<PlanCardProps> = React.memo(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)}
|
|
73
|
-
</View>
|
|
74
|
-
|
|
75
|
-
<View style={styles.textSection}>
|
|
76
|
-
<AtomicText type="titleSmall" style={{ color: tokens.colors.textPrimary, fontWeight: "600" }}>
|
|
77
|
-
{title}
|
|
78
|
-
</AtomicText>
|
|
79
|
-
|
|
80
|
-
{/* Credits info */}
|
|
81
|
-
{creditAmount != null && creditAmount > 0 && creditsLabel && (
|
|
82
|
-
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
83
|
-
{creditAmount} {creditsLabel}
|
|
84
|
-
</AtomicText>
|
|
85
|
-
)}
|
|
86
|
-
|
|
87
|
-
{/* Trial info - Apple-compliant: small, gray, subordinate */}
|
|
88
|
-
{hasFreeTrial && trialSubtitleText && (
|
|
89
|
-
<AtomicText
|
|
90
|
-
type="bodySmall"
|
|
91
|
-
style={{
|
|
92
|
-
color: tokens.colors.textTertiary ?? tokens.colors.textSecondary,
|
|
93
|
-
fontSize: 11,
|
|
94
|
-
marginTop: 2,
|
|
95
|
-
}}
|
|
96
|
-
>
|
|
97
|
-
{trialSubtitleText}
|
|
98
|
-
</AtomicText>
|
|
99
|
-
)}
|
|
100
|
-
</View>
|
|
101
|
-
</View>
|
|
102
|
-
|
|
103
|
-
{/* Price - MOST PROMINENT (Apple compliance) */}
|
|
104
|
-
<AtomicText
|
|
105
|
-
type="titleMedium"
|
|
106
|
-
style={{
|
|
107
|
-
color: isSelected ? tokens.colors.primary : tokens.colors.textPrimary,
|
|
108
|
-
fontWeight: "700",
|
|
109
|
-
fontSize: 18,
|
|
110
|
-
}}
|
|
111
|
-
>
|
|
112
|
-
{price}
|
|
113
|
-
</AtomicText>
|
|
114
|
-
</View>
|
|
115
|
-
</View>
|
|
116
|
-
</TouchableOpacity>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
8
|
+
({ pkg, isSelected, onSelect, badge, creditAmount, creditsLabel, hasFreeTrial, trialSubtitleText }) => {
|
|
9
|
+
const tokens = useAppDesignTokens();
|
|
10
|
+
const title = pkg.product.title;
|
|
11
|
+
const price = formatPriceWithPeriod(pkg.product.price, pkg.product.currencyCode, pkg.identifier);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<TouchableOpacity onPress={onSelect} activeOpacity={0.7} style={styles.touchable}>
|
|
15
|
+
<View style={[styles.container, {
|
|
16
|
+
backgroundColor: tokens.colors.surface,
|
|
17
|
+
borderColor: isSelected ? tokens.colors.primary : tokens.colors.border,
|
|
18
|
+
borderWidth: isSelected ? 2 : 1
|
|
19
|
+
}]}>
|
|
20
|
+
{badge && <View style={styles.badgeContainer}><AtomicBadge text={badge} variant="primary" size="sm" /></View>}
|
|
21
|
+
<View style={styles.content}>
|
|
22
|
+
<View style={styles.leftSection}>
|
|
23
|
+
<View style={[styles.radio, {
|
|
24
|
+
borderColor: isSelected ? tokens.colors.primary : tokens.colors.border,
|
|
25
|
+
backgroundColor: isSelected ? tokens.colors.primary : "transparent"
|
|
26
|
+
}]}>
|
|
27
|
+
{isSelected && <AtomicIcon name="checkmark-circle-outline" customSize={12} customColor={tokens.colors.onPrimary} />}
|
|
28
|
+
</View>
|
|
29
|
+
<View style={styles.textSection}>
|
|
30
|
+
<AtomicText type="titleSmall" style={{ color: tokens.colors.textPrimary, fontWeight: "600" }}>{title}</AtomicText>
|
|
31
|
+
{creditAmount != null && creditAmount > 0 && creditsLabel && (
|
|
32
|
+
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>{creditAmount} {creditsLabel}</AtomicText>
|
|
33
|
+
)}
|
|
34
|
+
{hasFreeTrial && trialSubtitleText && (
|
|
35
|
+
<AtomicText type="bodySmall" style={{ color: tokens.colors.textTertiary ?? tokens.colors.textSecondary, fontSize: 11, marginTop: 2 }}>{trialSubtitleText}</AtomicText>
|
|
36
|
+
)}
|
|
37
|
+
</View>
|
|
38
|
+
</View>
|
|
39
|
+
<AtomicText type="titleMedium" style={{ color: isSelected ? tokens.colors.primary : tokens.colors.textPrimary, fontWeight: "700", fontSize: 18 }}>{price}</AtomicText>
|
|
40
|
+
</View>
|
|
41
|
+
</View>
|
|
42
|
+
</TouchableOpacity>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
119
45
|
);
|
|
120
46
|
|
|
121
47
|
PlanCard.displayName = "PlanCard";
|
|
122
48
|
|
|
123
49
|
const styles = StyleSheet.create({
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
position: "relative",
|
|
132
|
-
},
|
|
133
|
-
badgeContainer: {
|
|
134
|
-
position: "absolute",
|
|
135
|
-
top: -10,
|
|
136
|
-
right: 16,
|
|
137
|
-
},
|
|
138
|
-
content: {
|
|
139
|
-
flexDirection: "row",
|
|
140
|
-
alignItems: "center",
|
|
141
|
-
justifyContent: "space-between",
|
|
142
|
-
},
|
|
143
|
-
leftSection: {
|
|
144
|
-
flexDirection: "row",
|
|
145
|
-
alignItems: "center",
|
|
146
|
-
flex: 1,
|
|
147
|
-
},
|
|
148
|
-
radio: {
|
|
149
|
-
width: 22,
|
|
150
|
-
height: 22,
|
|
151
|
-
borderRadius: 11,
|
|
152
|
-
borderWidth: 2,
|
|
153
|
-
alignItems: "center",
|
|
154
|
-
justifyContent: "center",
|
|
155
|
-
marginRight: 12,
|
|
156
|
-
},
|
|
157
|
-
textSection: {
|
|
158
|
-
flex: 1,
|
|
159
|
-
},
|
|
50
|
+
touchable: { marginBottom: 10, marginHorizontal: 24 },
|
|
51
|
+
container: { borderRadius: 16, padding: 16, position: "relative" },
|
|
52
|
+
badgeContainer: { position: "absolute", top: -10, right: 16 },
|
|
53
|
+
content: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
|
54
|
+
leftSection: { flexDirection: "row", alignItems: "center", flex: 1 },
|
|
55
|
+
radio: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, alignItems: "center", justifyContent: "center", marginRight: 12 },
|
|
56
|
+
textSection: { flex: 1 },
|
|
160
57
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
|
+
|
|
3
|
+
export interface PlanCardProps {
|
|
4
|
+
pkg: PurchasesPackage;
|
|
5
|
+
isSelected: boolean;
|
|
6
|
+
onSelect: () => void;
|
|
7
|
+
badge?: string;
|
|
8
|
+
creditAmount?: number;
|
|
9
|
+
creditsLabel?: string;
|
|
10
|
+
hasFreeTrial?: boolean;
|
|
11
|
+
trialSubtitleText?: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
+
import type { RevenueCatData } from "../core/RevenueCatData";
|
|
3
|
+
import {
|
|
4
|
+
type PeriodType,
|
|
5
|
+
type PurchaseSource,
|
|
6
|
+
PURCHASE_SOURCE,
|
|
7
|
+
PURCHASE_TYPE
|
|
8
|
+
} from "../core/SubscriptionConstants";
|
|
9
|
+
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
10
|
+
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
11
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
12
|
+
|
|
13
|
+
export class SubscriptionSyncProcessor {
|
|
14
|
+
constructor(private entitlementId: string) {}
|
|
15
|
+
|
|
16
|
+
async processPurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource) {
|
|
17
|
+
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
18
|
+
const purchaseId = revenueCatData.originalTransactionId
|
|
19
|
+
? `purchase_${revenueCatData.originalTransactionId}`
|
|
20
|
+
: `purchase_${productId}_${Date.now()}`;
|
|
21
|
+
|
|
22
|
+
await getCreditsRepository().initializeCredits(
|
|
23
|
+
userId,
|
|
24
|
+
purchaseId,
|
|
25
|
+
productId,
|
|
26
|
+
source ?? PURCHASE_SOURCE.SETTINGS,
|
|
27
|
+
revenueCatData,
|
|
28
|
+
PURCHASE_TYPE.INITIAL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async processRenewal(userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) {
|
|
35
|
+
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
36
|
+
revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
|
|
37
|
+
const purchaseId = revenueCatData.originalTransactionId
|
|
38
|
+
? `renewal_${revenueCatData.originalTransactionId}_${newExpirationDate}`
|
|
39
|
+
: `renewal_${productId}_${Date.now()}`;
|
|
40
|
+
|
|
41
|
+
await getCreditsRepository().initializeCredits(
|
|
42
|
+
userId,
|
|
43
|
+
purchaseId,
|
|
44
|
+
productId,
|
|
45
|
+
PURCHASE_SOURCE.RENEWAL,
|
|
46
|
+
revenueCatData,
|
|
47
|
+
PURCHASE_TYPE.RENEWAL
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async processStatusChange(
|
|
54
|
+
userId: string,
|
|
55
|
+
isPremium: boolean,
|
|
56
|
+
productId?: string,
|
|
57
|
+
expiresAt?: string,
|
|
58
|
+
willRenew?: boolean,
|
|
59
|
+
periodType?: PeriodType
|
|
60
|
+
) {
|
|
61
|
+
const repository = getCreditsRepository();
|
|
62
|
+
|
|
63
|
+
if (!isPremium && !productId) {
|
|
64
|
+
const currentCredits = await repository.getCredits(userId);
|
|
65
|
+
if (currentCredits.success && currentCredits.data?.isPremium) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!isPremium && productId) {
|
|
71
|
+
await repository.syncExpiredStatus(userId);
|
|
72
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isPremium && !productId) {
|
|
77
|
+
const stableSyncId = `init_sync_${userId}`;
|
|
78
|
+
await repository.initializeCredits(
|
|
79
|
+
userId,
|
|
80
|
+
stableSyncId,
|
|
81
|
+
'no_subscription',
|
|
82
|
+
PURCHASE_SOURCE.SETTINGS,
|
|
83
|
+
{
|
|
84
|
+
isPremium: false,
|
|
85
|
+
expirationDate: null,
|
|
86
|
+
willRenew: false,
|
|
87
|
+
periodType: null,
|
|
88
|
+
originalTransactionId: null
|
|
89
|
+
},
|
|
90
|
+
PURCHASE_TYPE.INITIAL
|
|
91
|
+
);
|
|
92
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const revenueCatData: RevenueCatData = {
|
|
97
|
+
expirationDate: expiresAt ?? null,
|
|
98
|
+
willRenew: willRenew ?? false,
|
|
99
|
+
isPremium,
|
|
100
|
+
periodType: periodType ?? null,
|
|
101
|
+
originalTransactionId: null
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const statusSyncId = `status_sync_${userId}_${isPremium ? 'premium' : 'free'}`;
|
|
105
|
+
await repository.initializeCredits(
|
|
106
|
+
userId,
|
|
107
|
+
statusSyncId,
|
|
108
|
+
productId ?? 'no_subscription',
|
|
109
|
+
PURCHASE_SOURCE.SETTINGS,
|
|
110
|
+
revenueCatData,
|
|
111
|
+
PURCHASE_TYPE.INITIAL
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -1,61 +1,30 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
-
import type
|
|
3
|
-
import { type PeriodType, type PurchaseSource, PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
4
|
-
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
5
|
-
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
2
|
+
import { type PeriodType, type PurchaseSource } from "../core/SubscriptionConstants";
|
|
6
3
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
4
|
+
import { SubscriptionSyncProcessor } from "./SubscriptionSyncProcessor";
|
|
7
5
|
|
|
8
|
-
/**
|
|
9
|
-
* Service to synchronize RevenueCat state with Firestore.
|
|
10
|
-
* Acts as a subscriber/handler for subscription events.
|
|
11
|
-
*/
|
|
12
6
|
export class SubscriptionSyncService {
|
|
13
|
-
|
|
7
|
+
private processor: SubscriptionSyncProcessor;
|
|
8
|
+
|
|
9
|
+
constructor(entitlementId: string) {
|
|
10
|
+
this.processor = new SubscriptionSyncProcessor(entitlementId);
|
|
11
|
+
}
|
|
14
12
|
|
|
15
13
|
async handlePurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource) {
|
|
16
14
|
try {
|
|
17
|
-
|
|
18
|
-
const purchaseId = revenueCatData.originalTransactionId
|
|
19
|
-
? `purchase_${revenueCatData.originalTransactionId}`
|
|
20
|
-
: `purchase_${productId}_${Date.now()}`;
|
|
21
|
-
|
|
22
|
-
await getCreditsRepository().initializeCredits(
|
|
23
|
-
userId,
|
|
24
|
-
purchaseId,
|
|
25
|
-
productId,
|
|
26
|
-
source ?? PURCHASE_SOURCE.SETTINGS,
|
|
27
|
-
revenueCatData,
|
|
28
|
-
PURCHASE_TYPE.INITIAL
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
15
|
+
await this.processor.processPurchase(userId, productId, customerInfo, source);
|
|
32
16
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PURCHASE_COMPLETED, { userId, productId });
|
|
33
|
-
} catch {
|
|
34
|
-
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error("[SubscriptionSyncService] purchase error:", err);
|
|
35
19
|
}
|
|
36
20
|
}
|
|
37
21
|
|
|
38
22
|
async handleRenewal(userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) {
|
|
39
23
|
try {
|
|
40
|
-
|
|
41
|
-
revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
|
|
42
|
-
const purchaseId = revenueCatData.originalTransactionId
|
|
43
|
-
? `renewal_${revenueCatData.originalTransactionId}_${newExpirationDate}`
|
|
44
|
-
: `renewal_${productId}_${Date.now()}`;
|
|
45
|
-
|
|
46
|
-
await getCreditsRepository().initializeCredits(
|
|
47
|
-
userId,
|
|
48
|
-
purchaseId,
|
|
49
|
-
productId,
|
|
50
|
-
PURCHASE_SOURCE.RENEWAL,
|
|
51
|
-
revenueCatData,
|
|
52
|
-
PURCHASE_TYPE.RENEWAL
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
24
|
+
await this.processor.processRenewal(userId, productId, newExpirationDate, customerInfo);
|
|
56
25
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.RENEWAL_DETECTED, { userId, productId });
|
|
57
|
-
} catch {
|
|
58
|
-
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error("[SubscriptionSyncService] renewal error:", err);
|
|
59
28
|
}
|
|
60
29
|
}
|
|
61
30
|
|
|
@@ -68,62 +37,11 @@ export class SubscriptionSyncService {
|
|
|
68
37
|
periodType?: PeriodType
|
|
69
38
|
) {
|
|
70
39
|
try {
|
|
71
|
-
|
|
72
|
-
if (!isPremium && productId) {
|
|
73
|
-
await getCreditsRepository().syncExpiredStatus(userId);
|
|
74
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// If not premium and no product, this is a freemium user.
|
|
79
|
-
// We only want to run initializeCredits for them if it's their first time,
|
|
80
|
-
// which initializeCredits handles, but we should avoid doing it on every sync.
|
|
81
|
-
if (!isPremium && !productId) {
|
|
82
|
-
// Option 1: Just skip if they are already known non-premium (handled by repository check)
|
|
83
|
-
// For now, let's just use a more stable sync ID to allow the repository to skip if possible
|
|
84
|
-
const stableSyncId = `init_sync_${userId}`;
|
|
85
|
-
|
|
86
|
-
await getCreditsRepository().initializeCredits(
|
|
87
|
-
userId,
|
|
88
|
-
stableSyncId,
|
|
89
|
-
'no_subscription',
|
|
90
|
-
PURCHASE_SOURCE.SETTINGS,
|
|
91
|
-
{
|
|
92
|
-
isPremium: false,
|
|
93
|
-
expirationDate: null,
|
|
94
|
-
willRenew: false,
|
|
95
|
-
periodType: null,
|
|
96
|
-
originalTransactionId: null
|
|
97
|
-
},
|
|
98
|
-
PURCHASE_TYPE.INITIAL
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Standard status sync for premium users
|
|
106
|
-
const revenueCatData: RevenueCatData = {
|
|
107
|
-
expirationDate: expiresAt ?? null,
|
|
108
|
-
willRenew: willRenew ?? false,
|
|
109
|
-
isPremium,
|
|
110
|
-
periodType: periodType ?? null,
|
|
111
|
-
originalTransactionId: null
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
await getCreditsRepository().initializeCredits(
|
|
115
|
-
userId,
|
|
116
|
-
`status_sync_${Date.now()}`,
|
|
117
|
-
productId ?? 'no_subscription',
|
|
118
|
-
PURCHASE_SOURCE.SETTINGS,
|
|
119
|
-
revenueCatData,
|
|
120
|
-
PURCHASE_TYPE.INITIAL
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
40
|
+
await this.processor.processStatusChange(userId, isPremium, productId, expiresAt, willRenew, periodType);
|
|
124
41
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, { userId, isPremium });
|
|
125
|
-
} catch {
|
|
126
|
-
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error("[SubscriptionSyncService] status change error:", err);
|
|
127
44
|
}
|
|
128
45
|
}
|
|
129
46
|
}
|
|
47
|
+
|
|
@@ -2,7 +2,6 @@ import type { CustomerInfo } from "react-native-purchases";
|
|
|
2
2
|
import type { RevenueCatData } from "../core/RevenueCatData";
|
|
3
3
|
import { type PeriodType } from "../core/SubscriptionStatus";
|
|
4
4
|
|
|
5
|
-
/** Extract RevenueCat data from CustomerInfo (Single Source of Truth) */
|
|
6
5
|
export const extractRevenueCatData = (customerInfo: CustomerInfo, entitlementId: string): RevenueCatData => {
|
|
7
6
|
const entitlement = customerInfo.entitlements.active[entitlementId]
|
|
8
7
|
?? customerInfo.entitlements.all[entitlementId];
|
|
@@ -10,7 +9,6 @@ export const extractRevenueCatData = (customerInfo: CustomerInfo, entitlementId:
|
|
|
10
9
|
return {
|
|
11
10
|
expirationDate: entitlement?.expirationDate ?? customerInfo.latestExpirationDate ?? null,
|
|
12
11
|
willRenew: entitlement?.willRenew ?? false,
|
|
13
|
-
// Use latestPurchaseDate if originalPurchaseDate is missing, or a combine id
|
|
14
12
|
originalTransactionId: entitlement?.originalPurchaseDate || customerInfo.firstSeen,
|
|
15
13
|
periodType: (entitlement?.periodType as PeriodType) ?? null,
|
|
16
14
|
isPremium: !!customerInfo.entitlements.active[entitlementId],
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subscription Constants and Types
|
|
3
|
-
* Centralized source of truth for subscription-related enums and types.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/** User tier constants */
|
|
7
1
|
export const USER_TIER = {
|
|
8
2
|
ANONYMOUS: 'anonymous',
|
|
9
3
|
FREEMIUM: 'freemium',
|
|
@@ -12,10 +6,8 @@ export const USER_TIER = {
|
|
|
12
6
|
|
|
13
7
|
export type UserTierType = (typeof USER_TIER)[keyof typeof USER_TIER];
|
|
14
8
|
|
|
15
|
-
/** Default entitlement identifier */
|
|
16
9
|
export const DEFAULT_ENTITLEMENT_ID = 'premium';
|
|
17
10
|
|
|
18
|
-
/** Subscription status constants */
|
|
19
11
|
export const SUBSCRIPTION_STATUS = {
|
|
20
12
|
ACTIVE: 'active',
|
|
21
13
|
TRIAL: 'trial',
|
|
@@ -27,7 +19,6 @@ export const SUBSCRIPTION_STATUS = {
|
|
|
27
19
|
|
|
28
20
|
export type SubscriptionStatusType = (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS];
|
|
29
21
|
|
|
30
|
-
/** RevenueCat period type constants */
|
|
31
22
|
export const PERIOD_TYPE = {
|
|
32
23
|
NORMAL: 'NORMAL',
|
|
33
24
|
INTRO: 'INTRO',
|
|
@@ -36,7 +27,6 @@ export const PERIOD_TYPE = {
|
|
|
36
27
|
|
|
37
28
|
export type PeriodType = (typeof PERIOD_TYPE)[keyof typeof PERIOD_TYPE];
|
|
38
29
|
|
|
39
|
-
/** Subscription package type constants */
|
|
40
30
|
export const PACKAGE_TYPE = {
|
|
41
31
|
WEEKLY: 'weekly',
|
|
42
32
|
MONTHLY: 'monthly',
|
|
@@ -47,7 +37,6 @@ export const PACKAGE_TYPE = {
|
|
|
47
37
|
|
|
48
38
|
export type PackageType = (typeof PACKAGE_TYPE)[keyof typeof PACKAGE_TYPE];
|
|
49
39
|
|
|
50
|
-
/** Platform constants */
|
|
51
40
|
export const PLATFORM = {
|
|
52
41
|
IOS: 'ios',
|
|
53
42
|
ANDROID: 'android',
|
|
@@ -55,7 +44,6 @@ export const PLATFORM = {
|
|
|
55
44
|
|
|
56
45
|
export type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];
|
|
57
46
|
|
|
58
|
-
/** Purchase source constants */
|
|
59
47
|
export const PURCHASE_SOURCE = {
|
|
60
48
|
ONBOARDING: 'onboarding',
|
|
61
49
|
SETTINGS: 'settings',
|
|
@@ -68,7 +56,6 @@ export const PURCHASE_SOURCE = {
|
|
|
68
56
|
|
|
69
57
|
export type PurchaseSource = (typeof PURCHASE_SOURCE)[keyof typeof PURCHASE_SOURCE];
|
|
70
58
|
|
|
71
|
-
/** Purchase type constants */
|
|
72
59
|
export const PURCHASE_TYPE = {
|
|
73
60
|
INITIAL: 'initial',
|
|
74
61
|
RENEWAL: 'renewal',
|
|
@@ -77,3 +64,4 @@ export const PURCHASE_TYPE = {
|
|
|
77
64
|
} as const;
|
|
78
65
|
|
|
79
66
|
export type PurchaseType = (typeof PURCHASE_TYPE)[keyof typeof PURCHASE_TYPE];
|
|
67
|
+
|
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subscription Manager
|
|
3
|
-
* Facade for subscription operations. Coordinates state and operations.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
7
|
-
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
8
2
|
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
9
3
|
import { initializeRevenueCatService, getRevenueCatService } from "../services/RevenueCatService";
|
|
10
4
|
import { PackageHandler } from "../handlers/PackageHandler";
|
|
11
|
-
import type { PremiumStatus, RestoreResultInfo } from "../handlers/PackageHandler";
|
|
12
5
|
import { SubscriptionInternalState } from "./SubscriptionInternalState";
|
|
13
6
|
import {
|
|
14
7
|
ensureConfigured,
|
|
@@ -17,11 +10,12 @@ import {
|
|
|
17
10
|
ensureServiceAvailable,
|
|
18
11
|
} from "./subscriptionManagerUtils";
|
|
19
12
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
13
|
+
import type {
|
|
14
|
+
SubscriptionManagerConfig,
|
|
15
|
+
PremiumStatus,
|
|
16
|
+
RestoreResultInfo
|
|
17
|
+
} from "./SubscriptionManager.types";
|
|
18
|
+
|
|
25
19
|
|
|
26
20
|
class SubscriptionManagerImpl {
|
|
27
21
|
private managerConfig: SubscriptionManagerConfig | null = null;
|
|
@@ -142,4 +136,4 @@ class SubscriptionManagerImpl {
|
|
|
142
136
|
}
|
|
143
137
|
|
|
144
138
|
export const SubscriptionManager = new SubscriptionManagerImpl();
|
|
145
|
-
|
|
139
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
2
|
+
import type { PremiumStatus } from "../handlers/PurchaseStatusResolver";
|
|
3
|
+
|
|
4
|
+
export interface SubscriptionManagerConfig {
|
|
5
|
+
config: RevenueCatConfig;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
getAnonymousUserId: () => Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type { PremiumStatus };
|
|
11
|
+
|
|
12
|
+
export interface RestoreResultInfo {
|
|
13
|
+
success: boolean;
|
|
14
|
+
productId: string | null;
|
|
15
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Validation and helper functions for SubscriptionManager
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { SubscriptionManagerConfig } from "./SubscriptionManager";
|
|
6
|
+
import type { SubscriptionManagerConfig } from "./SubscriptionManager.types";
|
|
7
|
+
|
|
7
8
|
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
8
9
|
import { SubscriptionInternalState } from "./SubscriptionInternalState";
|
|
9
10
|
|