@webstudio-is/plans 0.260.2 → 0.261.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/package.json +2 -2
- package/src/index.server.ts +1 -0
- package/src/index.ts +1 -1
- package/src/plan-client.server.test.ts +1 -19
- package/src/plan-client.server.ts +56 -12
- package/src/plan-features.test.ts +56 -13
- package/src/plan-features.ts +34 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webstudio-is/plans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.261.0",
|
|
4
4
|
"description": "Plan features and billing logic for Webstudio",
|
|
5
5
|
"author": "Webstudio <github@webstudio.is>",
|
|
6
6
|
"homepage": "https://webstudio.is",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"vitest": "^3.1.2",
|
|
14
|
-
"@webstudio-is/postgrest": "0.
|
|
14
|
+
"@webstudio-is/postgrest": "0.261.0",
|
|
15
15
|
"@webstudio-is/tsconfig": "1.0.7"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
package/src/index.server.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { type PlanFeatures, defaultPlanFeatures } from "./plan-features";
|
|
9
9
|
import { getPlanInfo, __testing__ } from "./plan-client.server";
|
|
10
10
|
|
|
11
|
-
const { parseProductMeta, mergeProductMetas
|
|
11
|
+
const { parseProductMeta, mergeProductMetas } = __testing__;
|
|
12
12
|
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// Unit tests for private helpers
|
|
@@ -17,7 +17,6 @@ const { parseProductMeta, mergeProductMetas, resetProductCache } = __testing__;
|
|
|
17
17
|
const server = createTestServer();
|
|
18
18
|
|
|
19
19
|
beforeEach(() => {
|
|
20
|
-
resetProductCache();
|
|
21
20
|
vi.unstubAllEnvs();
|
|
22
21
|
});
|
|
23
22
|
|
|
@@ -137,23 +136,6 @@ describe("getPlanInfo (msw)", () => {
|
|
|
137
136
|
const result = await getPlanInfo(["user-1"], testContext);
|
|
138
137
|
expect(result.get("user-1")?.purchases[0].planName).toBe("Pro");
|
|
139
138
|
});
|
|
140
|
-
|
|
141
|
-
test("Product table is queried only once across two calls (cache hit)", async () => {
|
|
142
|
-
vi.stubEnv("PLANS", JSON.stringify([]));
|
|
143
|
-
let productFetchCount = 0;
|
|
144
|
-
server.use(
|
|
145
|
-
db.get("UserProduct", () => json([proUserProduct])),
|
|
146
|
-
db.get("Product", () => {
|
|
147
|
-
productFetchCount += 1;
|
|
148
|
-
return json([proProduct]);
|
|
149
|
-
})
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
await getPlanInfo(["user-1"], testContext);
|
|
153
|
-
await getPlanInfo(["user-1"], testContext);
|
|
154
|
-
|
|
155
|
-
expect(productFetchCount).toBe(1);
|
|
156
|
-
});
|
|
157
139
|
});
|
|
158
140
|
|
|
159
141
|
/** Full-featured plan for use in tests — all booleans true, numeric limits at max */
|
|
@@ -75,22 +75,33 @@ type Product = { id: string; name: string; meta: unknown };
|
|
|
75
75
|
// The promise itself is cached to deduplicate concurrent cold-start requests.
|
|
76
76
|
let productCachePromise: Promise<Map<string, Product>> | undefined;
|
|
77
77
|
|
|
78
|
-
const
|
|
78
|
+
const fetchProducts = (
|
|
79
79
|
postgrest: PostgrestContext
|
|
80
|
-
): Promise<Map<string, Product>> =>
|
|
81
|
-
|
|
82
|
-
return productCachePromise;
|
|
83
|
-
}
|
|
84
|
-
const promise: Promise<Map<string, Product>> = Promise.resolve(
|
|
80
|
+
): Promise<Map<string, Product>> =>
|
|
81
|
+
Promise.resolve(
|
|
85
82
|
postgrest.client.from("Product").select("id, name, meta")
|
|
86
83
|
).then((result) => {
|
|
87
84
|
if (result.error) {
|
|
88
|
-
productCachePromise = undefined;
|
|
89
85
|
console.error(result.error);
|
|
90
86
|
throw new Error("Failed to fetch products");
|
|
91
87
|
}
|
|
92
88
|
return new Map(result.data.map((p) => [p.id, p as Product]));
|
|
93
89
|
});
|
|
90
|
+
|
|
91
|
+
const getProductCache = (
|
|
92
|
+
postgrest: PostgrestContext
|
|
93
|
+
): Promise<Map<string, Product>> => {
|
|
94
|
+
// Skip caching in test environment so each test gets a fresh fetch.
|
|
95
|
+
if (process.env.VITEST !== undefined) {
|
|
96
|
+
return fetchProducts(postgrest);
|
|
97
|
+
}
|
|
98
|
+
if (productCachePromise !== undefined) {
|
|
99
|
+
return productCachePromise;
|
|
100
|
+
}
|
|
101
|
+
const promise = fetchProducts(postgrest);
|
|
102
|
+
promise.catch(() => {
|
|
103
|
+
productCachePromise = undefined;
|
|
104
|
+
});
|
|
94
105
|
productCachePromise = promise;
|
|
95
106
|
return promise;
|
|
96
107
|
};
|
|
@@ -177,7 +188,7 @@ export const getPlanInfo = async (
|
|
|
177
188
|
}
|
|
178
189
|
return [
|
|
179
190
|
{
|
|
180
|
-
...(plansByName.get(product.name) ?? defaultPlanFeatures),
|
|
191
|
+
...(plansByName.get(product.name)?.features ?? defaultPlanFeatures),
|
|
181
192
|
...parseProductMeta(product.meta),
|
|
182
193
|
},
|
|
183
194
|
];
|
|
@@ -194,15 +205,48 @@ export const getPlanInfo = async (
|
|
|
194
205
|
);
|
|
195
206
|
};
|
|
196
207
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Returns the Stripe subscription item quantity for the given user, derived
|
|
210
|
+
* from the latest customer.subscription.updated/created event in TransactionLog.
|
|
211
|
+
* Returns null when no subscription event exists yet (free plan, AppSumo, etc.).
|
|
212
|
+
*/
|
|
213
|
+
export const getPaidSeats = async (
|
|
214
|
+
userId: string,
|
|
215
|
+
context: { postgrest: PostgrestContext }
|
|
216
|
+
): Promise<number | null> => {
|
|
217
|
+
const result = await context.postgrest.client
|
|
218
|
+
.from("TransactionLog")
|
|
219
|
+
.select("eventData")
|
|
220
|
+
.eq("userId", userId)
|
|
221
|
+
.in("eventType", [
|
|
222
|
+
"customer.subscription.updated",
|
|
223
|
+
"customer.subscription.created",
|
|
224
|
+
])
|
|
225
|
+
.order("eventCreated", { ascending: false })
|
|
226
|
+
.limit(1)
|
|
227
|
+
.maybeSingle();
|
|
228
|
+
|
|
229
|
+
if (result.error) {
|
|
230
|
+
throw result.error;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const eventData = result.data?.eventData as
|
|
234
|
+
| {
|
|
235
|
+
data?: {
|
|
236
|
+
object?: {
|
|
237
|
+
items?: { data?: Array<{ quantity?: unknown }> };
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
| null
|
|
242
|
+
| undefined;
|
|
243
|
+
const rawQuantity = eventData?.data?.object?.items?.data?.[0]?.quantity;
|
|
244
|
+
return typeof rawQuantity === "number" ? rawQuantity : null;
|
|
200
245
|
};
|
|
201
246
|
|
|
202
247
|
export const __testing__ = {
|
|
203
248
|
parseProductMeta,
|
|
204
249
|
mergeProductMetas,
|
|
205
|
-
resetProductCache,
|
|
206
250
|
};
|
|
207
251
|
|
|
208
252
|
export const getAuthorizationOwnerId = (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
type PlanFeatures,
|
|
4
|
+
type PlanConfig,
|
|
4
5
|
defaultPlanFeatures,
|
|
5
6
|
parsePlansEnv,
|
|
6
7
|
} from "./plan-features";
|
|
@@ -21,6 +22,13 @@ const fullFeatures: PlanFeatures = {
|
|
|
21
22
|
maxProjectsAllowedPerUser: 100,
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
/** Helper to build expected PlanConfig objects in tests */
|
|
26
|
+
const planConfig = (
|
|
27
|
+
name: string,
|
|
28
|
+
features: PlanFeatures,
|
|
29
|
+
prices: Record<string, string> = {}
|
|
30
|
+
): PlanConfig => ({ name, features, prices });
|
|
31
|
+
|
|
24
32
|
describe("parsePlansEnv", () => {
|
|
25
33
|
const validEntry = JSON.stringify({ name: "Pro", features: fullFeatures });
|
|
26
34
|
const twoEntries = JSON.stringify([
|
|
@@ -48,13 +56,14 @@ describe("parsePlansEnv", () => {
|
|
|
48
56
|
])
|
|
49
57
|
);
|
|
50
58
|
expect(result.size).toBe(2);
|
|
51
|
-
expect(result.get("LTD T2")).toEqual(
|
|
59
|
+
expect(result.get("LTD T2")).toEqual(planConfig("LTD T2", fullFeatures));
|
|
60
|
+
expect(result.get("Pro")).toEqual(planConfig("Pro", fullFeatures));
|
|
52
61
|
});
|
|
53
62
|
|
|
54
63
|
test("entry with no features key and no extends resolves to defaultPlanFeatures", () => {
|
|
55
64
|
const result = parsePlansEnv(JSON.stringify([{ name: "Free" }]));
|
|
56
65
|
expect(result.size).toBe(1);
|
|
57
|
-
expect(result.get("Free")).toEqual(defaultPlanFeatures);
|
|
66
|
+
expect(result.get("Free")).toEqual(planConfig("Free", defaultPlanFeatures));
|
|
58
67
|
});
|
|
59
68
|
|
|
60
69
|
test("extends: child inherits parent features and overrides specific fields", () => {
|
|
@@ -65,9 +74,11 @@ describe("parsePlansEnv", () => {
|
|
|
65
74
|
])
|
|
66
75
|
);
|
|
67
76
|
expect(result.size).toBe(2);
|
|
68
|
-
expect(result.get("Team")!.canDownloadAssets).toBe(true);
|
|
69
|
-
expect(result.get("Team")!.maxWorkspaces).toBe(50);
|
|
70
|
-
expect(result.get("Pro")!.maxWorkspaces).toBe(
|
|
77
|
+
expect(result.get("Team")!.features.canDownloadAssets).toBe(true);
|
|
78
|
+
expect(result.get("Team")!.features.maxWorkspaces).toBe(50);
|
|
79
|
+
expect(result.get("Pro")!.features.maxWorkspaces).toBe(
|
|
80
|
+
fullFeatures.maxWorkspaces
|
|
81
|
+
);
|
|
71
82
|
});
|
|
72
83
|
|
|
73
84
|
test("extends: throws when extending an unknown plan", () => {
|
|
@@ -87,13 +98,46 @@ describe("parsePlansEnv", () => {
|
|
|
87
98
|
test("parses a single valid entry", () => {
|
|
88
99
|
const result = parsePlansEnv(`[${validEntry}]`);
|
|
89
100
|
expect(result.size).toBe(1);
|
|
90
|
-
expect(result.get("Pro")).toEqual(fullFeatures);
|
|
101
|
+
expect(result.get("Pro")).toEqual(planConfig("Pro", fullFeatures));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("parses prices from entry", () => {
|
|
105
|
+
const result = parsePlansEnv(
|
|
106
|
+
JSON.stringify([
|
|
107
|
+
{
|
|
108
|
+
name: "Pro",
|
|
109
|
+
features: fullFeatures,
|
|
110
|
+
prices: { monthly: "price_abc", yearly: "price_def" },
|
|
111
|
+
},
|
|
112
|
+
])
|
|
113
|
+
);
|
|
114
|
+
expect(result.size).toBe(1);
|
|
115
|
+
expect(result.get("Pro")!.prices).toEqual({
|
|
116
|
+
monthly: "price_abc",
|
|
117
|
+
yearly: "price_def",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("prices default to empty object when not provided", () => {
|
|
122
|
+
const result = parsePlansEnv(`[${validEntry}]`);
|
|
123
|
+
expect(result.get("Pro")!.prices).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("skips entry with invalid prices (non-string value)", () => {
|
|
127
|
+
const result = parsePlansEnv(
|
|
128
|
+
JSON.stringify([
|
|
129
|
+
{ name: "Bad", features: fullFeatures, prices: { monthly: 123 } },
|
|
130
|
+
{ name: "Pro", features: fullFeatures },
|
|
131
|
+
])
|
|
132
|
+
);
|
|
133
|
+
expect(result.size).toBe(1);
|
|
134
|
+
expect(result.get("Pro")).toBeDefined();
|
|
91
135
|
});
|
|
92
136
|
|
|
93
137
|
test("parses multiple valid entries", () => {
|
|
94
138
|
const result = parsePlansEnv(twoEntries);
|
|
95
139
|
expect(result.size).toBe(2);
|
|
96
|
-
expect(result.get("Workspaces")!.maxWorkspaces).toBe(10);
|
|
140
|
+
expect(result.get("Workspaces")!.features.maxWorkspaces).toBe(10);
|
|
97
141
|
});
|
|
98
142
|
|
|
99
143
|
test("skips entries with invalid features (wrong type), keeps valid ones", () => {
|
|
@@ -104,7 +148,7 @@ describe("parsePlansEnv", () => {
|
|
|
104
148
|
])
|
|
105
149
|
);
|
|
106
150
|
expect(result.size).toBe(1);
|
|
107
|
-
expect(result.get("Pro")).toEqual(fullFeatures);
|
|
151
|
+
expect(result.get("Pro")).toEqual(planConfig("Pro", fullFeatures));
|
|
108
152
|
});
|
|
109
153
|
|
|
110
154
|
test("partial features without extends fills in from defaultPlanFeatures", () => {
|
|
@@ -112,10 +156,9 @@ describe("parsePlansEnv", () => {
|
|
|
112
156
|
JSON.stringify([{ name: "Pro", features: { canDownloadAssets: true } }])
|
|
113
157
|
);
|
|
114
158
|
expect(result.size).toBe(1);
|
|
115
|
-
expect(result.get("Pro")).toEqual(
|
|
116
|
-
...defaultPlanFeatures,
|
|
117
|
-
|
|
118
|
-
});
|
|
159
|
+
expect(result.get("Pro")).toEqual(
|
|
160
|
+
planConfig("Pro", { ...defaultPlanFeatures, canDownloadAssets: true })
|
|
161
|
+
);
|
|
119
162
|
});
|
|
120
163
|
|
|
121
164
|
test("strips unknown keys from features", () => {
|
|
@@ -126,7 +169,7 @@ describe("parsePlansEnv", () => {
|
|
|
126
169
|
);
|
|
127
170
|
expect(result.size).toBe(1);
|
|
128
171
|
expect(
|
|
129
|
-
(result.get("Pro") as Record<string, unknown>)["admin"]
|
|
172
|
+
(result.get("Pro")!.features as Record<string, unknown>)["admin"]
|
|
130
173
|
).toBeUndefined();
|
|
131
174
|
});
|
|
132
175
|
});
|
package/src/plan-features.ts
CHANGED
|
@@ -55,15 +55,24 @@ export type Purchase = {
|
|
|
55
55
|
subscriptionId?: string;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
const PricesSchema = z.record(z.string(), z.string());
|
|
59
|
+
|
|
60
|
+
/** A parsed plan entry with resolved features and price IDs keyed by billing cycle */
|
|
61
|
+
export type PlanConfig = {
|
|
62
|
+
name: string;
|
|
63
|
+
features: PlanFeatures;
|
|
64
|
+
prices: Record<string, string>;
|
|
65
|
+
};
|
|
66
|
+
|
|
58
67
|
/**
|
|
59
|
-
* Parse the PLANS env variable (JSON array of {name, extends?, features}).
|
|
68
|
+
* Parse the PLANS env variable (JSON array of {name, extends?, features, prices?}).
|
|
60
69
|
* - features is partial when extends is used; the parent plan fills in the rest.
|
|
61
70
|
* - The final merged features are validated against the full PlanFeaturesSchema.
|
|
62
71
|
* - Invalid entries are skipped with a console.error.
|
|
63
72
|
* - An extends reference to an unknown plan name throws an error.
|
|
64
|
-
* Returns a Map of plan name →
|
|
73
|
+
* Returns a Map of plan name → { name, features, prices }.
|
|
65
74
|
*/
|
|
66
|
-
export const parsePlansEnv = (raw: string): Map<string,
|
|
75
|
+
export const parsePlansEnv = (raw: string): Map<string, PlanConfig> => {
|
|
67
76
|
try {
|
|
68
77
|
const parsed = JSON.parse(raw);
|
|
69
78
|
if (!Array.isArray(parsed)) {
|
|
@@ -74,6 +83,7 @@ export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
|
|
|
74
83
|
name: string;
|
|
75
84
|
extends?: string;
|
|
76
85
|
features: Partial<PlanFeatures>;
|
|
86
|
+
prices: Record<string, string>;
|
|
77
87
|
};
|
|
78
88
|
|
|
79
89
|
// First pass: validate entry structure and collect partial features.
|
|
@@ -90,13 +100,23 @@ export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
|
|
|
90
100
|
console.error("Invalid PLANS entry (extends must be a string):", item);
|
|
91
101
|
return [];
|
|
92
102
|
}
|
|
93
|
-
const
|
|
103
|
+
const featuresResult = PlanFeaturesSchema.partial().safeParse(
|
|
94
104
|
"features" in item ? item.features : {}
|
|
95
105
|
);
|
|
96
|
-
if (!
|
|
106
|
+
if (!featuresResult.success) {
|
|
97
107
|
console.error(
|
|
98
108
|
`Invalid PLANS entry "${item.name}" features:`,
|
|
99
|
-
|
|
109
|
+
featuresResult.error.flatten()
|
|
110
|
+
);
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const pricesResult = PricesSchema.safeParse(
|
|
114
|
+
"prices" in item ? item.prices : {}
|
|
115
|
+
);
|
|
116
|
+
if (!pricesResult.success) {
|
|
117
|
+
console.error(
|
|
118
|
+
`Invalid PLANS entry "${item.name}" prices:`,
|
|
119
|
+
pricesResult.error.flatten()
|
|
100
120
|
);
|
|
101
121
|
return [];
|
|
102
122
|
}
|
|
@@ -104,7 +124,8 @@ export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
|
|
|
104
124
|
{
|
|
105
125
|
name: item.name,
|
|
106
126
|
extends: "extends" in item ? (item.extends as string) : undefined,
|
|
107
|
-
features:
|
|
127
|
+
features: featuresResult.data,
|
|
128
|
+
prices: pricesResult.data,
|
|
108
129
|
} satisfies PlanEntry,
|
|
109
130
|
];
|
|
110
131
|
});
|
|
@@ -116,7 +137,7 @@ export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
|
|
|
116
137
|
|
|
117
138
|
// Second pass: resolve extends and validate the full feature set.
|
|
118
139
|
// Every entry implicitly extends defaultPlanFeatures; "extends" picks a named plan on top of that.
|
|
119
|
-
const resolved = new Map<string,
|
|
140
|
+
const resolved = new Map<string, PlanConfig>();
|
|
120
141
|
for (const entry of entries) {
|
|
121
142
|
if (entry.extends !== undefined && !byName.has(entry.extends)) {
|
|
122
143
|
throw new Error(
|
|
@@ -138,7 +159,11 @@ export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
|
|
|
138
159
|
);
|
|
139
160
|
continue;
|
|
140
161
|
}
|
|
141
|
-
resolved.set(entry.name,
|
|
162
|
+
resolved.set(entry.name, {
|
|
163
|
+
name: entry.name,
|
|
164
|
+
features: result.data,
|
|
165
|
+
prices: entry.prices,
|
|
166
|
+
});
|
|
142
167
|
}
|
|
143
168
|
return resolved;
|
|
144
169
|
} catch (error) {
|