@startsimpli/billing 0.4.14 → 0.4.16

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.14",
3
+ "version": "0.4.16",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -18,15 +18,6 @@
18
18
  "publishConfig": {
19
19
  "access": "public"
20
20
  },
21
- "scripts": {
22
- "build": "tsup",
23
- "dev": "tsup --watch",
24
- "type-check": "tsc --noEmit",
25
- "test": "vitest run",
26
- "test:watch": "vitest",
27
- "test:coverage": "vitest run --coverage",
28
- "clean": "rm -rf dist"
29
- },
30
21
  "peerDependencies": {
31
22
  "next": ">=14.0.0",
32
23
  "react": "^18.0.0 || ^19.0.0",
@@ -54,5 +45,14 @@
54
45
  "nextjs",
55
46
  "django",
56
47
  "startsimpli"
57
- ]
58
- }
48
+ ],
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "dev": "tsup --watch",
52
+ "type-check": "tsc --noEmit",
53
+ "test": "vitest run",
54
+ "test:watch": "vitest",
55
+ "test:coverage": "vitest run --coverage",
56
+ "clean": "rm -rf dist"
57
+ }
58
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Theme-contract guard for @startsimpli/billing (startsim-1f3n.3).
3
+ *
4
+ * Shared components may only use the semantic tokens from
5
+ * packages/ui/theme/contract.css. The numeric Tailwind ramp
6
+ * (`bg-primary-600`, `focus:ring-primary-500`, …) is not part of it — an app
7
+ * that declares `--color-primary` without a ramp gets NO rule for those
8
+ * classes, which renders a plan card border as invisible white-on-white.
9
+ * Use an opacity modifier on the token instead (`hover:bg-primary/90`).
10
+ */
11
+ import * as fs from 'fs'
12
+ import * as path from 'path'
13
+ import { describe, it, expect } from 'vitest'
14
+
15
+ const SRC = path.resolve(__dirname, '..')
16
+ const RAMP = /\b[a-z-]*-(primary|accent|secondary)-\d{2,3}\b/
17
+
18
+ function sources(dir: string, acc: string[] = []): string[] {
19
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
20
+ const full = path.join(dir, e.name)
21
+ if (e.isDirectory()) {
22
+ if (e.name !== '__tests__' && e.name !== 'node_modules') sources(full, acc)
23
+ } else if (/\.tsx?$/.test(e.name)) acc.push(full)
24
+ }
25
+ return acc
26
+ }
27
+
28
+ describe('@startsimpli/billing theme contract', () => {
29
+ it('no component styles against a numeric color ramp', () => {
30
+ const offenders: string[] = []
31
+ for (const file of sources(SRC)) {
32
+ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
33
+ const code = line.replace(/^\s*(\*|\/\/).*$/, '')
34
+ const hit = code.match(RAMP)
35
+ if (hit) offenders.push(`${path.relative(SRC, file)}:${i + 1} ${hit[0]}`)
36
+ })
37
+ }
38
+ expect(offenders).toEqual([])
39
+ })
40
+ })
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { describe, it, expect, vi } from "vitest";
17
- import { render, screen, waitFor } from "@testing-library/react";
17
+ import { render, screen, waitFor, within } from "@testing-library/react";
18
18
  import userEvent from "@testing-library/user-event";
19
19
  import React from "react";
20
20
  import { BillingProvider } from "../hooks/BillingProvider";
@@ -1212,4 +1212,71 @@ describe("SubscriptionManager", () => {
1212
1212
  // strictly above proCurrent.offer.sortOrder=1.
1213
1213
  expect(upgrade?.textContent).toMatch(/upgrade to enterprise/i);
1214
1214
  });
1215
+
1216
+ // Regression: the subscription/current endpoint's Redis cache path returns a
1217
+ // FLAT payload (offerId/offerSlug/productSlug, NO nested `offer`). The UI used
1218
+ // to deref `subscription.offer.isFree` and white-screen ("client-side
1219
+ // exception"). BillingApiClient now hydrates the offer from the product, so
1220
+ // the rich CurrentPlanCard renders instead. startsim — billing-offer-null.
1221
+ it("hydrates the flat cache-shape subscription payload instead of crashing", async () => {
1222
+ const flatCachePayload = {
1223
+ // Shape of CachedSubscriptionResponseSerializer after the camelCase proxy:
1224
+ subscriptionId: "sub-cache-1",
1225
+ providerSubscriptionId: "stripe_sub_1",
1226
+ status: "active",
1227
+ customerId: "cus_1",
1228
+ priceId: "price_1",
1229
+ offerId: "offer-2", // Pro
1230
+ productSlug: "raise-simpli",
1231
+ offerSlug: "pro-monthly",
1232
+ currentPeriodStart: 1735689600, // unix seconds, not ISO
1233
+ currentPeriodEnd: 1738368000,
1234
+ cancelAtPeriodEnd: false,
1235
+ trialEnd: null,
1236
+ paymentMethod: null,
1237
+ };
1238
+ const fetcher = mockFetchRouter({
1239
+ "/billing/subscription/current/": flatCachePayload,
1240
+ "/billing/products/": mockProduct,
1241
+ });
1242
+ const Wrapper = createWrapper(fetcher);
1243
+
1244
+ render(
1245
+ <Wrapper>
1246
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1247
+ </Wrapper>
1248
+ );
1249
+
1250
+ // Renders the rich current-plan card (Pro) — proves no crash AND that the
1251
+ // offer was hydrated from the product rather than dropped.
1252
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
1253
+ const card = screen.getByTestId("current-plan");
1254
+ expect(within(card).getByText("Pro")).toBeDefined();
1255
+ // Active paid sub → "Next billing date" derived from the unix-seconds period.
1256
+ expect(within(card).getByTestId("next-billing-date")).toBeDefined();
1257
+ });
1258
+
1259
+ it("degrades to 'no active subscription' when a flat payload can't resolve an offer", async () => {
1260
+ const fetcher = mockFetchRouter({
1261
+ "/billing/subscription/current/": {
1262
+ status: "active",
1263
+ offerId: "does-not-exist",
1264
+ offerSlug: "ghost",
1265
+ productSlug: "raise-simpli",
1266
+ },
1267
+ "/billing/products/": mockProduct,
1268
+ });
1269
+ const Wrapper = createWrapper(fetcher);
1270
+
1271
+ render(
1272
+ <Wrapper>
1273
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1274
+ </Wrapper>
1275
+ );
1276
+
1277
+ // No crash; falls through to the empty-state copy.
1278
+ await waitFor(() =>
1279
+ expect(screen.getByText("No active subscription")).toBeDefined()
1280
+ );
1281
+ });
1215
1282
  });
@@ -228,8 +228,13 @@ export function SubscriptionManager({
228
228
  );
229
229
  }
230
230
 
231
- const currentOfferId = subscription?.offer?.id ?? null;
232
- const currentOffer = subscription?.offer ?? null;
231
+ // A subscription is only renderable if it carries a nested `offer` object —
232
+ // the cache-path payload (offerId/offerSlug, no `offer`) is normalized away in
233
+ // BillingApiClient, but guard here too so CurrentPlanCard can never deref
234
+ // `subscription.offer.*` on undefined and white-screen the page.
235
+ const usableSubscription = subscription?.offer ? subscription : null;
236
+ const currentOfferId = usableSubscription?.offer?.id ?? null;
237
+ const currentOffer = usableSubscription?.offer ?? null;
233
238
  // Show ALL offers in the Change Plan grid (not just non-current ones) so users
234
239
  // can compare and see what they have. The card matching their current sub
235
240
  // gets a "Current Plan" badge + disabled CTA. startsim-1f1.
@@ -339,30 +344,30 @@ export function SubscriptionManager({
339
344
  </div>
340
345
  )}
341
346
 
342
- {subscription ? (
347
+ {usableSubscription ? (
343
348
  <>
344
349
  {/* Non-owner team members get a clear read-only callout. The OWNER
345
350
  gates that follow (Upgrade CTA, portal button, plan-change CTAs)
346
351
  all flip off when viewerIsOwner === false. startsim-wo9. */}
347
- {subscription.viewerIsOwner === false && (
352
+ {usableSubscription.viewerIsOwner === false && (
348
353
  <div
349
354
  data-testid="non-owner-billing-notice"
350
355
  className="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900"
351
356
  role="status"
352
357
  >
353
358
  You&apos;re viewing your team&apos;s billing.{" "}
354
- {subscription.ownerEmail
355
- ? <>The team owner <span className="font-medium">{subscription.ownerEmail}</span> manages plan changes.</>
359
+ {usableSubscription.ownerEmail
360
+ ? <>The team owner <span className="font-medium">{usableSubscription.ownerEmail}</span> manages plan changes.</>
356
361
  : <>The team owner manages plan changes.</>
357
362
  }
358
363
  </div>
359
364
  )}
360
365
  <CurrentPlanCard
361
- subscription={subscription}
366
+ subscription={usableSubscription}
362
367
  usage={usage}
363
- onPortalClick={subscription.viewerIsOwner === false ? undefined : handlePortalClick}
368
+ onPortalClick={usableSubscription.viewerIsOwner === false ? undefined : handlePortalClick}
364
369
  upgradeCta={
365
- subscription.viewerIsOwner !== false && subscription.offer.isFree && featuredPaidOffer ? (
370
+ usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
366
371
  <button
367
372
  data-testid="upgrade-cta-current-plan"
368
373
  onClick={() => handleOfferClick(featuredPaidOffer)}
@@ -428,7 +433,7 @@ export function SubscriptionManager({
428
433
 
429
434
  // Border: current = solid ring; featured upgrade = blue; else gray.
430
435
  const cardBorder =
431
- isCurrent ? "border-2 border-primary-600 ring-2 ring-primary-100 bg-white shadow-md"
436
+ isCurrent ? "border-2 border-primary ring-2 ring-primary/20 bg-white shadow-md"
432
437
  : offer.isFeatured ? "border border-blue-600 shadow-md bg-white"
433
438
  : "border border-gray-200 bg-white shadow-sm";
434
439
 
@@ -452,7 +457,7 @@ export function SubscriptionManager({
452
457
  {isCurrent && (
453
458
  <span
454
459
  data-testid={`current-plan-badge-${offer.slug}`}
455
- className="absolute -top-2.5 right-4 inline-block bg-primary-600 text-white text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
460
+ className="absolute -top-2.5 right-4 inline-block bg-primary text-primary-foreground text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
456
461
  >
457
462
  Current Plan
458
463
  </span>
@@ -501,7 +506,7 @@ export function SubscriptionManager({
501
506
  Free-tier users (provider='none') have no Stripe customer, so the
502
507
  portal call would 404 — they get the Upgrade CTA on the current-plan
503
508
  card instead. startsim-b1z. */}
504
- {subscription && subscription.status !== "cancelled" && !subscription.offer.isFree && subscription.viewerIsOwner !== false && (
509
+ {usableSubscription && usableSubscription.status !== "cancelled" && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
505
510
  <div className="pt-4 border-t border-gray-100">
506
511
  <button
507
512
  data-testid="portal-button"
package/src/utils/api.ts CHANGED
@@ -149,6 +149,61 @@ export class BillingApiClient {
149
149
  data.detail ?? `Failed to fetch subscription: ${resp.status}`
150
150
  );
151
151
  }
152
- return resp.json();
152
+ return this.normalizeSubscription(await resp.json());
153
+ }
154
+
155
+ /**
156
+ * The `subscription/current/` endpoint is polymorphic: the DB path returns
157
+ * the rich SubscriptionInfo (nested `offer` object), but the Redis cache path
158
+ * returns the flat CachedSubscriptionState (offerId/offerSlug/productSlug, no
159
+ * nested `offer`). The UI reads `subscription.offer.*` unconditionally, so a
160
+ * flat payload white-screens the page. This guard hydrates the flat shape
161
+ * into the rich one by resolving the offer from the product; if it can't, it
162
+ * returns null ("no usable subscription") rather than letting the page crash.
163
+ * Defense-in-depth against backend contract drift. startsim — billing-offer-null.
164
+ */
165
+ private async normalizeSubscription(raw: unknown): Promise<SubscriptionInfo | null> {
166
+ if (!raw || typeof raw !== "object") return null;
167
+ const r = raw as Record<string, unknown>;
168
+
169
+ // Already the rich shape — nested offer object present.
170
+ if (r.offer && typeof r.offer === "object") return raw as SubscriptionInfo;
171
+
172
+ // Flat cache shape: try to hydrate the offer from the product.
173
+ const productSlug = r.productSlug as string | undefined;
174
+ const offerId = r.offerId as string | undefined;
175
+ const offerSlug = r.offerSlug as string | undefined;
176
+ const status = r.status as string | undefined;
177
+ // No status or nothing to resolve an offer from → not a renderable sub.
178
+ if (!status || status === "none" || !productSlug || (!offerId && !offerSlug)) {
179
+ return null;
180
+ }
181
+
182
+ try {
183
+ const product = await this.getProduct(productSlug);
184
+ const offer = product.offers.find(
185
+ (o) => (offerId && o.id === offerId) || (offerSlug && o.slug === offerSlug)
186
+ );
187
+ if (!offer) return null;
188
+ const toIso = (v: unknown): string | null => {
189
+ if (v == null) return null;
190
+ // Cache stores unix seconds; SubscriptionInfo expects ISO strings.
191
+ if (typeof v === "number") return new Date(v * 1000).toISOString();
192
+ if (typeof v === "string") return v;
193
+ return null;
194
+ };
195
+ return {
196
+ id: (r.subscriptionId as string) ?? offer.id,
197
+ offer,
198
+ status: status as SubscriptionInfo["status"],
199
+ currentPeriodStart: toIso(r.currentPeriodStart),
200
+ currentPeriodEnd: toIso(r.currentPeriodEnd),
201
+ cancelAtPeriodEnd: Boolean(r.cancelAtPeriodEnd),
202
+ trialEnd: toIso(r.trialEnd),
203
+ };
204
+ } catch {
205
+ // Product fetch failed — degrade to "no subscription" instead of crashing.
206
+ return null;
207
+ }
153
208
  }
154
209
  }