@startsimpli/billing 0.4.24 → 0.4.25

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.
@@ -376,12 +376,15 @@ describe("SubscriptionManager", () => {
376
376
  expect(screen.getByTestId("available-plans")).toBeDefined();
377
377
  });
378
378
 
379
- // Click Enterprise upgrade option
379
+ // Click Enterprise upgrade option: already paying, so it asks first.
380
380
  const enterpriseButton = screen.getByTestId("upgrade-cta-enterprise-monthly");
381
381
  await userEvent.click(enterpriseButton);
382
+ expect(onPlanChange).not.toHaveBeenCalled();
383
+ await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
382
384
 
383
385
  expect(onPlanChange).toHaveBeenCalledWith(
384
- expect.objectContaining({ slug: "enterprise-monthly" })
386
+ expect.objectContaining({ slug: "enterprise-monthly" }),
387
+ null,
385
388
  );
386
389
  });
387
390
 
@@ -655,8 +658,10 @@ describe("SubscriptionManager", () => {
655
658
 
656
659
  await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
657
660
 
658
- // Click the Free card's CTA (slug "starter-monthly", isFree=true)
659
- (await screen.findByTestId("upgrade-cta-starter-monthly")).click();
661
+ // Click the Free card's CTA (slug "starter-monthly", isFree=true), then
662
+ // confirm: leaving a paid plan is never one click (crochet_patterns-lr7mj).
663
+ await userEvent.click(await screen.findByTestId("upgrade-cta-starter-monthly"));
664
+ await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
660
665
 
661
666
  // subscribe-free is hit (server fetcher saw the POST)
662
667
  await waitFor(() => {
@@ -830,10 +835,12 @@ describe("SubscriptionManager", () => {
830
835
  cardSlug: "pro-monthly",
831
836
  expectedCta: /current plan/i, shouldBeDisabled: true,
832
837
  shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
833
- { label: "pro · Enterprise card → upgrade CTA, trial copy if applicable",
838
+ // Already paying: an upgrade changes the plan in place, so it says so
839
+ // instead of the new-buyer pitch (crochet_patterns-anw61).
840
+ { label: "pro · Enterprise card → 'Upgrade to Enterprise', no trial copy",
834
841
  subscription: mockSubscriptionActive,
835
842
  cardSlug: "enterprise-monthly",
836
- expectedCta: /contact sales/i, shouldBeDisabled: false,
843
+ expectedCta: /upgrade to enterprise/i, shouldBeDisabled: false,
837
844
  shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
838
845
 
839
846
  // TRIALING Pro current sub
@@ -965,8 +972,10 @@ describe("SubscriptionManager", () => {
965
972
  </Wrapper>
966
973
  );
967
974
 
968
- // Banner renders
969
- await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
975
+ // No subscription found yet: "Activating…", never "activated" beside
976
+ // Free (crochet_patterns-nag60).
977
+ await waitFor(() => expect(screen.getByTestId("status-banner-activating")).toBeDefined());
978
+ expect(screen.queryByTestId("status-banner-success")).toBeNull();
970
979
 
971
980
  // success-sync was hit
972
981
  await waitFor(() => {
@@ -993,7 +1002,10 @@ describe("SubscriptionManager", () => {
993
1002
  </Wrapper>
994
1003
  );
995
1004
 
996
- await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
1005
+ await waitFor(() => {
1006
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
1007
+ expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(true);
1008
+ });
997
1009
  rerender(
998
1010
  <Wrapper>
999
1011
  <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" statusMessage="success" />
@@ -12,15 +12,26 @@
12
12
  * />
13
13
  */
14
14
 
15
- import React, { useEffect, useRef } from "react";
15
+ import React, { useCallback, useEffect, useRef, useState } from "react";
16
16
  import { useSubscription } from "../hooks/useSubscription";
17
17
  import { useProduct } from "../hooks/useProduct";
18
18
  import { usePortal } from "../hooks/usePortal";
19
19
  import { useCheckout } from "../hooks/useCheckout";
20
20
  import { useUsage } from "../hooks/useUsage";
21
21
  import { useSuccessSync } from "../hooks/useSuccessSync";
22
- import type { OfferFeature, ProductOffer } from "../types";
22
+ import { usePlanChange } from "../hooks/usePlanChange";
23
+ import type { OfferFeature, PlanChangePreview, ProductOffer } from "../types";
23
24
  import { CurrentPlanCard } from "./CurrentPlanCard";
25
+ import { PlanChangeConfirm } from "./PlanChangeConfirm";
26
+ import {
27
+ isPayingSubscription,
28
+ isUnlistedOffer,
29
+ planCtaLabel,
30
+ planDirection,
31
+ trialApplies,
32
+ TRIAL_NOTE_FEATURE_KEY,
33
+ type PlanDirection,
34
+ } from "../utils/plan";
24
35
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
25
36
  import { formatFeatureLabel } from "../utils/format";
26
37
 
@@ -31,13 +42,26 @@ export interface SubscriptionManagerProps {
31
42
  /** URL to return to after portal session */
32
43
  returnUrl: string;
33
44
 
34
- /** Called when user clicks an available plan's CTA button */
35
- onPlanChange?: (offer: ProductOffer) => void;
45
+ /** Does a paid plan change once the viewer has chosen (and, when they
46
+ * already pay, confirmed) it — typically a checkout via useCheckout. Gets
47
+ * the backend's preview when one was made. Return "navigating" after
48
+ * sending the browser away; throw to show an error. */
49
+ onPlanChange?: (offer: ProductOffer, preview?: PlanChangePreview | null) => unknown;
36
50
 
37
- /** Optional status message to display (e.g., after checkout redirect) */
38
- statusMessage?: "success" | "cancelled" | "activating" | null;
51
+ /** Optional status message to display (e.g., after checkout redirect).
52
+ * "success" shows "Activating…" until the paid plan is actually found;
53
+ * "scheduled" is a change that takes effect at period end. */
54
+ statusMessage?: "success" | "cancelled" | "activating" | "scheduled" | null;
55
+
56
+ /** The checkout session id from the success URL ({CHECKOUT_SESSION_ID}),
57
+ * passed to success-sync so it reads the checkout that was paid. */
58
+ checkoutSessionId?: string | null;
39
59
  }
40
60
 
61
+ /** How long to keep looking for a just-paid subscription before saying so. */
62
+ const ACTIVATION_POLLS = 5;
63
+ const ACTIVATION_POLL_MS = 2000;
64
+
41
65
  /** Render one OfferFeature row. Skips boolean-false (the plan _doesn't_ have
42
66
  * that feature so we don't pretend it does). Numeric limits surface as
43
67
  * "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
@@ -57,11 +81,12 @@ function FeatureRow({ feature }: { feature: OfferFeature }) {
57
81
  );
58
82
  }
59
83
 
60
- function PlanFeatureList({ features }: { features: OfferFeature[] }) {
84
+ function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFeature[]; showTrialNote?: boolean }) {
61
85
  if (!features || features.length === 0) return null;
86
+ const shown = showTrialNote ? features : features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
62
87
  return (
63
88
  <ul data-testid="plan-features" className="space-y-1.5 mb-4">
64
- {features.map((f) => <FeatureRow key={f.key} feature={f} />)}
89
+ {shown.map((f) => <FeatureRow key={f.key} feature={f} />)}
65
90
  </ul>
66
91
  );
67
92
  }
@@ -167,6 +192,7 @@ export function SubscriptionManager({
167
192
  returnUrl,
168
193
  onPlanChange,
169
194
  statusMessage,
195
+ checkoutSessionId = null,
170
196
  }: SubscriptionManagerProps) {
171
197
  const { subscription, loading: subLoading, error: subError, refetch: refetchSubscription } = useSubscription(productId);
172
198
  const { product, loading: productLoading, error: productError, refetch: refetchProduct } = useProduct(productId);
@@ -179,24 +205,58 @@ export function SubscriptionManager({
179
205
  subscription?.offer?.id ?? null,
180
206
  );
181
207
 
208
+ // Free offers → POST /billing/subscribe-free/ then refetch (no Stripe
209
+ // round-trip for $0 plans — startsim-6vk). Paid offers → the app's
210
+ // onPlanChange (a checkout). Either way only after the viewer has confirmed
211
+ // a change to a plan they already have (crochet_patterns-lr7mj).
212
+ const proceed = useCallback(
213
+ async (offer: ProductOffer, preview: PlanChangePreview | null) => {
214
+ if (offer.isFree) {
215
+ await subscribeFree(offer.id);
216
+ await refetchSubscription();
217
+ return;
218
+ }
219
+ return onPlanChange?.(offer, preview);
220
+ },
221
+ [subscribeFree, refetchSubscription, onPlanChange],
222
+ );
223
+ const planChange = usePlanChange(proceed);
224
+
182
225
  // startsim-ngw — when the user lands on ?status=success (just back from
183
226
  // Stripe checkout), POST /billing/success-sync/ ONCE to populate the cache
184
227
  // from Stripe so the UI flips to Trialing/Active without waiting for the
185
228
  // webhook, then refetch the subscription. The ref guard prevents the effect
186
229
  // re-firing on React StrictMode double-invokes.
187
230
  const successSyncedRef = useRef(false);
231
+ // "syncing" until the paid plan is found or we have looked long enough.
232
+ // The banner said "activated" from the URL alone, beside "You're on the
233
+ // Free plan" when the sync had not found it (crochet_patterns-nag60).
234
+ const [activation, setActivation] = useState<"idle" | "syncing" | "done">("idle");
235
+ const planFoundRef = useRef(false);
236
+ // Back from a checkout (session id in the URL): only a paid row is the
237
+ // plan just bought. Otherwise any row the change produced counts.
238
+ planFoundRef.current = !!subscription?.offer && (!checkoutSessionId || !subscription.offer.isFree);
188
239
  useEffect(() => {
189
240
  if (statusMessage === "success" && !successSyncedRef.current) {
190
241
  successSyncedRef.current = true;
242
+ setActivation("syncing");
191
243
  (async () => {
192
244
  try {
193
- await successSync();
194
- } finally {
245
+ await successSync({ sessionId: checkoutSessionId, productSlug: productId });
246
+ } catch {
247
+ /* the refetch below still tells us what the backend has */
248
+ }
249
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
250
+ for (let i = 0; i < ACTIVATION_POLLS; i++) {
195
251
  await refetchSubscription();
252
+ await sleep(50); // let the refetched row render before reading it
253
+ if (planFoundRef.current) break;
254
+ if (i < ACTIVATION_POLLS - 1) await sleep(ACTIVATION_POLL_MS);
196
255
  }
256
+ setActivation("done");
197
257
  })();
198
258
  }
199
- }, [statusMessage, successSync, refetchSubscription]);
259
+ }, [statusMessage, successSync, refetchSubscription, checkoutSessionId, productId]);
200
260
 
201
261
  // startsim-z6f: skeleton matching the final layout (current-plan box +
202
262
  // 2-card grid) so the transition from loading → loaded doesn't reflow.
@@ -246,53 +306,17 @@ export function SubscriptionManager({
246
306
  // for the chosen interval. The card matching their current plan gets a
247
307
  // "Current plan" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
248
308
 
249
- /** Per-card direction relative to the current subscription:
250
- * - "current" → user is on this exact offer → "Current Plan" badge, no action
251
- * - "upgrade" → card is a higher tier (paid vs current free, or higher sortOrder)
252
- * - "downgrade" → card is a lower tier (free when current is paid, or lower sortOrder)
253
- * startsim-21f. */
254
- type Direction = "current" | "upgrade" | "downgrade" | "switch";
255
- function directionFor(cardOffer: ProductOffer): Direction {
256
- if (!currentOffer) return "upgrade"; // plan still unknown → every card is an upgrade
257
- if (cardOffer.id === currentOffer.id) return "current";
258
- // Same plan, other billing interval (Lite ↔ Lite Annual).
259
- const tierKey = tierKeyOf(cardOffer.id);
260
- if (tierKey !== null && tierKey === tierKeyOf(currentOffer.id)) return "switch";
261
- // Free vs Paid: paid is always an upgrade, free is always a downgrade.
262
- if (cardOffer.isFree && !currentOffer.isFree) return "downgrade";
263
- if (!cardOffer.isFree && currentOffer.isFree) return "upgrade";
264
- // Both paid → compare sortOrder (apps order their tiers low → high).
265
- return cardOffer.sortOrder > currentOffer.sortOrder ? "upgrade" : "downgrade";
266
- }
267
-
268
- /** Visible CTA label for the card, branching on direction. The default
269
- * `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
270
- * on a Free card when a Pro user is looking is the bug startsim-21f closes. */
271
- function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
272
- if (direction === "current") return "Current plan";
273
- if (direction === "switch") return cardOffer.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
274
- if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
275
- return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
276
- }
309
+ // Direction and label per card, shared with /pricing (utils/plan):
310
+ // startsim-21f, crochet_patterns-anw61.
311
+ const paying = isPayingSubscription(usableSubscription);
312
+ const onUnlistedPlan = !!product && isUnlistedOffer(product.offers, usableSubscription?.offer);
313
+ const directionFor = (cardOffer: ProductOffer): PlanDirection =>
314
+ planDirection(cardOffer, currentOffer, tierKeyOf);
277
315
 
278
- // Branch the offer CTA internally so apps don't reinvent it:
279
- // - Free offers → POST /billing/subscribe-free/ then refetch subscription
280
- // (no Stripe round-trip needed for $0 plans — startsim-6vk).
281
- // - Paid offers → app-supplied onPlanChange (typically a Stripe checkout
282
- // redirect via useCheckout).
283
- const handleOfferClick = async (offer: ProductOffer) => {
316
+ const handleOfferClick = (offer: ProductOffer) => {
284
317
  // Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
285
318
  if (subscription?.viewerIsOwner === false) return;
286
- if (offer.isFree) {
287
- try {
288
- await subscribeFree(offer.id);
289
- await refetchSubscription();
290
- } catch {
291
- /* Error captured by hook state — banner not added here yet. */
292
- }
293
- return;
294
- }
295
- onPlanChange?.(offer);
319
+ void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
296
320
  };
297
321
 
298
322
  // The "Upgrade" CTA on the current-plan card points at the NEXT tier above
@@ -326,7 +350,7 @@ export function SubscriptionManager({
326
350
 
327
351
  return (
328
352
  <div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12 space-y-8">
329
- {statusMessage === "success" && (
353
+ {statusMessage === "success" && activation === "done" && planFoundRef.current && (
330
354
  <div data-testid="status-banner-success" className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
331
355
  <svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
332
356
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@@ -344,7 +368,32 @@ export function SubscriptionManager({
344
368
  </div>
345
369
  )}
346
370
 
347
- {statusMessage === "activating" && (
371
+ {statusMessage === "success" && activation === "done" && !planFoundRef.current && (
372
+ <div data-testid="status-banner-pending" role="status" className="rounded-xl bg-amber-50 border border-amber-200 p-4">
373
+ <p className="text-sm font-medium text-amber-900">
374
+ We haven&apos;t received the payment confirmation yet. It can take a minute; refresh this page to check again. Please don&apos;t pay a second time.
375
+ </p>
376
+ </div>
377
+ )}
378
+
379
+ {statusMessage === "scheduled" && (
380
+ <div data-testid="status-banner-scheduled" role="status" className="rounded-xl bg-blue-50 border border-blue-200 p-4">
381
+ <p className="text-sm font-medium text-blue-900">
382
+ Your plan change is scheduled.
383
+ {usableSubscription?.currentPeriodEnd
384
+ ? <> You keep your current plan until {new Date(usableSubscription.currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })}.</>
385
+ : <> You keep your current plan until the end of this billing period.</>}
386
+ </p>
387
+ </div>
388
+ )}
389
+
390
+ {planChange.error && !planChange.pending && (
391
+ <div data-testid="plan-change-error" role="alert" className="rounded-xl bg-red-50 border border-red-200 p-4">
392
+ <p className="text-sm text-red-700">{planChange.error}</p>
393
+ </div>
394
+ )}
395
+
396
+ {(statusMessage === "activating" || (statusMessage === "success" && activation !== "done")) && (
348
397
  <div data-testid="status-banner-activating" className="rounded-xl bg-blue-50 border border-blue-200 p-4 flex items-center gap-3">
349
398
  <svg className="w-5 h-5 text-blue-600 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
350
399
  <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -380,7 +429,8 @@ export function SubscriptionManager({
380
429
  usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
381
430
  <button
382
431
  data-testid="upgrade-cta-current-plan"
383
- onClick={() => handleOfferClick(featuredPaidOffer)}
432
+ onClick={() => !planChange.busy && handleOfferClick(featuredPaidOffer)}
433
+ disabled={planChange.busy}
384
434
  className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold rounded-xl bg-blue-600 text-white hover:bg-blue-700 cursor-pointer transition-colors"
385
435
  >
386
436
  Upgrade to {featuredPaidOffer.name} →
@@ -389,7 +439,7 @@ export function SubscriptionManager({
389
439
  }
390
440
  />
391
441
  </>
392
- ) : onFreeByDefault && freeOffer ? (
442
+ ) : statusMessage === "success" && activation !== "done" ? null : onFreeByDefault && freeOffer ? (
393
443
  <div data-testid="free-by-default" className="text-center py-8">
394
444
  <p className="text-lg text-gray-600">You&apos;re on the {freeOffer.name} plan</p>
395
445
  <p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
@@ -446,7 +496,8 @@ export function SubscriptionManager({
446
496
  const direction = directionFor(offer);
447
497
  const isCurrent = direction === "current";
448
498
  const isDowngrade = direction === "downgrade" || direction === "switch";
449
- const ctaLabel = ctaLabelFor(offer, direction);
499
+ const ctaLabel = planCtaLabel(offer, direction, paying);
500
+ const showTrial = trialApplies(offer, direction, paying);
450
501
 
451
502
  // Border: current = solid ring; featured upgrade = blue; else gray.
452
503
  const cardBorder =
@@ -491,13 +542,14 @@ export function SubscriptionManager({
491
542
  {/* Trial copy is for prospective trialers only — once the
492
543
  user is on this offer or moving downward, drop the
493
544
  "14-day free trial" sales pitch. */}
494
- {direction === "upgrade" && <TrialCopy trialDays={offer.trialDays} />}
495
- <PlanFeatureList features={offer.features} />
545
+ {showTrial && <TrialCopy trialDays={offer.trialDays} />}
546
+ <PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
496
547
  <button
497
548
  data-testid={`upgrade-cta-${offer.slug}`}
498
- onClick={() => !isCurrent && !isViewerNonOwner && handleOfferClick(offer)}
499
- disabled={isCurrent || isViewerNonOwner}
500
- aria-disabled={isCurrent || isViewerNonOwner || undefined}
549
+ onClick={() => !isCurrent && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
550
+ disabled={isCurrent || isViewerNonOwner || planChange.busy}
551
+ aria-disabled={isCurrent || isViewerNonOwner || planChange.busy || undefined}
552
+ aria-busy={planChange.busy && planChange.pending?.offer.id === offer.id ? true : undefined}
501
553
  title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
502
554
  className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
503
555
  >
@@ -532,6 +584,14 @@ export function SubscriptionManager({
532
584
  </button>
533
585
  </div>
534
586
  )}
587
+
588
+ <PlanChangeConfirm
589
+ state={planChange.pending}
590
+ confirming={planChange.confirming}
591
+ error={planChange.pending ? planChange.error : null}
592
+ onConfirm={planChange.confirm}
593
+ onCancel={planChange.cancel}
594
+ />
535
595
  </div>
536
596
  );
537
597
  }
@@ -11,17 +11,31 @@
11
11
 
12
12
  import React, { useEffect, useRef, useCallback } from "react";
13
13
  import { useProduct } from "../hooks/useProduct";
14
- import type { ProductOffer } from "../types";
14
+ import type { PlanChangePreview, ProductOffer } from "../types";
15
15
  import { FeatureIcon } from "./FeatureIcon";
16
16
  import { formatFeatureLabel } from "../utils/format";
17
- import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
17
+ import {
18
+ isPayingSubscription,
19
+ isUnlistedOffer,
20
+ planCtaLabel,
21
+ planDirection,
22
+ resolveCurrentOffer,
23
+ trialApplies,
24
+ TRIAL_NOTE_FEATURE_KEY,
25
+ type CurrentPlanState,
26
+ } from "../utils/plan";
27
+ import { usePlanChange } from "../hooks/usePlanChange";
28
+ import { PlanChangeConfirm } from "./PlanChangeConfirm";
18
29
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
19
30
 
20
31
  export interface UpgradeModalProps {
21
32
  productId: string;
22
33
  open: boolean;
23
34
  onClose: () => void;
24
- onSelectOffer?: (offer: ProductOffer) => void;
35
+ /** Does the purchase / change. For a viewer who already pays it runs only
36
+ * after they confirm the charge (crochet_patterns-anw61). Return
37
+ * "navigating" after sending the browser away; throw to show an error. */
38
+ onSelectOffer?: (offer: ProductOffer, preview?: PlanChangePreview | null) => unknown;
25
39
 
26
40
  /** The viewer's plan, so their card reads "Current plan" and is disabled.
27
41
  * Omit on signed-out pages. crochet_patterns-34n7. */
@@ -36,10 +50,23 @@ export function UpgradeModal({
36
50
  currentPlan,
37
51
  }: UpgradeModalProps) {
38
52
  const { product, loading, error } = useProduct(productId);
53
+ const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
39
54
  const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
40
55
  product?.offers ?? [],
41
- resolveCurrentOfferId(product?.offers ?? [], currentPlan),
56
+ currentOffer?.id ?? null,
42
57
  );
58
+ const planChange = usePlanChange(async (offer, preview) => onSelectOffer?.(offer, preview));
59
+ const paying = !!currentOffer && isPayingSubscription(currentPlan?.subscription);
60
+ const onUnlistedPlan = isUnlistedOffer(product?.offers ?? [], currentOffer);
61
+ const tierKeyOf = (offerId: string) =>
62
+ entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
63
+ const select = (offer: ProductOffer) => {
64
+ if (!currentOffer) {
65
+ onSelectOffer?.(offer);
66
+ return;
67
+ }
68
+ void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
69
+ };
43
70
  const modalRef = useRef<HTMLDivElement>(null);
44
71
  const previousFocusRef = useRef<HTMLElement | null>(null);
45
72
 
@@ -143,7 +170,12 @@ export function UpgradeModal({
143
170
  {entries.map((entry) => {
144
171
  const { offer, tier } = entry;
145
172
  const isFeatured = offer.isFeatured;
146
- const isCurrent = offer.id === resolveCurrentOfferId(product.offers, currentPlan);
173
+ const direction = currentOffer ? planDirection(offer, currentOffer, tierKeyOf) : null;
174
+ const isCurrent = direction === "current";
175
+ const trial = direction ? trialApplies(offer, direction, paying) : offer.trialDays > 0;
176
+ const features = trial || !(offer.trialDays > 0)
177
+ ? offer.features
178
+ : offer.features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
147
179
  return (
148
180
  <div
149
181
  key={offer.id}
@@ -167,15 +199,15 @@ export function UpgradeModal({
167
199
  <AnnualNote entry={entry} className="mt-1" />
168
200
  </div>
169
201
 
170
- {offer.trialDays > 0 && (
171
- <span className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2.5 py-0.5 rounded-md mb-3 w-fit">
202
+ {trial && (
203
+ <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2.5 py-0.5 rounded-md mb-3 w-fit">
172
204
  {offer.trialDays}-day free trial
173
205
  </span>
174
206
  )}
175
207
 
176
- {offer.features.length > 0 && (
208
+ {features.length > 0 && (
177
209
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
178
- {offer.features.map((f) => (
210
+ {features.map((f) => (
179
211
  <li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
180
212
  <FeatureIcon value={f.value} size="w-3.5 h-3.5" />
181
213
  <span>{formatFeatureLabel(f)}</span>
@@ -185,10 +217,10 @@ export function UpgradeModal({
185
217
  )}
186
218
 
187
219
  <button
188
- onClick={() => !isCurrent && onSelectOffer?.(offer)}
220
+ onClick={() => !isCurrent && !planChange.busy && select(offer)}
189
221
  data-testid={`upgrade-cta-${offer.slug}`}
190
- disabled={isCurrent}
191
- aria-disabled={isCurrent || undefined}
222
+ disabled={isCurrent || planChange.busy}
223
+ aria-disabled={isCurrent || planChange.busy || undefined}
192
224
  className={`
193
225
  w-full py-2.5 px-4 text-sm font-bold rounded-lg transition-colors
194
226
  ${isCurrent
@@ -199,14 +231,26 @@ export function UpgradeModal({
199
231
  }
200
232
  `}
201
233
  >
202
- {isCurrent ? "Current plan" : offer.ctaText}
234
+ {isCurrent ? "Current plan" : direction ? planCtaLabel(offer, direction, paying) : offer.ctaText}
203
235
  </button>
204
236
  </div>
205
237
  );
206
238
  })}
207
239
  </div>
208
240
  )}
241
+ {planChange.error && !planChange.pending && (
242
+ <p data-testid="plan-change-error" role="alert" className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
243
+ {planChange.error}
244
+ </p>
245
+ )}
209
246
  </div>
247
+ <PlanChangeConfirm
248
+ state={planChange.pending}
249
+ confirming={planChange.confirming}
250
+ error={planChange.pending ? planChange.error : null}
251
+ onConfirm={planChange.confirm}
252
+ onCancel={planChange.cancel}
253
+ />
210
254
  </div>
211
255
  );
212
256
  }
@@ -16,3 +16,5 @@ export { CurrentPlanCard } from "./CurrentPlanCard";
16
16
  export type { CurrentPlanCardProps } from "./CurrentPlanCard";
17
17
  export { LimitNudge } from "./LimitNudge";
18
18
  export type { LimitNudgeProps } from "./LimitNudge";
19
+ export { PlanChangeConfirm, planChangeCopy } from "./PlanChangeConfirm";
20
+ export type { PlanChangeConfirmProps } from "./PlanChangeConfirm";
@@ -6,3 +6,5 @@ export { usePortal } from "./usePortal";
6
6
  export { useSubscription } from "./useSubscription";
7
7
  export { useSuccessSync } from "./useSuccessSync";
8
8
  export { useUsage, notifyUsageChanged, USAGE_CHANGED_EVENT } from "./useUsage";
9
+ export { usePlanChange } from "./usePlanChange";
10
+ export type { PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./usePlanChange";