@startsimpli/billing 0.4.19 → 0.4.21

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.19",
3
+ "version": "0.4.21",
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
  },
@@ -181,4 +181,24 @@ describe("useSubscription", () => {
181
181
  expect(result.current.subscription?.id).toBe("sub-2")
182
182
  );
183
183
  });
184
+
185
+ it("fetches again when refetchKey changes (the signed-in user arrives after the first fetch)", async () => {
186
+ // First call: the session isn't ready yet, so the API says 401. Second: signed in.
187
+ const mockFetch = vi.fn()
188
+ .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ error: "Not authenticated" }) })
189
+ .mockResolvedValue({ ok: true, status: 200, json: async () => mockSubscription }) as unknown as typeof fetch;
190
+ const wrapper = createWrapper(mockFetch);
191
+
192
+ const { result, rerender } = renderHook(({ user }) => useSubscription(undefined, { refetchKey: user }), {
193
+ wrapper,
194
+ initialProps: { user: undefined as string | undefined },
195
+ });
196
+ await waitFor(() => expect(result.current.loading).toBe(false));
197
+ expect(result.current.subscription).toBeNull();
198
+
199
+ rerender({ user: "user-1" });
200
+ await waitFor(() => expect(result.current.subscription?.id).toBe("sub-123"));
201
+ expect(result.current.error).toBeNull();
202
+ });
184
203
  });
204
+
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * Pass the app's product slug so another app's plan is never returned
5
5
  * (startsim-oyr3a.2).
6
+ *
7
+ * Pass the signed-in user's id as `refetchKey`. The first fetch can run before
8
+ * the session is ready (straight back from sign-in it answers 401), and a plan
9
+ * read once and never again showed paying users as Free until they reloaded.
6
10
  */
7
11
 
8
12
  import { useCallback, useEffect, useState } from "react";
@@ -16,7 +20,13 @@ interface UseSubscriptionResult {
16
20
  refetch: () => void;
17
21
  }
18
22
 
19
- export function useSubscription(productId?: string): UseSubscriptionResult {
23
+ export interface UseSubscriptionOptions {
24
+ /** Fetch again whenever this changes, e.g. the signed-in user's id. */
25
+ refetchKey?: unknown;
26
+ }
27
+
28
+ export function useSubscription(productId?: string, options: UseSubscriptionOptions = {}): UseSubscriptionResult {
29
+ const { refetchKey } = options;
20
30
  const { client } = useBillingContext();
21
31
  const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
22
32
  const [loading, setLoading] = useState(true);
@@ -37,7 +47,7 @@ export function useSubscription(productId?: string): UseSubscriptionResult {
37
47
 
38
48
  useEffect(() => {
39
49
  fetchSubscription();
40
- }, [fetchSubscription]);
50
+ }, [fetchSubscription, refetchKey]);
41
51
 
42
52
  return { subscription, loading, error, refetch: fetchSubscription };
43
53
  }
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";
@@ -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
+ }