@startsimpli/billing 0.4.18 → 0.4.20
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 -1
- package/src/components/PricingDetailPage.tiers.test.tsx +178 -0
- package/src/components/PricingDetailPage.tsx +14 -9
- package/src/components/PricingSection.test.tsx +20 -0
- package/src/components/PricingSection.tsx +11 -1
- package/src/index.ts +2 -1
- package/src/utils/plan.test.ts +227 -0
- package/src/utils/plan.ts +174 -1
- package/src/utils/tiers.ts +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
4
4
|
"description": "Universal billing integration for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./client": "./src/index.ts",
|
|
10
10
|
"./server": "./src/server/index.ts",
|
|
11
|
+
"./plan": "./src/utils/plan.ts",
|
|
11
12
|
"./types": "./src/types/index.ts",
|
|
12
13
|
"./mock": "./src/mock/index.ts"
|
|
13
14
|
},
|
|
@@ -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
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
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
|
package/src/index.ts
CHANGED
|
@@ -57,4 +57,5 @@ export type {
|
|
|
57
57
|
export { BillingApiClient } from "./utils/api";
|
|
58
58
|
|
|
59
59
|
// Pure helpers
|
|
60
|
-
export { isPaidSubscription } from "./utils/plan";
|
|
60
|
+
export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from "./utils/plan";
|
|
61
|
+
export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow } from "./utils/plan";
|
package/src/utils/plan.test.ts
CHANGED
|
@@ -85,3 +85,230 @@ describe('isPaidSubscription', () => {
|
|
|
85
85
|
expect(isPaidSubscription(sub)).toBe(false)
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Entitlement resolution (startsim-ol36d.9 / .12)
|
|
91
|
+
//
|
|
92
|
+
// `isPaidSubscription` answers "is this a paid plan". Gating needs three more
|
|
93
|
+
// things it cannot express:
|
|
94
|
+
// 1. what to do when billing is LOADING or ERRORED — the old crochet gate
|
|
95
|
+
// treated "unknown" as paid and opened Pro features to everyone;
|
|
96
|
+
// 2. how big the allowance is — read from the offer's own feature list, not
|
|
97
|
+
// hardcoded in each app;
|
|
98
|
+
// 3. which window to count usage in — a free subscription's
|
|
99
|
+
// current_period_start is frozen at signup and never rolls.
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
import { getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from './plan'
|
|
103
|
+
|
|
104
|
+
const freeOffer = makeOffer({
|
|
105
|
+
slug: 'crochet-free',
|
|
106
|
+
isFree: true,
|
|
107
|
+
unitPrice: '0.00',
|
|
108
|
+
features: [
|
|
109
|
+
{ key: 'patterns_per_month', name: 'Patterns per month', value: 3, limit: 3 },
|
|
110
|
+
{ key: 'save_patterns', name: 'Save patterns', value: true, limit: null },
|
|
111
|
+
],
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const proOffer = makeOffer({
|
|
115
|
+
slug: 'crochet-pro',
|
|
116
|
+
isFree: false,
|
|
117
|
+
unitPrice: '9.00',
|
|
118
|
+
trialDays: 7,
|
|
119
|
+
features: [
|
|
120
|
+
{ key: 'patterns_per_month', name: 'Patterns per month', value: -1, limit: null },
|
|
121
|
+
{ key: 'pdf_export', name: 'PDF export', value: true, limit: null },
|
|
122
|
+
],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('getFeatureLimit', () => {
|
|
126
|
+
it('reads the numeric allowance from the offer', () => {
|
|
127
|
+
expect(getFeatureLimit(freeOffer, ['patterns_per_month'])).toBe(3)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('maps -1 to Infinity (unlimited)', () => {
|
|
131
|
+
expect(getFeatureLimit(proOffer, ['patterns_per_month'])).toBe(Infinity)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('maps the string "unlimited" to Infinity', () => {
|
|
135
|
+
const offer = makeOffer({
|
|
136
|
+
features: [{ key: 'patterns_per_month', name: 'x', value: 'unlimited', limit: null }],
|
|
137
|
+
})
|
|
138
|
+
expect(getFeatureLimit(offer, ['patterns_per_month'])).toBe(Infinity)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('prefers `limit` over `value` when both are set', () => {
|
|
142
|
+
const offer = makeOffer({
|
|
143
|
+
features: [{ key: 'patterns_per_month', name: 'x', value: 99, limit: 3 }],
|
|
144
|
+
})
|
|
145
|
+
expect(getFeatureLimit(offer, ['patterns_per_month'])).toBe(3)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('accepts an alias key', () => {
|
|
149
|
+
const offer = makeOffer({
|
|
150
|
+
features: [{ key: 'monthly_generations', name: 'x', value: 7, limit: null }],
|
|
151
|
+
})
|
|
152
|
+
expect(getFeatureLimit(offer, ['patterns_per_month', 'monthly_generations'])).toBe(7)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('returns null when the offer declares no such feature', () => {
|
|
156
|
+
expect(getFeatureLimit(makeOffer(), ['patterns_per_month'])).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns null for a missing offer rather than throwing', () => {
|
|
160
|
+
expect(getFeatureLimit(null, ['patterns_per_month'])).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('resolvePlanEntitlement', () => {
|
|
165
|
+
const opts = { featureKeys: ['patterns_per_month'], freeFallbackLimit: 3 }
|
|
166
|
+
|
|
167
|
+
it('treats an active FREE offer as free and uses the offer allowance', () => {
|
|
168
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'active', offer: freeOffer }), opts)
|
|
169
|
+
expect(e.isFree).toBe(true)
|
|
170
|
+
expect(e.isPaid).toBe(false)
|
|
171
|
+
expect(e.limit).toBe(3)
|
|
172
|
+
expect(e.isUnlimited).toBe(false)
|
|
173
|
+
expect(e.unknown).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does NOT depend on the offer slug being the literal "free"', () => {
|
|
177
|
+
// The real slug is `crochet-free`. A slug === 'free' test silently
|
|
178
|
+
// misclassified all 120 real free holders as paid.
|
|
179
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'active', offer: freeOffer }), opts)
|
|
180
|
+
expect(e.isFree).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('treats an active PAID offer as paid and unlimited', () => {
|
|
184
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'active', offer: proOffer }), opts)
|
|
185
|
+
expect(e.isPaid).toBe(true)
|
|
186
|
+
expect(e.isFree).toBe(false)
|
|
187
|
+
expect(e.isUnlimited).toBe(true)
|
|
188
|
+
expect(e.limit).toBe(Infinity)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('treats a TRIALING paid plan as paid, not free', () => {
|
|
192
|
+
// A 7-day Pro trial is a paying customer mid-trial; the old gate gave them
|
|
193
|
+
// free limits because it tested `status === 'active'`.
|
|
194
|
+
const e = resolvePlanEntitlement(makeSub({ status: 'trialing', offer: proOffer }), opts)
|
|
195
|
+
expect(e.isPaid).toBe(true)
|
|
196
|
+
expect(e.isFree).toBe(false)
|
|
197
|
+
expect(e.isUnlimited).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('fails CLOSED while billing is loading', () => {
|
|
201
|
+
const e = resolvePlanEntitlement(null, { ...opts, loading: true })
|
|
202
|
+
expect(e.isFree).toBe(true)
|
|
203
|
+
expect(e.isPaid).toBe(false)
|
|
204
|
+
expect(e.unknown).toBe(true)
|
|
205
|
+
expect(e.limit).toBe(3)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('fails CLOSED when the billing call errored', () => {
|
|
209
|
+
// The old gate did `!subscriptionError && (...)`, so an error made
|
|
210
|
+
// isFreeUser false — i.e. an outage handed everyone Pro.
|
|
211
|
+
const e = resolvePlanEntitlement(null, { ...opts, error: new Error('boom') })
|
|
212
|
+
expect(e.isFree).toBe(true)
|
|
213
|
+
expect(e.isPaid).toBe(false)
|
|
214
|
+
expect(e.unknown).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('treats no subscription at all as free', () => {
|
|
218
|
+
const e = resolvePlanEntitlement(null, opts)
|
|
219
|
+
expect(e.isFree).toBe(true)
|
|
220
|
+
expect(e.unknown).toBe(false)
|
|
221
|
+
expect(e.limit).toBe(3)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('treats a subscription with no offer as free', () => {
|
|
225
|
+
const sub = { ...makeSub(), offer: null as unknown as ProductOffer }
|
|
226
|
+
expect(resolvePlanEntitlement(sub, opts).isFree).toBe(true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const deadStatuses = ['past_due', 'cancelled', 'unpaid', 'trial_expired'] as const
|
|
230
|
+
for (const status of deadStatuses) {
|
|
231
|
+
it(`treats status=${status} on a paid offer as NOT paid`, () => {
|
|
232
|
+
const e = resolvePlanEntitlement(makeSub({ status, offer: proOffer }), opts)
|
|
233
|
+
expect(e.isPaid).toBe(false)
|
|
234
|
+
expect(e.isFree).toBe(true)
|
|
235
|
+
expect(e.limit).toBe(3)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
it('falls back to freeFallbackLimit when a free offer declares no allowance', () => {
|
|
240
|
+
const bare = makeOffer({ isFree: true, features: [] })
|
|
241
|
+
expect(resolvePlanEntitlement(makeSub({ offer: bare }), opts).limit).toBe(3)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('reads the allowance from the offer rather than the fallback when both exist', () => {
|
|
245
|
+
const e = resolvePlanEntitlement(makeSub({ offer: freeOffer }), {
|
|
246
|
+
...opts,
|
|
247
|
+
freeFallbackLimit: 5,
|
|
248
|
+
})
|
|
249
|
+
expect(e.limit).toBe(3)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('has a hasFeature() that gates a named Pro capability', () => {
|
|
253
|
+
const pro = resolvePlanEntitlement(makeSub({ offer: proOffer }), opts)
|
|
254
|
+
const free = resolvePlanEntitlement(makeSub({ offer: freeOffer }), opts)
|
|
255
|
+
expect(pro.hasFeature('pdf_export')).toBe(true)
|
|
256
|
+
expect(free.hasFeature('pdf_export')).toBe(false)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('hasFeature() is false while unknown (fail closed)', () => {
|
|
260
|
+
const e = resolvePlanEntitlement(null, { ...opts, error: new Error('boom') })
|
|
261
|
+
expect(e.hasFeature('pdf_export')).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('resolveUsageWindow', () => {
|
|
266
|
+
const now = new Date('2026-09-18T12:00:00.000Z')
|
|
267
|
+
|
|
268
|
+
it('uses the calendar month when the subscription has no period bounds', () => {
|
|
269
|
+
const w = resolveUsageWindow(null, now)
|
|
270
|
+
expect(w.start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
271
|
+
expect(w.end.toISOString()).toBe('2026-10-01T00:00:00.000Z')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('IGNORES a frozen free-subscription period that never rolls', () => {
|
|
275
|
+
// SubscribeFreeViewSet writes current_period_start=now, end=None at signup
|
|
276
|
+
// and never updates it. Counting from that start is "count patterns ever"
|
|
277
|
+
// wearing a different hat — the exact bug startsim-ol36d.12 names.
|
|
278
|
+
const frozen = makeSub({
|
|
279
|
+
currentPeriodStart: '2026-03-04T09:00:00.000Z',
|
|
280
|
+
currentPeriodEnd: null,
|
|
281
|
+
})
|
|
282
|
+
const w = resolveUsageWindow(frozen, now)
|
|
283
|
+
expect(w.start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('ignores a period whose end is already in the past', () => {
|
|
287
|
+
const stale = makeSub({
|
|
288
|
+
currentPeriodStart: '2026-07-01T00:00:00.000Z',
|
|
289
|
+
currentPeriodEnd: '2026-08-01T00:00:00.000Z',
|
|
290
|
+
})
|
|
291
|
+
expect(resolveUsageWindow(stale, now).start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('uses a real, current billing period when both bounds are present', () => {
|
|
295
|
+
const real = makeSub({
|
|
296
|
+
currentPeriodStart: '2026-09-04T09:00:00.000Z',
|
|
297
|
+
currentPeriodEnd: '2026-10-04T09:00:00.000Z',
|
|
298
|
+
})
|
|
299
|
+
const w = resolveUsageWindow(real, now)
|
|
300
|
+
expect(w.start.toISOString()).toBe('2026-09-04T09:00:00.000Z')
|
|
301
|
+
expect(w.end.toISOString()).toBe('2026-10-04T09:00:00.000Z')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('falls back to the calendar month on an unparseable period start', () => {
|
|
305
|
+
const bad = makeSub({ currentPeriodStart: 'not-a-date', currentPeriodEnd: 'also-bad' })
|
|
306
|
+
expect(resolveUsageWindow(bad, now).start.toISOString()).toBe('2026-09-01T00:00:00.000Z')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('rolls into the next calendar month at the boundary', () => {
|
|
310
|
+
const w = resolveUsageWindow(null, new Date('2026-12-31T23:59:59.000Z'))
|
|
311
|
+
expect(w.start.toISOString()).toBe('2026-12-01T00:00:00.000Z')
|
|
312
|
+
expect(w.end.toISOString()).toBe('2027-01-01T00:00:00.000Z')
|
|
313
|
+
})
|
|
314
|
+
})
|
package/src/utils/plan.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* they don't.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { SubscriptionInfo } from "../types";
|
|
10
|
+
import type { OfferFeature, ProductOffer, SubscriptionInfo } from "../types";
|
|
11
11
|
|
|
12
12
|
const PAID_STATUSES: ReadonlyArray<SubscriptionInfo["status"]> = ["active", "trialing"];
|
|
13
13
|
|
|
@@ -28,3 +28,176 @@ export function isPaidSubscription(
|
|
|
28
28
|
if (sub.offer.isFree) return false;
|
|
29
29
|
return PAID_STATUSES.includes(sub.status);
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a numeric allowance out of an offer's declared features.
|
|
34
|
+
*
|
|
35
|
+
* `keys` is tried in order so an app can accept an alias
|
|
36
|
+
* (e.g. `patterns_per_month` or `monthly_generations`).
|
|
37
|
+
*
|
|
38
|
+
* Returns `Infinity` for an explicit unlimited marker (`-1` or `"unlimited"`),
|
|
39
|
+
* and `null` when the offer declares no such allowance — which is NOT the same
|
|
40
|
+
* as zero, and callers must decide what an undeclared allowance means for them.
|
|
41
|
+
*
|
|
42
|
+
* `limit` wins over `value`: the backend puts the enforceable number in
|
|
43
|
+
* `limit` and uses `value` for display, and they can disagree.
|
|
44
|
+
*/
|
|
45
|
+
export function getFeatureLimit(
|
|
46
|
+
offer: ProductOffer | null | undefined,
|
|
47
|
+
keys: readonly string[]
|
|
48
|
+
): number | null {
|
|
49
|
+
if (!offer) return null;
|
|
50
|
+
const features: OfferFeature[] = offer.features ?? [];
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const feature = features.find((f) => f?.key === key);
|
|
53
|
+
if (!feature) continue;
|
|
54
|
+
const raw = feature.limit ?? feature.value;
|
|
55
|
+
if (raw === -1 || raw === "unlimited") return Infinity;
|
|
56
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return raw;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PlanEntitlement {
|
|
62
|
+
/**
|
|
63
|
+
* True when the user is on a free plan — or when we could not prove they
|
|
64
|
+
* are not. Gates read this, so it fails CLOSED: the worst case is showing
|
|
65
|
+
* an upgrade prompt to someone who has already paid, which is recoverable.
|
|
66
|
+
* The opposite mistake hands Pro away during a billing outage.
|
|
67
|
+
*/
|
|
68
|
+
isFree: boolean;
|
|
69
|
+
/** True only when an active or trialing PAID subscription is proven. */
|
|
70
|
+
isPaid: boolean;
|
|
71
|
+
/** The allowance for the metered feature. `Infinity` when unlimited. */
|
|
72
|
+
limit: number;
|
|
73
|
+
isUnlimited: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* True when billing could not be resolved (still loading, or the call
|
|
76
|
+
* failed), so `isFree` is a fail-closed assumption rather than a fact.
|
|
77
|
+
* Use it to soften the copy ("checking your plan…"), never to open a gate.
|
|
78
|
+
*/
|
|
79
|
+
unknown: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* True when the plan's offer explicitly declares the named capability.
|
|
82
|
+
* Always false while `unknown`.
|
|
83
|
+
*/
|
|
84
|
+
hasFeature: (key: string) => boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ResolvePlanEntitlementOptions {
|
|
88
|
+
/** Feature keys to read the metered allowance from, in priority order. */
|
|
89
|
+
featureKeys?: readonly string[];
|
|
90
|
+
/**
|
|
91
|
+
* Allowance to assume for a free user whose offer declares none — e.g. a
|
|
92
|
+
* user with no subscription row at all, who therefore has no offer to read.
|
|
93
|
+
*/
|
|
94
|
+
freeFallbackLimit?: number;
|
|
95
|
+
/** Billing is still in flight. */
|
|
96
|
+
loading?: boolean;
|
|
97
|
+
/** The billing call failed. */
|
|
98
|
+
error?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive what a subscription actually entitles someone to.
|
|
103
|
+
*
|
|
104
|
+
* This exists because the same three-line derivation was copied into every
|
|
105
|
+
* gate, and each copy got it wrong in a different way: testing
|
|
106
|
+
* `offer.slug === 'free'` when the real slug is `crochet-free`, testing
|
|
107
|
+
* `status === 'active'` so a paid trial counted as free, and treating a
|
|
108
|
+
* billing error as proof of a paid plan.
|
|
109
|
+
*/
|
|
110
|
+
export function resolvePlanEntitlement(
|
|
111
|
+
sub: SubscriptionInfo | null | undefined,
|
|
112
|
+
options: ResolvePlanEntitlementOptions = {}
|
|
113
|
+
): PlanEntitlement {
|
|
114
|
+
const {
|
|
115
|
+
featureKeys = [],
|
|
116
|
+
freeFallbackLimit = Infinity,
|
|
117
|
+
loading = false,
|
|
118
|
+
error = null,
|
|
119
|
+
} = options;
|
|
120
|
+
|
|
121
|
+
// Unresolved billing is not evidence of a paid plan.
|
|
122
|
+
if (loading || error) {
|
|
123
|
+
return {
|
|
124
|
+
isFree: true,
|
|
125
|
+
isPaid: false,
|
|
126
|
+
limit: freeFallbackLimit,
|
|
127
|
+
isUnlimited: freeFallbackLimit === Infinity,
|
|
128
|
+
unknown: true,
|
|
129
|
+
hasFeature: () => false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const offer = sub?.offer ?? null;
|
|
134
|
+
const paid = isPaidSubscription(sub);
|
|
135
|
+
const declared = getFeatureLimit(offer, featureKeys);
|
|
136
|
+
|
|
137
|
+
// An offer's allowance only counts while that offer is actually in force.
|
|
138
|
+
// A paid plan with no declared ceiling is unlimited; a free plan falls back
|
|
139
|
+
// to what the caller says free means. A LAPSED paid plan (cancelled,
|
|
140
|
+
// past_due, unpaid, expired trial) must not keep spending its old ceiling —
|
|
141
|
+
// it drops to the free allowance like anyone else.
|
|
142
|
+
let limit: number;
|
|
143
|
+
if (paid) {
|
|
144
|
+
limit = declared ?? Infinity;
|
|
145
|
+
} else if (offer?.isFree) {
|
|
146
|
+
limit = declared ?? freeFallbackLimit;
|
|
147
|
+
} else {
|
|
148
|
+
limit = freeFallbackLimit;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
isFree: !paid,
|
|
153
|
+
isPaid: paid,
|
|
154
|
+
limit,
|
|
155
|
+
isUnlimited: limit === Infinity,
|
|
156
|
+
unknown: false,
|
|
157
|
+
hasFeature: (key: string) =>
|
|
158
|
+
(offer?.features ?? []).some((f) => f?.key === key && f.value !== false),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface UsageWindow {
|
|
163
|
+
start: Date;
|
|
164
|
+
end: Date;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function startOfUtcMonth(d: Date): Date {
|
|
168
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parse(value: string | null | undefined): Date | null {
|
|
172
|
+
if (!value) return null;
|
|
173
|
+
const d = new Date(value);
|
|
174
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The window a per-period allowance should be counted over.
|
|
179
|
+
*
|
|
180
|
+
* Prefers the subscription's real billing period, but only when BOTH bounds
|
|
181
|
+
* are present and the period has not already ended. Free subscriptions are
|
|
182
|
+
* created with `current_period_start = now` and `current_period_end = NULL`
|
|
183
|
+
* and are never rolled forward, so trusting that start would count every
|
|
184
|
+
* pattern the user has ever made — which is the bug this replaces, not a fix
|
|
185
|
+
* for it. Everything else falls back to the calendar month.
|
|
186
|
+
*/
|
|
187
|
+
export function resolveUsageWindow(
|
|
188
|
+
sub: SubscriptionInfo | null | undefined,
|
|
189
|
+
now: Date = new Date()
|
|
190
|
+
): UsageWindow {
|
|
191
|
+
const start = parse(sub?.currentPeriodStart);
|
|
192
|
+
const end = parse(sub?.currentPeriodEnd);
|
|
193
|
+
|
|
194
|
+
if (start && end && end.getTime() > now.getTime() && start.getTime() <= now.getTime()) {
|
|
195
|
+
return { start, end };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const monthStart = startOfUtcMonth(now);
|
|
199
|
+
const monthEnd = new Date(
|
|
200
|
+
Date.UTC(monthStart.getUTCFullYear(), monthStart.getUTCMonth() + 1, 1)
|
|
201
|
+
);
|
|
202
|
+
return { start: monthStart, end: monthEnd };
|
|
203
|
+
}
|
|
@@ -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
|
+
}
|