@startsimpli/billing 0.4.26 → 0.4.27

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.26",
3
+ "version": "0.4.27",
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,146 @@
1
+ /**
2
+ * Billing page states from the crochet design review (2026-10-18):
3
+ * - a plan ending at period end has one status, "Ends <date>", and the free
4
+ * plan it falls back to reads "Starts <date>", disabled (crochet_patterns-3bzni);
5
+ * a cancelled paid plan keeps Manage Billing for its invoices (-pes38)
6
+ * - a trial says when the first charge is and how much (-iwl0f), shows its end
7
+ * date once (-rjg2e), and counts day one of a 7-day trial as 7 days (-lm7nu)
8
+ * - a failed payment is not dated with the period end (-lb8u3)
9
+ * - back from a trial checkout: "Your Pro trial has started" (-reg6a)
10
+ * - the clicked plan button says "Opening checkout…" while it opens (-jnkso)
11
+ * - a complimentary plan on a tier marks that tier's card current (-lgqp2)
12
+ */
13
+ import { describe, it, expect, vi } from "vitest";
14
+ import { render, screen, waitFor } from "@testing-library/react";
15
+ import userEvent from "@testing-library/user-event";
16
+ import React from "react";
17
+ import { BillingProvider } from "../hooks/BillingProvider";
18
+ import { CurrentPlanCard } from "./CurrentPlanCard";
19
+ import { SubscriptionManager } from "./SubscriptionManager";
20
+ import type { BillingProduct, ProductOffer, SubscriptionInfo } from "../types";
21
+
22
+ const base: Omit<ProductOffer, "id" | "name" | "slug" | "unitPrice" | "isFree" | "sortOrder"> = {
23
+ currency: "USD",
24
+ pricingModel: "flat",
25
+ intervalUnit: "month",
26
+ intervalCount: 1,
27
+ intervalDisplay: "per month",
28
+ isRecurring: true,
29
+ features: [],
30
+ isActive: true,
31
+ isFeatured: false,
32
+ trialDays: 0,
33
+ ctaText: "",
34
+ };
35
+ const FREE: ProductOffer = { ...base, id: "o-free", name: "Free", slug: "crochet-free", unitPrice: "0.00", isFree: true, sortOrder: 0, tier: "free" };
36
+ const LITE: ProductOffer = { ...base, id: "o-lite", name: "Lite", slug: "crochet-lite", unitPrice: "2.99", isFree: false, sortOrder: 1, tier: "lite", ctaText: "Get Lite" };
37
+ const PRO: ProductOffer = { ...base, id: "o-pro", name: "Pro", slug: "crochet-pro", unitPrice: "9.00", isFree: false, sortOrder: 2, tier: "pro", trialDays: 7, isFeatured: true, ctaText: "Start Free Trial" };
38
+ const COMP: ProductOffer = { ...base, id: "o-comp", name: "Pro (complimentary)", slug: "crochet-pro-comp", unitPrice: "0.00", isFree: true, sortOrder: 2, tier: "pro", isActive: false };
39
+ const PRODUCT: BillingProduct = { id: "p", slug: "crochet-patterns", name: "Crochet", description: "", offers: [FREE, LITE, PRO] };
40
+
41
+ const sub = (offer: ProductOffer, over: Partial<SubscriptionInfo> = {}): SubscriptionInfo => ({
42
+ id: "s1",
43
+ offer,
44
+ status: "active",
45
+ currentPeriodStart: "2026-10-17T00:00:00Z",
46
+ currentPeriodEnd: "2099-11-17T00:00:00Z",
47
+ cancelAtPeriodEnd: false,
48
+ trialEnd: null,
49
+ viewerIsOwner: true,
50
+ ...over,
51
+ });
52
+
53
+ function renderManager(subscription: SubscriptionInfo | null, props: Partial<React.ComponentProps<typeof SubscriptionManager>> = {}) {
54
+ const fetcher = vi.fn((url: string) => {
55
+ if (url.includes("/billing/subscription/current/")) {
56
+ return Promise.resolve(subscription
57
+ ? { ok: true, json: () => Promise.resolve(subscription) }
58
+ : { ok: false, status: 404, json: () => Promise.resolve({}) });
59
+ }
60
+ if (url.includes("/billing/products/")) return Promise.resolve({ ok: true, json: () => Promise.resolve(PRODUCT) });
61
+ if (url.includes("/billing/success-sync/")) return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
62
+ if (url.includes("/billing/usage/")) return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
63
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
64
+ }) as unknown as typeof fetch;
65
+ return render(
66
+ <BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={fetcher}>
67
+ <SubscriptionManager productId="crochet-patterns" returnUrl="https://x/settings/billing" {...props} />
68
+ </BillingProvider>,
69
+ );
70
+ }
71
+
72
+ describe("CurrentPlanCard states", () => {
73
+ it("a plan cancelled at period end reads 'Ends <date>', not Active", () => {
74
+ render(<CurrentPlanCard subscription={sub(PRO, { cancelAtPeriodEnd: true, currentPeriodEnd: "2099-11-17T00:00:00Z" })} usage={{}} />);
75
+ expect(screen.getByTestId("plan-status").textContent).toBe("Ends Nov 17, 2099");
76
+ });
77
+
78
+ it("a cancelled plan reads the same as one cancelled at period end", () => {
79
+ render(<CurrentPlanCard subscription={sub(PRO, { status: "cancelled", currentPeriodEnd: "2099-11-17T00:00:00Z" })} usage={{}} />);
80
+ expect(screen.getByTestId("plan-status").textContent).toBe("Ends Nov 17, 2099");
81
+ });
82
+
83
+ it("a trial says the first charge and its amount, with the end date shown once", () => {
84
+ const trialEnd = "2099-10-24T00:00:00Z";
85
+ render(<CurrentPlanCard subscription={sub(PRO, { status: "trialing", trialEnd, currentPeriodEnd: trialEnd })} usage={{}} onPortalClick={() => {}} />);
86
+ expect(screen.getByTestId("first-charge").textContent).toBe("First charge $9 on Oct 24, 2099");
87
+ expect(screen.queryByText(/billing period/i)).toBeNull();
88
+ expect(screen.getByTestId("plan-status").textContent).toBe("Trialing");
89
+ });
90
+
91
+ it("day one of a 7-day trial reads 7 days", () => {
92
+ const trialEnd = new Date(Date.now() + 7 * 86_400_000 - 60_000).toISOString();
93
+ render(<CurrentPlanCard subscription={sub(PRO, { status: "trialing", trialEnd })} usage={{}} onPortalClick={() => {}} />);
94
+ expect(screen.getByTestId("banner-trialing").textContent).toMatch(/ends in 7 days/);
95
+ });
96
+
97
+ it("a failed payment is not dated with the period end", () => {
98
+ render(<CurrentPlanCard subscription={sub(PRO, { status: "past_due", currentPeriodEnd: "2099-11-17T00:00:00Z" })} usage={{}} onPortalClick={() => {}} />);
99
+ const banner = screen.getByTestId("banner-past-due");
100
+ expect(banner.textContent).toMatch(/your last payment failed/i);
101
+ expect(banner.textContent).not.toMatch(/2099/);
102
+ });
103
+
104
+ it("the next billing date uses the same date format as the banners", () => {
105
+ render(<CurrentPlanCard subscription={sub(PRO)} usage={{}} />);
106
+ expect(screen.getByTestId("next-billing-date").textContent).toBe("Next billing date: Nov 17, 2099");
107
+ });
108
+ });
109
+
110
+ describe("SubscriptionManager states", () => {
111
+ it("while Pro ends at period end, the Free card reads 'Starts <date>' and is disabled", async () => {
112
+ renderManager(sub(PRO, { cancelAtPeriodEnd: true }));
113
+ const btn = await screen.findByTestId("upgrade-cta-crochet-free");
114
+ expect(btn.textContent).toBe("Starts Nov 17, 2099");
115
+ expect((btn as HTMLButtonElement).disabled).toBe(true);
116
+ });
117
+
118
+ it("a cancelled paid plan keeps Manage Billing for its invoices", async () => {
119
+ renderManager(sub(PRO, { status: "cancelled" }));
120
+ expect(await screen.findByTestId("portal-button")).toBeTruthy();
121
+ });
122
+
123
+ it("back from a trial checkout says the trial has started", async () => {
124
+ renderManager(sub(PRO, { status: "trialing", trialEnd: "2099-10-24T00:00:00Z" }), { statusMessage: "success" });
125
+ expect((await screen.findByTestId("status-banner-success", {}, { timeout: 4000 })).textContent).toContain("Your Pro trial has started.");
126
+ });
127
+
128
+ it("the clicked plan button says 'Opening checkout…' and is disabled while checkout opens", async () => {
129
+ const onPlanChange = vi.fn(() => new Promise(() => {}));
130
+ renderManager(null, { onPlanChange });
131
+ const lite = await screen.findByTestId("upgrade-cta-crochet-lite");
132
+ await userEvent.click(lite);
133
+ await waitFor(() => expect(lite.textContent).toBe("Opening checkout…"));
134
+ expect((lite as HTMLButtonElement).disabled).toBe(true);
135
+ expect((screen.getByTestId("upgrade-cta-crochet-pro") as HTMLButtonElement).disabled).toBe(true);
136
+ });
137
+
138
+ it("a complimentary plan on the Pro tier marks the Pro card current, with no trial offered", async () => {
139
+ renderManager(sub(COMP, { currentPeriodEnd: null }));
140
+ const pro = await screen.findByTestId("upgrade-cta-crochet-pro");
141
+ expect(pro.textContent).toBe("Current plan");
142
+ expect((pro as HTMLButtonElement).disabled).toBe(true);
143
+ expect(screen.getByTestId("upgrade-cta-crochet-lite").textContent).toBe("Downgrade to Lite");
144
+ expect(screen.queryByText(/start free trial/i)).toBeNull();
145
+ });
146
+ });
@@ -45,7 +45,25 @@ function featureLabel(f: OfferFeature): string | null {
45
45
  return formatFeatureLabel(f);
46
46
  }
47
47
 
48
- function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
48
+ /**
49
+ * The card's one status pill. A plan that is ending (cancelled, or cancelled
50
+ * at period end) reads "Ends <date>": it used to say "Active" beside a
51
+ * "your plan is cancelled" banner (crochet_patterns-3bzni, -pes38). A trial
52
+ * reads "Trialing" only; its end date is in the banner (crochet_patterns-rjg2e).
53
+ */
54
+ function StatusBadge({ status, endsOn }: { status: string; endsOn: string | null }) {
55
+ if (endsOn) {
56
+ return (
57
+ <span
58
+ data-testid="plan-status"
59
+ role="status"
60
+ aria-label={`Subscription status: ends ${prettyDate(endsOn)}`}
61
+ className="inline-block text-xs font-semibold px-3 py-1 rounded-full bg-gray-100 text-gray-600"
62
+ >
63
+ {`Ends ${prettyDate(endsOn)}`}
64
+ </span>
65
+ );
66
+ }
49
67
  let cls = "inline-block text-xs font-semibold px-3 py-1 rounded-full ";
50
68
  switch (status) {
51
69
  case "active": cls += "bg-green-50 text-green-700"; break;
@@ -75,16 +93,10 @@ function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string |
75
93
  className={cls}
76
94
  >
77
95
  {readable}
78
- {status === "trialing" && trialEnd && (
79
- <span className="ml-1">until {formatDate(trialEnd)}</span>
80
- )}
81
96
  </span>
82
97
  );
83
98
  }
84
99
 
85
- function formatDate(iso: string): string {
86
- return new Date(iso).toISOString().slice(0, 10);
87
- }
88
100
 
89
101
  function ProgressBar({ used, limit }: { used: number; limit: number }) {
90
102
  // Cap at 100% so a hard-overage display can't blow out the layout.
@@ -126,10 +138,11 @@ function FeatureRow({ feature, used }: { feature: OfferFeature; used: number | n
126
138
  );
127
139
  }
128
140
 
129
- /** Days until ISO date, rounded down; negative if past. */
141
+ /** Calendar days (UTC, as the dates are shown) from today to the ISO date;
142
+ * negative if past. Day one of a 7-day trial is 7, not 6 (crochet_patterns-lm7nu). */
130
143
  function daysUntil(iso: string): number {
131
- const ms = new Date(iso).getTime() - Date.now();
132
- return Math.floor(ms / 86_400_000);
144
+ const day = (d: Date) => Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
145
+ return Math.round((day(new Date(iso)) - day(new Date())) / 86_400_000);
133
146
  }
134
147
 
135
148
  /** Pretty "Jun 27, 2026" style — easier to scan than ISO yyyy-mm-dd in banners. */
@@ -179,6 +192,8 @@ function StateBanner({ testId, variant, message, action }: BannerProps) {
179
192
  export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick }: CurrentPlanCardProps) {
180
193
  const offer = subscription.offer;
181
194
  const features = offer.features ?? [];
195
+ const ending = subscription.status === "cancelled" || subscription.cancelAtPeriodEnd;
196
+ const endsOn = ending ? subscription.currentPeriodEnd : null;
182
197
 
183
198
  // State-driven banner choice — at most one banner at the top of the card.
184
199
  // Precedence: past_due > cancelled-in-period > trialing > none.
@@ -191,8 +206,8 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
191
206
  variant="danger"
192
207
  message={
193
208
  <>
194
- Payment failed{subscription.currentPeriodEnd ? ` on ${prettyDate(subscription.currentPeriodEnd)}` : ""}.
195
- {" "}Update your card to keep your subscription active.
209
+ {/* Not dated: the period end is not when the payment failed (crochet_patterns-lb8u3). */}
210
+ Your last payment failed. Update your card to keep your subscription active.
196
211
  </>
197
212
  }
198
213
  action={{ label: "Update card", onClick: onPortalClick }}
@@ -260,7 +275,7 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
260
275
  {stateBanner}
261
276
  <div className="flex items-center justify-between mb-4">
262
277
  <h2 className="text-xl font-bold text-gray-900">{offer.name}</h2>
263
- <StatusBadge status={subscription.status} trialEnd={subscription.trialEnd} />
278
+ <StatusBadge status={subscription.status} endsOn={endsOn} />
264
279
  </div>
265
280
 
266
281
  <div className="mb-4">
@@ -280,14 +295,21 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
280
295
  {/* Active paid subs surface "Next billing date" prominently — that's
281
296
  what users actually want to know. Free + cancelled + no-period subs
282
297
  fall back to the period range / nothing. startsim-7j4. */}
283
- {subscription.currentPeriodEnd && !offer.isFree && subscription.status === "active" && !subscription.cancelAtPeriodEnd ? (
298
+ {/* A trial says when it first charges and how much, in place of a
299
+ billing period that only repeats the trial end (crochet_patterns-iwl0f).
300
+ One date format on the card, the banners' (crochet_patterns-rjg2e). */}
301
+ {subscription.status === "trialing" && subscription.trialEnd && !offer.isFree && !ending ? (
302
+ <p data-testid="first-charge" className="text-sm text-gray-600 mb-6">
303
+ {`First charge ${priceLabel(offer)} on ${prettyDate(subscription.trialEnd)}`}
304
+ </p>
305
+ ) : subscription.currentPeriodEnd && !offer.isFree && subscription.status === "active" && !subscription.cancelAtPeriodEnd ? (
284
306
  <p data-testid="next-billing-date" className="text-sm text-gray-600 mb-6">
285
- Next billing date: {formatDate(subscription.currentPeriodEnd)}
307
+ Next billing date: {prettyDate(subscription.currentPeriodEnd)}
286
308
  </p>
287
- ) : subscription.currentPeriodStart && subscription.currentPeriodEnd ? (
309
+ ) : subscription.currentPeriodStart && subscription.currentPeriodEnd && !ending ? (
288
310
  <p className="text-sm text-gray-500 mb-6">
289
- Billing period: {formatDate(subscription.currentPeriodStart)} to{" "}
290
- {formatDate(subscription.currentPeriodEnd)}
311
+ Billing period: {prettyDate(subscription.currentPeriodStart)} to{" "}
312
+ {prettyDate(subscription.currentPeriodEnd)}
291
313
  </p>
292
314
  ) : null}
293
315
 
@@ -290,8 +290,8 @@ describe("SubscriptionManager", () => {
290
290
  // Check trialing status
291
291
  expect(screen.getByText(/trialing/i)).toBeDefined();
292
292
 
293
- // Check trial end date is displayed
294
- expect(screen.getByText(/2026-01-15/)).toBeDefined();
293
+ // The trial end is shown in the banner's format (crochet_patterns-rjg2e).
294
+ expect(screen.getByTestId("first-charge").textContent).toMatch(/Jan 15, 2026/);
295
295
  });
296
296
 
297
297
  it("shows billing period dates", async () => {
@@ -319,7 +319,7 @@ describe("SubscriptionManager", () => {
319
319
  // or free states after startsim-7j4. Both dates are still in the DOM via
320
320
  // the grid card's renderings, so target the current-plan card specifically.
321
321
  const currentPlan = screen.getByTestId("current-plan");
322
- expect(currentPlan.textContent).toMatch(/2026-02-01/);
322
+ expect(currentPlan.textContent).toMatch(/Feb 1, 2026/);
323
323
  });
324
324
 
325
325
  it("shows ALL offers in the Change Plan grid (current marked, not hidden) — startsim-1f1", async () => {
@@ -934,7 +934,7 @@ describe("SubscriptionManager", () => {
934
934
 
935
935
  const nextBilling = screen.getByTestId("next-billing-date");
936
936
  expect(nextBilling.textContent).toMatch(/next billing date/i);
937
- expect(nextBilling.textContent).toMatch(/2026-02-01/);
937
+ expect(nextBilling.textContent).toMatch(/Feb 1, 2026/);
938
938
  // The verbose "Billing period:" line is hidden on active paid subs.
939
939
  expect(screen.queryByText(/^Billing period:/i)).toBeNull();
940
940
  });
@@ -311,8 +311,27 @@ export function SubscriptionManager({
311
311
  // startsim-21f, crochet_patterns-anw61.
312
312
  const paying = isPayingSubscription(usableSubscription);
313
313
  const onUnlistedPlan = !!product && isUnlistedOffer(product.offers, usableSubscription?.offer);
314
+ // A complimentary plan on a listed tier ("Pro (complimentary)", tier pro)
315
+ // IS that tier: its card reads "Current plan" and nothing sells the viewer a
316
+ // trial of what they have (crochet_patterns-lgqp2).
317
+ const ownTierKey = onUnlistedPlan && usableSubscription?.offer.tier ? `tier:${usableSubscription.offer.tier}` : null;
318
+ const ownTier = ownTierKey ? entries.find(({ tier }) => tier.key === ownTierKey) ?? null : null;
319
+ const rankOffer = ownTier?.offer ?? currentOffer;
314
320
  const directionFor = (cardOffer: ProductOffer): PlanDirection =>
315
- planDirection(cardOffer, currentOffer, tierKeyOf);
321
+ ownTier && tierKeyOf(cardOffer.id) === ownTier.tier.key
322
+ ? "current"
323
+ : planDirection(cardOffer, rankOffer, tierKeyOf);
324
+
325
+ // A paid plan that is ending falls back to the free plan on its end date, so
326
+ // the free card says when instead of offering a second downgrade
327
+ // (crochet_patterns-3bzni, -pes38).
328
+ const endingOn =
329
+ usableSubscription && !usableSubscription.offer.isFree &&
330
+ (usableSubscription.status === "cancelled" || usableSubscription.cancelAtPeriodEnd) &&
331
+ usableSubscription.currentPeriodEnd &&
332
+ new Date(usableSubscription.currentPeriodEnd).getTime() > Date.now()
333
+ ? usableSubscription.currentPeriodEnd
334
+ : null;
316
335
 
317
336
  const handleOfferClick = (offer: ProductOffer) => {
318
337
  // Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
@@ -329,8 +348,8 @@ export function SubscriptionManager({
329
348
  // only apps), so the Upgrade CTA simply doesn't render — startsim-ft2.
330
349
  const featuredPaidOffer: ProductOffer | null = (() => {
331
350
  if (!product) return null;
332
- if (currentOffer) {
333
- const above = product.offers.filter((o) => !o.isFree && o.sortOrder > currentOffer.sortOrder);
351
+ if (rankOffer) {
352
+ const above = product.offers.filter((o) => !o.isFree && o.sortOrder > rankOffer.sortOrder);
334
353
  return above.find((o) => o.isFeatured) ?? above[0] ?? null;
335
354
  }
336
355
  return (
@@ -356,7 +375,12 @@ export function SubscriptionManager({
356
375
  <svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
357
376
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
358
377
  </svg>
359
- <p className="text-sm font-medium text-green-800">Subscription activated successfully!</p>
378
+ <p className="text-sm font-medium text-green-800">
379
+ {/* A trial is not yet a paid plan (crochet_patterns-reg6a). */}
380
+ {usableSubscription?.status === "trialing"
381
+ ? `Your ${usableSubscription.offer.name} trial has started.`
382
+ : "Subscription activated successfully!"}
383
+ </p>
360
384
  </div>
361
385
  )}
362
386
 
@@ -497,7 +521,13 @@ export function SubscriptionManager({
497
521
  const direction = directionFor(offer);
498
522
  const isCurrent = direction === "current";
499
523
  const isDowngrade = direction === "downgrade" || direction === "switch";
500
- const ctaLabel = planCtaLabel(offer, direction, paying);
524
+ const fallsBackHere = !!endingOn && offer.isFree;
525
+ // Straight to checkout: say so until the page goes (crochet_patterns-jnkso).
526
+ const opening = planChange.busy && planChange.choosing?.id === offer.id && !planChange.pending && !offer.isFree && !paying;
527
+ const ctaLabel = fallsBackHere
528
+ ? `Starts ${new Date(endingOn).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" })}`
529
+ : opening ? "Opening checkout\u2026"
530
+ : planCtaLabel(offer, direction, paying);
501
531
  const showTrial = trialApplies(offer, direction, paying);
502
532
 
503
533
  // Border: current = solid ring; featured upgrade = blue; else gray.
@@ -522,7 +552,7 @@ export function SubscriptionManager({
522
552
  data-testid={`plan-card-${offer.slug}`}
523
553
  data-direction={direction}
524
554
  data-current={isCurrent || undefined}
525
- className={`relative rounded-xl p-6 transition-colors ${cardBorder}`}
555
+ className={`relative flex flex-col rounded-xl p-6 transition-colors ${cardBorder}`}
526
556
  >
527
557
  <h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
528
558
  <div className="mb-3">
@@ -540,12 +570,12 @@ export function SubscriptionManager({
540
570
  <PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
541
571
  <button
542
572
  data-testid={`upgrade-cta-${offer.slug}`}
543
- onClick={() => !isCurrent && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
544
- disabled={isCurrent || isViewerNonOwner || planChange.busy}
545
- aria-disabled={isCurrent || isViewerNonOwner || planChange.busy || undefined}
546
- aria-busy={planChange.busy && planChange.pending?.offer.id === offer.id ? true : undefined}
573
+ onClick={() => !isCurrent && !fallsBackHere && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
574
+ disabled={isCurrent || fallsBackHere || isViewerNonOwner || planChange.busy}
575
+ aria-disabled={isCurrent || fallsBackHere || isViewerNonOwner || planChange.busy || undefined}
576
+ aria-busy={planChange.busy && planChange.choosing?.id === offer.id ? true : undefined}
547
577
  title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
548
- className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
578
+ className={`mt-auto w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${fallsBackHere ? "bg-gray-100 text-gray-500 cursor-not-allowed" : ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
549
579
  >
550
580
  {ctaLabel}
551
581
  </button>
@@ -561,7 +591,8 @@ export function SubscriptionManager({
561
591
  Free-tier users (provider='none') have no Stripe customer, so the
562
592
  portal call would 404 — they get the Upgrade CTA on the current-plan
563
593
  card instead. startsim-b1z. */}
564
- {usableSubscription && usableSubscription.status !== "cancelled" && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
594
+ {/* A cancelled paid plan keeps it too: its invoices are there (crochet_patterns-pes38). */}
595
+ {usableSubscription && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
565
596
  <div className="pt-4 border-t border-gray-100">
566
597
  <button
567
598
  data-testid="portal-button"
@@ -48,6 +48,8 @@ export interface UsePlanChangeResult {
48
48
  /** Open when the viewer must confirm. */
49
49
  pending: PlanChangeConfirmState | null;
50
50
  busy: boolean;
51
+ /** The offer being chosen while `busy`, so its button can say what is happening. */
52
+ choosing: ProductOffer | null;
51
53
  /** The confirm step itself is running. */
52
54
  confirming: boolean;
53
55
  error: string | null;
@@ -57,7 +59,12 @@ export interface UsePlanChangeResult {
57
59
  export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
58
60
  const { client } = useBillingContext();
59
61
  const [pending, setPending] = useState<PlanChangeConfirmState | null>(null);
60
- const [busy, setBusy] = useState(false);
62
+ const [busy, setBusyState] = useState(false);
63
+ const [choosing, setChoosing] = useState<ProductOffer | null>(null);
64
+ const setBusy = useCallback((value: boolean, offer: ProductOffer | null = null) => {
65
+ setBusyState(value);
66
+ setChoosing(value ? offer : null);
67
+ }, []);
61
68
  const [confirming, setConfirming] = useState(false);
62
69
  const [error, setError] = useState<string | null>(null);
63
70
  const inFlight = useRef(false);
@@ -78,14 +85,14 @@ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
78
85
  return false;
79
86
  }
80
87
  },
81
- [proceed],
88
+ [proceed, setBusy],
82
89
  );
83
90
 
84
91
  const choose = useCallback(
85
92
  async (offer: ProductOffer, context: PlanChangeContext) => {
86
93
  if (inFlight.current) return;
87
94
  inFlight.current = true;
88
- setBusy(true);
95
+ setBusy(true, offer);
89
96
  setError(null);
90
97
 
91
98
  const leavingUnlisted = !!context.leavingUnlisted;
@@ -118,7 +125,7 @@ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
118
125
  leavingUnlisted: preview?.currentOffer ? !preview.currentOffer.isListed : leavingUnlisted,
119
126
  });
120
127
  },
121
- [client, run],
128
+ [client, run, setBusy],
122
129
  );
123
130
 
124
131
  const confirm = useCallback(async () => {
@@ -136,9 +143,9 @@ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
136
143
  setError(null);
137
144
  inFlight.current = false;
138
145
  setBusy(false);
139
- }, [confirming]);
146
+ }, [confirming, setBusy]);
140
147
 
141
148
  const clearError = useCallback(() => setError(null), []);
142
149
 
143
- return { choose, confirm, cancel, pending, busy, confirming, error, clearError };
150
+ return { choose, confirm, cancel, pending, busy, choosing, confirming, error, clearError };
144
151
  }