@umituz/react-native-subscription 2.27.66 → 2.27.68
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/CreditLimitCalculator.ts +17 -0
- package/src/domains/credits/application/CreditsInitializer.ts +85 -0
- package/src/domains/credits/application/DeductCreditsCommand.ts +52 -0
- package/src/domains/credits/application/PurchaseMetadataGenerator.ts +59 -0
- package/src/domains/credits/application/credit-strategies/CreditAllocationContext.ts +35 -0
- package/src/domains/credits/application/credit-strategies/ICreditStrategy.ts +18 -0
- package/src/domains/credits/application/credit-strategies/StandardPurchaseCreditStrategy.ts +16 -0
- package/src/domains/credits/application/credit-strategies/SyncCreditStrategy.ts +15 -0
- package/src/domains/credits/application/credit-strategies/TrialCreditStrategy.ts +18 -0
- package/src/{infrastructure/mappers → domains/credits/core}/CreditsMapper.ts +4 -4
- package/src/domains/credits/infrastructure/CreditsRepository.ts +102 -0
- package/src/{presentation/hooks → domains/credits/presentation}/useCredits.ts +21 -4
- package/src/domains/subscription/application/SubscriptionAuthListener.ts +26 -0
- package/src/domains/subscription/application/SubscriptionInitializer.ts +77 -0
- package/src/{infrastructure/services → domains/subscription/application}/SubscriptionInitializerTypes.ts +21 -1
- package/src/domains/subscription/application/SubscriptionSyncService.ts +71 -0
- package/src/domains/subscription/application/SubscriptionSyncUtils.ts +16 -0
- package/src/{revenuecat/domain/value-objects → domains/subscription/core}/RevenueCatConfig.ts +1 -1
- package/src/{domain/types → domains/subscription/core}/RevenueCatData.ts +1 -1
- package/src/{domain/entities → domains/subscription/core}/SubscriptionStatus.ts +13 -21
- package/src/domains/subscription/core/SubscriptionStatusHandlers.ts +51 -0
- package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +67 -0
- package/src/domains/subscription/infrastructure/handlers/PurchaseStatusResolver.ts +27 -0
- package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useRevenueCat.ts +1 -1
- package/src/domains/subscription/infrastructure/managers/SubscriptionInternalState.ts +12 -0
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +110 -0
- package/src/{revenuecat → domains/subscription}/infrastructure/services/PurchaseHandler.ts +1 -1
- package/src/{revenuecat → domains/subscription}/infrastructure/services/RestoreHandler.ts +1 -1
- package/src/{revenuecat → domains/subscription}/infrastructure/services/RevenueCatInitializer.ts +1 -1
- package/src/{revenuecat → domains/subscription}/infrastructure/services/RevenueCatService.ts +2 -2
- package/src/{presentation/hooks → domains/subscription/presentation}/usePremium.ts +7 -4
- package/src/domains/trial/application/TrialEligibilityService.ts +25 -0
- package/src/domains/trial/application/TrialService.ts +69 -0
- package/src/{infrastructure/services → domains/trial/core}/TrialTypes.ts +1 -1
- package/src/domains/trial/infrastructure/DeviceTrialRepository.ts +30 -0
- package/src/index.ts +28 -59
- package/src/init/createSubscriptionInitModule.ts +1 -1
- package/src/presentation/components/details/PremiumStatusBadge.tsx +1 -3
- package/src/presentation/hooks/feedback/useFeedbackSubmit.ts +1 -1
- package/src/presentation/hooks/index.ts +11 -11
- package/src/shared/application/ports/IRevenueCatService.ts +32 -0
- package/src/shared/infrastructure/SubscriptionEventBus.ts +51 -0
- package/src/application/README.md +0 -50
- package/src/domain/README.md +0 -54
- package/src/domain/entities/README.md +0 -50
- package/src/domain/entities/SubscriptionStatus.test.ts +0 -149
- package/src/domain/errors/README.md +0 -53
- package/src/domain/value-objects/README.md +0 -50
- package/src/infrastructure/README.md +0 -55
- package/src/infrastructure/mappers/README.md +0 -21
- package/src/infrastructure/models/README.md +0 -26
- package/src/infrastructure/repositories/CreditsRepository.ts +0 -132
- package/src/infrastructure/repositories/README.md +0 -99
- package/src/infrastructure/services/CreditsInitializer.ts +0 -170
- package/src/infrastructure/services/README.md +0 -99
- package/src/infrastructure/services/SubscriptionInitializer.ts +0 -176
- package/src/infrastructure/services/SubscriptionService.ts +0 -133
- package/src/infrastructure/services/TrialService.ts +0 -197
- package/src/infrastructure/services/app-service-helpers.ts +0 -111
- package/src/revenuecat/README.md +0 -104
- package/src/revenuecat/application/README.md +0 -43
- package/src/revenuecat/application/ports/IRevenueCatService.ts +0 -76
- package/src/revenuecat/application/ports/README.md +0 -41
- package/src/revenuecat/domain/README.md +0 -48
- package/src/revenuecat/domain/constants/README.md +0 -41
- package/src/revenuecat/domain/entities/README.md +0 -42
- package/src/revenuecat/domain/errors/README.md +0 -53
- package/src/revenuecat/domain/types/README.md +0 -41
- package/src/revenuecat/domain/value-objects/README.md +0 -41
- package/src/revenuecat/index.ts +0 -13
- package/src/revenuecat/infrastructure/handlers/PackageHandler.ts +0 -161
- package/src/revenuecat/infrastructure/managers/SubscriptionManager.ts +0 -165
- package/src/revenuecat/presentation/README.md +0 -42
- /package/src/{domain/entities → domains/credits/core}/Credits.ts +0 -0
- /package/src/{infrastructure/models → domains/credits/core}/UserCreditsDocument.ts +0 -0
- /package/src/{infrastructure/repositories → domains/credits/infrastructure}/CreditsRepositoryProvider.ts +0 -0
- /package/src/{presentation/hooks → domains/credits/presentation}/useDeductCredit.ts +0 -0
- /package/src/{revenuecat/domain/constants → domains/subscription/core}/RevenueCatConstants.ts +0 -0
- /package/src/{revenuecat/domain/errors → domains/subscription/core}/RevenueCatError.ts +0 -0
- /package/src/{revenuecat/domain/types → domains/subscription/core}/RevenueCatTypes.ts +0 -0
- /package/src/{domain/entities → domains/subscription/core}/SubscriptionConstants.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/README.md +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/config/README.md +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/handlers/README.md +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/README.md +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/subscriptionQueryKeys.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useCustomerInfo.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useInitializeSubscription.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/usePaywallFlow.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/usePurchasePackage.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useRestorePurchase.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useRevenueCatTrialEligibility.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useSubscriptionPackages.ts +0 -0
- /package/src/{revenuecat/presentation → domains/subscription/infrastructure}/hooks/useSubscriptionQueries.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/managers/README.md +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/services/CustomerInfoListenerManager.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/services/OfferingsFetcher.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/services/README.md +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/services/ServiceStateManager.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/utils/ApiKeyResolver.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/utils/InitializationCache.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/utils/PremiumStatusSyncer.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/utils/README.md +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/utils/RenewalDetector.ts +0 -0
- /package/src/{revenuecat → domains/subscription}/infrastructure/utils/UserIdProvider.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useAuthAwarePurchase.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useAuthSubscriptionSync.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useFeatureGate.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/usePaywallVisibility.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/usePremiumGate.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useSavedPurchaseAutoExecution.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useSubscriptionSettingsConfig.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useSubscriptionSettingsConfig.utils.ts +0 -0
- /package/src/{presentation/hooks → domains/subscription/presentation}/useSubscriptionStatus.ts +0 -0
- /package/src/{infrastructure/services → shared/application}/ActivationHandler.ts +0 -0
- /package/src/{infrastructure/services → shared/application}/FeedbackService.ts +0 -0
- /package/src/{application → shared/application}/ports/ISubscriptionRepository.ts +0 -0
- /package/src/{application → shared/application}/ports/ISubscriptionService.ts +0 -0
- /package/src/{application → shared/application}/ports/README.md +0 -0
- /package/src/{domain/errors → shared/utils}/InsufficientCreditsError.ts +0 -0
- /package/src/{infrastructure → shared}/utils/Logger.ts +0 -0
- /package/src/{domain/value-objects → shared/utils}/Result.ts +0 -0
- /package/src/{domain/value-objects → shared/utils}/SubscriptionConfig.ts +0 -0
- /package/src/{domain/errors → shared/utils}/SubscriptionError.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.68",
|
|
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",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
2
|
+
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
3
|
+
import { getCreditAllocation } from "../../utils/creditMapper";
|
|
4
|
+
|
|
5
|
+
export class CreditLimitCalculator {
|
|
6
|
+
static calculate(productId: string | undefined, config: CreditsConfig): number {
|
|
7
|
+
if (!productId) return config.creditLimit;
|
|
8
|
+
|
|
9
|
+
const explicitAmount = config.creditPackageAmounts?.[productId];
|
|
10
|
+
if (explicitAmount) return explicitAmount;
|
|
11
|
+
|
|
12
|
+
const packageType = detectPackageType(productId);
|
|
13
|
+
const dynamicLimit = getCreditAllocation(packageType, config.packageAllocations);
|
|
14
|
+
|
|
15
|
+
return dynamicLimit ?? config.creditLimit;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
import Constants from "expo-constants";
|
|
3
|
+
import {
|
|
4
|
+
runTransaction,
|
|
5
|
+
serverTimestamp,
|
|
6
|
+
Timestamp,
|
|
7
|
+
type Firestore,
|
|
8
|
+
type Transaction,
|
|
9
|
+
type DocumentReference,
|
|
10
|
+
} from "firebase/firestore";
|
|
11
|
+
import type { CreditsConfig } from "../core/Credits";
|
|
12
|
+
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
13
|
+
import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
|
|
14
|
+
import { CreditLimitCalculator } from "./CreditLimitCalculator";
|
|
15
|
+
import { PurchaseMetadataGenerator } from "./PurchaseMetadataGenerator";
|
|
16
|
+
import { creditAllocationContext } from "./credit-strategies/CreditAllocationContext";
|
|
17
|
+
import type { InitializeCreditsMetadata, InitializationResult } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
18
|
+
|
|
19
|
+
export async function initializeCreditsTransaction(
|
|
20
|
+
db: Firestore,
|
|
21
|
+
creditsRef: DocumentReference,
|
|
22
|
+
config: CreditsConfig,
|
|
23
|
+
purchaseId?: string,
|
|
24
|
+
metadata?: InitializeCreditsMetadata
|
|
25
|
+
): Promise<InitializationResult> {
|
|
26
|
+
return runTransaction(db, async (transaction: Transaction) => {
|
|
27
|
+
const creditsDoc = await transaction.get(creditsRef);
|
|
28
|
+
const now = serverTimestamp();
|
|
29
|
+
const existingData = creditsDoc.exists() ? creditsDoc.data() as UserCreditsDocumentRead : null;
|
|
30
|
+
|
|
31
|
+
if (existingData && purchaseId && existingData.processedPurchases?.includes(purchaseId)) {
|
|
32
|
+
return { credits: existingData.credits, alreadyProcessed: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const creditLimit = CreditLimitCalculator.calculate(metadata?.productId, config);
|
|
36
|
+
const { purchaseType, purchaseHistory } = PurchaseMetadataGenerator.generate({
|
|
37
|
+
productId: metadata?.productId,
|
|
38
|
+
source: metadata?.source,
|
|
39
|
+
type: metadata?.type,
|
|
40
|
+
creditLimit,
|
|
41
|
+
platform: Platform.OS as "ios" | "android",
|
|
42
|
+
appVersion: Constants.expoConfig?.version,
|
|
43
|
+
}, existingData);
|
|
44
|
+
|
|
45
|
+
const isPremium = metadata?.isPremium ?? true;
|
|
46
|
+
const isExpired = metadata?.expirationDate ? new Date(metadata.expirationDate).getTime() < Date.now() : false;
|
|
47
|
+
const status = resolveSubscriptionStatus({
|
|
48
|
+
isPremium, willRenew: metadata?.willRenew, isExpired, periodType: metadata?.periodType
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Resolve credits using Strategy Pattern
|
|
52
|
+
const isStatusSync = purchaseId?.startsWith("status_sync_") ?? false;
|
|
53
|
+
const isSubscriptionActive = isPremium && !isExpired;
|
|
54
|
+
|
|
55
|
+
const newCredits = creditAllocationContext.allocate({
|
|
56
|
+
status,
|
|
57
|
+
isStatusSync,
|
|
58
|
+
existingData,
|
|
59
|
+
creditLimit,
|
|
60
|
+
isSubscriptionActive
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const creditsData: Record<string, any> = {
|
|
64
|
+
isPremium, status, credits: newCredits, creditLimit,
|
|
65
|
+
lastUpdatedAt: now,
|
|
66
|
+
processedPurchases: (purchaseId ? [...(existingData?.processedPurchases || []), purchaseId].slice(-10) : existingData?.processedPurchases) || [],
|
|
67
|
+
purchaseHistory: purchaseHistory.length ? purchaseHistory : undefined
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isNewPurchaseOrRenewal = purchaseId?.startsWith("purchase_") || purchaseId?.startsWith("renewal_");
|
|
71
|
+
if (isNewPurchaseOrRenewal) creditsData.lastPurchaseAt = now;
|
|
72
|
+
if (metadata?.expirationDate) creditsData.expirationDate = Timestamp.fromDate(new Date(metadata.expirationDate));
|
|
73
|
+
if (metadata?.willRenew !== undefined) creditsData.willRenew = metadata.willRenew;
|
|
74
|
+
if (metadata?.originalTransactionId) creditsData.originalTransactionId = metadata.originalTransactionId;
|
|
75
|
+
if (metadata?.productId) {
|
|
76
|
+
creditsData.productId = metadata.productId;
|
|
77
|
+
creditsData.platform = Platform.OS;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
transaction.set(creditsRef, creditsData, { merge: true });
|
|
81
|
+
|
|
82
|
+
const finalData = { ...(existingData || {}), ...creditsData } as UserCreditsDocumentRead;
|
|
83
|
+
return { credits: newCredits, finalData };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { runTransaction, serverTimestamp, type Firestore, type Transaction, type DocumentReference } from "firebase/firestore";
|
|
2
|
+
import { getFirestore } from "@umituz/react-native-firebase";
|
|
3
|
+
import type { DeductCreditsResult } from "../../../domain/entities/Credits";
|
|
4
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../SubscriptionEventBus";
|
|
5
|
+
|
|
6
|
+
export interface IDeductCreditsCommand {
|
|
7
|
+
execute(userId: string, cost: number): Promise<DeductCreditsResult>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Command for deducting credits.
|
|
12
|
+
* Encapsulates the domain rules and transaction logic for credit usage.
|
|
13
|
+
*/
|
|
14
|
+
export class DeductCreditsCommand implements IDeductCreditsCommand {
|
|
15
|
+
constructor(
|
|
16
|
+
private getCreditsRef: (db: Firestore, userId: string) => DocumentReference
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async execute(userId: string, cost: number = 1): Promise<DeductCreditsResult> {
|
|
20
|
+
const db = getFirestore();
|
|
21
|
+
if (!db) return { success: false, error: { message: "No DB", code: "ERR" } };
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const remaining = await runTransaction(db, async (tx: Transaction) => {
|
|
25
|
+
const ref = this.getCreditsRef(db, userId);
|
|
26
|
+
const docSnap = await tx.get(ref);
|
|
27
|
+
|
|
28
|
+
if (!docSnap.exists()) throw new Error("NO_CREDITS");
|
|
29
|
+
|
|
30
|
+
const current = docSnap.data().credits as number;
|
|
31
|
+
if (current < cost) throw new Error("CREDITS_EXHAUSTED");
|
|
32
|
+
|
|
33
|
+
const updated = current - cost;
|
|
34
|
+
tx.update(ref, {
|
|
35
|
+
credits: updated,
|
|
36
|
+
lastUpdatedAt: serverTimestamp()
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return updated;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Emit event via EventBus (Observer Pattern)
|
|
43
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
44
|
+
|
|
45
|
+
return { success: true, remainingCredits: remaining };
|
|
46
|
+
} catch (e: unknown) {
|
|
47
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
48
|
+
const code = message === "NO_CREDITS" || message === "CREDITS_EXHAUSTED" ? message : "DEDUCT_ERR";
|
|
49
|
+
return { success: false, error: { message, code } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Timestamp } from "firebase/firestore";
|
|
2
|
+
import type {
|
|
3
|
+
PurchaseType,
|
|
4
|
+
PurchaseMetadata,
|
|
5
|
+
UserCreditsDocumentRead,
|
|
6
|
+
PurchaseSource
|
|
7
|
+
} from "../models/UserCreditsDocument";
|
|
8
|
+
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
9
|
+
|
|
10
|
+
export interface MetadataGeneratorConfig {
|
|
11
|
+
productId?: string;
|
|
12
|
+
source?: PurchaseSource;
|
|
13
|
+
type?: PurchaseType;
|
|
14
|
+
creditLimit: number;
|
|
15
|
+
platform: "ios" | "android";
|
|
16
|
+
appVersion?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class PurchaseMetadataGenerator {
|
|
20
|
+
static generate(
|
|
21
|
+
config: MetadataGeneratorConfig,
|
|
22
|
+
existingData: UserCreditsDocumentRead | null
|
|
23
|
+
): { purchaseType: PurchaseType; purchaseHistory: PurchaseMetadata[] } {
|
|
24
|
+
const { productId, source, type, creditLimit, platform, appVersion } = config;
|
|
25
|
+
|
|
26
|
+
if (!productId || !source) {
|
|
27
|
+
return {
|
|
28
|
+
purchaseType: type ?? "initial",
|
|
29
|
+
purchaseHistory: existingData?.purchaseHistory || []
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const packageType = detectPackageType(productId);
|
|
34
|
+
let purchaseType: PurchaseType = type ?? "initial";
|
|
35
|
+
|
|
36
|
+
if (existingData?.packageType && packageType !== "unknown") {
|
|
37
|
+
const oldLimit = existingData.creditLimit || 0;
|
|
38
|
+
if (creditLimit > oldLimit) purchaseType = "upgrade";
|
|
39
|
+
else if (creditLimit < oldLimit) purchaseType = "downgrade";
|
|
40
|
+
// This check is a bit fragile if purchaseId is not passed here,
|
|
41
|
+
// but we use the explicit 'type' if provided.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const newMetadata: PurchaseMetadata = {
|
|
45
|
+
productId,
|
|
46
|
+
packageType,
|
|
47
|
+
creditLimit,
|
|
48
|
+
source,
|
|
49
|
+
type: purchaseType,
|
|
50
|
+
platform,
|
|
51
|
+
appVersion,
|
|
52
|
+
timestamp: Timestamp.fromDate(new Date()) as any,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const purchaseHistory = [...(existingData?.purchaseHistory || []), newMetadata].slice(-10);
|
|
56
|
+
|
|
57
|
+
return { purchaseType, purchaseHistory };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ICreditStrategy, CreditAllocationParams } from "./ICreditStrategy";
|
|
2
|
+
import { SyncCreditStrategy } from "./SyncCreditStrategy";
|
|
3
|
+
import { TrialCreditStrategy } from "./TrialCreditStrategy";
|
|
4
|
+
import { StandardPurchaseCreditStrategy } from "./StandardPurchaseCreditStrategy";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Strategy Context to coordinate credit allocation logic using the Strategy Pattern.
|
|
8
|
+
*/
|
|
9
|
+
export class CreditAllocationContext {
|
|
10
|
+
private strategies: ICreditStrategy[] = [
|
|
11
|
+
new SyncCreditStrategy(),
|
|
12
|
+
new TrialCreditStrategy(),
|
|
13
|
+
new StandardPurchaseCreditStrategy(), // Fallback strategy
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Finds the first applicable strategy and executes its logic.
|
|
18
|
+
*/
|
|
19
|
+
allocate(params: CreditAllocationParams): number {
|
|
20
|
+
const strategy = this.strategies.find(s => s.canHandle(params));
|
|
21
|
+
|
|
22
|
+
if (!strategy) {
|
|
23
|
+
// Should theoretically never happen due to StandardPurchaseCreditStrategy fallback
|
|
24
|
+
return params.creditLimit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (__DEV__) {
|
|
28
|
+
console.log(`[CreditAllocationContext] Using strategy: ${strategy.constructor.name}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return strategy.execute(params);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const creditAllocationContext = new CreditAllocationContext();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SubscriptionStatusType } from "../../domain/entities/SubscriptionStatus";
|
|
2
|
+
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
3
|
+
|
|
4
|
+
export interface CreditStrategyParams {
|
|
5
|
+
status: SubscriptionStatusType;
|
|
6
|
+
isStatusSync: boolean;
|
|
7
|
+
existingData: UserCreditsDocumentRead | null;
|
|
8
|
+
creditLimit: number;
|
|
9
|
+
isSubscriptionActive: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ICreditStrategy {
|
|
13
|
+
canHandle(params: CreditAllocationParams): boolean;
|
|
14
|
+
execute(params: CreditAllocationParams): number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Renaming the input for clarity
|
|
18
|
+
export type CreditAllocationParams = CreditStrategyParams;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ICreditStrategy, type CreditAllocationParams } from "./ICreditStrategy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default strategy for new purchases, renewals, or upgrades.
|
|
5
|
+
* Resets credits to the calculated credit limit.
|
|
6
|
+
*/
|
|
7
|
+
export class StandardPurchaseCreditStrategy implements ICreditStrategy {
|
|
8
|
+
canHandle(_params: CreditAllocationParams): boolean {
|
|
9
|
+
// This is a catch-all strategy
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
execute(params: CreditAllocationParams): number {
|
|
14
|
+
return params.creditLimit;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ICreditStrategy, type CreditAllocationParams } from "./ICreditStrategy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strategy for existing premium users during a simple status synchronization.
|
|
5
|
+
* Preserves their current credits to avoid accidental resets.
|
|
6
|
+
*/
|
|
7
|
+
export class SyncCreditStrategy implements ICreditStrategy {
|
|
8
|
+
canHandle(params: CreditAllocationParams): boolean {
|
|
9
|
+
return params.isStatusSync && params.existingData?.isPremium === true && params.isSubscriptionActive;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
execute(params: CreditAllocationParams): number {
|
|
13
|
+
return params.existingData?.credits ?? params.creditLimit;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ICreditStrategy, type CreditAllocationParams } from "./ICreditStrategy";
|
|
2
|
+
import { SUBSCRIPTION_STATUS } from "../../../domain/entities/SubscriptionStatus";
|
|
3
|
+
import { TRIAL_CONFIG } from "../TrialService";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strategy for Trial and Trial Canceled users.
|
|
7
|
+
* Allocates credits based on trial configuration.
|
|
8
|
+
*/
|
|
9
|
+
export class TrialCreditStrategy implements ICreditStrategy {
|
|
10
|
+
canHandle(params: CreditAllocationParams): boolean {
|
|
11
|
+
return params.status === SUBSCRIPTION_STATUS.TRIAL ||
|
|
12
|
+
params.status === SUBSCRIPTION_STATUS.TRIAL_CANCELED;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
execute(_params: CreditAllocationParams): number {
|
|
16
|
+
return TRIAL_CONFIG.CREDITS;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { UserCredits } from "
|
|
2
|
-
import { resolveSubscriptionStatus } from "../../
|
|
3
|
-
import type { PeriodType, SubscriptionStatusType } from "../../
|
|
4
|
-
import type { UserCreditsDocumentRead } from "
|
|
1
|
+
import type { UserCredits } from "./Credits";
|
|
2
|
+
import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
|
|
3
|
+
import type { PeriodType, SubscriptionStatusType } from "../../subscription/core/SubscriptionConstants";
|
|
4
|
+
import type { UserCreditsDocumentRead } from "./UserCreditsDocument";
|
|
5
5
|
|
|
6
6
|
/** Maps Firestore document to domain entity with expiration validation */
|
|
7
7
|
export class CreditsMapper {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credits Repository
|
|
3
|
+
* Optimized to use Design Patterns: Command, Observer, and Strategy.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { doc, getDoc, serverTimestamp, updateDoc, type Firestore } from "firebase/firestore";
|
|
7
|
+
import { BaseRepository, getFirestore } from "@umituz/react-native-firebase";
|
|
8
|
+
import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../core/Credits";
|
|
9
|
+
import type { UserCreditsDocumentRead, PurchaseSource } from "../core/UserCreditsDocument";
|
|
10
|
+
import { initializeCreditsTransaction } from "../application/CreditsInitializer";
|
|
11
|
+
import { CreditsMapper } from "../core/CreditsMapper";
|
|
12
|
+
import type { RevenueCatData } from "../../subscription/core/RevenueCatData";
|
|
13
|
+
import { DeductCreditsCommand } from "../application/DeductCreditsCommand";
|
|
14
|
+
import { CreditLimitCalculator } from "../application/CreditLimitCalculator";
|
|
15
|
+
|
|
16
|
+
export class CreditsRepository extends BaseRepository {
|
|
17
|
+
private deductCommand: DeductCreditsCommand;
|
|
18
|
+
|
|
19
|
+
constructor(private config: CreditsConfig) {
|
|
20
|
+
super();
|
|
21
|
+
this.deductCommand = new DeductCreditsCommand((db, uid) => this.getRef(db, uid));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private getRef(db: Firestore, userId: string) {
|
|
25
|
+
return this.config.useUserSubcollection
|
|
26
|
+
? doc(db, "users", userId, "credits", "balance")
|
|
27
|
+
: doc(db, this.config.collectionName, userId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getCredits(userId: string): Promise<CreditsResult> {
|
|
31
|
+
const db = getFirestore();
|
|
32
|
+
if (!db) return { success: false, error: { message: "No DB", code: "DB_ERR" } };
|
|
33
|
+
try {
|
|
34
|
+
const snap = await getDoc(this.getRef(db, userId));
|
|
35
|
+
if (!snap.exists()) return { success: true, data: undefined };
|
|
36
|
+
|
|
37
|
+
const entity = CreditsMapper.toEntity(snap.data() as UserCreditsDocumentRead);
|
|
38
|
+
return { success: true, data: entity };
|
|
39
|
+
} catch (e: unknown) {
|
|
40
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
41
|
+
return { success: false, error: { message, code: "FETCH_ERR" } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async initializeCredits(
|
|
46
|
+
userId: string, purchaseId?: string, productId?: string,
|
|
47
|
+
source?: PurchaseSource, revenueCatData?: RevenueCatData
|
|
48
|
+
): Promise<CreditsResult> {
|
|
49
|
+
const db = getFirestore();
|
|
50
|
+
if (!db) return { success: false, error: { message: "No DB", code: "INIT_ERR" } };
|
|
51
|
+
try {
|
|
52
|
+
// Use CreditLimitCalculator (Refactoring Logic)
|
|
53
|
+
const creditLimit = CreditLimitCalculator.calculate(productId, this.config);
|
|
54
|
+
const cfg = { ...this.config, creditLimit };
|
|
55
|
+
|
|
56
|
+
const result = await initializeCreditsTransaction(db, this.getRef(db, userId), cfg, purchaseId, {
|
|
57
|
+
productId, source,
|
|
58
|
+
expirationDate: revenueCatData?.expirationDate,
|
|
59
|
+
willRenew: revenueCatData?.willRenew,
|
|
60
|
+
originalTransactionId: revenueCatData?.originalTransactionId,
|
|
61
|
+
isPremium: revenueCatData?.isPremium,
|
|
62
|
+
periodType: revenueCatData?.periodType,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
success: true,
|
|
67
|
+
data: result.finalData ? CreditsMapper.toEntity(result.finalData) : undefined,
|
|
68
|
+
};
|
|
69
|
+
} catch (e: unknown) {
|
|
70
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
71
|
+
return { success: false, error: { message, code: "INIT_ERR" } };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delegates to DeductCreditsCommand (Command Pattern)
|
|
77
|
+
*/
|
|
78
|
+
async deductCredit(userId: string, cost: number = 1): Promise<DeductCreditsResult> {
|
|
79
|
+
return this.deductCommand.execute(userId, cost);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async hasCredits(userId: string, cost: number = 1): Promise<boolean> {
|
|
83
|
+
const res = await this.getCredits(userId);
|
|
84
|
+
return !!(res.success && res.data && res.data.credits >= cost);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async syncExpiredStatus(userId: string): Promise<void> {
|
|
88
|
+
const db = getFirestore();
|
|
89
|
+
if (!db) return;
|
|
90
|
+
try {
|
|
91
|
+
await updateDoc(this.getRef(db, userId), {
|
|
92
|
+
isPremium: false,
|
|
93
|
+
status: "expired",
|
|
94
|
+
lastUpdatedAt: serverTimestamp()
|
|
95
|
+
});
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (__DEV__) console.error("[CreditsRepository] Sync expired failed:", e);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const createCreditsRepository = (c: CreditsConfig) => new CreditsRepository(c);
|
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
* Uses status-based state management for reliable loading detection.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useQuery } from "@umituz/react-native-design-system";
|
|
9
|
-
import { useCallback, useMemo } from "react";
|
|
8
|
+
import { useQuery, useQueryClient } from "@umituz/react-native-design-system";
|
|
9
|
+
import { useCallback, useMemo, useEffect } from "react";
|
|
10
10
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
11
|
-
import
|
|
11
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
12
|
+
import type { UserCredits } from "../core/Credits";
|
|
12
13
|
import {
|
|
13
14
|
getCreditsRepository,
|
|
14
15
|
getCreditsConfig,
|
|
15
16
|
isCreditsRepositoryConfigured,
|
|
16
|
-
} from "
|
|
17
|
+
} from "../infrastructure/CreditsRepositoryProvider";
|
|
17
18
|
|
|
18
19
|
export const creditsQueryKeys = {
|
|
19
20
|
all: ["credits"] as const,
|
|
@@ -90,6 +91,22 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
90
91
|
refetchOnReconnect: true,
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
const queryClient = useQueryClient();
|
|
95
|
+
|
|
96
|
+
// Observer Pattern: Listen for credit updates
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!userId) return;
|
|
99
|
+
|
|
100
|
+
const unsubscribe = subscriptionEventBus.on(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, (updatedUserId) => {
|
|
101
|
+
if (updatedUserId === userId) {
|
|
102
|
+
if (__DEV__) console.log("[useCredits] Event received: CREDITS_UPDATED, refetching...");
|
|
103
|
+
queryClient.invalidateQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return unsubscribe;
|
|
108
|
+
}, [userId, queryClient]);
|
|
109
|
+
|
|
93
110
|
const credits = data ?? null;
|
|
94
111
|
|
|
95
112
|
const derivedValues = useMemo(() => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FirebaseAuthLike } from "./SubscriptionInitializerTypes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gets the current user ID from Firebase auth.
|
|
5
|
+
*/
|
|
6
|
+
export const getCurrentUserId = (getAuth: () => FirebaseAuthLike | null): string | undefined => {
|
|
7
|
+
const auth = getAuth();
|
|
8
|
+
if (!auth) return undefined;
|
|
9
|
+
return auth.currentUser?.uid;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sets up auth state listener that will re-initialize subscription
|
|
14
|
+
* when user auth state changes (login/logout).
|
|
15
|
+
*/
|
|
16
|
+
export const setupAuthStateListener = (
|
|
17
|
+
getAuth: () => FirebaseAuthLike | null,
|
|
18
|
+
onUserChange: (userId: string | undefined) => void
|
|
19
|
+
): (() => void) | null => {
|
|
20
|
+
const auth = getAuth();
|
|
21
|
+
if (!auth) return null;
|
|
22
|
+
|
|
23
|
+
return auth.onAuthStateChanged((user) => {
|
|
24
|
+
onUserChange(user?.uid);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Initializer
|
|
3
|
+
*
|
|
4
|
+
* Uses RevenueCat best practices:
|
|
5
|
+
* - Non-blocking initialization (fire and forget)
|
|
6
|
+
* - Relies on CustomerInfoUpdateListener for state updates
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Platform } from "react-native";
|
|
10
|
+
import { configureCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryProvider";
|
|
11
|
+
import { SubscriptionManager } from "../infrastructure/managers/SubscriptionManager";
|
|
12
|
+
import { configureAuthProvider } from "../presentation/useAuthAwarePurchase";
|
|
13
|
+
import { SubscriptionSyncService } from "./SubscriptionSyncService";
|
|
14
|
+
import { getCurrentUserId, setupAuthStateListener } from "./SubscriptionAuthListener";
|
|
15
|
+
import type { SubscriptionInitConfig } from "./SubscriptionInitializerTypes";
|
|
16
|
+
|
|
17
|
+
export type { FirebaseAuthLike, CreditPackageConfig, SubscriptionInitConfig } from "./SubscriptionInitializerTypes";
|
|
18
|
+
|
|
19
|
+
export const initializeSubscription = async (config: SubscriptionInitConfig): Promise<void> => {
|
|
20
|
+
const {
|
|
21
|
+
apiKey, apiKeyIos, apiKeyAndroid, entitlementId, credits,
|
|
22
|
+
getAnonymousUserId, getFirebaseAuth, showAuthModal,
|
|
23
|
+
onCreditsUpdated, creditPackages,
|
|
24
|
+
} = config;
|
|
25
|
+
|
|
26
|
+
const key = Platform.OS === 'ios' ? (apiKeyIos || apiKey || '') : (apiKeyAndroid || apiKey || '');
|
|
27
|
+
if (!key) throw new Error('API key required');
|
|
28
|
+
|
|
29
|
+
// 1. Configure Repository
|
|
30
|
+
configureCreditsRepository({ ...credits, creditPackageAmounts: creditPackages?.amounts });
|
|
31
|
+
|
|
32
|
+
// 2. Setup Sync Service
|
|
33
|
+
const syncService = new SubscriptionSyncService(entitlementId);
|
|
34
|
+
|
|
35
|
+
// 3. Configure Subscription Manager
|
|
36
|
+
SubscriptionManager.configure({
|
|
37
|
+
config: {
|
|
38
|
+
apiKey: key,
|
|
39
|
+
entitlementIdentifier: entitlementId,
|
|
40
|
+
consumableProductIdentifiers: [creditPackages?.identifierPattern || 'credit'],
|
|
41
|
+
onPurchaseCompleted: (u: string, p: string, c: any, s: any) => syncService.handlePurchase(u, p, c, s),
|
|
42
|
+
onRenewalDetected: (u: string, p: string, expires: string, c: any) => syncService.handleRenewal(u, p, expires, c),
|
|
43
|
+
onPremiumStatusChanged: (u: string, isP: boolean, pId: any, exp: any, willR: any, pt: any) => syncService.handlePremiumStatusChanged(u, isP, pId, exp, willR, pt),
|
|
44
|
+
onCreditsUpdated,
|
|
45
|
+
},
|
|
46
|
+
apiKey: key,
|
|
47
|
+
getAnonymousUserId,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 4. Configure Auth aware actions
|
|
51
|
+
configureAuthProvider({
|
|
52
|
+
isAuthenticated: () => {
|
|
53
|
+
const u = getFirebaseAuth()?.currentUser;
|
|
54
|
+
return !!(u && !u.isAnonymous);
|
|
55
|
+
},
|
|
56
|
+
showAuthModal,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const initializeInBackground = async (userId?: string) => {
|
|
60
|
+
try {
|
|
61
|
+
await SubscriptionManager.initialize(userId);
|
|
62
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Background init complete');
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Background init failed (non-critical):', error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 5. Start Background Init
|
|
69
|
+
const initialUserId = getCurrentUserId(getFirebaseAuth);
|
|
70
|
+
initializeInBackground(initialUserId);
|
|
71
|
+
|
|
72
|
+
// 6. Listen for Auth Changes
|
|
73
|
+
setupAuthStateListener(getFirebaseAuth, (newUserId) => {
|
|
74
|
+
if (__DEV__) console.log('[SubscriptionInitializer] Auth changed, re-init:', newUserId);
|
|
75
|
+
initializeInBackground(newUserId);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Subscription Initializer Types
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { CreditsConfig } from "../../
|
|
5
|
+
import type { CreditsConfig } from "../../credits/core/Credits";
|
|
6
|
+
import type { UserCreditsDocumentRead } from "../../credits/core/UserCreditsDocument";
|
|
7
|
+
import type { PurchaseSource, PurchaseType } from "../core/SubscriptionConstants";
|
|
8
|
+
import type { PeriodType } from "../core/SubscriptionStatus";
|
|
6
9
|
|
|
7
10
|
export interface FirebaseAuthLike {
|
|
8
11
|
currentUser: { uid: string; isAnonymous: boolean } | null;
|
|
@@ -28,3 +31,20 @@ export interface SubscriptionInitConfig {
|
|
|
28
31
|
timeoutMs?: number;
|
|
29
32
|
authStateTimeoutMs?: number;
|
|
30
33
|
}
|
|
34
|
+
|
|
35
|
+
export interface InitializeCreditsMetadata {
|
|
36
|
+
productId?: string;
|
|
37
|
+
source?: PurchaseSource;
|
|
38
|
+
type?: PurchaseType;
|
|
39
|
+
expirationDate?: string | null;
|
|
40
|
+
willRenew?: boolean;
|
|
41
|
+
originalTransactionId?: string;
|
|
42
|
+
isPremium?: boolean;
|
|
43
|
+
periodType?: PeriodType;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface InitializationResult {
|
|
47
|
+
credits: number;
|
|
48
|
+
alreadyProcessed?: boolean;
|
|
49
|
+
finalData?: UserCreditsDocumentRead;
|
|
50
|
+
}
|