@umituz/react-native-subscription 2.14.94 → 2.14.96
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 +7 -8
- package/src/domain/entities/Credits.ts +18 -8
- package/src/domain/entities/SubscriptionStatus.ts +4 -6
- package/src/domains/wallet/presentation/components/TransactionItem.tsx +2 -8
- package/src/domains/wallet/presentation/hooks/useWallet.ts +3 -9
- package/src/infrastructure/mappers/CreditsMapper.ts +4 -11
- package/src/infrastructure/models/UserCreditsDocument.ts +1 -2
- package/src/infrastructure/repositories/CreditsRepository.ts +14 -14
- package/src/infrastructure/services/ActivationHandler.ts +4 -7
- package/src/infrastructure/services/CreditsInitializer.ts +7 -12
- package/src/infrastructure/services/SubscriptionService.ts +2 -1
- package/src/presentation/hooks/useCreditChecker.ts +9 -9
- package/src/presentation/hooks/useCredits.ts +16 -32
- package/src/presentation/hooks/useDeductCredit.ts +18 -16
- package/src/presentation/hooks/useDevTestCallbacks.ts +4 -5
- package/src/presentation/hooks/useSubscriptionSettingsConfig.ts +2 -2
- package/src/presentation/hooks/useSubscriptionSettingsConfig.utils.ts +6 -9
- package/src/presentation/types/SubscriptionSettingsTypes.ts +4 -4
- package/src/presentation/utils/subscriptionDateUtils.ts +5 -7
- package/src/utils/aiCreditHelpers.ts +19 -20
- package/src/utils/creditChecker.ts +11 -18
- package/src/utils/creditMapper.ts +12 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.96",
|
|
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",
|
|
@@ -32,10 +32,9 @@
|
|
|
32
32
|
"url": "git+https://github.com/umituz/react-native-subscription.git"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@umituz/react-native-design-system": "latest",
|
|
36
35
|
"@umituz/react-native-firebase": "latest",
|
|
37
|
-
"@umituz/react-native-
|
|
38
|
-
"@umituz/react-native-
|
|
36
|
+
"@umituz/react-native-storage": "latest",
|
|
37
|
+
"@umituz/react-native-timezone": "latest"
|
|
39
38
|
},
|
|
40
39
|
"peerDependencies": {
|
|
41
40
|
"@tanstack/react-query": ">=5.0.0",
|
|
@@ -60,12 +59,12 @@
|
|
|
60
59
|
"@types/react": "~19.1.10",
|
|
61
60
|
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
62
61
|
"@typescript-eslint/parser": "^8.50.1",
|
|
63
|
-
"@umituz/react-native-auth": "
|
|
64
|
-
"@umituz/react-native-design-system": "
|
|
65
|
-
"@umituz/react-native-filesystem": "^2.1.
|
|
62
|
+
"@umituz/react-native-auth": "^3.4.26",
|
|
63
|
+
"@umituz/react-native-design-system": "^2.6.82",
|
|
64
|
+
"@umituz/react-native-filesystem": "^2.1.21",
|
|
66
65
|
"@umituz/react-native-firebase": "*",
|
|
67
66
|
"@umituz/react-native-haptics": "^1.0.5",
|
|
68
|
-
"@umituz/react-native-localization": "
|
|
67
|
+
"@umituz/react-native-localization": "^3.5.52",
|
|
69
68
|
"@umituz/react-native-storage": "*",
|
|
70
69
|
"@umituz/react-native-uuid": "^1.2.6",
|
|
71
70
|
"eslint": "^9.39.2",
|
|
@@ -5,23 +5,34 @@
|
|
|
5
5
|
* Designed to be used across hundreds of apps with configurable limits.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { SubscriptionPackageType } from "../../utils/packageTypeDetector";
|
|
9
|
+
|
|
8
10
|
export type CreditType = "text" | "image";
|
|
9
11
|
|
|
10
12
|
export interface UserCredits {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
credits: number;
|
|
14
|
+
purchasedAt: Date | null;
|
|
15
|
+
lastUpdatedAt: Date | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CreditAllocation {
|
|
19
|
+
credits: number;
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
export type PackageAllocationMap = Record<
|
|
23
|
+
Exclude<SubscriptionPackageType, "unknown">,
|
|
24
|
+
CreditAllocation
|
|
25
|
+
>;
|
|
26
|
+
|
|
17
27
|
export interface CreditsConfig {
|
|
18
28
|
collectionName: string;
|
|
19
|
-
|
|
20
|
-
imageCreditLimit: number;
|
|
29
|
+
creditLimit: number;
|
|
21
30
|
/** When true, stores credits at users/{userId}/credits instead of {collectionName}/{userId} */
|
|
22
31
|
useUserSubcollection?: boolean;
|
|
23
32
|
/** Credit amounts per product ID for consumable credit packages */
|
|
24
33
|
creditPackageAmounts?: Record<string, number>;
|
|
34
|
+
/** Credit allocations for different subscription types (weekly, monthly, yearly) */
|
|
35
|
+
packageAllocations?: PackageAllocationMap;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export interface CreditsResult<T = UserCredits> {
|
|
@@ -44,6 +55,5 @@ export interface DeductCreditsResult {
|
|
|
44
55
|
|
|
45
56
|
export const DEFAULT_CREDITS_CONFIG: CreditsConfig = {
|
|
46
57
|
collectionName: "user_credits",
|
|
47
|
-
|
|
48
|
-
imageCreditLimit: 100,
|
|
58
|
+
creditLimit: 100,
|
|
49
59
|
};
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Subscription Status Entity
|
|
3
|
-
*/
|
|
1
|
+
import { timezoneService } from "@umituz/react-native-timezone";
|
|
4
2
|
|
|
5
3
|
export const SUBSCRIPTION_STATUS = {
|
|
6
4
|
ACTIVE: 'active',
|
|
@@ -34,12 +32,12 @@ export const createDefaultSubscriptionStatus = (): SubscriptionStatus => ({
|
|
|
34
32
|
export const isSubscriptionValid = (status: SubscriptionStatus | null): boolean => {
|
|
35
33
|
if (!status || !status.isPremium) return false;
|
|
36
34
|
if (!status.expiresAt) return true; // Lifetime
|
|
37
|
-
|
|
35
|
+
|
|
36
|
+
return timezoneService.isFuture(new Date(status.expiresAt));
|
|
38
37
|
};
|
|
39
38
|
|
|
40
39
|
export const calculateDaysRemaining = (expiresAt: string | null): number | null => {
|
|
41
40
|
if (!expiresAt) return null;
|
|
42
|
-
|
|
43
|
-
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
|
|
41
|
+
return timezoneService.getDaysUntil(new Date(expiresAt));
|
|
44
42
|
};
|
|
45
43
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
AtomicText,
|
|
13
13
|
AtomicIcon,
|
|
14
14
|
} from "@umituz/react-native-design-system";
|
|
15
|
+
import { timezoneService } from "@umituz/react-native-timezone";
|
|
15
16
|
import type { CreditLog, TransactionReason } from "../../domain/types/transaction.types";
|
|
16
17
|
|
|
17
18
|
export interface TransactionItemTranslations {
|
|
@@ -46,14 +47,7 @@ const getReasonIcon = (reason: TransactionReason): string => {
|
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const defaultDateFormatter = (timestamp: number): string => {
|
|
49
|
-
|
|
50
|
-
const d = String(date.getDate()).padStart(2, '0');
|
|
51
|
-
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
52
|
-
const y = date.getFullYear();
|
|
53
|
-
const th = String(date.getHours()).padStart(2, '0');
|
|
54
|
-
const tm = String(date.getMinutes()).padStart(2, '0');
|
|
55
|
-
|
|
56
|
-
return `${d}.${m}.${y} ${th}:${tm}`;
|
|
50
|
+
return timezoneService.formatToDisplayDateTime(new Date(timestamp));
|
|
57
51
|
};
|
|
58
52
|
|
|
59
53
|
export const TransactionItem: React.FC<TransactionItemProps> = ({
|
|
@@ -25,8 +25,6 @@ export interface UseWalletParams {
|
|
|
25
25
|
|
|
26
26
|
export interface UseWalletResult {
|
|
27
27
|
balance: number;
|
|
28
|
-
textCredits: number;
|
|
29
|
-
imageCredits: number;
|
|
30
28
|
balanceLoading: boolean;
|
|
31
29
|
transactions: CreditLog[];
|
|
32
30
|
transactionsLoading: boolean;
|
|
@@ -58,8 +56,7 @@ export function useWallet({
|
|
|
58
56
|
credits,
|
|
59
57
|
isLoading: balanceLoading,
|
|
60
58
|
refetch: refetchBalance,
|
|
61
|
-
|
|
62
|
-
hasImageCredits,
|
|
59
|
+
hasCredits,
|
|
63
60
|
} = useCredits(creditsParams);
|
|
64
61
|
|
|
65
62
|
const {
|
|
@@ -69,8 +66,7 @@ export function useWallet({
|
|
|
69
66
|
} = useTransactionHistory(transactionParams);
|
|
70
67
|
|
|
71
68
|
const balance = useMemo(() => {
|
|
72
|
-
|
|
73
|
-
return credits.textCredits + credits.imageCredits;
|
|
69
|
+
return credits?.credits ?? 0;
|
|
74
70
|
}, [credits]);
|
|
75
71
|
|
|
76
72
|
const refetchAll = useCallback(() => {
|
|
@@ -80,12 +76,10 @@ export function useWallet({
|
|
|
80
76
|
|
|
81
77
|
return {
|
|
82
78
|
balance,
|
|
83
|
-
textCredits: credits?.textCredits ?? 0,
|
|
84
|
-
imageCredits: credits?.imageCredits ?? 0,
|
|
85
79
|
balanceLoading,
|
|
86
80
|
transactions,
|
|
87
81
|
transactionsLoading,
|
|
88
|
-
hasCredits
|
|
82
|
+
hasCredits,
|
|
89
83
|
refetchBalance,
|
|
90
84
|
refetchTransactions,
|
|
91
85
|
refetchAll,
|
|
@@ -1,25 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credits Mapper
|
|
3
|
-
* Maps Firestore data to UserCredits entity and vice-versa.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import type { UserCredits } from "../../domain/entities/Credits";
|
|
7
2
|
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
8
3
|
|
|
9
4
|
export class CreditsMapper {
|
|
10
5
|
static toEntity(snapData: UserCreditsDocumentRead): UserCredits {
|
|
11
6
|
return {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
lastUpdatedAt: snapData.lastUpdatedAt?.toDate?.() || new Date(),
|
|
7
|
+
credits: snapData.credits,
|
|
8
|
+
purchasedAt: snapData.purchasedAt?.toDate?.() || null,
|
|
9
|
+
lastUpdatedAt: snapData.lastUpdatedAt?.toDate?.() || null,
|
|
16
10
|
};
|
|
17
11
|
}
|
|
18
12
|
|
|
19
13
|
static toFirestore(data: Partial<UserCredits>): Record<string, any> {
|
|
20
14
|
return {
|
|
21
|
-
|
|
22
|
-
imageCredits: data.imageCredits,
|
|
15
|
+
credits: data.credits,
|
|
23
16
|
// Timestamps are usually handled by serverTimestamp() in repos,
|
|
24
17
|
// but we can map them if needed.
|
|
25
18
|
};
|
|
@@ -5,8 +5,7 @@ export interface FirestoreTimestamp {
|
|
|
5
5
|
|
|
6
6
|
// Document structure when READING from Firestore
|
|
7
7
|
export interface UserCreditsDocumentRead {
|
|
8
|
-
|
|
9
|
-
imageCredits: number;
|
|
8
|
+
credits: number;
|
|
10
9
|
purchasedAt?: FirestoreTimestamp;
|
|
11
10
|
lastUpdatedAt?: FirestoreTimestamp;
|
|
12
11
|
lastPurchaseAt?: FirestoreTimestamp;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { doc, getDoc, runTransaction, serverTimestamp, type Firestore, type Transaction } from "firebase/firestore";
|
|
6
6
|
import { BaseRepository, getFirestore } from "@umituz/react-native-firebase";
|
|
7
|
-
import type {
|
|
7
|
+
import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../../domain/entities/Credits";
|
|
8
8
|
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
9
9
|
import { initializeCreditsTransaction } from "../services/CreditsInitializer";
|
|
10
10
|
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
@@ -39,10 +39,11 @@ export class CreditsRepository extends BaseRepository {
|
|
|
39
39
|
let cfg = { ...this.config };
|
|
40
40
|
if (productId) {
|
|
41
41
|
const amt = this.config.creditPackageAmounts?.[productId];
|
|
42
|
-
if (amt) cfg = { ...cfg,
|
|
42
|
+
if (amt) cfg = { ...cfg, creditLimit: amt };
|
|
43
43
|
else {
|
|
44
|
-
const
|
|
45
|
-
|
|
44
|
+
const packageType = detectPackageType(productId);
|
|
45
|
+
const dynamicLimit = getCreditAllocation(packageType, this.config.packageAllocations);
|
|
46
|
+
if (dynamicLimit !== null) cfg = { ...cfg, creditLimit: dynamicLimit };
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
const res = await initializeCreditsTransaction(db, this.getRef(db, userId), cfg, purchaseId);
|
|
@@ -50,25 +51,24 @@ export class CreditsRepository extends BaseRepository {
|
|
|
50
51
|
success: true,
|
|
51
52
|
data: CreditsMapper.toEntity({
|
|
52
53
|
...res,
|
|
53
|
-
purchasedAt:
|
|
54
|
-
lastUpdatedAt:
|
|
54
|
+
purchasedAt: undefined,
|
|
55
|
+
lastUpdatedAt: undefined,
|
|
55
56
|
})
|
|
56
57
|
};
|
|
57
58
|
} catch (e: any) { return { success: false, error: { message: e.message, code: "INIT_ERR" } }; }
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
async deductCredit(userId: string,
|
|
61
|
+
async deductCredit(userId: string, cost: number = 1): Promise<DeductCreditsResult> {
|
|
61
62
|
const db = getFirestore();
|
|
62
63
|
if (!db) return { success: false, error: { message: "No DB", code: "ERR" } };
|
|
63
|
-
const field = type === "text" ? "textCredits" : "imageCredits";
|
|
64
64
|
try {
|
|
65
65
|
const remaining = await runTransaction(db, async (tx: Transaction) => {
|
|
66
66
|
const docSnap = await tx.get(this.getRef(db, userId));
|
|
67
67
|
if (!docSnap.exists()) throw new Error("NO_CREDITS");
|
|
68
|
-
const current = docSnap.data()
|
|
69
|
-
if (current
|
|
70
|
-
const updated = current -
|
|
71
|
-
tx.update(this.getRef(db, userId), {
|
|
68
|
+
const current = docSnap.data().credits as number;
|
|
69
|
+
if (current < cost) throw new Error("CREDITS_EXHAUSTED");
|
|
70
|
+
const updated = current - cost;
|
|
71
|
+
tx.update(this.getRef(db, userId), { credits: updated, lastUpdatedAt: serverTimestamp() });
|
|
72
72
|
return updated;
|
|
73
73
|
});
|
|
74
74
|
return { success: true, remainingCredits: remaining };
|
|
@@ -78,9 +78,9 @@ export class CreditsRepository extends BaseRepository {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
async hasCredits(userId: string,
|
|
81
|
+
async hasCredits(userId: string, cost: number = 1): Promise<boolean> {
|
|
82
82
|
const res = await this.getCredits(userId);
|
|
83
|
-
return !!(res.success && res.data &&
|
|
83
|
+
return !!(res.success && res.data && res.data.credits >= cost);
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Activation Handler
|
|
3
|
-
* Handles subscription activation and deactivation
|
|
4
|
-
*/
|
|
5
|
-
|
|
1
|
+
import { timezoneService } from "@umituz/react-native-timezone";
|
|
6
2
|
import type { ISubscriptionRepository } from "../../application/ports/ISubscriptionRepository";
|
|
7
3
|
import type { SubscriptionStatus } from "../../domain/entities/SubscriptionStatus";
|
|
8
4
|
import { SubscriptionRepositoryError } from "../../domain/errors/SubscriptionError";
|
|
@@ -32,11 +28,12 @@ export async function activateSubscription(
|
|
|
32
28
|
isPremium: true,
|
|
33
29
|
productId,
|
|
34
30
|
expiresAt,
|
|
35
|
-
purchasedAt:
|
|
36
|
-
syncedAt:
|
|
31
|
+
purchasedAt: timezoneService.getCurrentISOString(),
|
|
32
|
+
syncedAt: timezoneService.getCurrentISOString(),
|
|
37
33
|
}
|
|
38
34
|
);
|
|
39
35
|
|
|
36
|
+
|
|
40
37
|
await notifyStatusChange(config, userId, updatedStatus);
|
|
41
38
|
return updatedStatus;
|
|
42
39
|
} catch (error) {
|
|
@@ -10,8 +10,7 @@ import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
|
10
10
|
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
11
11
|
|
|
12
12
|
interface InitializationResult {
|
|
13
|
-
|
|
14
|
-
imageCredits: number;
|
|
13
|
+
credits: number;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export async function initializeCreditsTransaction(
|
|
@@ -24,8 +23,7 @@ export async function initializeCreditsTransaction(
|
|
|
24
23
|
const creditsDoc = await transaction.get(creditsRef);
|
|
25
24
|
const now = serverTimestamp();
|
|
26
25
|
|
|
27
|
-
let
|
|
28
|
-
let newImageCredits = config.imageCreditLimit;
|
|
26
|
+
let newCredits = config.creditLimit;
|
|
29
27
|
let purchasedAt = now;
|
|
30
28
|
let processedPurchases: string[] = [];
|
|
31
29
|
|
|
@@ -35,14 +33,12 @@ export async function initializeCreditsTransaction(
|
|
|
35
33
|
|
|
36
34
|
if (purchaseId && processedPurchases.includes(purchaseId)) {
|
|
37
35
|
return {
|
|
38
|
-
|
|
39
|
-
imageCredits: existing.imageCredits,
|
|
36
|
+
credits: existing.credits,
|
|
40
37
|
alreadyProcessed: true,
|
|
41
|
-
};
|
|
38
|
+
} as any;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
newImageCredits = (existing.imageCredits || 0) + config.imageCreditLimit;
|
|
41
|
+
newCredits = (existing.credits || 0) + config.creditLimit;
|
|
46
42
|
|
|
47
43
|
if (existing.purchasedAt) {
|
|
48
44
|
purchasedAt = existing.purchasedAt as unknown as FieldValue;
|
|
@@ -54,8 +50,7 @@ export async function initializeCreditsTransaction(
|
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
const creditsData = {
|
|
57
|
-
|
|
58
|
-
imageCredits: newImageCredits,
|
|
53
|
+
credits: newCredits,
|
|
59
54
|
purchasedAt,
|
|
60
55
|
lastUpdatedAt: now,
|
|
61
56
|
lastPurchaseAt: now,
|
|
@@ -65,6 +60,6 @@ export async function initializeCreditsTransaction(
|
|
|
65
60
|
// Use merge:true to avoid overwriting other user fields
|
|
66
61
|
transaction.set(creditsRef, creditsData, { merge: true });
|
|
67
62
|
|
|
68
|
-
return {
|
|
63
|
+
return { credits: newCredits };
|
|
69
64
|
});
|
|
70
65
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Database-first subscription management
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { timezoneService } from "@umituz/react-native-timezone";
|
|
6
7
|
import type { ISubscriptionService } from "../../application/ports/ISubscriptionService";
|
|
7
8
|
import type { ISubscriptionRepository } from "../../application/ports/ISubscriptionRepository";
|
|
8
9
|
import type { SubscriptionStatus } from "../../domain/entities/SubscriptionStatus";
|
|
@@ -89,7 +90,7 @@ export class SubscriptionService implements ISubscriptionService {
|
|
|
89
90
|
try {
|
|
90
91
|
const updatesWithSync = {
|
|
91
92
|
...updates,
|
|
92
|
-
syncedAt:
|
|
93
|
+
syncedAt: timezoneService.getCurrentISOString(),
|
|
93
94
|
};
|
|
94
95
|
|
|
95
96
|
const updatedStatus = await this.repository.updateSubscriptionStatus(
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useMemo } from "react";
|
|
8
|
-
import type { CreditType } from "../../domain/entities/Credits";
|
|
9
8
|
import { getCreditsRepository } from "../../infrastructure/repositories/CreditsRepositoryProvider";
|
|
10
9
|
import {
|
|
11
10
|
createCreditChecker,
|
|
@@ -13,28 +12,29 @@ import {
|
|
|
13
12
|
} from "../../utils/creditChecker";
|
|
14
13
|
|
|
15
14
|
export interface UseCreditCheckerParams {
|
|
16
|
-
|
|
15
|
+
onCreditDeducted?: (userId: string, cost: number) => void;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export interface UseCreditCheckerResult {
|
|
20
19
|
checkCreditsAvailable: (
|
|
21
20
|
userId: string | undefined,
|
|
22
|
-
|
|
21
|
+
cost?: number
|
|
23
22
|
) => Promise<CreditCheckResult>;
|
|
24
23
|
deductCreditsAfterSuccess: (
|
|
25
24
|
userId: string | undefined,
|
|
26
|
-
|
|
25
|
+
cost?: number
|
|
27
26
|
) => Promise<void>;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
export const useCreditChecker = (
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
export const useCreditChecker = (
|
|
30
|
+
params?: UseCreditCheckerParams
|
|
31
|
+
): UseCreditCheckerResult => {
|
|
33
32
|
const repository = getCreditsRepository();
|
|
33
|
+
const onCreditDeducted = params?.onCreditDeducted;
|
|
34
34
|
|
|
35
35
|
const checker = useMemo(
|
|
36
|
-
() => createCreditChecker({ repository,
|
|
37
|
-
[
|
|
36
|
+
() => createCreditChecker({ repository, onCreditDeducted }),
|
|
37
|
+
[repository, onCreditDeducted]
|
|
38
38
|
);
|
|
39
39
|
|
|
40
40
|
return checker;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { useQuery } from "@tanstack/react-query";
|
|
9
9
|
import { useCallback, useMemo } from "react";
|
|
10
|
-
import type { UserCredits
|
|
10
|
+
import type { UserCredits } from "../../domain/entities/Credits";
|
|
11
11
|
import {
|
|
12
12
|
getCreditsRepository,
|
|
13
13
|
getCreditsConfig,
|
|
@@ -42,13 +42,11 @@ export interface UseCreditsResult {
|
|
|
42
42
|
credits: UserCredits | null;
|
|
43
43
|
isLoading: boolean;
|
|
44
44
|
error: Error | null;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
textCreditsPercent: number;
|
|
48
|
-
imageCreditsPercent: number;
|
|
45
|
+
hasCredits: boolean;
|
|
46
|
+
creditsPercent: number;
|
|
49
47
|
refetch: () => void;
|
|
50
48
|
/** Check if user can afford a specific credit cost */
|
|
51
|
-
canAfford: (cost: number
|
|
49
|
+
canAfford: (cost: number) => boolean;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
export const useCredits = ({
|
|
@@ -86,30 +84,22 @@ export const useCredits = ({
|
|
|
86
84
|
|
|
87
85
|
// Memoize derived values to prevent unnecessary re-renders
|
|
88
86
|
const derivedValues = useMemo(() => {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
? Math.round((credits.textCredits / config.textCreditLimit) * 100)
|
|
93
|
-
: 0;
|
|
94
|
-
const imagePercent = credits
|
|
95
|
-
? Math.round((credits.imageCredits / config.imageCreditLimit) * 100)
|
|
87
|
+
const has = (credits?.credits ?? 0) > 0;
|
|
88
|
+
const percent = credits
|
|
89
|
+
? Math.round((credits.credits / config.creditLimit) * 100)
|
|
96
90
|
: 0;
|
|
97
91
|
|
|
98
92
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
textCreditsPercent: textPercent,
|
|
102
|
-
imageCreditsPercent: imagePercent,
|
|
93
|
+
hasCredits: has,
|
|
94
|
+
creditsPercent: percent,
|
|
103
95
|
};
|
|
104
|
-
}, [credits, config.
|
|
96
|
+
}, [credits, config.creditLimit]);
|
|
105
97
|
|
|
106
98
|
// Memoize canAfford to prevent recreation on every render
|
|
107
99
|
const canAfford = useCallback(
|
|
108
|
-
(cost: number
|
|
100
|
+
(cost: number): boolean => {
|
|
109
101
|
if (!credits) return false;
|
|
110
|
-
return
|
|
111
|
-
? credits.textCredits >= cost
|
|
112
|
-
: credits.imageCredits >= cost;
|
|
102
|
+
return credits.credits >= cost;
|
|
113
103
|
},
|
|
114
104
|
[credits]
|
|
115
105
|
);
|
|
@@ -118,23 +108,17 @@ export const useCredits = ({
|
|
|
118
108
|
credits,
|
|
119
109
|
isLoading,
|
|
120
110
|
error: error as Error | null,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
textCreditsPercent: derivedValues.textCreditsPercent,
|
|
124
|
-
imageCreditsPercent: derivedValues.imageCreditsPercent,
|
|
111
|
+
hasCredits: derivedValues.hasCredits,
|
|
112
|
+
creditsPercent: derivedValues.creditsPercent,
|
|
125
113
|
refetch,
|
|
126
114
|
canAfford,
|
|
127
115
|
};
|
|
128
116
|
};
|
|
129
117
|
|
|
130
118
|
export const useHasCredits = (
|
|
131
|
-
userId: string | undefined
|
|
132
|
-
creditType: CreditType
|
|
119
|
+
userId: string | undefined
|
|
133
120
|
): boolean => {
|
|
134
121
|
const { credits } = useCredits({ userId });
|
|
135
122
|
if (!credits) return false;
|
|
136
|
-
|
|
137
|
-
return creditType === "text"
|
|
138
|
-
? credits.textCredits > 0
|
|
139
|
-
: credits.imageCredits > 0;
|
|
123
|
+
return credits.credits > 0;
|
|
140
124
|
};
|
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
|
|
6
6
|
import { useCallback } from "react";
|
|
7
7
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
8
|
-
import type {
|
|
8
|
+
import type { UserCredits } from "../../domain/entities/Credits";
|
|
9
9
|
import { getCreditsRepository } from "../../infrastructure/repositories/CreditsRepositoryProvider";
|
|
10
10
|
import { creditsQueryKeys } from "./useCredits";
|
|
11
11
|
|
|
12
|
+
import { timezoneService } from "@umituz/react-native-timezone";
|
|
13
|
+
|
|
12
14
|
export interface UseDeductCreditParams {
|
|
13
15
|
userId: string | undefined;
|
|
14
16
|
onCreditsExhausted?: () => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface UseDeductCreditResult {
|
|
18
|
-
deductCredit: (
|
|
19
|
-
deductCredits: (cost: number
|
|
20
|
+
deductCredit: (cost?: number) => Promise<boolean>;
|
|
21
|
+
deductCredits: (cost: number) => Promise<boolean>;
|
|
20
22
|
isDeducting: boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -28,22 +30,25 @@ export const useDeductCredit = ({
|
|
|
28
30
|
const queryClient = useQueryClient();
|
|
29
31
|
|
|
30
32
|
const mutation = useMutation({
|
|
31
|
-
mutationFn: async (
|
|
33
|
+
mutationFn: async (cost: number) => {
|
|
32
34
|
if (!userId) throw new Error("User not authenticated");
|
|
33
|
-
return repository.deductCredit(userId,
|
|
35
|
+
return repository.deductCredit(userId, cost);
|
|
34
36
|
},
|
|
35
|
-
onMutate: async (
|
|
37
|
+
onMutate: async (cost: number) => {
|
|
36
38
|
if (!userId) return;
|
|
37
39
|
await queryClient.cancelQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
38
40
|
const previousCredits = queryClient.getQueryData<UserCredits>(creditsQueryKeys.user(userId));
|
|
39
41
|
queryClient.setQueryData<UserCredits | null>(creditsQueryKeys.user(userId), (old) => {
|
|
40
42
|
if (!old) return old;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
return {
|
|
44
|
+
...old,
|
|
45
|
+
credits: Math.max(0, old.credits - cost),
|
|
46
|
+
lastUpdatedAt: timezoneService.getNow()
|
|
47
|
+
};
|
|
43
48
|
});
|
|
44
49
|
return { previousCredits };
|
|
45
50
|
},
|
|
46
|
-
onError: (_err,
|
|
51
|
+
onError: (_err, _cost, context) => {
|
|
47
52
|
if (userId && context?.previousCredits) {
|
|
48
53
|
queryClient.setQueryData(creditsQueryKeys.user(userId), context.previousCredits);
|
|
49
54
|
}
|
|
@@ -53,9 +58,9 @@ export const useDeductCredit = ({
|
|
|
53
58
|
},
|
|
54
59
|
});
|
|
55
60
|
|
|
56
|
-
const deductCredit = useCallback(async (
|
|
61
|
+
const deductCredit = useCallback(async (cost: number = 1): Promise<boolean> => {
|
|
57
62
|
try {
|
|
58
|
-
const res = await mutation.mutateAsync(
|
|
63
|
+
const res = await mutation.mutateAsync(cost);
|
|
59
64
|
if (!res.success) {
|
|
60
65
|
if (res.error?.code === "CREDITS_EXHAUSTED") onCreditsExhausted?.();
|
|
61
66
|
return false;
|
|
@@ -64,11 +69,8 @@ export const useDeductCredit = ({
|
|
|
64
69
|
} catch { return false; }
|
|
65
70
|
}, [mutation, onCreditsExhausted]);
|
|
66
71
|
|
|
67
|
-
const deductCredits = useCallback(async (cost: number
|
|
68
|
-
|
|
69
|
-
if (!(await deductCredit(type))) return false;
|
|
70
|
-
}
|
|
71
|
-
return true;
|
|
72
|
+
const deductCredits = useCallback(async (cost: number): Promise<boolean> => {
|
|
73
|
+
return await deductCredit(cost);
|
|
72
74
|
}, [deductCredit]);
|
|
73
75
|
|
|
74
76
|
return { deductCredit, deductCredits, isDeducting: mutation.isPending };
|
|
@@ -42,8 +42,7 @@ export const useDevTestCallbacks = (): DevTestActions | undefined => {
|
|
|
42
42
|
if (__DEV__) {
|
|
43
43
|
console.log("✅ [Dev Test] Renewal completed:", {
|
|
44
44
|
success: result.success,
|
|
45
|
-
|
|
46
|
-
imageCredits: result.data?.imageCredits,
|
|
45
|
+
credits: result.data?.credits,
|
|
47
46
|
});
|
|
48
47
|
}
|
|
49
48
|
|
|
@@ -51,7 +50,7 @@ export const useDevTestCallbacks = (): DevTestActions | undefined => {
|
|
|
51
50
|
|
|
52
51
|
Alert.alert(
|
|
53
52
|
"✅ Test Renewal Success",
|
|
54
|
-
`Credits Updated!\n\
|
|
53
|
+
`Credits Updated!\n\nNew Balance: ${result.data?.credits || 0}\n\n(ACCUMULATE mode - credits added to existing)`,
|
|
55
54
|
[{ text: "OK" }],
|
|
56
55
|
);
|
|
57
56
|
} catch (error) {
|
|
@@ -73,7 +72,7 @@ export const useDevTestCallbacks = (): DevTestActions | undefined => {
|
|
|
73
72
|
|
|
74
73
|
Alert.alert(
|
|
75
74
|
"📊 Current Credits",
|
|
76
|
-
`
|
|
75
|
+
`Credits: ${credits.credits}\n\nPurchased: ${credits.purchasedAt?.toLocaleDateString() || "N/A"}`,
|
|
77
76
|
[{ text: "OK" }],
|
|
78
77
|
);
|
|
79
78
|
}, [credits]);
|
|
@@ -113,7 +112,7 @@ export const useDevTestCallbacks = (): DevTestActions | undefined => {
|
|
|
113
112
|
await refetch();
|
|
114
113
|
|
|
115
114
|
const duplicateProtectionWorks =
|
|
116
|
-
result2.data?.
|
|
115
|
+
result2.data?.credits === result1.data?.credits;
|
|
117
116
|
|
|
118
117
|
Alert.alert(
|
|
119
118
|
"Duplicate Test",
|
|
@@ -37,7 +37,7 @@ export const useSubscriptionSettingsConfig = (
|
|
|
37
37
|
const {
|
|
38
38
|
userId,
|
|
39
39
|
translations,
|
|
40
|
-
|
|
40
|
+
creditLimit,
|
|
41
41
|
upgradePrompt,
|
|
42
42
|
} = params;
|
|
43
43
|
|
|
@@ -96,7 +96,7 @@ export const useSubscriptionSettingsConfig = (
|
|
|
96
96
|
const statusType: SubscriptionStatusType = getSubscriptionStatusType(isPremium);
|
|
97
97
|
|
|
98
98
|
// Credits array
|
|
99
|
-
const creditsArray = useCreditsArray(credits,
|
|
99
|
+
const creditsArray = useCreditsArray(credits, creditLimit, translations);
|
|
100
100
|
|
|
101
101
|
// Build config
|
|
102
102
|
const config = useMemo(
|
|
@@ -19,23 +19,20 @@ export interface CreditsInfo {
|
|
|
19
19
|
*/
|
|
20
20
|
export function useCreditsArray(
|
|
21
21
|
credits: UserCredits | null | undefined,
|
|
22
|
-
|
|
22
|
+
creditLimit: number | undefined,
|
|
23
23
|
translations: SubscriptionSettingsTranslations
|
|
24
24
|
): CreditsInfo[] {
|
|
25
25
|
return useMemo(() => {
|
|
26
26
|
if (!credits) return [];
|
|
27
|
-
const total = getCreditLimit
|
|
28
|
-
? getCreditLimit(credits.imageCredits)
|
|
29
|
-
: credits.imageCredits;
|
|
30
27
|
return [
|
|
31
28
|
{
|
|
32
|
-
id: "
|
|
33
|
-
label: translations.
|
|
34
|
-
current: credits.
|
|
35
|
-
total,
|
|
29
|
+
id: "credits",
|
|
30
|
+
label: translations.creditsLabel || "Credits",
|
|
31
|
+
current: credits.credits,
|
|
32
|
+
total: creditLimit ?? credits.credits,
|
|
36
33
|
},
|
|
37
34
|
];
|
|
38
|
-
}, [credits,
|
|
35
|
+
}, [credits, creditLimit, translations.creditsLabel]);
|
|
39
36
|
}
|
|
40
37
|
|
|
41
38
|
/**
|
|
@@ -51,8 +51,8 @@ export interface SubscriptionSettingsTranslations {
|
|
|
51
51
|
remainingLabel: string;
|
|
52
52
|
manageButton: string;
|
|
53
53
|
upgradeButton: string;
|
|
54
|
-
/** Credit label (e.g., "
|
|
55
|
-
|
|
54
|
+
/** Credit label (e.g., "Credits") */
|
|
55
|
+
creditsLabel?: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Parameters for useSubscriptionSettingsConfig hook */
|
|
@@ -63,8 +63,8 @@ export interface UseSubscriptionSettingsConfigParams {
|
|
|
63
63
|
isAnonymous?: boolean;
|
|
64
64
|
/** Translation strings */
|
|
65
65
|
translations: SubscriptionSettingsTranslations;
|
|
66
|
-
/**
|
|
67
|
-
|
|
66
|
+
/** Fixed credit limit (if not available in UserCredits) */
|
|
67
|
+
creditLimit?: number;
|
|
68
68
|
/** Upgrade prompt configuration for free users */
|
|
69
69
|
upgradePrompt?: UpgradePromptConfig;
|
|
70
70
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { timezoneService } from "@umituz/react-native-timezone";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Converts Firestore timestamp or Date to ISO string
|
|
3
5
|
*/
|
|
@@ -17,22 +19,18 @@ export const convertPurchasedAt = (purchasedAt: unknown): string | null => {
|
|
|
17
19
|
return null;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
return
|
|
22
|
+
return timezoneService.formatToISOString(date);
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
|
-
* Formats a date string to a simple DD.MM.YYYY format
|
|
26
|
+
* Formats a date string to a simple DD.MM.YYYY format using timezoneService
|
|
25
27
|
*/
|
|
26
28
|
export const formatDate = (dateStr: string | null): string | null => {
|
|
27
29
|
if (!dateStr) return null;
|
|
28
30
|
const date = new Date(dateStr);
|
|
29
31
|
if (isNaN(date.getTime())) return null;
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
33
|
-
const y = date.getFullYear();
|
|
34
|
-
|
|
35
|
-
return `${d}.${m}.${y}`;
|
|
33
|
+
return timezoneService.formatToDisplayDate(date);
|
|
36
34
|
};
|
|
37
35
|
|
|
38
36
|
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* });
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type { CreditType } from "../domain/entities/Credits";
|
|
18
17
|
import type { CreditsRepository } from "../infrastructure/repositories/CreditsRepository";
|
|
19
18
|
import { createCreditChecker } from "./creditChecker";
|
|
20
19
|
|
|
@@ -25,24 +24,24 @@ export interface AICreditHelpersConfig {
|
|
|
25
24
|
repository: CreditsRepository;
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
27
|
+
* Optional map of operation types to credit costs.
|
|
28
|
+
* If an operation isn't in this map, cost defaults to 1.
|
|
29
|
+
* @example { 'high_res_image': 5, 'text_summary': 1 }
|
|
31
30
|
*/
|
|
32
|
-
|
|
31
|
+
operationCosts?: Record<string, number>;
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
34
|
* Optional callback called after successful credit deduction.
|
|
36
35
|
* Use this to invalidate TanStack Query cache or trigger UI updates.
|
|
37
36
|
*/
|
|
38
|
-
onCreditDeducted?: (userId: string,
|
|
37
|
+
onCreditDeducted?: (userId: string, cost: number) => void;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
export interface AICreditHelpers {
|
|
42
41
|
/**
|
|
43
42
|
* Check if user has credits for a specific generation type
|
|
44
43
|
* @param userId - User ID
|
|
45
|
-
* @param generationType - Type of generation
|
|
44
|
+
* @param generationType - Type of generation
|
|
46
45
|
* @returns boolean indicating if credits are available
|
|
47
46
|
*/
|
|
48
47
|
checkCreditsForGeneration: (
|
|
@@ -61,11 +60,11 @@ export interface AICreditHelpers {
|
|
|
61
60
|
) => Promise<void>;
|
|
62
61
|
|
|
63
62
|
/**
|
|
64
|
-
* Get
|
|
63
|
+
* Get cost for a generation type
|
|
65
64
|
* @param generationType - Type of generation
|
|
66
|
-
* @returns
|
|
65
|
+
* @returns number of credits
|
|
67
66
|
*/
|
|
68
|
-
|
|
67
|
+
getCost: (generationType: string) => number;
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
/**
|
|
@@ -74,17 +73,16 @@ export interface AICreditHelpers {
|
|
|
74
73
|
export function createAICreditHelpers(
|
|
75
74
|
config: AICreditHelpersConfig
|
|
76
75
|
): AICreditHelpers {
|
|
77
|
-
const { repository,
|
|
76
|
+
const { repository, operationCosts = {}, onCreditDeducted } = config;
|
|
78
77
|
|
|
79
|
-
// Map generation type to
|
|
80
|
-
const
|
|
81
|
-
return
|
|
78
|
+
// Map generation type to cost
|
|
79
|
+
const getCost = (generationType: string): number => {
|
|
80
|
+
return operationCosts[generationType] ?? 1;
|
|
82
81
|
};
|
|
83
82
|
|
|
84
|
-
// Create credit checker
|
|
83
|
+
// Create credit checker
|
|
85
84
|
const checker = createCreditChecker({
|
|
86
85
|
repository,
|
|
87
|
-
getCreditType,
|
|
88
86
|
onCreditDeducted,
|
|
89
87
|
});
|
|
90
88
|
|
|
@@ -93,7 +91,8 @@ export function createAICreditHelpers(
|
|
|
93
91
|
userId: string | undefined,
|
|
94
92
|
generationType: string
|
|
95
93
|
): Promise<boolean> => {
|
|
96
|
-
const
|
|
94
|
+
const cost = getCost(generationType);
|
|
95
|
+
const result = await checker.checkCreditsAvailable(userId, cost);
|
|
97
96
|
return result.success;
|
|
98
97
|
};
|
|
99
98
|
|
|
@@ -102,13 +101,13 @@ export function createAICreditHelpers(
|
|
|
102
101
|
userId: string | undefined,
|
|
103
102
|
generationType: string
|
|
104
103
|
): Promise<void> => {
|
|
105
|
-
const
|
|
106
|
-
await checker.deductCreditsAfterSuccess(userId,
|
|
104
|
+
const cost = getCost(generationType);
|
|
105
|
+
await checker.deductCreditsAfterSuccess(userId, cost);
|
|
107
106
|
};
|
|
108
107
|
|
|
109
108
|
return {
|
|
110
109
|
checkCreditsForGeneration,
|
|
111
110
|
deductCreditsForGeneration,
|
|
112
|
-
|
|
111
|
+
getCost,
|
|
113
112
|
};
|
|
114
113
|
}
|
|
@@ -5,58 +5,50 @@
|
|
|
5
5
|
* Generic - works with any generation type mapping.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { CreditType } from "../domain/entities/Credits";
|
|
9
8
|
import type { CreditsRepository } from "../infrastructure/repositories/CreditsRepository";
|
|
10
9
|
|
|
11
10
|
export interface CreditCheckResult {
|
|
12
11
|
success: boolean;
|
|
13
12
|
error?: string;
|
|
14
|
-
creditType?: CreditType;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export interface CreditCheckerConfig {
|
|
18
16
|
repository: CreditsRepository;
|
|
19
|
-
getCreditType: (operationType: string) => CreditType;
|
|
20
17
|
/**
|
|
21
18
|
* Optional callback called after successful credit deduction.
|
|
22
19
|
* Use this to invalidate TanStack Query cache or trigger UI updates.
|
|
23
20
|
* @param userId - The user whose credits were deducted
|
|
24
|
-
* @param
|
|
21
|
+
* @param cost - The amount of credits deducted
|
|
25
22
|
*/
|
|
26
|
-
onCreditDeducted?: (userId: string,
|
|
23
|
+
onCreditDeducted?: (userId: string, cost: number) => void;
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export const createCreditChecker = (config: CreditCheckerConfig) => {
|
|
30
|
-
const { repository,
|
|
27
|
+
const { repository, onCreditDeducted } = config;
|
|
31
28
|
|
|
32
29
|
const checkCreditsAvailable = async (
|
|
33
30
|
userId: string | undefined,
|
|
34
|
-
|
|
31
|
+
cost: number = 1
|
|
35
32
|
): Promise<CreditCheckResult> => {
|
|
36
33
|
if (!userId) {
|
|
37
34
|
return { success: false, error: "anonymous_user_blocked" };
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
const
|
|
41
|
-
const hasCreditsAvailable = await repository.hasCredits(userId, creditType);
|
|
37
|
+
const hasCreditsAvailable = await repository.hasCredits(userId, cost);
|
|
42
38
|
|
|
43
39
|
if (!hasCreditsAvailable) {
|
|
44
40
|
return {
|
|
45
41
|
success: false,
|
|
46
|
-
error:
|
|
47
|
-
creditType === "image"
|
|
48
|
-
? "credits_exhausted_image"
|
|
49
|
-
: "credits_exhausted_text",
|
|
50
|
-
creditType,
|
|
42
|
+
error: "credits_exhausted",
|
|
51
43
|
};
|
|
52
44
|
}
|
|
53
45
|
|
|
54
|
-
return { success: true
|
|
46
|
+
return { success: true };
|
|
55
47
|
};
|
|
56
48
|
|
|
57
49
|
const deductCreditsAfterSuccess = async (
|
|
58
50
|
userId: string | undefined,
|
|
59
|
-
|
|
51
|
+
cost: number = 1
|
|
60
52
|
): Promise<void> => {
|
|
61
53
|
if (!userId) return;
|
|
62
54
|
|
|
@@ -64,10 +56,10 @@ export const createCreditChecker = (config: CreditCheckerConfig) => {
|
|
|
64
56
|
let lastError: Error | null = null;
|
|
65
57
|
|
|
66
58
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
67
|
-
const result = await repository.deductCredit(userId,
|
|
59
|
+
const result = await repository.deductCredit(userId, cost);
|
|
68
60
|
if (result.success) {
|
|
69
61
|
// Notify subscribers that credits were deducted
|
|
70
|
-
onCreditDeducted?.(userId,
|
|
62
|
+
onCreditDeducted?.(userId, cost);
|
|
71
63
|
return;
|
|
72
64
|
}
|
|
73
65
|
lastError = new Error(result.error?.message || "Deduction failed");
|
|
@@ -79,6 +71,7 @@ export const createCreditChecker = (config: CreditCheckerConfig) => {
|
|
|
79
71
|
}
|
|
80
72
|
};
|
|
81
73
|
|
|
74
|
+
|
|
82
75
|
return {
|
|
83
76
|
checkCreditsAvailable,
|
|
84
77
|
deductCreditsAfterSuccess,
|
|
@@ -1,95 +1,36 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credit Mapper
|
|
3
|
-
* Maps subscription package types to credit amounts
|
|
4
|
-
* Based on SUBSCRIPTION_GUIDE.md pricing strategy
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
1
|
import { detectPackageType, type SubscriptionPackageType } from "./packageTypeDetector";
|
|
8
|
-
|
|
9
|
-
export interface CreditAllocation {
|
|
10
|
-
imageCredits: number;
|
|
11
|
-
textCredits: number;
|
|
12
|
-
}
|
|
2
|
+
import type { PackageAllocationMap } from "../domain/entities/Credits";
|
|
13
3
|
|
|
14
4
|
/**
|
|
15
|
-
*
|
|
16
|
-
* Based on profitability analysis and value ladder strategy
|
|
17
|
-
*
|
|
18
|
-
* Weekly: 6 images - $2.99 (62% margin) - Trial users
|
|
19
|
-
* Monthly: 25 images - $9.99 (60% margin) - Regular users
|
|
20
|
-
* Yearly: 300 images - $79.99 (55% margin) - Best value (46% cheaper/image)
|
|
21
|
-
*/
|
|
22
|
-
export const CREDIT_ALLOCATIONS: Record<
|
|
23
|
-
Exclude<SubscriptionPackageType, "unknown">,
|
|
24
|
-
CreditAllocation
|
|
25
|
-
> = {
|
|
26
|
-
weekly: {
|
|
27
|
-
imageCredits: 6,
|
|
28
|
-
textCredits: 6,
|
|
29
|
-
},
|
|
30
|
-
monthly: {
|
|
31
|
-
imageCredits: 25,
|
|
32
|
-
textCredits: 25,
|
|
33
|
-
},
|
|
34
|
-
yearly: {
|
|
35
|
-
imageCredits: 300,
|
|
36
|
-
textCredits: 300,
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Get credit allocation for a package type
|
|
42
|
-
* Returns null for unknown package types to prevent incorrect credit assignment
|
|
5
|
+
* Get credit allocation for a package type from provided allocations map
|
|
43
6
|
*/
|
|
44
7
|
export function getCreditAllocation(
|
|
45
|
-
packageType: SubscriptionPackageType
|
|
46
|
-
|
|
47
|
-
if (packageType === "unknown") return null;
|
|
48
|
-
return CREDIT_ALLOCATIONS[packageType];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Get image credits for a package type
|
|
53
|
-
*/
|
|
54
|
-
export function getImageCreditsForPackage(
|
|
55
|
-
packageType: SubscriptionPackageType
|
|
8
|
+
packageType: SubscriptionPackageType,
|
|
9
|
+
allocations?: PackageAllocationMap
|
|
56
10
|
): number | null {
|
|
57
|
-
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Get text credits for a package type
|
|
63
|
-
*/
|
|
64
|
-
export function getTextCreditsForPackage(
|
|
65
|
-
packageType: SubscriptionPackageType
|
|
66
|
-
): number | null {
|
|
67
|
-
const allocation = getCreditAllocation(packageType);
|
|
68
|
-
return allocation?.textCredits ?? null;
|
|
11
|
+
if (packageType === "unknown" || !allocations) return null;
|
|
12
|
+
return allocations[packageType]?.credits ?? null;
|
|
69
13
|
}
|
|
70
14
|
|
|
71
15
|
/**
|
|
72
16
|
* Create credit amounts mapping for PaywallModal from RevenueCat packages
|
|
73
|
-
* Maps product.identifier to credit amount
|
|
74
|
-
*
|
|
75
|
-
* @example
|
|
76
|
-
* ```typescript
|
|
77
|
-
* const creditAmounts = createCreditAmountsFromPackages(packages);
|
|
78
|
-
* // { "futureus_weekly_2_99": 6, "futureus_monthly_9_99": 25, "futureus_yearly_79_99": 300 }
|
|
79
|
-
* ```
|
|
17
|
+
* Maps product.identifier to credit amount using dynamic allocations
|
|
80
18
|
*/
|
|
81
19
|
export function createCreditAmountsFromPackages(
|
|
82
|
-
packages: Array<{ product: { identifier: string } }
|
|
20
|
+
packages: Array<{ product: { identifier: string } }>,
|
|
21
|
+
allocations?: PackageAllocationMap
|
|
83
22
|
): Record<string, number> {
|
|
84
23
|
const result: Record<string, number> = {};
|
|
85
24
|
|
|
25
|
+
if (!allocations) return result;
|
|
26
|
+
|
|
86
27
|
for (const pkg of packages) {
|
|
87
28
|
const identifier = pkg?.product?.identifier;
|
|
88
29
|
|
|
89
30
|
if (!identifier) continue;
|
|
90
31
|
|
|
91
32
|
const packageType = detectPackageType(identifier);
|
|
92
|
-
const credits =
|
|
33
|
+
const credits = getCreditAllocation(packageType, allocations);
|
|
93
34
|
|
|
94
35
|
if (credits !== null) {
|
|
95
36
|
result[identifier] = credits;
|