@umituz/react-native-subscription 2.32.0 → 2.32.1
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/infrastructure/CreditsRepository.ts +21 -102
- package/src/domains/credits/infrastructure/operations/CreditsFetcher.ts +23 -0
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +98 -0
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.32.
|
|
3
|
+
"version": "2.32.1",
|
|
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",
|
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { BaseRepository
|
|
1
|
+
import type { Firestore, DocumentReference } from "@umituz/react-native-firebase";
|
|
2
|
+
import { BaseRepository } from "@umituz/react-native-firebase";
|
|
3
3
|
import type { CreditsConfig, CreditsResult, DeductCreditsResult } from "../core/Credits";
|
|
4
|
-
import type {
|
|
5
|
-
import { initializeCreditsTransaction } from "../application/CreditsInitializer";
|
|
6
|
-
import { mapCreditsDocumentToEntity } from "../core/CreditsMapper";
|
|
4
|
+
import type { PurchaseSource } from "../core/UserCreditsDocument";
|
|
7
5
|
import type { RevenueCatData } from "../../revenuecat/core/types";
|
|
8
6
|
import { deductCreditsOperation } from "../application/DeductCreditsCommand";
|
|
9
|
-
import { calculateCreditLimit } from "../application/CreditLimitCalculator";
|
|
10
7
|
import { PURCHASE_TYPE, type PurchaseType } from "../../subscription/core/SubscriptionConstants";
|
|
11
8
|
import { requireFirestore, buildDocRef, type CollectionConfig } from "../../../shared/infrastructure/firestore";
|
|
12
|
-
import {
|
|
9
|
+
import { fetchCredits, checkHasCredits } from "./operations/CreditsFetcher";
|
|
10
|
+
import { syncExpiredStatus } from "./operations/CreditsWriter";
|
|
11
|
+
import { initializeCreditsWithRetry } from "./operations/CreditsInitializer";
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
* Credits Repository
|
|
16
|
-
* Provides domain-specific database operations for credits system.
|
|
17
|
-
*/
|
|
18
13
|
export class CreditsRepository extends BaseRepository {
|
|
19
14
|
constructor(private config: CreditsConfig) {
|
|
20
15
|
super(config.collectionName);
|
|
@@ -34,14 +29,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
34
29
|
|
|
35
30
|
async getCredits(userId: string): Promise<CreditsResult> {
|
|
36
31
|
const db = requireFirestore();
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!snap.exists()) {
|
|
40
|
-
return { success: true, data: null, error: null };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const entity = mapCreditsDocumentToEntity(snap.data() as UserCreditsDocumentRead);
|
|
44
|
-
return { success: true, data: entity, error: null };
|
|
32
|
+
return fetchCredits(this.getRef(db, userId));
|
|
45
33
|
}
|
|
46
34
|
|
|
47
35
|
async initializeCredits(
|
|
@@ -53,101 +41,32 @@ export class CreditsRepository extends BaseRepository {
|
|
|
53
41
|
type: PurchaseType = PURCHASE_TYPE.INITIAL
|
|
54
42
|
): Promise<CreditsResult> {
|
|
55
43
|
const db = requireFirestore();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
cfg,
|
|
68
|
-
purchaseId,
|
|
69
|
-
{
|
|
70
|
-
productId,
|
|
71
|
-
source,
|
|
72
|
-
expirationDate: revenueCatData.expirationDate,
|
|
73
|
-
willRenew: revenueCatData.willRenew,
|
|
74
|
-
originalTransactionId: revenueCatData.originalTransactionId,
|
|
75
|
-
isPremium: revenueCatData.isPremium,
|
|
76
|
-
periodType: revenueCatData.periodType,
|
|
77
|
-
unsubscribeDetectedAt: revenueCatData.unsubscribeDetectedAt,
|
|
78
|
-
billingIssueDetectedAt: revenueCatData.billingIssueDetectedAt,
|
|
79
|
-
store: revenueCatData.store,
|
|
80
|
-
ownershipType: revenueCatData.ownershipType,
|
|
81
|
-
type,
|
|
82
|
-
}
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
success: true,
|
|
87
|
-
data: result.finalData ? mapCreditsDocumentToEntity(result.finalData) : null,
|
|
88
|
-
error: null,
|
|
89
|
-
};
|
|
90
|
-
} catch (error: any) {
|
|
91
|
-
lastError = error;
|
|
92
|
-
|
|
93
|
-
const isTransientError =
|
|
94
|
-
error?.code === 'already-exists' ||
|
|
95
|
-
error?.code === 'DEADLINE_EXCEEDED' ||
|
|
96
|
-
error?.code === 'UNAVAILABLE' ||
|
|
97
|
-
error?.code === 'RESOURCE_EXHAUSTED' ||
|
|
98
|
-
error?.message?.includes('already-exists') ||
|
|
99
|
-
error?.message?.includes('timeout') ||
|
|
100
|
-
error?.message?.includes('unavailable');
|
|
101
|
-
|
|
102
|
-
if (isTransientError && attempt < maxRetries - 1) {
|
|
103
|
-
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const errorMessage = lastError instanceof Error
|
|
111
|
-
? lastError.message
|
|
112
|
-
: typeof lastError === 'string'
|
|
113
|
-
? lastError
|
|
114
|
-
: 'Unknown error during credit initialization';
|
|
115
|
-
|
|
116
|
-
const errorCode = lastError?.code ?? 'UNKNOWN_ERROR';
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
success: false,
|
|
120
|
-
data: null,
|
|
121
|
-
error: {
|
|
122
|
-
message: errorMessage,
|
|
123
|
-
code: errorCode,
|
|
124
|
-
},
|
|
125
|
-
};
|
|
44
|
+
return initializeCreditsWithRetry({
|
|
45
|
+
db,
|
|
46
|
+
ref: this.getRef(db, userId),
|
|
47
|
+
config: this.config,
|
|
48
|
+
userId,
|
|
49
|
+
purchaseId,
|
|
50
|
+
productId,
|
|
51
|
+
source,
|
|
52
|
+
revenueCatData,
|
|
53
|
+
type,
|
|
54
|
+
});
|
|
126
55
|
}
|
|
127
56
|
|
|
128
|
-
/**
|
|
129
|
-
* Deducts credits using atomic transaction logic.
|
|
130
|
-
*/
|
|
131
57
|
async deductCredit(userId: string, cost: number): Promise<DeductCreditsResult> {
|
|
132
58
|
const db = requireFirestore();
|
|
133
59
|
return deductCreditsOperation(db, this.getRef(db, userId), cost, userId);
|
|
134
60
|
}
|
|
135
61
|
|
|
136
62
|
async hasCredits(userId: string, cost: number): Promise<boolean> {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
return result.data.credits >= cost;
|
|
63
|
+
const db = requireFirestore();
|
|
64
|
+
return checkHasCredits(this.getRef(db, userId), cost);
|
|
140
65
|
}
|
|
141
66
|
|
|
142
67
|
async syncExpiredStatus(userId: string): Promise<void> {
|
|
143
68
|
const db = requireFirestore();
|
|
144
|
-
|
|
145
|
-
await setDoc(ref, {
|
|
146
|
-
isPremium: false,
|
|
147
|
-
status: SUBSCRIPTION_STATUS.EXPIRED,
|
|
148
|
-
willRenew: false,
|
|
149
|
-
expirationDate: serverTimestamp(),
|
|
150
|
-
}, { merge: true });
|
|
69
|
+
await syncExpiredStatus(this.getRef(db, userId));
|
|
151
70
|
}
|
|
152
71
|
}
|
|
153
72
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getDoc } from "firebase/firestore";
|
|
2
|
+
import type { Firestore, DocumentReference } from "@umituz/react-native-firebase";
|
|
3
|
+
import type { CreditsResult } from "../../core/Credits";
|
|
4
|
+
import type { UserCreditsDocumentRead } from "../../core/UserCreditsDocument";
|
|
5
|
+
import { mapCreditsDocumentToEntity } from "../../core/CreditsMapper";
|
|
6
|
+
import { requireFirestore } from "../../../../shared/infrastructure/firestore";
|
|
7
|
+
|
|
8
|
+
export async function fetchCredits(ref: DocumentReference): Promise<CreditsResult> {
|
|
9
|
+
const snap = await getDoc(ref);
|
|
10
|
+
|
|
11
|
+
if (!snap.exists()) {
|
|
12
|
+
return { success: true, data: null, error: null };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const entity = mapCreditsDocumentToEntity(snap.data() as UserCreditsDocumentRead);
|
|
16
|
+
return { success: true, data: entity, error: null };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function checkHasCredits(ref: DocumentReference, cost: number): Promise<boolean> {
|
|
20
|
+
const result = await fetchCredits(ref);
|
|
21
|
+
if (!result.success || !result.data) return false;
|
|
22
|
+
return result.data.credits >= cost;
|
|
23
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Firestore, DocumentReference } from "@umituz/react-native-firebase";
|
|
2
|
+
import type { CreditsConfig, CreditsResult } from "../../core/Credits";
|
|
3
|
+
import type { UserCreditsDocumentRead, PurchaseSource } from "../../core/UserCreditsDocument";
|
|
4
|
+
import { initializeCreditsTransaction } from "../../application/CreditsInitializer";
|
|
5
|
+
import { mapCreditsDocumentToEntity } from "../../core/CreditsMapper";
|
|
6
|
+
import type { RevenueCatData } from "../../../revenuecat/core/types";
|
|
7
|
+
import { calculateCreditLimit } from "../../application/CreditLimitCalculator";
|
|
8
|
+
import { PURCHASE_TYPE, type PurchaseType } from "../../../subscription/core/SubscriptionConstants";
|
|
9
|
+
|
|
10
|
+
interface InitializeCreditsParams {
|
|
11
|
+
db: Firestore;
|
|
12
|
+
ref: DocumentReference;
|
|
13
|
+
config: CreditsConfig;
|
|
14
|
+
userId: string;
|
|
15
|
+
purchaseId: string;
|
|
16
|
+
productId: string;
|
|
17
|
+
source: PurchaseSource;
|
|
18
|
+
revenueCatData: RevenueCatData;
|
|
19
|
+
type?: PurchaseType;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isTransientError(error: any): boolean {
|
|
23
|
+
return (
|
|
24
|
+
error?.code === 'already-exists' ||
|
|
25
|
+
error?.code === 'DEADLINE_EXCEEDED' ||
|
|
26
|
+
error?.code === 'UNAVAILABLE' ||
|
|
27
|
+
error?.code === 'RESOURCE_EXHAUSTED' ||
|
|
28
|
+
error?.message?.includes('already-exists') ||
|
|
29
|
+
error?.message?.includes('timeout') ||
|
|
30
|
+
error?.message?.includes('unavailable')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function initializeCreditsWithRetry(params: InitializeCreditsParams): Promise<CreditsResult> {
|
|
35
|
+
const { db, ref, config, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
|
|
36
|
+
|
|
37
|
+
const creditLimit = calculateCreditLimit(productId, config);
|
|
38
|
+
const cfg = { ...config, creditLimit };
|
|
39
|
+
|
|
40
|
+
const maxRetries = 3;
|
|
41
|
+
let lastError: any;
|
|
42
|
+
|
|
43
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
const result = await initializeCreditsTransaction(
|
|
46
|
+
db,
|
|
47
|
+
ref,
|
|
48
|
+
cfg,
|
|
49
|
+
purchaseId,
|
|
50
|
+
{
|
|
51
|
+
productId,
|
|
52
|
+
source,
|
|
53
|
+
expirationDate: revenueCatData.expirationDate,
|
|
54
|
+
willRenew: revenueCatData.willRenew,
|
|
55
|
+
originalTransactionId: revenueCatData.originalTransactionId,
|
|
56
|
+
isPremium: revenueCatData.isPremium,
|
|
57
|
+
periodType: revenueCatData.periodType,
|
|
58
|
+
unsubscribeDetectedAt: revenueCatData.unsubscribeDetectedAt,
|
|
59
|
+
billingIssueDetectedAt: revenueCatData.billingIssueDetectedAt,
|
|
60
|
+
store: revenueCatData.store,
|
|
61
|
+
ownershipType: revenueCatData.ownershipType,
|
|
62
|
+
type,
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
data: result.finalData ? mapCreditsDocumentToEntity(result.finalData) : null,
|
|
69
|
+
error: null,
|
|
70
|
+
};
|
|
71
|
+
} catch (error: any) {
|
|
72
|
+
lastError = error;
|
|
73
|
+
|
|
74
|
+
if (isTransientError(error) && attempt < maxRetries - 1) {
|
|
75
|
+
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const errorMessage = lastError instanceof Error
|
|
83
|
+
? lastError.message
|
|
84
|
+
: typeof lastError === 'string'
|
|
85
|
+
? lastError
|
|
86
|
+
: 'Unknown error during credit initialization';
|
|
87
|
+
|
|
88
|
+
const errorCode = lastError?.code ?? 'UNKNOWN_ERROR';
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
data: null,
|
|
93
|
+
error: {
|
|
94
|
+
message: errorMessage,
|
|
95
|
+
code: errorCode,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { setDoc } from "firebase/firestore";
|
|
2
|
+
import type { DocumentReference } from "@umituz/react-native-firebase";
|
|
3
|
+
import { serverTimestamp } from "@umituz/react-native-firebase";
|
|
4
|
+
import { SUBSCRIPTION_STATUS } from "../../../subscription/core/SubscriptionConstants";
|
|
5
|
+
|
|
6
|
+
export async function syncExpiredStatus(ref: DocumentReference): Promise<void> {
|
|
7
|
+
await setDoc(ref, {
|
|
8
|
+
isPremium: false,
|
|
9
|
+
status: SUBSCRIPTION_STATUS.EXPIRED,
|
|
10
|
+
willRenew: false,
|
|
11
|
+
expirationDate: serverTimestamp(),
|
|
12
|
+
}, { merge: true });
|
|
13
|
+
}
|