@startsimpli/billing 0.4.18 → 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.18",
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
@@ -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
+ }