revenuecat-server 1.0.0
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/LICENSE +21 -0
- package/README.md +213 -0
- package/index.d.ts +74 -0
- package/index.js +7 -0
- package/package.json +46 -0
- package/src/RevenueCatClient.js +242 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 despia-native
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# revenuecat-server
|
|
2
|
+
|
|
3
|
+
A lightweight server-side SDK for the [RevenueCat v2 REST API](https://www.revenuecat.com/docs/api-v2). Makes entitlement checks and offering/package management simple — so you can follow security best practices without wrestling with webhooks or complex API calls.
|
|
4
|
+
|
|
5
|
+
**Works with:** Node.js 18+, Deno, Bun — and any backend (Express, Fastify, Hono, Next.js API routes, Lambda, etc.). Zero dependencies.
|
|
6
|
+
|
|
7
|
+
> **Server-side only.** API keys must **never** be on the frontend. Use this SDK in your backend only. Build secure API proxies: your frontend calls *your* backend; your backend uses this SDK to fetch offerings, check entitlements, etc. Never expose RevenueCat API keys to the client.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install revenuecat-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add revenuecat-server
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
yarn add revenuecat-server
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Node.js 18+ (uses native `fetch`), or Deno/Bun
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const { RevenueCatClient } = require("revenuecat-server");
|
|
31
|
+
|
|
32
|
+
const rc = new RevenueCatClient({
|
|
33
|
+
apiKey: process.env.REVENUECAT_API_KEY, // v2 secret key
|
|
34
|
+
projectId: process.env.REVENUECAT_PROJECT_ID,
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
### Customers
|
|
43
|
+
|
|
44
|
+
#### `getCustomer(customerId, options?)`
|
|
45
|
+
|
|
46
|
+
Fetch a single customer. `active_entitlements` is always included.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
const customer = await rc.getCustomer("user-uuid");
|
|
50
|
+
|
|
51
|
+
// With attributes expanded
|
|
52
|
+
const customer = await rc.getCustomer("user-uuid", {
|
|
53
|
+
expand: ["attributes"],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
#### `getActiveEntitlements(customerId)`
|
|
60
|
+
|
|
61
|
+
Returns all active entitlements as a flat array (auto-paginates).
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const entitlements = await rc.getActiveEntitlements("user-uuid");
|
|
65
|
+
// [{ entitlement_id: "premium", expires_at: 1234567890000 }, ...]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
#### `hasEntitlement(customerId, entitlementId)`
|
|
71
|
+
|
|
72
|
+
Quick boolean check for a specific entitlement.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const isPremium = await rc.hasEntitlement("user-uuid", "premium");
|
|
76
|
+
// true | false
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
#### `requireEntitlement(customerId, entitlementId)`
|
|
82
|
+
|
|
83
|
+
Throws `AccessDeniedError` if the customer lacks the entitlement. Ideal for API route guards.
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
try {
|
|
87
|
+
await rc.requireEntitlement("user-uuid", "premium");
|
|
88
|
+
// User has access — serve premium content or proceed with operation
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof AccessDeniedError) {
|
|
91
|
+
return res.status(403).json({ error: "Premium required" });
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
#### `requireAnyEntitlement(customerId, entitlementIds)`
|
|
100
|
+
|
|
101
|
+
Throws `AccessDeniedError` if the customer has **none** of the given entitlements. Use for "any premium tier" checks.
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
await rc.requireAnyEntitlement("user-uuid", ["premium", "pro", "enterprise"]);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### Server-side access control
|
|
110
|
+
|
|
111
|
+
| Need | Use |
|
|
112
|
+
|------|-----|
|
|
113
|
+
| Gate an API route or operation | `requireEntitlement(customerId, "premium")` |
|
|
114
|
+
| Conditional logic / custom UI | `hasEntitlement(customerId, "premium")` |
|
|
115
|
+
| "Any of these tiers" check | `requireAnyEntitlement(customerId, ["premium", "pro"])` |
|
|
116
|
+
| Sync entitlements to your DB (cron) | `getActiveEntitlements(customerId)` |
|
|
117
|
+
| Handle access denied | `catch (err) { if (err instanceof AccessDeniedError) { ... } }` |
|
|
118
|
+
|
|
119
|
+
You can rely on live checks instead of webhooks if that fits your stack better — this SDK keeps the flow simple.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### Offerings
|
|
124
|
+
|
|
125
|
+
#### `getOffering(offeringId, options?)`
|
|
126
|
+
|
|
127
|
+
Fetch a raw offering. Defaults to `expand=["package.product"]`.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const offering = await rc.getOffering("ofrnge1a2b3c4d5");
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
#### `getProductsByPlatform(offeringId, packageId)`
|
|
136
|
+
|
|
137
|
+
Get products for a specific package, grouped by platform.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const products = await rc.getProductsByPlatform(
|
|
141
|
+
"ofrnge1a2b3c4d5",
|
|
142
|
+
"pkge1a2b3c4d5"
|
|
143
|
+
);
|
|
144
|
+
// {
|
|
145
|
+
// ios: [{ store_identifier: "com.app.monthly", display_name: "...", ... }],
|
|
146
|
+
// android: [{ store_identifier: "com.app.monthly.android", ... }],
|
|
147
|
+
// other: []
|
|
148
|
+
// }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
#### `getOfferingPackages(offeringId)`
|
|
154
|
+
|
|
155
|
+
All packages in an offering, each with products grouped by platform. Auto-paginates both packages and products.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
const packages = await rc.getOfferingPackages("ofrnge1a2b3c4d5");
|
|
159
|
+
// [
|
|
160
|
+
// {
|
|
161
|
+
// id: "pkge1a2b3c4d5",
|
|
162
|
+
// lookup_key: "monthly",
|
|
163
|
+
// display_name: "Monthly",
|
|
164
|
+
// position: 1,
|
|
165
|
+
// products: { ios: [...], android: [...], other: [...] }
|
|
166
|
+
// }
|
|
167
|
+
// ]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Error Handling
|
|
173
|
+
|
|
174
|
+
- **RevenueCatError** — Thrown on API errors (4xx, 5xx). Has `status` and `body`.
|
|
175
|
+
- **AccessDeniedError** — Thrown by `requireEntitlement` and `requireAnyEntitlement` when access is denied. Has `customerId` and `entitlementIds`.
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
const { RevenueCatError, AccessDeniedError } = require("revenuecat-server");
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const customer = await rc.getCustomer("bad-id");
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err instanceof RevenueCatError) {
|
|
184
|
+
console.error(err.status); // 404
|
|
185
|
+
console.error(err.body); // { message: "Not found" }
|
|
186
|
+
}
|
|
187
|
+
if (err instanceof AccessDeniedError) {
|
|
188
|
+
console.error(err.customerId, err.entitlementIds);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Notes
|
|
196
|
+
|
|
197
|
+
- **Server-side only** — Run this SDK in your backend. Expose data to your frontend via secure proxy endpoints (e.g. `GET /api/offerings`, `GET /api/me/entitlements`). Your frontend never touches RevenueCat directly.
|
|
198
|
+
- **Prices are not available** via RevenueCat — fetch those from Apple StoreKit / Google Play Billing directly using `store_identifier`.
|
|
199
|
+
- Your API key must have the correct permissions:
|
|
200
|
+
- `customer_information:customers:read` — for customer/entitlement endpoints
|
|
201
|
+
- `project_configuration:packages:read` — for packages
|
|
202
|
+
- `project_configuration:products:read` — for products
|
|
203
|
+
- Rate limits: **480 req/min** for customer endpoints, **60 req/min** for offering/project config endpoints.
|
|
204
|
+
|
|
205
|
+
## Run Tests
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
npm test
|
|
209
|
+
# or
|
|
210
|
+
pnpm test
|
|
211
|
+
# or
|
|
212
|
+
yarn test
|
|
213
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revenuecat-server — Server-side SDK for RevenueCat v2 API
|
|
3
|
+
* @see https://www.revenuecat.com/docs/api-v2
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface RevenueCatClientConfig {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GetCustomerOptions {
|
|
12
|
+
expand?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GetOfferingOptions {
|
|
16
|
+
expand?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProductsByPlatform {
|
|
20
|
+
ios: Product[];
|
|
21
|
+
android: Product[];
|
|
22
|
+
other: Product[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PackageWithProducts {
|
|
26
|
+
id: string;
|
|
27
|
+
lookup_key: string;
|
|
28
|
+
display_name: string;
|
|
29
|
+
position: number;
|
|
30
|
+
products: ProductsByPlatform;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ActiveEntitlement {
|
|
34
|
+
entitlement_id: string;
|
|
35
|
+
expires_at?: number;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Product {
|
|
40
|
+
id: string;
|
|
41
|
+
store_identifier: string;
|
|
42
|
+
display_name?: string;
|
|
43
|
+
type?: string;
|
|
44
|
+
app?: { type?: string };
|
|
45
|
+
subscription?: { duration?: string; trial_duration?: string | null };
|
|
46
|
+
eligibility_criteria?: string;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class RevenueCatClient {
|
|
51
|
+
constructor(config: RevenueCatClientConfig);
|
|
52
|
+
|
|
53
|
+
getCustomer(customerId: string, options?: GetCustomerOptions): Promise<Record<string, unknown>>;
|
|
54
|
+
getActiveEntitlements(customerId: string): Promise<ActiveEntitlement[]>;
|
|
55
|
+
hasEntitlement(customerId: string, entitlementId: string): Promise<boolean>;
|
|
56
|
+
requireEntitlement(customerId: string, entitlementId: string): Promise<void>;
|
|
57
|
+
requireAnyEntitlement(customerId: string, entitlementIds: string[]): Promise<void>;
|
|
58
|
+
|
|
59
|
+
getOffering(offeringId: string, options?: GetOfferingOptions): Promise<Record<string, unknown>>;
|
|
60
|
+
getProductsByPlatform(offeringId: string, packageId: string): Promise<ProductsByPlatform>;
|
|
61
|
+
getOfferingPackages(offeringId: string): Promise<PackageWithProducts[]>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class RevenueCatError extends Error {
|
|
65
|
+
status: number;
|
|
66
|
+
body: Record<string, unknown>;
|
|
67
|
+
constructor(status: number, body: Record<string, unknown>);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class AccessDeniedError extends Error {
|
|
71
|
+
customerId: string;
|
|
72
|
+
entitlementIds: string[];
|
|
73
|
+
constructor(customerId: string, entitlementIdOrIds: string | string[]);
|
|
74
|
+
}
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "revenuecat-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight server-side SDK for the RevenueCat v2 API. Entitlement checks, offerings, packages — works with any backend (Node, Deno, Bun).",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"require": "./index.js",
|
|
11
|
+
"import": "./index.js",
|
|
12
|
+
"default": "./index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"index.d.ts",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node tests/client.test.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"revenuecat",
|
|
25
|
+
"subscriptions",
|
|
26
|
+
"entitlements",
|
|
27
|
+
"iap",
|
|
28
|
+
"in-app-purchase",
|
|
29
|
+
"server",
|
|
30
|
+
"backend",
|
|
31
|
+
"paywall"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/yourusername/revenuecat-server"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/yourusername/revenuecat-server/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/yourusername/revenuecat-server#readme",
|
|
45
|
+
"sideEffects": false
|
|
46
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const BASE_URL = "https://api.revenuecat.com/v2";
|
|
2
|
+
|
|
3
|
+
class RevenueCatClient {
|
|
4
|
+
constructor({ apiKey, projectId }) {
|
|
5
|
+
if (!apiKey) throw new Error("RevenueCatClient: apiKey is required");
|
|
6
|
+
if (!projectId) throw new Error("RevenueCatClient: projectId is required");
|
|
7
|
+
|
|
8
|
+
this.apiKey = apiKey;
|
|
9
|
+
this.projectId = projectId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
get #headers() {
|
|
15
|
+
return {
|
|
16
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async #get(path, params = {}) {
|
|
22
|
+
const url = new URL(`${BASE_URL}${path}`);
|
|
23
|
+
for (const [key, value] of Object.entries(params)) {
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
value.forEach((v) => url.searchParams.append(key, v));
|
|
26
|
+
} else {
|
|
27
|
+
url.searchParams.set(key, value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const response = await fetch(url.toString(), { headers: this.#headers });
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const error = await response.json().catch(() => ({}));
|
|
35
|
+
throw new RevenueCatError(response.status, error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return response.json();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async #paginate(firstPage, getItems, getNextPage) {
|
|
42
|
+
const items = [...getItems(firstPage)];
|
|
43
|
+
let nextPage = getNextPage(firstPage);
|
|
44
|
+
|
|
45
|
+
while (nextPage) {
|
|
46
|
+
const page = await this.#get(nextPage);
|
|
47
|
+
items.push(...getItems(page));
|
|
48
|
+
nextPage = getNextPage(page);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Customers ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get a customer by ID.
|
|
58
|
+
* @param {string} customerId
|
|
59
|
+
* @param {{ expand?: string[] }} options
|
|
60
|
+
*/
|
|
61
|
+
async getCustomer(customerId, { expand = [] } = {}) {
|
|
62
|
+
const params = expand.length ? { expand } : {};
|
|
63
|
+
return this.#get(
|
|
64
|
+
`/projects/${this.projectId}/customers/${customerId}`,
|
|
65
|
+
params
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get all active entitlements for a customer (auto-paginates).
|
|
71
|
+
* @param {string} customerId
|
|
72
|
+
* @returns {Promise<ActiveEntitlement[]>}
|
|
73
|
+
*/
|
|
74
|
+
async getActiveEntitlements(customerId) {
|
|
75
|
+
const customer = await this.getCustomer(customerId);
|
|
76
|
+
return this.#paginate(
|
|
77
|
+
customer.active_entitlements,
|
|
78
|
+
(page) => page.items,
|
|
79
|
+
(page) => page.next_page
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a customer has a specific entitlement active.
|
|
85
|
+
* @param {string} customerId
|
|
86
|
+
* @param {string} entitlementId
|
|
87
|
+
* @returns {Promise<boolean>}
|
|
88
|
+
*/
|
|
89
|
+
async hasEntitlement(customerId, entitlementId) {
|
|
90
|
+
const entitlements = await this.getActiveEntitlements(customerId);
|
|
91
|
+
return entitlements.some((e) => e.entitlement_id === entitlementId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Require a specific entitlement — throws AccessDeniedError if the customer
|
|
96
|
+
* does not have it. Use in API route guards or before protected operations.
|
|
97
|
+
* @param {string} customerId
|
|
98
|
+
* @param {string} entitlementId
|
|
99
|
+
* @throws {AccessDeniedError} when customer lacks the entitlement
|
|
100
|
+
*/
|
|
101
|
+
async requireEntitlement(customerId, entitlementId) {
|
|
102
|
+
const has = await this.hasEntitlement(customerId, entitlementId);
|
|
103
|
+
if (!has) {
|
|
104
|
+
throw new AccessDeniedError(customerId, entitlementId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Require at least one of the given entitlements — throws AccessDeniedError
|
|
110
|
+
* if the customer has none. Useful for "any premium tier" checks.
|
|
111
|
+
* @param {string} customerId
|
|
112
|
+
* @param {string[]} entitlementIds
|
|
113
|
+
* @throws {AccessDeniedError} when customer lacks all entitlements
|
|
114
|
+
*/
|
|
115
|
+
async requireAnyEntitlement(customerId, entitlementIds) {
|
|
116
|
+
const entitlements = await this.getActiveEntitlements(customerId);
|
|
117
|
+
const ids = new Set(entitlements.map((e) => e.entitlement_id));
|
|
118
|
+
const hasAny = entitlementIds.some((id) => ids.has(id));
|
|
119
|
+
if (!hasAny) {
|
|
120
|
+
throw new AccessDeniedError(customerId, entitlementIds);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Offerings ───────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get a full offering with packages and products.
|
|
128
|
+
* @param {string} offeringId
|
|
129
|
+
* @param {{ expand?: string[] }} options
|
|
130
|
+
*/
|
|
131
|
+
async getOffering(offeringId, { expand = ["package.product"] } = {}) {
|
|
132
|
+
return this.#get(
|
|
133
|
+
`/projects/${this.projectId}/offerings/${offeringId}`,
|
|
134
|
+
{ expand }
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get products for a specific package, grouped by platform.
|
|
140
|
+
* @param {string} offeringId
|
|
141
|
+
* @param {string} packageId
|
|
142
|
+
* @returns {Promise<{ ios: Product[], android: Product[], other: Product[] }>}
|
|
143
|
+
*/
|
|
144
|
+
async getProductsByPlatform(offeringId, packageId) {
|
|
145
|
+
const offering = await this.getOffering(offeringId);
|
|
146
|
+
|
|
147
|
+
const pkg = offering.packages.items.find((p) => p.id === packageId);
|
|
148
|
+
if (!pkg) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Package "${packageId}" not found in offering "${offeringId}"`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Paginate products if needed
|
|
155
|
+
const products = await this.#paginate(
|
|
156
|
+
pkg.products,
|
|
157
|
+
(page) => page.items,
|
|
158
|
+
(page) => page.next_page
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return groupProductsByPlatform(products);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get all packages in an offering, each with products grouped by platform.
|
|
166
|
+
* @param {string} offeringId
|
|
167
|
+
* @returns {Promise<PackageWithProducts[]>}
|
|
168
|
+
*/
|
|
169
|
+
async getOfferingPackages(offeringId) {
|
|
170
|
+
const offering = await this.getOffering(offeringId);
|
|
171
|
+
|
|
172
|
+
// Paginate packages if needed
|
|
173
|
+
const packages = await this.#paginate(
|
|
174
|
+
offering.packages,
|
|
175
|
+
(page) => page.items,
|
|
176
|
+
(page) => page.next_page
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return Promise.all(
|
|
180
|
+
packages.map(async (pkg) => {
|
|
181
|
+
const products = await this.#paginate(
|
|
182
|
+
pkg.products,
|
|
183
|
+
(page) => page.items,
|
|
184
|
+
(page) => page.next_page
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
id: pkg.id,
|
|
188
|
+
lookup_key: pkg.lookup_key,
|
|
189
|
+
display_name: pkg.display_name,
|
|
190
|
+
position: pkg.position,
|
|
191
|
+
products: groupProductsByPlatform(products),
|
|
192
|
+
};
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function groupProductsByPlatform(productItems) {
|
|
201
|
+
const result = { ios: [], android: [], other: [] };
|
|
202
|
+
|
|
203
|
+
for (const { product, eligibility_criteria } of productItems) {
|
|
204
|
+
const appType = product.app?.type;
|
|
205
|
+
const entry = { ...product, eligibility_criteria };
|
|
206
|
+
|
|
207
|
+
if (appType === "app_store" || appType === "mac_app_store") {
|
|
208
|
+
result.ios.push(entry);
|
|
209
|
+
} else if (appType === "play_store") {
|
|
210
|
+
result.android.push(entry);
|
|
211
|
+
} else {
|
|
212
|
+
result.other.push(entry);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
class RevenueCatError extends Error {
|
|
220
|
+
constructor(status, body) {
|
|
221
|
+
super(`RevenueCat API error ${status}: ${JSON.stringify(body)}`);
|
|
222
|
+
this.status = status;
|
|
223
|
+
this.body = body;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
class AccessDeniedError extends Error {
|
|
228
|
+
constructor(customerId, entitlementIdOrIds) {
|
|
229
|
+
const ids =
|
|
230
|
+
typeof entitlementIdOrIds === "string"
|
|
231
|
+
? entitlementIdOrIds
|
|
232
|
+
: entitlementIdOrIds.join(", ");
|
|
233
|
+
super(`Access denied: customer ${customerId} lacks entitlement(s) [${ids}]`);
|
|
234
|
+
this.customerId = customerId;
|
|
235
|
+
this.entitlementIds =
|
|
236
|
+
typeof entitlementIdOrIds === "string"
|
|
237
|
+
? [entitlementIdOrIds]
|
|
238
|
+
: entitlementIdOrIds;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { RevenueCatClient, RevenueCatError, AccessDeniedError };
|