@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.14.94",
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-localization": "latest",
38
- "@umituz/react-native-storage": "latest"
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.20",
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
- textCredits: number;
12
- imageCredits: number;
13
- purchasedAt: Date;
14
- lastUpdatedAt: Date;
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
- textCreditLimit: number;
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
- textCreditLimit: 1000,
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
- return new Date(status.expiresAt).getTime() > Date.now();
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
- const diff = new Date(expiresAt).getTime() - Date.now();
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
- const date = new Date(timestamp);
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
- hasTextCredits,
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
- if (!credits) return 0;
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: hasTextCredits || hasImageCredits,
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
- textCredits: snapData.textCredits,
13
- imageCredits: snapData.imageCredits,
14
- purchasedAt: snapData.purchasedAt?.toDate?.() || new Date(),
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
- textCredits: data.textCredits,
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
- textCredits: number;
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 { CreditType, CreditsConfig, CreditsResult, DeductCreditsResult } from "../../domain/entities/Credits";
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, imageCreditLimit: amt, textCreditLimit: amt };
42
+ if (amt) cfg = { ...cfg, creditLimit: amt };
43
43
  else {
44
- const alloc = getCreditAllocation(detectPackageType(productId));
45
- if (alloc) cfg = { ...cfg, imageCreditLimit: alloc.imageCredits, textCreditLimit: alloc.textCredits };
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: { toDate: () => new Date() } as any,
54
- lastUpdatedAt: { toDate: () => new Date() } as any,
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, type: CreditType): Promise<DeductCreditsResult> {
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()[field] as number;
69
- if (current <= 0) throw new Error("CREDITS_EXHAUSTED");
70
- const updated = current - 1;
71
- tx.update(this.getRef(db, userId), { [field]: updated, lastUpdatedAt: serverTimestamp() });
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, type: CreditType): Promise<boolean> {
81
+ async hasCredits(userId: string, cost: number = 1): Promise<boolean> {
82
82
  const res = await this.getCredits(userId);
83
- return !!(res.success && res.data && (type === "text" ? res.data.textCredits : res.data.imageCredits) > 0);
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: new Date().toISOString(),
36
- syncedAt: new Date().toISOString(),
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
- textCredits: number;
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 newTextCredits = config.textCreditLimit;
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
- textCredits: existing.textCredits,
39
- imageCredits: existing.imageCredits,
36
+ credits: existing.credits,
40
37
  alreadyProcessed: true,
41
- };
38
+ } as any;
42
39
  }
43
40
 
44
- newTextCredits = (existing.textCredits || 0) + config.textCreditLimit;
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
- textCredits: newTextCredits,
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 { textCredits: newTextCredits, imageCredits: newImageCredits };
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: new Date().toISOString(),
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
- getCreditType: (operationType: string) => CreditType;
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
- operationType: string
21
+ cost?: number
23
22
  ) => Promise<CreditCheckResult>;
24
23
  deductCreditsAfterSuccess: (
25
24
  userId: string | undefined,
26
- creditType: CreditType
25
+ cost?: number
27
26
  ) => Promise<void>;
28
27
  }
29
28
 
30
- export const useCreditChecker = ({
31
- getCreditType,
32
- }: UseCreditCheckerParams): UseCreditCheckerResult => {
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, getCreditType }),
37
- [getCreditType, repository]
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, CreditType } from "../../domain/entities/Credits";
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
- hasTextCredits: boolean;
46
- hasImageCredits: boolean;
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, type?: CreditType) => boolean;
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 hasText = (credits?.textCredits ?? 0) > 0;
90
- const hasImage = (credits?.imageCredits ?? 0) > 0;
91
- const textPercent = credits
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
- hasTextCredits: hasText,
100
- hasImageCredits: hasImage,
101
- textCreditsPercent: textPercent,
102
- imageCreditsPercent: imagePercent,
93
+ hasCredits: has,
94
+ creditsPercent: percent,
103
95
  };
104
- }, [credits, config.textCreditLimit, config.imageCreditLimit]);
96
+ }, [credits, config.creditLimit]);
105
97
 
106
98
  // Memoize canAfford to prevent recreation on every render
107
99
  const canAfford = useCallback(
108
- (cost: number, type: CreditType = "text"): boolean => {
100
+ (cost: number): boolean => {
109
101
  if (!credits) return false;
110
- return type === "text"
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
- hasTextCredits: derivedValues.hasTextCredits,
122
- hasImageCredits: derivedValues.hasImageCredits,
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 { CreditType, UserCredits } from "../../domain/entities/Credits";
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: (creditType: CreditType) => Promise<boolean>;
19
- deductCredits: (cost: number, creditType?: CreditType) => Promise<boolean>;
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 (creditType: CreditType) => {
33
+ mutationFn: async (cost: number) => {
32
34
  if (!userId) throw new Error("User not authenticated");
33
- return repository.deductCredit(userId, creditType);
35
+ return repository.deductCredit(userId, cost);
34
36
  },
35
- onMutate: async (creditType: CreditType) => {
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
- const field = creditType === "text" ? "textCredits" : "imageCredits";
42
- return { ...old, [field]: Math.max(0, old[field] - 1), lastUpdatedAt: new Date() };
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, _type, context) => {
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 (type: CreditType): Promise<boolean> => {
61
+ const deductCredit = useCallback(async (cost: number = 1): Promise<boolean> => {
57
62
  try {
58
- const res = await mutation.mutateAsync(type);
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, type: CreditType = "image"): Promise<boolean> => {
68
- for (let i = 0; i < cost; i++) {
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
- textCredits: result.data?.textCredits,
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\nText: ${result.data?.textCredits || 0}\nImage: ${result.data?.imageCredits || 0}\n\n(ACCUMULATE mode - credits added to existing)`,
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
- `Text Generation: ${credits.textCredits}\nImage Generation: ${credits.imageCredits}\n\nPurchased: ${credits.purchasedAt?.toLocaleDateString() || "N/A"}`,
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?.textCredits === result1.data?.textCredits;
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
- getCreditLimit,
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, getCreditLimit, translations);
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
- getCreditLimit: ((credits: number) => number) | undefined,
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: "image",
33
- label: translations.imageCreditsLabel || "Image Credits",
34
- current: credits.imageCredits,
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, getCreditLimit, translations.imageCreditsLabel]);
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., "Image Credits") */
55
- imageCreditsLabel?: string;
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
- /** Credit limit calculator */
67
- getCreditLimit?: (currentCredits: number) => number;
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 date.toISOString();
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
- const d = String(date.getDate()).padStart(2, '0');
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
- * List of operation types that should use "image" credits.
29
- * All other types will use "text" credits.
30
- * @example ['future_image', 'santa_transform', 'photo_generation']
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
- imageGenerationTypes: string[];
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, creditType: CreditType) => void;
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 (e.g., 'future_image', 'text_summary')
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 credit type for a generation type (useful for UI display)
63
+ * Get cost for a generation type
65
64
  * @param generationType - Type of generation
66
- * @returns "image" or "text"
65
+ * @returns number of credits
67
66
  */
68
- getCreditType: (generationType: string) => CreditType;
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, imageGenerationTypes, onCreditDeducted } = config;
76
+ const { repository, operationCosts = {}, onCreditDeducted } = config;
78
77
 
79
- // Map generation type to credit type
80
- const getCreditType = (generationType: string): CreditType => {
81
- return imageGenerationTypes.includes(generationType) ? "image" : "text";
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 with the mapping
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 result = await checker.checkCreditsAvailable(userId, generationType);
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 creditType = getCreditType(generationType);
106
- await checker.deductCreditsAfterSuccess(userId, creditType);
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
- getCreditType,
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 creditType - The type of credit that was deducted
21
+ * @param cost - The amount of credits deducted
25
22
  */
26
- onCreditDeducted?: (userId: string, creditType: CreditType) => void;
23
+ onCreditDeducted?: (userId: string, cost: number) => void;
27
24
  }
28
25
 
29
26
  export const createCreditChecker = (config: CreditCheckerConfig) => {
30
- const { repository, getCreditType, onCreditDeducted } = config;
27
+ const { repository, onCreditDeducted } = config;
31
28
 
32
29
  const checkCreditsAvailable = async (
33
30
  userId: string | undefined,
34
- operationType: string
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 creditType = getCreditType(operationType);
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, creditType };
46
+ return { success: true };
55
47
  };
56
48
 
57
49
  const deductCreditsAfterSuccess = async (
58
50
  userId: string | undefined,
59
- creditType: CreditType
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, creditType);
59
+ const result = await repository.deductCredit(userId, cost);
68
60
  if (result.success) {
69
61
  // Notify subscribers that credits were deducted
70
- onCreditDeducted?.(userId, creditType);
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
- * Standard credit allocations per package type
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
- ): CreditAllocation | null {
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
- const allocation = getCreditAllocation(packageType);
58
- return allocation?.imageCredits ?? null;
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 = getImageCreditsForPackage(packageType);
33
+ const credits = getCreditAllocation(packageType, allocations);
93
34
 
94
35
  if (credits !== null) {
95
36
  result[identifier] = credits;