@webstudio-is/plans 0.260.2 → 0.261.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webstudio-is/plans",
3
- "version": "0.260.2",
3
+ "version": "0.261.1",
4
4
  "description": "Plan features and billing logic for Webstudio",
5
5
  "author": "Webstudio <github@webstudio.is>",
6
6
  "homepage": "https://webstudio.is",
@@ -11,8 +11,8 @@
11
11
  },
12
12
  "devDependencies": {
13
13
  "vitest": "^3.1.2",
14
- "@webstudio-is/postgrest": "0.260.2",
15
- "@webstudio-is/tsconfig": "1.0.7"
14
+ "@webstudio-is/tsconfig": "1.0.7",
15
+ "@webstudio-is/postgrest": "0.261.1"
16
16
  },
17
17
  "exports": {
18
18
  ".": {
@@ -1,6 +1,7 @@
1
1
  export * from "./index";
2
2
  export {
3
3
  getPlanInfo,
4
+ getPaidSeats,
4
5
  getAuthorizationOwnerId,
5
6
  parsePlansEnv,
6
7
  parseProductMeta,
package/src/index.ts CHANGED
@@ -3,4 +3,4 @@ export {
3
3
  defaultPlanFeatures,
4
4
  parsePlansEnv,
5
5
  } from "./plan-features";
6
- export type { PlanFeatures, Purchase } from "./plan-features";
6
+ export type { PlanFeatures, Purchase, PlanConfig } from "./plan-features";
@@ -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, resetProductCache } = __testing__;
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 getProductCache = (
78
+ const fetchProducts = (
79
79
  postgrest: PostgrestContext
80
- ): Promise<Map<string, Product>> => {
81
- if (productCachePromise !== undefined) {
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
- /** Resets the module-level product cache. Only for use in tests. */
198
- const resetProductCache = () => {
199
- productCachePromise = undefined;
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(result.get("Pro"));
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(fullFeatures.maxWorkspaces);
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
- canDownloadAssets: true,
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
  });
@@ -18,7 +18,7 @@ export const PlanFeaturesSchema = z.object({
18
18
  maxWorkspaces: z.number().nonnegative(),
19
19
  maxProjectsAllowedPerUser: z.number().nonnegative(),
20
20
  maxAssetsPerProject: z.number().nonnegative(),
21
- minSeats: z.number().nonnegative(),
21
+ seatsIncluded: z.number().nonnegative(),
22
22
  maxSeatsPerWorkspace: z.number().nonnegative(),
23
23
  });
24
24
 
@@ -45,7 +45,7 @@ export const defaultPlanFeatures: PlanFeatures = {
45
45
  maxWorkspaces: 1,
46
46
  maxProjectsAllowedPerUser: 100,
47
47
  maxAssetsPerProject: 50,
48
- minSeats: 0,
48
+ seatsIncluded: 0,
49
49
  maxSeatsPerWorkspace: 0,
50
50
  };
51
51
 
@@ -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 → resolved PlanFeatures.
73
+ * Returns a Map of plan name → { name, features, prices }.
65
74
  */
66
- export const parsePlansEnv = (raw: string): Map<string, PlanFeatures> => {
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 result = PlanFeaturesSchema.partial().safeParse(
103
+ const featuresResult = PlanFeaturesSchema.partial().safeParse(
94
104
  "features" in item ? item.features : {}
95
105
  );
96
- if (!result.success) {
106
+ if (!featuresResult.success) {
97
107
  console.error(
98
108
  `Invalid PLANS entry "${item.name}" features:`,
99
- result.error.flatten()
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: result.data,
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, PlanFeatures>();
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, result.data);
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) {