@umituz/react-native-subscription 2.27.126 → 2.27.134
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 +10 -10
- package/src/domains/credits/application/CreditsInitializer.ts +10 -20
- package/src/domains/credits/application/DeductCreditsCommand.ts +47 -60
- package/src/domains/credits/application/PurchaseMetadataGenerator.ts +24 -30
- package/src/domains/credits/application/credit-strategies/StandardPurchaseCreditStrategy.ts +4 -13
- package/src/domains/credits/application/creditDocumentHelpers.ts +5 -2
- package/src/domains/credits/application/creditOperationUtils.ts +26 -20
- package/src/domains/credits/application/creditOperationUtils.types.ts +2 -1
- package/src/domains/credits/core/CreditsConstants.ts +18 -0
- package/src/domains/credits/core/CreditsMapper.ts +53 -65
- package/src/domains/credits/infrastructure/CreditsRepository.ts +18 -20
- package/src/domains/credits/presentation/useCredits.ts +2 -2
- package/src/domains/credits/presentation/useDeductCredit.ts +2 -2
- package/src/domains/subscription/core/SubscriptionConstants.ts +1 -1
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCard.tsx +1 -1
- package/src/domains/credits/utils/creditCalculations.ts +0 -28
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.134",
|
|
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",
|
|
@@ -2,16 +2,16 @@ import type { CreditsConfig } from "../core/Credits";
|
|
|
2
2
|
import { detectPackageType } from "../../../utils/packageTypeDetector";
|
|
3
3
|
import { getCreditAllocation } from "../../../utils/creditMapper";
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
if (!productId) return config.creditLimit;
|
|
5
|
+
export function calculateCreditLimit(productId: string | undefined, config: CreditsConfig): number {
|
|
6
|
+
if (!productId) return config.creditLimit;
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const explicitAmount = config.creditPackageAmounts?.[productId];
|
|
9
|
+
if (explicitAmount) return explicitAmount;
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
11
|
+
const packageType = detectPackageType(productId);
|
|
12
|
+
const dynamicLimit = getCreditAllocation(packageType, config.packageAllocations);
|
|
13
|
+
|
|
14
|
+
return dynamicLimit ?? config.creditLimit;
|
|
17
15
|
}
|
|
16
|
+
|
|
17
|
+
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import type { CreditsConfig } from "../core/Credits";
|
|
2
|
-
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
3
2
|
import { getAppVersion, validatePlatform } from "../../../utils/appUtils";
|
|
4
3
|
|
|
5
4
|
import type { InitializeCreditsMetadata, InitializationResult } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
6
|
-
import { runTransaction, type Transaction, type DocumentReference } from "firebase/firestore";
|
|
7
|
-
import type { Firestore } from "firebase/firestore";
|
|
5
|
+
import { runTransaction, type Transaction, type DocumentReference, type Firestore } from "firebase/firestore";
|
|
8
6
|
import { getCreditDocumentOrDefault } from "./creditDocumentHelpers";
|
|
9
7
|
import { calculateNewCredits, buildCreditsData, shouldSkipStatusSyncWrite } from "./creditOperationUtils";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
8
|
+
import { calculateCreditLimit } from "./CreditLimitCalculator";
|
|
9
|
+
import { generatePurchaseMetadata } from "./PurchaseMetadataGenerator";
|
|
12
10
|
|
|
13
11
|
export async function initializeCreditsTransaction(
|
|
14
12
|
db: Firestore,
|
|
@@ -17,14 +15,13 @@ export async function initializeCreditsTransaction(
|
|
|
17
15
|
purchaseId: string,
|
|
18
16
|
metadata: InitializeCreditsMetadata
|
|
19
17
|
): Promise<InitializationResult> {
|
|
20
|
-
if (!db)
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
if (!db) throw new Error("Firestore instance is not available");
|
|
19
|
+
|
|
20
|
+
const platform = validatePlatform();
|
|
21
|
+
const appVersion = getAppVersion();
|
|
23
22
|
|
|
24
23
|
return runTransaction(db, async (transaction: Transaction) => {
|
|
25
24
|
const creditsDoc = await transaction.get(creditsRef);
|
|
26
|
-
const platform = validatePlatform();
|
|
27
|
-
|
|
28
25
|
const existingData = getCreditDocumentOrDefault(creditsDoc, platform);
|
|
29
26
|
|
|
30
27
|
if (existingData.processedPurchases.includes(purchaseId)) {
|
|
@@ -35,10 +32,8 @@ export async function initializeCreditsTransaction(
|
|
|
35
32
|
};
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
const creditLimit =
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const { purchaseHistory } = PurchaseMetadataGenerator.generate({
|
|
35
|
+
const creditLimit = calculateCreditLimit(metadata.productId, config);
|
|
36
|
+
const { purchaseHistory } = generatePurchaseMetadata({
|
|
42
37
|
productId: metadata.productId,
|
|
43
38
|
source: metadata.source,
|
|
44
39
|
type: metadata.type,
|
|
@@ -74,15 +69,10 @@ export async function initializeCreditsTransaction(
|
|
|
74
69
|
|
|
75
70
|
transaction.set(creditsRef, creditsData, { merge: true });
|
|
76
71
|
|
|
77
|
-
const finalData: UserCreditsDocumentRead = {
|
|
78
|
-
...existingData,
|
|
79
|
-
...creditsData,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
72
|
return {
|
|
83
73
|
credits: newCredits,
|
|
84
74
|
alreadyProcessed: false,
|
|
85
|
-
finalData
|
|
75
|
+
finalData: { ...existingData, ...creditsData }
|
|
86
76
|
};
|
|
87
77
|
});
|
|
88
78
|
}
|
|
@@ -1,70 +1,57 @@
|
|
|
1
|
-
import { runTransaction, serverTimestamp, type
|
|
2
|
-
import { getFirestore } from "@umituz/react-native-firebase";
|
|
1
|
+
import { runTransaction, serverTimestamp, type Transaction, type DocumentReference, type Firestore } from "firebase/firestore";
|
|
3
2
|
import type { DeductCreditsResult } from "../core/Credits";
|
|
3
|
+
import { CREDIT_ERROR_CODES } from "../core/CreditsConstants";
|
|
4
4
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
5
5
|
|
|
6
|
-
export interface IDeductCreditsCommand {
|
|
7
|
-
execute(userId: string, cost: number): Promise<DeductCreditsResult>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
6
|
/**
|
|
11
|
-
*
|
|
7
|
+
* Deducts credits from a user's balance.
|
|
12
8
|
* Encapsulates the domain rules and transaction logic for credit usage.
|
|
13
9
|
*/
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const current = docSnap.data().credits as number;
|
|
39
|
-
if (current < cost) {
|
|
40
|
-
throw new Error("CREDITS_EXHAUSTED");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const updated = current - cost;
|
|
44
|
-
tx.update(ref, {
|
|
45
|
-
credits: updated,
|
|
46
|
-
lastUpdatedAt: serverTimestamp()
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
return updated;
|
|
10
|
+
export async function deductCreditsOperation(
|
|
11
|
+
db: Firestore,
|
|
12
|
+
creditsRef: DocumentReference,
|
|
13
|
+
cost: number,
|
|
14
|
+
userId: string
|
|
15
|
+
): Promise<DeductCreditsResult> {
|
|
16
|
+
try {
|
|
17
|
+
const remaining = await runTransaction(db, async (tx: Transaction) => {
|
|
18
|
+
const docSnap = await tx.get(creditsRef);
|
|
19
|
+
|
|
20
|
+
if (!docSnap.exists()) {
|
|
21
|
+
throw new Error(CREDIT_ERROR_CODES.NO_CREDITS);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const current = docSnap.data().credits as number;
|
|
25
|
+
if (current < cost) {
|
|
26
|
+
throw new Error(CREDIT_ERROR_CODES.CREDITS_EXHAUSTED);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const updated = current - cost;
|
|
30
|
+
tx.update(creditsRef, {
|
|
31
|
+
credits: updated,
|
|
32
|
+
lastUpdatedAt: serverTimestamp()
|
|
50
33
|
});
|
|
51
34
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
35
|
+
return updated;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
success: true,
|
|
42
|
+
remainingCredits: remaining,
|
|
43
|
+
error: null
|
|
44
|
+
};
|
|
45
|
+
} catch (e: unknown) {
|
|
46
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
47
|
+
const code = (message === CREDIT_ERROR_CODES.NO_CREDITS || message === CREDIT_ERROR_CODES.CREDITS_EXHAUSTED)
|
|
48
|
+
? message
|
|
49
|
+
: CREDIT_ERROR_CODES.DEDUCT_ERR;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
success: false,
|
|
53
|
+
remainingCredits: null,
|
|
54
|
+
error: { message, code }
|
|
55
|
+
};
|
|
69
56
|
}
|
|
70
57
|
}
|
|
@@ -6,48 +6,42 @@ import type {
|
|
|
6
6
|
PurchaseSource
|
|
7
7
|
} from "../core/UserCreditsDocument";
|
|
8
8
|
import { detectPackageType } from "../../../utils/packageTypeDetector";
|
|
9
|
+
import { PACKAGE_TYPE, PURCHASE_TYPE, type Platform } from "../../subscription/core/SubscriptionConstants";
|
|
9
10
|
|
|
10
11
|
export interface MetadataGeneratorConfig {
|
|
11
12
|
productId: string;
|
|
12
13
|
source: PurchaseSource;
|
|
13
14
|
type: PurchaseType;
|
|
14
15
|
creditLimit: number;
|
|
15
|
-
platform:
|
|
16
|
+
platform: Platform;
|
|
16
17
|
appVersion: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const { productId, source, type, creditLimit, platform, appVersion } = config;
|
|
20
|
+
export function generatePurchaseMetadata(
|
|
21
|
+
config: MetadataGeneratorConfig,
|
|
22
|
+
existingData: UserCreditsDocumentRead
|
|
23
|
+
): { purchaseType: PurchaseType; purchaseHistory: PurchaseMetadata[] } {
|
|
24
|
+
const { productId, source, type, creditLimit, platform, appVersion } = config;
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const packageType = detectPackageType(productId);
|
|
27
|
+
let purchaseType: PurchaseType = type;
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
purchaseType = "upgrade";
|
|
33
|
-
} else if (creditLimit < oldLimit) {
|
|
34
|
-
purchaseType = "downgrade";
|
|
35
|
-
}
|
|
36
|
-
}
|
|
29
|
+
if (packageType !== PACKAGE_TYPE.UNKNOWN && creditLimit > existingData.creditLimit) {
|
|
30
|
+
purchaseType = PURCHASE_TYPE.UPGRADE;
|
|
31
|
+
}
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
33
|
+
const newMetadata: PurchaseMetadata = {
|
|
34
|
+
productId,
|
|
35
|
+
packageType,
|
|
36
|
+
creditLimit,
|
|
37
|
+
source,
|
|
38
|
+
type: purchaseType,
|
|
39
|
+
platform,
|
|
40
|
+
appVersion,
|
|
41
|
+
timestamp: Timestamp.fromDate(new Date()),
|
|
42
|
+
};
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
const purchaseHistory = [...existingData.purchaseHistory, newMetadata].slice(-10);
|
|
50
45
|
|
|
51
|
-
|
|
52
|
-
}
|
|
46
|
+
return { purchaseType, purchaseHistory };
|
|
53
47
|
}
|
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
import { ICreditStrategy, type CreditAllocationParams } from "./ICreditStrategy";
|
|
2
2
|
import { isCreditPackage } from "../../../../utils/packageTypeDetector";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Default strategy for new purchases, renewals, or upgrades.
|
|
6
|
-
* Resets credits for subscriptions, but ADDS credits for consumable packages.
|
|
7
|
-
*/
|
|
8
4
|
export class StandardPurchaseCreditStrategy implements ICreditStrategy {
|
|
9
5
|
canHandle(_params: CreditAllocationParams): boolean {
|
|
10
|
-
// This is a catch-all strategy
|
|
11
6
|
return true;
|
|
12
7
|
}
|
|
13
8
|
|
|
14
9
|
execute(params: CreditAllocationParams): number {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Standard subscription behavior: Reset to the calculated limit (e.g. 100/mo)
|
|
22
|
-
return params.creditLimit;
|
|
10
|
+
const isConsumable = params.productId && isCreditPackage(params.productId);
|
|
11
|
+
return isConsumable
|
|
12
|
+
? (params.existingData?.credits ?? 0) + params.creditLimit
|
|
13
|
+
: params.creditLimit;
|
|
23
14
|
}
|
|
24
15
|
}
|
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
7
7
|
import { serverTimestamp, type DocumentSnapshot } from "firebase/firestore";
|
|
8
|
+
import { SUBSCRIPTION_STATUS, type Platform } from "../../subscription/core/SubscriptionConstants";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Get existing credit document or create default
|
|
11
12
|
*/
|
|
12
13
|
export function getCreditDocumentOrDefault(
|
|
13
14
|
creditsDoc: DocumentSnapshot,
|
|
14
|
-
platform:
|
|
15
|
+
platform: Platform
|
|
15
16
|
): UserCreditsDocumentRead {
|
|
16
17
|
if (creditsDoc.exists()) {
|
|
17
18
|
return creditsDoc.data() as UserCreditsDocumentRead;
|
|
@@ -23,7 +24,7 @@ export function getCreditDocumentOrDefault(
|
|
|
23
24
|
credits: 0,
|
|
24
25
|
creditLimit: 0,
|
|
25
26
|
isPremium: false,
|
|
26
|
-
status:
|
|
27
|
+
status: SUBSCRIPTION_STATUS.NONE,
|
|
27
28
|
processedPurchases: [],
|
|
28
29
|
purchaseHistory: [],
|
|
29
30
|
platform,
|
|
@@ -42,6 +43,8 @@ export function getCreditDocumentOrDefault(
|
|
|
42
43
|
trialEndDate: null,
|
|
43
44
|
trialCredits: 0,
|
|
44
45
|
convertedFromTrial: false,
|
|
46
|
+
purchaseSource: null,
|
|
47
|
+
purchaseType: null,
|
|
45
48
|
} as any;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -2,25 +2,27 @@ import { Timestamp, serverTimestamp } from "firebase/firestore";
|
|
|
2
2
|
import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
|
|
3
3
|
import { creditAllocationOrchestrator } from "./credit-strategies/CreditAllocationOrchestrator";
|
|
4
4
|
import { isPast } from "../../../utils/dateUtils";
|
|
5
|
-
|
|
5
|
+
import { isCreditPackage } from "../../../utils/packageTypeDetector";
|
|
6
6
|
import {
|
|
7
7
|
CalculateCreditsParams,
|
|
8
8
|
BuildCreditsDataParams
|
|
9
9
|
} from "./creditOperationUtils.types";
|
|
10
|
+
import { PURCHASE_ID_PREFIXES } from "../core/CreditsConstants";
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
export function calculateNewCredits({ metadata, existingData, creditLimit, purchaseId }: CalculateCreditsParams): number {
|
|
12
|
-
const isPremium = metadata.isPremium;
|
|
13
14
|
const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
|
|
15
|
+
const isPremium = metadata.isPremium;
|
|
14
16
|
const status = resolveSubscriptionStatus({
|
|
15
17
|
isPremium,
|
|
16
18
|
willRenew: metadata.willRenew ?? false,
|
|
17
19
|
isExpired,
|
|
18
20
|
periodType: metadata.periodType ?? undefined,
|
|
19
21
|
});
|
|
20
|
-
|
|
22
|
+
|
|
21
23
|
return creditAllocationOrchestrator.allocate({
|
|
22
24
|
status,
|
|
23
|
-
isStatusSync,
|
|
25
|
+
isStatusSync: purchaseId.startsWith(PURCHASE_ID_PREFIXES.STATUS_SYNC),
|
|
24
26
|
existingData,
|
|
25
27
|
creditLimit,
|
|
26
28
|
isSubscriptionActive: isPremium && !isExpired,
|
|
@@ -31,8 +33,13 @@ export function calculateNewCredits({ metadata, existingData, creditLimit, purch
|
|
|
31
33
|
export function buildCreditsData({
|
|
32
34
|
existingData, newCredits, creditLimit, purchaseId, metadata, purchaseHistory, platform
|
|
33
35
|
}: BuildCreditsDataParams): Record<string, any> {
|
|
34
|
-
const
|
|
36
|
+
const isConsumable = isCreditPackage(metadata.productId ?? "");
|
|
37
|
+
const isPremium = isConsumable ? (existingData?.isPremium ?? metadata.isPremium) : metadata.isPremium;
|
|
35
38
|
const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
|
|
39
|
+
const resolvedCreditLimit = isConsumable
|
|
40
|
+
? (existingData?.creditLimit || creditLimit)
|
|
41
|
+
: creditLimit;
|
|
42
|
+
|
|
36
43
|
const status = resolveSubscriptionStatus({
|
|
37
44
|
isPremium,
|
|
38
45
|
willRenew: metadata.willRenew ?? false,
|
|
@@ -40,24 +47,24 @@ export function buildCreditsData({
|
|
|
40
47
|
periodType: metadata.periodType ?? undefined,
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
const
|
|
50
|
+
const isPurchaseOrRenewal = purchaseId.startsWith(PURCHASE_ID_PREFIXES.PURCHASE) ||
|
|
51
|
+
purchaseId.startsWith(PURCHASE_ID_PREFIXES.RENEWAL);
|
|
52
|
+
|
|
53
|
+
return {
|
|
44
54
|
isPremium,
|
|
45
55
|
status,
|
|
46
56
|
credits: newCredits,
|
|
47
|
-
creditLimit,
|
|
57
|
+
creditLimit: resolvedCreditLimit,
|
|
48
58
|
lastUpdatedAt: serverTimestamp(),
|
|
49
59
|
processedPurchases: [...(existingData?.processedPurchases ?? []), purchaseId].slice(-50),
|
|
50
60
|
productId: metadata.productId,
|
|
51
61
|
platform,
|
|
62
|
+
...(purchaseHistory.length > 0 && { purchaseHistory }),
|
|
63
|
+
...(isPurchaseOrRenewal && { lastPurchaseAt: serverTimestamp() }),
|
|
64
|
+
...(metadata.expirationDate && { expirationDate: Timestamp.fromDate(new Date(metadata.expirationDate)) }),
|
|
65
|
+
...(metadata.willRenew !== undefined && { willRenew: metadata.willRenew }),
|
|
66
|
+
...(metadata.originalTransactionId && { originalTransactionId: metadata.originalTransactionId }),
|
|
52
67
|
};
|
|
53
|
-
|
|
54
|
-
if (purchaseHistory.length > 0) creditsData.purchaseHistory = purchaseHistory;
|
|
55
|
-
if (purchaseId.startsWith("purchase_") || purchaseId.startsWith("renewal_")) creditsData.lastPurchaseAt = serverTimestamp();
|
|
56
|
-
if (metadata.expirationDate) creditsData.expirationDate = Timestamp.fromDate(new Date(metadata.expirationDate));
|
|
57
|
-
if (metadata.willRenew !== undefined) creditsData.willRenew = metadata.willRenew;
|
|
58
|
-
if (metadata.originalTransactionId) creditsData.originalTransactionId = metadata.originalTransactionId;
|
|
59
|
-
|
|
60
|
-
return creditsData;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
export function shouldSkipStatusSyncWrite(
|
|
@@ -65,12 +72,11 @@ export function shouldSkipStatusSyncWrite(
|
|
|
65
72
|
existingData: any,
|
|
66
73
|
newCreditsData: Record<string, any>
|
|
67
74
|
): boolean {
|
|
68
|
-
if (!purchaseId.startsWith(
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
if (!purchaseId.startsWith(PURCHASE_ID_PREFIXES.STATUS_SYNC)) return false;
|
|
76
|
+
|
|
77
|
+
return existingData.isPremium === newCreditsData.isPremium &&
|
|
71
78
|
existingData.status === newCreditsData.status &&
|
|
72
79
|
existingData.credits === newCreditsData.credits &&
|
|
73
80
|
existingData.creditLimit === newCreditsData.creditLimit &&
|
|
74
|
-
existingData.productId === newCreditsData.productId
|
|
75
|
-
);
|
|
81
|
+
existingData.productId === newCreditsData.productId;
|
|
76
82
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
2
2
|
import type { InitializeCreditsMetadata } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
3
|
+
import type { Platform } from "../../subscription/core/SubscriptionConstants";
|
|
3
4
|
|
|
4
5
|
export interface CalculateCreditsParams {
|
|
5
6
|
metadata: InitializeCreditsMetadata;
|
|
@@ -15,5 +16,5 @@ export interface BuildCreditsDataParams {
|
|
|
15
16
|
purchaseId: string;
|
|
16
17
|
metadata: InitializeCreditsMetadata;
|
|
17
18
|
purchaseHistory: any[];
|
|
18
|
-
platform:
|
|
19
|
+
platform: Platform;
|
|
19
20
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit Error Codes
|
|
3
|
+
*/
|
|
4
|
+
export const CREDIT_ERROR_CODES = {
|
|
5
|
+
NO_CREDITS: 'NO_CREDITS',
|
|
6
|
+
CREDITS_EXHAUSTED: 'CREDITS_EXHAUSTED',
|
|
7
|
+
DEDUCT_ERR: 'DEDUCT_ERR',
|
|
8
|
+
DB_ERROR: 'ERR',
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Purchase ID Prefixes
|
|
13
|
+
*/
|
|
14
|
+
export const PURCHASE_ID_PREFIXES = {
|
|
15
|
+
STATUS_SYNC: 'status_sync_',
|
|
16
|
+
PURCHASE: 'purchase_',
|
|
17
|
+
RENEWAL: 'renewal_',
|
|
18
|
+
} as const;
|
|
@@ -2,76 +2,64 @@ import type { UserCredits } from "./Credits";
|
|
|
2
2
|
import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
|
|
3
3
|
import type { PeriodType, SubscriptionStatusType } from "../../subscription/core/SubscriptionConstants";
|
|
4
4
|
import type { UserCreditsDocumentRead } from "./UserCreditsDocument";
|
|
5
|
-
|
|
6
5
|
import { toSafeDate } from "../../../utils/dateUtils";
|
|
7
6
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
isPremium,
|
|
20
|
-
status,
|
|
21
|
-
|
|
22
|
-
// Dates
|
|
23
|
-
purchasedAt: toSafeDate(doc.purchasedAt) ?? new Date(),
|
|
24
|
-
expirationDate,
|
|
25
|
-
lastUpdatedAt: toSafeDate(doc.lastUpdatedAt) ?? new Date(),
|
|
26
|
-
lastPurchaseAt: toSafeDate(doc.lastPurchaseAt),
|
|
7
|
+
/**
|
|
8
|
+
* Validate subscription status against expirationDate and periodType
|
|
9
|
+
*/
|
|
10
|
+
function validateSubscription(
|
|
11
|
+
doc: UserCreditsDocumentRead,
|
|
12
|
+
expirationDate: Date | null,
|
|
13
|
+
periodType: PeriodType | null
|
|
14
|
+
): { isPremium: boolean; status: SubscriptionStatusType } {
|
|
15
|
+
const isPremium = doc.isPremium;
|
|
16
|
+
const willRenew = doc.willRenew ?? false;
|
|
17
|
+
const isExpired = expirationDate ? expirationDate < new Date() : false;
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
const status = resolveSubscriptionStatus({
|
|
20
|
+
isPremium,
|
|
21
|
+
willRenew,
|
|
22
|
+
isExpired,
|
|
23
|
+
periodType: periodType ?? undefined,
|
|
24
|
+
});
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
trialCredits: doc.trialCredits,
|
|
40
|
-
convertedFromTrial: doc.convertedFromTrial,
|
|
41
|
-
|
|
42
|
-
// Credits
|
|
43
|
-
credits: doc.credits,
|
|
44
|
-
creditLimit: doc.creditLimit,
|
|
45
|
-
|
|
46
|
-
// Metadata
|
|
47
|
-
purchaseSource: doc.purchaseSource,
|
|
48
|
-
purchaseType: doc.purchaseType,
|
|
49
|
-
platform: doc.platform,
|
|
50
|
-
appVersion: doc.appVersion,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
26
|
+
return {
|
|
27
|
+
isPremium: isExpired ? false : isPremium,
|
|
28
|
+
status,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
53
31
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const isPremium = doc.isPremium;
|
|
61
|
-
const willRenew = doc.willRenew ?? false;
|
|
62
|
-
const isExpired = expirationDate ? expirationDate < new Date() : false;
|
|
32
|
+
/**
|
|
33
|
+
* Maps Firestore document to domain entity with expiration validation
|
|
34
|
+
*/
|
|
35
|
+
export function mapCreditsDocumentToEntity(doc: UserCreditsDocumentRead): UserCredits {
|
|
36
|
+
const expirationDate = toSafeDate(doc.expirationDate);
|
|
37
|
+
const periodType = doc.periodType;
|
|
63
38
|
|
|
64
|
-
|
|
65
|
-
isPremium,
|
|
66
|
-
willRenew,
|
|
67
|
-
isExpired,
|
|
68
|
-
periodType: periodType ?? undefined,
|
|
69
|
-
});
|
|
39
|
+
const { isPremium, status } = validateSubscription(doc, expirationDate, periodType);
|
|
70
40
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
41
|
+
return {
|
|
42
|
+
isPremium,
|
|
43
|
+
status,
|
|
44
|
+
purchasedAt: toSafeDate(doc.purchasedAt) ?? new Date(),
|
|
45
|
+
expirationDate,
|
|
46
|
+
lastUpdatedAt: toSafeDate(doc.lastUpdatedAt) ?? new Date(),
|
|
47
|
+
lastPurchaseAt: toSafeDate(doc.lastPurchaseAt),
|
|
48
|
+
willRenew: doc.willRenew,
|
|
49
|
+
productId: doc.productId,
|
|
50
|
+
packageType: doc.packageType,
|
|
51
|
+
originalTransactionId: doc.originalTransactionId,
|
|
52
|
+
periodType,
|
|
53
|
+
isTrialing: doc.isTrialing,
|
|
54
|
+
trialStartDate: toSafeDate(doc.trialStartDate),
|
|
55
|
+
trialEndDate: toSafeDate(doc.trialEndDate),
|
|
56
|
+
trialCredits: doc.trialCredits,
|
|
57
|
+
convertedFromTrial: doc.convertedFromTrial,
|
|
58
|
+
credits: doc.credits,
|
|
59
|
+
creditLimit: doc.creditLimit,
|
|
60
|
+
purchaseSource: doc.purchaseSource,
|
|
61
|
+
purchaseType: doc.purchaseType,
|
|
62
|
+
platform: doc.platform,
|
|
63
|
+
appVersion: doc.appVersion,
|
|
64
|
+
};
|
|
77
65
|
}
|
|
@@ -1,36 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
* Credits Repository
|
|
3
|
-
* Optimized to use Design Patterns: Command, Observer, and Strategy.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { getDoc, setDoc, type Firestore } from "firebase/firestore";
|
|
1
|
+
import { getDoc, setDoc, type Firestore, type DocumentReference } from "firebase/firestore";
|
|
7
2
|
import { BaseRepository } from "@umituz/react-native-firebase";
|
|
8
3
|
import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../core/Credits";
|
|
9
4
|
import type { UserCreditsDocumentRead, PurchaseSource } from "../core/UserCreditsDocument";
|
|
10
5
|
import { initializeCreditsTransaction } from "../application/CreditsInitializer";
|
|
11
|
-
import {
|
|
6
|
+
import { mapCreditsDocumentToEntity } from "../core/CreditsMapper";
|
|
12
7
|
import type { RevenueCatData } from "../../subscription/core/RevenueCatData";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
8
|
+
import { deductCreditsOperation } from "../application/DeductCreditsCommand";
|
|
9
|
+
import { calculateCreditLimit } from "../application/CreditLimitCalculator";
|
|
15
10
|
import { PURCHASE_TYPE, type PurchaseType } from "../../subscription/core/SubscriptionConstants";
|
|
16
11
|
import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore";
|
|
12
|
+
import { SUBSCRIPTION_STATUS } from "../../subscription/core/SubscriptionConstants";
|
|
17
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Credits Repository
|
|
16
|
+
* Provides domain-specific database operations for credits system.
|
|
17
|
+
*/
|
|
18
18
|
export class CreditsRepository extends BaseRepository {
|
|
19
|
-
private deductCommand: DeductCreditsCommand;
|
|
20
|
-
|
|
21
19
|
constructor(private config: CreditsConfig) {
|
|
22
20
|
super();
|
|
23
|
-
this.deductCommand = new DeductCreditsCommand((db, uid) => this.getRef(db, uid));
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
private getCollectionConfig(): CollectionConfig {
|
|
27
24
|
return {
|
|
28
|
-
collectionName:
|
|
25
|
+
collectionName: this.config.collectionName,
|
|
29
26
|
useUserSubcollection: this.config.useUserSubcollection,
|
|
30
27
|
};
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
private getRef(db: Firestore, userId: string) {
|
|
30
|
+
private getRef(db: Firestore, userId: string): DocumentReference {
|
|
34
31
|
const config = this.getCollectionConfig();
|
|
35
32
|
return buildDocRef(db, userId, "balance", config);
|
|
36
33
|
}
|
|
@@ -43,7 +40,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
43
40
|
return { success: true, data: null, error: null };
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
const entity =
|
|
43
|
+
const entity = mapCreditsDocumentToEntity(snap.data() as UserCreditsDocumentRead);
|
|
47
44
|
return { success: true, data: entity, error: null };
|
|
48
45
|
}
|
|
49
46
|
|
|
@@ -56,7 +53,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
56
53
|
type: PurchaseType = PURCHASE_TYPE.INITIAL
|
|
57
54
|
): Promise<CreditsResult> {
|
|
58
55
|
const db = requireFirestore();
|
|
59
|
-
const creditLimit =
|
|
56
|
+
const creditLimit = calculateCreditLimit(productId, this.config);
|
|
60
57
|
const cfg = { ...this.config, creditLimit };
|
|
61
58
|
|
|
62
59
|
const result = await initializeCreditsTransaction(
|
|
@@ -78,16 +75,17 @@ export class CreditsRepository extends BaseRepository {
|
|
|
78
75
|
|
|
79
76
|
return {
|
|
80
77
|
success: true,
|
|
81
|
-
data: result.finalData ?
|
|
78
|
+
data: result.finalData ? mapCreditsDocumentToEntity(result.finalData) : null,
|
|
82
79
|
error: null,
|
|
83
80
|
};
|
|
84
81
|
}
|
|
85
82
|
|
|
86
83
|
/**
|
|
87
|
-
*
|
|
84
|
+
* Deducts credits using atomic transaction logic.
|
|
88
85
|
*/
|
|
89
86
|
async deductCredit(userId: string, cost: number): Promise<DeductCreditsResult> {
|
|
90
|
-
|
|
87
|
+
const db = requireFirestore();
|
|
88
|
+
return deductCreditsOperation(db, this.getRef(db, userId), cost, userId);
|
|
91
89
|
}
|
|
92
90
|
|
|
93
91
|
async hasCredits(userId: string, cost: number): Promise<boolean> {
|
|
@@ -101,7 +99,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
101
99
|
const ref = this.getRef(db, userId);
|
|
102
100
|
await setDoc(ref, {
|
|
103
101
|
isPremium: false,
|
|
104
|
-
status:
|
|
102
|
+
status: SUBSCRIPTION_STATUS.EXPIRED,
|
|
105
103
|
willRenew: false,
|
|
106
104
|
expirationDate: new Date().toISOString()
|
|
107
105
|
}, { merge: true });
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
getCreditsConfig,
|
|
9
9
|
isCreditsRepositoryConfigured,
|
|
10
10
|
} from "../infrastructure/CreditsRepositoryManager";
|
|
11
|
-
import { calculateCreditPercentage,
|
|
11
|
+
import { calculateCreditPercentage, canAfford as canAffordCheck } from "../../../shared/utils/numberUtils";
|
|
12
12
|
|
|
13
13
|
export const creditsQueryKeys = {
|
|
14
14
|
all: ["credits"] as const,
|
|
@@ -90,7 +90,7 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
90
90
|
}, [credits, config?.creditLimit]);
|
|
91
91
|
|
|
92
92
|
const canAfford = useCallback(
|
|
93
|
-
(cost: number): boolean =>
|
|
93
|
+
(cost: number): boolean => canAffordCheck(credits?.credits, cost),
|
|
94
94
|
[credits]
|
|
95
95
|
);
|
|
96
96
|
|
|
@@ -8,7 +8,7 @@ import { useMutation, useQueryClient } from "@umituz/react-native-design-system"
|
|
|
8
8
|
import type { UserCredits } from "../core/Credits";
|
|
9
9
|
import { getCreditsRepository } from "../infrastructure/CreditsRepositoryManager";
|
|
10
10
|
import { creditsQueryKeys } from "./useCredits";
|
|
11
|
-
import {
|
|
11
|
+
import { calculateRemaining } from "../../../shared/utils/numberUtils";
|
|
12
12
|
|
|
13
13
|
import { timezoneService } from "@umituz/react-native-design-system";
|
|
14
14
|
|
|
@@ -48,7 +48,7 @@ export const useDeductCredit = ({
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Calculate new credits using utility
|
|
51
|
-
const newCredits =
|
|
51
|
+
const newCredits = calculateRemaining(previousCredits.credits, cost);
|
|
52
52
|
|
|
53
53
|
queryClient.setQueryData<UserCredits | null>(creditsQueryKeys.user(userId), (old) => {
|
|
54
54
|
if (!old) return old;
|
|
@@ -28,7 +28,7 @@ export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
|
|
|
28
28
|
onUpgrade,
|
|
29
29
|
}) => {
|
|
30
30
|
const tokens = useAppDesignTokens();
|
|
31
|
-
const showCredits = credits && credits.length > 0;
|
|
31
|
+
const showCredits = isPremium && credits && credits.length > 0;
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
34
|
<View style={[styles.card, { backgroundColor: tokens.colors.surface }]}>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credit Calculation Utilities
|
|
3
|
-
* Centralized logic for credit mathematical operations
|
|
4
|
-
* Uses shared number utilities for consistency
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { calculateCreditPercentage as calcPct, canAfford as canAffordCheck, calculateRemaining } from "../../../shared/utils/numberUtils";
|
|
8
|
-
|
|
9
|
-
export const calculateCreditPercentage = (
|
|
10
|
-
currentCredits: number | null | undefined,
|
|
11
|
-
creditLimit: number
|
|
12
|
-
): number => {
|
|
13
|
-
return calcPct(currentCredits, creditLimit);
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export const canAffordCost = (
|
|
17
|
-
currentCredits: number | null | undefined,
|
|
18
|
-
cost: number
|
|
19
|
-
): boolean => {
|
|
20
|
-
return canAffordCheck(currentCredits, cost);
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export const calculateRemainingCredits = (
|
|
24
|
-
currentCredits: number,
|
|
25
|
-
cost: number
|
|
26
|
-
): number => {
|
|
27
|
-
return calculateRemaining(currentCredits, cost);
|
|
28
|
-
};
|