@startsimpli/billing 0.4.17 → 0.4.19

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": "@startsimpli/billing",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Tiers are cumulative: a paid plan includes what the cheaper plan includes.
3
+ *
4
+ * Real offer data only lists what a tier ADDS — crochet-pro declares
5
+ * advanced_generation / stitch_library / pdf_export / custom_sizing and omits
6
+ * the basic_generation and save_patterns that crochet-free declares. The table
7
+ * rendered every omission as an em-dash, so the paid plan showed a dash exactly
8
+ * where the free plan showed a tick, and read as though Pro could not save
9
+ * patterns. Reported as "the paid plan doesn't look like it has more checks".
10
+ *
11
+ * The fixture in PricingDetailPage.test.tsx restates every feature on every
12
+ * tier, which is why this never showed up there. This one is shaped like the
13
+ * data production actually serves.
14
+ */
15
+
16
+ import { describe, it, expect, vi } from "vitest";
17
+ import { render, screen, waitFor, within } from "@testing-library/react";
18
+ import React from "react";
19
+ import { BillingProvider } from "../hooks/BillingProvider";
20
+ import { PricingDetailPage } from "./PricingDetailPage";
21
+ import type { BillingProduct, OfferFeature } from "../types";
22
+
23
+ function offer(
24
+ over: Partial<BillingProduct["offers"][number]> & { name: string; slug: string; features: OfferFeature[] },
25
+ ) {
26
+ return {
27
+ id: over.slug,
28
+ unitPrice: "0.00",
29
+ currency: "USD",
30
+ pricingModel: "flat" as const,
31
+ intervalUnit: "month" as const,
32
+ intervalCount: 1,
33
+ intervalDisplay: "per month",
34
+ isRecurring: true,
35
+ isFree: false,
36
+ isActive: true,
37
+ isFeatured: false,
38
+ trialDays: 0,
39
+ sortOrder: 0,
40
+ ctaText: "Choose",
41
+ ...over,
42
+ };
43
+ }
44
+
45
+ /** Shaped like the live crochet-patterns product. */
46
+ const product: BillingProduct = {
47
+ id: "prod-crochet",
48
+ slug: "crochet-patterns",
49
+ name: "Crochet Patterns",
50
+ description: "AI-powered crochet pattern generator",
51
+ offers: [
52
+ offer({
53
+ name: "Free",
54
+ slug: "crochet-free",
55
+ isFree: true,
56
+ sortOrder: 0,
57
+ features: [
58
+ { key: "patterns_per_month", name: "Patterns per month", value: 3, limit: 3 },
59
+ { key: "basic_generation", name: "Basic pattern generation", value: true },
60
+ { key: "save_patterns", name: "Save patterns", value: true },
61
+ ],
62
+ }),
63
+ offer({
64
+ name: "Pro",
65
+ slug: "crochet-pro",
66
+ unitPrice: "9.00",
67
+ sortOrder: 1,
68
+ isFeatured: true,
69
+ features: [
70
+ { key: "patterns_per_month", name: "Patterns per month", value: -1 },
71
+ { key: "advanced_generation", name: "Advanced pattern generation", value: true },
72
+ { key: "pdf_export", name: "PDF export", value: true },
73
+ ],
74
+ }),
75
+ ],
76
+ };
77
+
78
+ function renderPricing(p: BillingProduct = product) {
79
+ const fetcher = vi.fn().mockResolvedValue({
80
+ ok: true,
81
+ json: () => Promise.resolve(p),
82
+ }) as unknown as typeof fetch;
83
+
84
+ return render(
85
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
86
+ <PricingDetailPage productId={p.slug} />
87
+ </BillingProvider>,
88
+ );
89
+ }
90
+
91
+ /** The cell for one offer in one feature row, by column position. */
92
+ function cell(featureKey: string, column: number) {
93
+ const row = screen.getByTestId(`comparison-row-${featureKey}`);
94
+ // column 0 is the feature name.
95
+ return within(row).getAllByRole("cell")[column + 1];
96
+ }
97
+
98
+ describe("comparison table — cumulative tiers", () => {
99
+ it("shows the paid plan as including what the free plan includes", async () => {
100
+ renderPricing();
101
+ await waitFor(() => expect(screen.getByTestId("comparison-table")).toBeDefined());
102
+
103
+ for (const key of ["basic_generation", "save_patterns"]) {
104
+ const pro = cell(key, 1);
105
+ expect(within(pro).queryByLabelText("Included"), key).not.toBeNull();
106
+ expect(pro.textContent, key).not.toContain("—");
107
+ }
108
+ });
109
+
110
+ it("gives the paid plan more ticks than the free plan, which is the whole point", async () => {
111
+ renderPricing();
112
+ await waitFor(() => expect(screen.getByTestId("comparison-table")).toBeDefined());
113
+
114
+ const ticks = (column: number) =>
115
+ ["basic_generation", "save_patterns", "advanced_generation", "pdf_export"].filter(
116
+ (key) => within(cell(key, column)).queryByLabelText("Included") !== null,
117
+ ).length;
118
+
119
+ expect(ticks(1)).toBeGreaterThan(ticks(0));
120
+ });
121
+
122
+ it("still lets a higher tier opt out by saying so explicitly", async () => {
123
+ renderPricing({
124
+ ...product,
125
+ offers: [
126
+ product.offers[0],
127
+ {
128
+ ...product.offers[1],
129
+ features: [
130
+ ...product.offers[1].features,
131
+ { key: "save_patterns", name: "Save patterns", value: false },
132
+ ],
133
+ },
134
+ ],
135
+ });
136
+ await waitFor(() => expect(screen.getByTestId("comparison-table")).toBeDefined());
137
+
138
+ expect(within(cell("save_patterns", 1)).queryByLabelText("Not included")).not.toBeNull();
139
+ });
140
+
141
+ it("never inherits a numeric allowance — a bigger plan must state its own", async () => {
142
+ renderPricing({
143
+ ...product,
144
+ offers: [
145
+ product.offers[0],
146
+ {
147
+ ...product.offers[1],
148
+ // Pro forgot to declare patterns_per_month.
149
+ features: product.offers[1].features.filter((f) => f.key !== "patterns_per_month"),
150
+ },
151
+ ],
152
+ });
153
+ await waitFor(() => expect(screen.getByTestId("comparison-table")).toBeDefined());
154
+
155
+ // Claiming Pro is capped at the free plan's 3 would be worse than saying nothing.
156
+ expect(cell("patterns_per_month", 1).textContent).not.toContain("3");
157
+ });
158
+
159
+ it("leaves the cheapest plan alone — nothing below it to inherit", async () => {
160
+ renderPricing();
161
+ await waitFor(() => expect(screen.getByTestId("comparison-table")).toBeDefined());
162
+
163
+ for (const key of ["advanced_generation", "pdf_export"]) {
164
+ expect(within(cell(key, 0)).queryByLabelText("Included"), key).toBeNull();
165
+ }
166
+ });
167
+
168
+ it("tells the reader on the card that a paid plan builds on the free one", async () => {
169
+ renderPricing();
170
+ await waitFor(() => expect(screen.getByTestId("detail-card-crochet-pro")).toBeDefined());
171
+
172
+ const proCard = screen.getByTestId("detail-card-crochet-pro");
173
+ expect(within(proCard).getByText(/everything in free/i)).toBeDefined();
174
+
175
+ const freeCard = screen.getByTestId("detail-card-crochet-free");
176
+ expect(within(freeCard).queryByText(/everything in/i)).toBeNull();
177
+ });
178
+ });
@@ -11,6 +11,7 @@
11
11
  import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer, OfferFeature } from "../types";
14
+ import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
14
15
 
15
16
  export interface PricingDetailPageProps {
16
17
  /** Product slug (e.g., "raise-simpli") */
@@ -83,6 +84,7 @@ export function PricingDetailPage({
83
84
  key={offer.id}
84
85
  offer={offer}
85
86
  onSelect={onSelectOffer}
87
+ buildsOn={tierBelow(product.offers, offer)?.name ?? null}
86
88
  />
87
89
  ))}
88
90
  </div>
@@ -101,9 +103,11 @@ export function PricingDetailPage({
101
103
  interface DetailCardProps {
102
104
  offer: ProductOffer;
103
105
  onSelect?: (offer: ProductOffer) => void;
106
+ /** Name of the tier just below this one, when there is one. */
107
+ buildsOn?: string | null;
104
108
  }
105
109
 
106
- function DetailCard({ offer, onSelect }: DetailCardProps) {
110
+ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
107
111
  const isFeatured = offer.isFeatured;
108
112
 
109
113
  return (
@@ -146,6 +150,12 @@ function DetailCard({ offer, onSelect }: DetailCardProps) {
146
150
  </span>
147
151
  )}
148
152
 
153
+ {buildsOn && (
154
+ <p className="text-sm font-semibold text-gray-900 mb-1">
155
+ Everything in {buildsOn}, plus:
156
+ </p>
157
+ )}
158
+
149
159
  <ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
150
160
  {offer.features.map((feature) => (
151
161
  <li
@@ -202,14 +212,9 @@ interface ComparisonTableProps {
202
212
  }
203
213
 
204
214
  function ComparisonTable({ offers, featureKeys, featureNameMap }: ComparisonTableProps) {
205
- // Build a lookup: offer slug -> feature key -> feature
206
- const featureLookup: Record<string, Record<string, OfferFeature>> = {};
207
- for (const offer of offers) {
208
- featureLookup[offer.slug] = {};
209
- for (const feature of offer.features) {
210
- featureLookup[offer.slug][feature.key] = feature;
211
- }
212
- }
215
+ // offer slug -> feature key -> feature, with cheaper tiers' capabilities
216
+ // carried upward so a paid plan never shows a dash where free shows a tick.
217
+ const featureLookup = cumulativeFeatureLookup(offers);
213
218
 
214
219
  return (
215
220
  <div data-testid="comparison-table" className="overflow-x-auto">
@@ -315,4 +315,24 @@ describe("PricingSection", () => {
315
315
  expect(screen.getByText(/unable to load/i)).toBeDefined();
316
316
  });
317
317
  });
318
+
319
+ // The home-page cards sit next to /pricing, which says "Everything in Free,
320
+ // plus:" on the paid tier. Without it, a card that lists only what the tier
321
+ // ADDS reads as a plan that lost the cheaper tier's features.
322
+ it("says a paid tier builds on the one below it", async () => {
323
+ const Wrapper = createWrapper(mockFetchSuccess());
324
+
325
+ render(
326
+ <Wrapper>
327
+ <PricingSection productId="raise-simpli" />
328
+ </Wrapper>
329
+ );
330
+
331
+ await waitFor(() => expect(screen.getByTestId("pricing-section")).toBeDefined());
332
+
333
+ expect(screen.getAllByText(/everything in starter, plus/i).length).toBeGreaterThan(0);
334
+ // The cheapest tier has nothing below it.
335
+ const starter = screen.getByTestId("section-card-starter-monthly");
336
+ expect(starter.textContent).not.toMatch(/everything in/i);
337
+ });
318
338
  });
@@ -11,6 +11,7 @@
11
11
  import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
+ import { tierBelow } from "../utils/tiers";
14
15
 
15
16
  export interface PricingSectionProps {
16
17
  /** Product slug (e.g., "raise-simpli") */
@@ -63,6 +64,7 @@ export function PricingSection({
63
64
  offer={offer}
64
65
  onSelect={onSelectOffer}
65
66
  maxFeatures={maxFeatures}
67
+ buildsOn={tierBelow(product.offers, offer)?.name ?? null}
66
68
  />
67
69
  ))}
68
70
  </div>
@@ -74,9 +76,11 @@ interface SectionCardProps {
74
76
  offer: ProductOffer;
75
77
  onSelect?: (offer: ProductOffer) => void;
76
78
  maxFeatures: number;
79
+ /** Name of the tier just below this one, when there is one. */
80
+ buildsOn?: string | null;
77
81
  }
78
82
 
79
- function SectionCard({ offer, onSelect, maxFeatures }: SectionCardProps) {
83
+ function SectionCard({ offer, onSelect, maxFeatures, buildsOn }: SectionCardProps) {
80
84
  const isFeatured = offer.isFeatured;
81
85
  const visibleFeatures = offer.features.slice(0, maxFeatures);
82
86
 
@@ -116,6 +120,12 @@ function SectionCard({ offer, onSelect, maxFeatures }: SectionCardProps) {
116
120
  </span>
117
121
  )}
118
122
 
123
+ {buildsOn && (
124
+ <p className="text-sm font-semibold text-gray-900 mb-1">
125
+ Everything in {buildsOn}, plus:
126
+ </p>
127
+ )}
128
+
119
129
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
120
130
  {visibleFeatures.map((feature) => (
121
131
  <li
@@ -108,6 +108,201 @@ describe("BillingApiClient", () => {
108
108
  });
109
109
  });
110
110
 
111
+ // startsim-ol36d.27: a buyer saw "Checkout failed: 400" while Django was
112
+ // returning a specific reason. The app route proxies through
113
+ // proxyBillingRequest, which wraps Django's raw error text in an envelope,
114
+ // so the reason arrives as a JSON *string* under `details`.
115
+ describe("error messages carry the server's reason", () => {
116
+ // Captured verbatim from POST https://crochets.site/api/billing/offer-checkout/
117
+ // (signed in) on 2026-09-17.
118
+ const LIVE = {
119
+ bogusOfferUuid:
120
+ '{"error":"Billing API error","status":400,"details":"{\\"offer_id\\":[\\"Invalid or inactive offer\\"]}"}',
121
+ malformedOfferId:
122
+ '{"error":"Billing API error","status":400,"details":"{\\"offer_id\\":[\\"Must be a valid UUID.\\"]}"}',
123
+ emptyBody:
124
+ '{"error":"Billing API error","status":400,"details":"{\\"offer_id\\":[\\"This field is required.\\"],\\"success_url\\":[\\"This field is required.\\"],\\"cancel_url\\":[\\"This field is required.\\"]}"}',
125
+ };
126
+
127
+ function failWith(status: number, body: unknown) {
128
+ mockFetch.mockResolvedValue({
129
+ ok: false,
130
+ status,
131
+ json: () => Promise.resolve(body),
132
+ });
133
+ }
134
+
135
+ function checkout() {
136
+ return createClient().createCheckout({
137
+ offerId: "bad",
138
+ successUrl: "x",
139
+ cancelUrl: "y",
140
+ });
141
+ }
142
+
143
+ it.each([
144
+ ["bogus offer id", LIVE.bogusOfferUuid, "Offer id: Invalid or inactive offer"],
145
+ ["malformed offer id", LIVE.malformedOfferId, "Offer id: Must be a valid UUID."],
146
+ ["empty body (first field wins)", LIVE.emptyBody, "Offer id: This field is required."],
147
+ ])("unwraps the live proxy envelope: %s", async (_name, raw, expected) => {
148
+ failWith(400, JSON.parse(raw));
149
+ await expect(checkout()).rejects.toThrow(new Error(expected));
150
+ });
151
+
152
+ it("unwraps the proxied non_field_errors that caused the original incident", async () => {
153
+ failWith(400, {
154
+ error: "Billing API error",
155
+ status: 400,
156
+ details: JSON.stringify({
157
+ non_field_errors: [
158
+ "BILLING_ALLOWED_REDIRECT_DOMAINS must be configured in production.",
159
+ ],
160
+ }),
161
+ });
162
+ await expect(checkout()).rejects.toThrow(
163
+ new Error("BILLING_ALLOWED_REDIRECT_DOMAINS must be configured in production.")
164
+ );
165
+ });
166
+
167
+ it("unwraps a camelCased body inside the proxy envelope", async () => {
168
+ failWith(400, {
169
+ error: "Billing API error",
170
+ status: 400,
171
+ details: JSON.stringify({ offerId: ["Invalid or inactive offer"] }),
172
+ });
173
+ await expect(checkout()).rejects.toThrow(
174
+ new Error("Offer id: Invalid or inactive offer")
175
+ );
176
+ });
177
+
178
+ it.each([
179
+ ["non_field_errors", { non_field_errors: ["Offer is not purchasable"] }],
180
+ ["nonFieldErrors", { nonFieldErrors: ["Offer is not purchasable"] }],
181
+ ])("reads a bare DRF %s array", async (_name, body) => {
182
+ failWith(400, body);
183
+ await expect(checkout()).rejects.toThrow(new Error("Offer is not purchasable"));
184
+ });
185
+
186
+ it.each([
187
+ ["offer_id", { offer_id: ["Invalid or inactive offer"] }],
188
+ ["offerId", { offerId: ["Invalid or inactive offer"] }],
189
+ ])("reads a bare DRF field error map keyed %s", async (_name, body) => {
190
+ failWith(400, body);
191
+ await expect(checkout()).rejects.toThrow(
192
+ new Error("Offer id: Invalid or inactive offer")
193
+ );
194
+ });
195
+
196
+ it("prefers detail over everything else", async () => {
197
+ failWith(400, {
198
+ detail: "Offer not synced",
199
+ non_field_errors: ["other"],
200
+ offer_id: ["other"],
201
+ });
202
+ await expect(checkout()).rejects.toThrow(new Error("Offer not synced"));
203
+ });
204
+
205
+ it("prefers non_field_errors over field errors", async () => {
206
+ failWith(400, {
207
+ offer_id: ["field message"],
208
+ non_field_errors: ["form message"],
209
+ });
210
+ await expect(checkout()).rejects.toThrow(new Error("form message"));
211
+ });
212
+
213
+ it.each([
214
+ [401, { error: "Not authenticated", code: "UNAUTHORIZED" }, "Not authenticated"],
215
+ [503, { error: "Billing service unavailable", code: "INTERNAL_ERROR" }, "Billing service unavailable"],
216
+ ])("surfaces an app route's {error, code} body (%i)", async (status, body, expected) => {
217
+ failWith(status, body);
218
+ await expect(checkout()).rejects.toThrow(new Error(expected));
219
+ });
220
+
221
+ it("never shows the proxy's generic label or an HTML page", async () => {
222
+ failWith(502, {
223
+ error: "Billing API error",
224
+ status: 502,
225
+ details: "<html><body><h1>502 Bad Gateway</h1></body></html>",
226
+ });
227
+ await expect(checkout()).rejects.toThrow(new Error("Checkout failed: 502"));
228
+ });
229
+
230
+ it("never shows the proxy's network-failure label", async () => {
231
+ failWith(500, { error: "Internal server error", details: "fetch failed" });
232
+ await expect(checkout()).rejects.toThrow(new Error("Checkout failed: 500"));
233
+ });
234
+
235
+ it("falls back to the status when the body is not JSON", async () => {
236
+ mockFetch.mockResolvedValue({
237
+ ok: false,
238
+ status: 400,
239
+ json: () => Promise.reject(new SyntaxError("Unexpected token <")),
240
+ });
241
+ await expect(checkout()).rejects.toThrow(new Error("Checkout failed: 400"));
242
+ });
243
+
244
+ it("falls back to the status when the body has no message", async () => {
245
+ failWith(400, {});
246
+ await expect(checkout()).rejects.toThrow(new Error("Checkout failed: 400"));
247
+ });
248
+
249
+ it("applies to subscribeFree", async () => {
250
+ failWith(400, JSON.parse(LIVE.bogusOfferUuid));
251
+ await expect(createClient().subscribeFree("bad")).rejects.toThrow(
252
+ new Error("Offer id: Invalid or inactive offer")
253
+ );
254
+ failWith(400, {});
255
+ await expect(createClient().subscribeFree("bad")).rejects.toThrow(
256
+ new Error("Free signup failed: 400")
257
+ );
258
+ });
259
+
260
+ it("applies to createPortal", async () => {
261
+ failWith(400, {
262
+ error: "Billing API error",
263
+ status: 400,
264
+ details: JSON.stringify({ return_url: ["Enter a valid URL."] }),
265
+ });
266
+ await expect(createClient().createPortal("nope")).rejects.toThrow(
267
+ new Error("Return url: Enter a valid URL.")
268
+ );
269
+ failWith(400, {});
270
+ await expect(createClient().createPortal("nope")).rejects.toThrow(
271
+ new Error("Portal session failed: 400")
272
+ );
273
+ });
274
+
275
+ it("applies to successSync", async () => {
276
+ failWith(400, {
277
+ error: "Billing API error",
278
+ status: 400,
279
+ details: JSON.stringify({ detail: "No checkout session to sync" }),
280
+ });
281
+ await expect(createClient().successSync()).rejects.toThrow(
282
+ new Error("No checkout session to sync")
283
+ );
284
+ failWith(500, {});
285
+ await expect(createClient().successSync()).rejects.toThrow(
286
+ new Error("Success sync failed: 500")
287
+ );
288
+ });
289
+
290
+ it("applies to getSubscription", async () => {
291
+ failWith(403, {
292
+ error: "Billing API error",
293
+ status: 403,
294
+ details: JSON.stringify({ detail: "You are not a member of this team." }),
295
+ });
296
+ await expect(createClient().getSubscription("p")).rejects.toThrow(
297
+ new Error("You are not a member of this team.")
298
+ );
299
+ failWith(500, {});
300
+ await expect(createClient().getSubscription("p")).rejects.toThrow(
301
+ new Error("Failed to fetch subscription: 500")
302
+ );
303
+ });
304
+ });
305
+
111
306
  describe("createPortal", () => {
112
307
  it("sends POST with return_url", async () => {
113
308
  mockFetch.mockResolvedValue({
package/src/utils/api.ts CHANGED
@@ -12,6 +12,7 @@ import type {
12
12
  SubscriptionInfo,
13
13
  UsageMap,
14
14
  } from "../types";
15
+ import { billingErrorMessage } from "./error-message";
15
16
 
16
17
  export class BillingApiClient {
17
18
  private config: BillingConfig;
@@ -60,9 +61,7 @@ export class BillingApiClient {
60
61
  });
61
62
  if (!resp.ok) {
62
63
  const data = await resp.json().catch(() => ({}));
63
- throw new Error(
64
- data.detail ?? `Checkout failed: ${resp.status}`
65
- );
64
+ throw new Error(billingErrorMessage(data, `Checkout failed: ${resp.status}`));
66
65
  }
67
66
  return resp.json();
68
67
  }
@@ -77,7 +76,7 @@ export class BillingApiClient {
77
76
  });
78
77
  if (!resp.ok) {
79
78
  const data = await resp.json().catch(() => ({}));
80
- throw new Error(data.detail ?? `Free signup failed: ${resp.status}`);
79
+ throw new Error(billingErrorMessage(data, `Free signup failed: ${resp.status}`));
81
80
  }
82
81
  return resp.json();
83
82
  }
@@ -92,9 +91,7 @@ export class BillingApiClient {
92
91
  });
93
92
  if (!resp.ok) {
94
93
  const data = await resp.json().catch(() => ({}));
95
- throw new Error(
96
- data.detail ?? `Portal session failed: ${resp.status}`
97
- );
94
+ throw new Error(billingErrorMessage(data, `Portal session failed: ${resp.status}`));
98
95
  }
99
96
  return resp.json();
100
97
  }
@@ -114,7 +111,7 @@ export class BillingApiClient {
114
111
  }
115
112
  if (!resp.ok) {
116
113
  const data = await resp.json().catch(() => ({}));
117
- throw new Error(data.detail ?? `Success sync failed: ${resp.status}`);
114
+ throw new Error(billingErrorMessage(data, `Success sync failed: ${resp.status}`));
118
115
  }
119
116
  return resp.json();
120
117
  }
@@ -150,9 +147,7 @@ export class BillingApiClient {
150
147
  }
151
148
  if (!resp.ok) {
152
149
  const data = await resp.json().catch(() => ({}));
153
- throw new Error(
154
- data.detail ?? `Failed to fetch subscription: ${resp.status}`
155
- );
150
+ throw new Error(billingErrorMessage(data, `Failed to fetch subscription: ${resp.status}`));
156
151
  }
157
152
  return this.normalizeSubscription(await resp.json());
158
153
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Turn a failed billing response body into one human-readable sentence.
3
+ *
4
+ * Shapes handled, in priority order:
5
+ * { detail: "..." } DRF's standard message
6
+ * { error: "Billing API error", details: "<json>" } proxyBillingRequest's envelope:
7
+ * Django's raw error text, as a string
8
+ * { non_field_errors: ["..."] } / { nonFieldErrors } first message
9
+ * { offer_id: ["..."] } / { offerId: [...] } "Offer id: ..." (first field wins)
10
+ * { error: "Not authenticated", code: "..." } app routes' own errors
11
+ * { error: { message: "..." } } (also app routes)
12
+ *
13
+ * Anything else (HTML error pages, the proxy's generic labels, empty bodies)
14
+ * yields `fallback`, so the user never sees raw JSON or markup.
15
+ */
16
+
17
+ // Labels proxyBillingRequest writes itself. They say that something failed,
18
+ // never why, so they must not be shown in place of the fallback.
19
+ const PROXY_LABELS = new Set(["Billing API error", "Internal server error"]);
20
+
21
+ const NON_FIELD_KEYS = new Set(["non_field_errors", "nonFieldErrors"]);
22
+
23
+ const META_KEYS = new Set([
24
+ "detail",
25
+ "details",
26
+ "error",
27
+ "message",
28
+ "code",
29
+ "status",
30
+ "statusCode",
31
+ "timestamp",
32
+ ]);
33
+
34
+ export function billingErrorMessage(body: unknown, fallback: string): string {
35
+ return messageFrom(body) ?? fallback;
36
+ }
37
+
38
+ function messageFrom(body: unknown): string | null {
39
+ if (Array.isArray(body)) return firstString(body);
40
+ if (!body || typeof body !== "object") return null;
41
+ const d = body as Record<string, unknown>;
42
+
43
+ const detail = typeof d.detail === "string" ? text(d.detail) : messageFrom(d.detail);
44
+ if (detail) return detail;
45
+
46
+ // The envelope carries Django's body as text. Only JSON is worth reading:
47
+ // plain text here is an HTML error page or a network error string.
48
+ if (typeof d.details === "string") {
49
+ const inner = messageFrom(parseJson(d.details));
50
+ if (inner) return inner;
51
+ } else {
52
+ const inner = messageFrom(d.details);
53
+ if (inner) return inner;
54
+ }
55
+
56
+ for (const key of NON_FIELD_KEYS) {
57
+ const s = firstString(d[key]);
58
+ if (s) return s;
59
+ }
60
+
61
+ for (const [key, val] of Object.entries(d)) {
62
+ if (META_KEYS.has(key) || NON_FIELD_KEYS.has(key)) continue;
63
+ // DRF field errors are always lists (or nested serializer maps); a scalar
64
+ // here is data, not an error.
65
+ if (!val || typeof val !== "object") continue;
66
+ const s = Array.isArray(val) ? firstString(val) : messageFrom(val);
67
+ if (s) return `${fieldLabel(key)}: ${s}`;
68
+ }
69
+
70
+ for (const key of ["message", "error"]) {
71
+ const val = d[key];
72
+ const s = typeof val === "string" ? text(val) : messageFrom(val);
73
+ if (s && !PROXY_LABELS.has(s)) return s;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function firstString(val: unknown): string | null {
79
+ if (typeof val === "string") return text(val);
80
+ if (!Array.isArray(val)) return null;
81
+ for (const item of val) {
82
+ const s = typeof item === "string" ? text(item) : messageFrom(item);
83
+ if (s) return s;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function text(s: string): string | null {
89
+ const t = s.trim();
90
+ return t ? t : null;
91
+ }
92
+
93
+ function parseJson(s: string): unknown {
94
+ const t = s.trim();
95
+ if (!t.startsWith("{") && !t.startsWith("[")) return null;
96
+ try {
97
+ return JSON.parse(t);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /** "offer_id" / "offerId" → "Offer id" */
104
+ function fieldLabel(key: string): string {
105
+ const words = key
106
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
107
+ .replace(/_+/g, " ")
108
+ .trim()
109
+ .toLowerCase();
110
+ return words.charAt(0).toUpperCase() + words.slice(1);
111
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How plan tiers relate to each other.
3
+ *
4
+ * Shared by the pricing cards and the comparison table so both read the offer
5
+ * data the same way — they disagreed before, and the table was the one that
6
+ * showed a paid plan as missing what the free plan had.
7
+ */
8
+ import type { ProductOffer, OfferFeature } from "../types";
9
+ /**
10
+ * Plans are cumulative: a paid tier includes what the cheaper tier includes.
11
+ *
12
+ * Offer data in the wild only lists what a tier ADDS — crochet-pro declares its
13
+ * advanced features and omits the "Save patterns" that crochet-free declares.
14
+ * Rendering that omission as "not included" put a dash on the paid plan exactly
15
+ * where the free plan had a tick, so the expensive plan looked WORSE.
16
+ *
17
+ * So a capability (`value === true`) established by a cheaper tier carries up
18
+ * into dearer ones. Two things deliberately do not carry:
19
+ * - an explicit `false`, which is how a tier opts out and stops the carry; and
20
+ * - numbers and strings, because an allowance is not a capability. Inheriting
21
+ * the free plan's "3 patterns" onto Pro would state something untrue; a tier
22
+ * has to declare its own allowance or say nothing.
23
+ */
24
+ export function cumulativeFeatureLookup(
25
+ offers: ProductOffer[],
26
+ ): Record<string, Record<string, OfferFeature>> {
27
+ const cheapestFirst = [...offers].sort((a, b) => a.sortOrder - b.sortOrder);
28
+ const lookup: Record<string, Record<string, OfferFeature>> = {};
29
+ const carried: Record<string, OfferFeature> = {};
30
+
31
+ for (const offer of cheapestFirst) {
32
+ const own: Record<string, OfferFeature> = {};
33
+ for (const feature of offer.features) own[feature.key] = feature;
34
+
35
+ // Own declarations always win over anything inherited.
36
+ lookup[offer.slug] = { ...carried, ...own };
37
+
38
+ for (const feature of offer.features) {
39
+ if (feature.value === true) carried[feature.key] = feature;
40
+ // Anything else ends the run, so a tier that opts out does not have the
41
+ // capability silently handed back to the tier above it.
42
+ else delete carried[feature.key];
43
+ }
44
+ }
45
+
46
+ return lookup;
47
+ }
48
+
49
+ /** The tier immediately below each offer, for the "Everything in X" line. */
50
+ export function tierBelow(offers: ProductOffer[], offer: ProductOffer): ProductOffer | null {
51
+ const cheapestFirst = [...offers].sort((a, b) => a.sortOrder - b.sortOrder);
52
+ const index = cheapestFirst.findIndex((o) => o.slug === offer.slug);
53
+ const below = index > 0 ? cheapestFirst[index - 1] : null;
54
+ return below && below.features.length > 0 ? below : null;
55
+ }