@startsimpli/billing 0.4.12 → 0.4.15

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.
@@ -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";
@@ -208,7 +208,9 @@ describe("SubscriptionManager", () => {
208
208
  );
209
209
 
210
210
  expect(screen.getByTestId("subscription-manager")).toBeDefined();
211
- expect(screen.getByText(/loading/i)).toBeDefined();
211
+ // Skeleton matching the final layout, not the old "Loading subscription..."
212
+ // text — startsim-z6f.
213
+ expect(screen.getByTestId("skeleton-current-plan")).toBeDefined();
212
214
  });
213
215
 
214
216
  it("shows current plan details when subscribed (plan name, price)", async () => {
@@ -231,11 +233,14 @@ describe("SubscriptionManager", () => {
231
233
  expect(screen.getByTestId("current-plan")).toBeDefined();
232
234
  });
233
235
 
234
- // Check plan name
235
- expect(screen.getByText("Pro")).toBeDefined();
236
+ // Plan name appears in BOTH the current-plan card AND the matching grid
237
+ // card (the matching one carries the "Current Plan" badge). Both expected
238
+ // after startsim-1f1 — assert at least one node, not exactly one.
239
+ expect(screen.getAllByText("Pro").length).toBeGreaterThanOrEqual(1);
236
240
 
237
- // Check price
238
- expect(screen.getByText(/\$49/)).toBeDefined();
241
+ // Price ($49) in the current-plan card (price also rendered in the
242
+ // matching grid card now, so >=1).
243
+ expect(screen.getAllByText(/\$49/).length).toBeGreaterThanOrEqual(1);
239
244
  });
240
245
 
241
246
  it("shows subscription status badge (active, trialing, past_due, cancelled)", async () => {
@@ -309,14 +314,20 @@ describe("SubscriptionManager", () => {
309
314
  expect(screen.getByTestId("current-plan")).toBeDefined();
310
315
  });
311
316
 
312
- // Check billing period dates are displayed
313
- expect(screen.getByText(/2026-01-01/)).toBeDefined();
314
- expect(screen.getByText(/2026-02-01/)).toBeDefined();
317
+ // Active paid sub → only the "Next billing date" line renders (2026-02-01).
318
+ // The verbose "Billing period: start to end" only appears for non-active
319
+ // or free states after startsim-7j4. Both dates are still in the DOM via
320
+ // the grid card's renderings, so target the current-plan card specifically.
321
+ const currentPlan = screen.getByTestId("current-plan");
322
+ expect(currentPlan.textContent).toMatch(/2026-02-01/);
315
323
  });
316
324
 
317
- it("shows available offers for upgrade (excludes current plan)", async () => {
325
+ it("shows ALL offers in the Change Plan grid (current marked, not hidden) — startsim-1f1", async () => {
326
+ // startsim-1f1: the user's current plan now appears IN the grid with a
327
+ // "Current Plan" badge instead of being silently filtered out. Lets users
328
+ // compare plans without scrolling between sections.
318
329
  const fetcher = mockFetchRouter({
319
- "/billing/subscription/current/": mockSubscriptionActive, // Currently on Pro
330
+ "/billing/subscription/current/": mockSubscriptionActive, // Pro
320
331
  "/billing/products/": mockProduct,
321
332
  });
322
333
  const Wrapper = createWrapper(fetcher);
@@ -330,17 +341,17 @@ describe("SubscriptionManager", () => {
330
341
  </Wrapper>
331
342
  );
332
343
 
333
- await waitFor(() => {
334
- expect(screen.getByTestId("available-plans")).toBeDefined();
335
- });
344
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
336
345
 
337
- // Should show Starter and Enterprise, but NOT Pro (current plan)
338
- expect(screen.getByText("Starter")).toBeDefined();
339
- expect(screen.getByText("Enterprise")).toBeDefined();
346
+ // All three plan-cards present in the grid
347
+ expect(screen.getByTestId("plan-card-starter-monthly")).toBeDefined();
348
+ expect(screen.getByTestId("plan-card-pro-monthly")).toBeDefined();
349
+ expect(screen.getByTestId("plan-card-enterprise-monthly")).toBeDefined();
340
350
 
341
- // Pro should only appear once (in current plan section)
342
- const proElements = screen.getAllByText("Pro");
343
- expect(proElements.length).toBe(1);
351
+ // The matching one (Pro) carries the badge; the others do not.
352
+ expect(screen.getByTestId("current-plan-badge-pro-monthly")).toBeDefined();
353
+ expect(screen.queryByTestId("current-plan-badge-starter-monthly")).toBeNull();
354
+ expect(screen.queryByTestId("current-plan-badge-enterprise-monthly")).toBeNull();
344
355
  });
345
356
 
346
357
  it("calls onPlanChange when upgrade option clicked", async () => {
@@ -527,4 +538,745 @@ describe("SubscriptionManager", () => {
527
538
  // Billing period line must not be rendered (null dates)
528
539
  expect(screen.queryByText(/billing period/i)).toBeNull();
529
540
  });
541
+
542
+ // ---------------------------------------------------------------
543
+ // startsim-o7u: plan cards show their features
544
+ // startsim-pih: trial copy under price on offers with trialDays > 0
545
+ // ---------------------------------------------------------------
546
+
547
+ it("renders the feature list on each available plan card (o7u)", async () => {
548
+ const fetcher = mockFetchRouter({
549
+ "/billing/subscription/current/": null,
550
+ "/billing/products/": mockProduct,
551
+ });
552
+ const Wrapper = createWrapper(fetcher);
553
+
554
+ render(
555
+ <Wrapper>
556
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
557
+ </Wrapper>
558
+ );
559
+
560
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
561
+
562
+ // Each available plan card renders <ul data-testid="plan-features">
563
+ const lists = screen.getAllByTestId("plan-features");
564
+ expect(lists.length).toBe(mockProduct.offers.length);
565
+
566
+ // Feature labels render — at least these signature ones from the fixtures:
567
+ // Starter: "Up to 1 team seats", "Up to 3 projects"
568
+ // Pro: "Up to 5 team seats", "Projects" (string-valued)
569
+ // Enterprise: "Team seats", "Projects"
570
+ expect(screen.getAllByText(/up to 1 team seats/i).length).toBeGreaterThan(0);
571
+ expect(screen.getAllByText(/up to 3 projects/i).length).toBeGreaterThan(0);
572
+ expect(screen.getAllByText(/up to 5 team seats/i).length).toBeGreaterThan(0);
573
+ });
574
+
575
+ it("renders trial copy under price for offers with trialDays > 0 (pih)", async () => {
576
+ const fetcher = mockFetchRouter({
577
+ "/billing/subscription/current/": null,
578
+ "/billing/products/": mockProduct,
579
+ });
580
+ const Wrapper = createWrapper(fetcher);
581
+
582
+ render(
583
+ <Wrapper>
584
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
585
+ </Wrapper>
586
+ );
587
+
588
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
589
+
590
+ // Only the Pro card has trialDays:14 → exactly one trial-copy node
591
+ const trialNodes = screen.getAllByTestId("trial-copy");
592
+ expect(trialNodes.length).toBe(1);
593
+ expect(trialNodes[0].textContent).toMatch(/14-day free trial/i);
594
+ expect(trialNodes[0].textContent).toMatch(/no card required/i);
595
+ });
596
+
597
+ it("renders 'Unlimited X' for features with limit === -1 (o7u)", async () => {
598
+ const productWithUnlimited = {
599
+ ...mockProduct,
600
+ offers: [
601
+ // Need >=2 offers to render the Change Plan grid after startsim-ft2 —
602
+ // give Starter the unlimited feature we care about + keep Pro alongside.
603
+ {
604
+ ...mockProduct.offers[0],
605
+ features: [{ key: "envs", name: "Environments", value: -1, limit: -1 }],
606
+ },
607
+ mockProduct.offers[1],
608
+ ],
609
+ };
610
+ const fetcher = mockFetchRouter({
611
+ "/billing/subscription/current/": null,
612
+ "/billing/products/": productWithUnlimited,
613
+ });
614
+ const Wrapper = createWrapper(fetcher);
615
+
616
+ render(
617
+ <Wrapper>
618
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
619
+ </Wrapper>
620
+ );
621
+
622
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
623
+ expect(screen.getByText(/unlimited environments/i)).toBeDefined();
624
+ });
625
+
626
+ // ---------------------------------------------------------------
627
+ // startsim-6vk: free-offer CTA hits subscribe-free, NOT checkout
628
+ // startsim-b1z: free-tier current plan shows "Upgrade to <PaidName>"
629
+ // and the portal button is hidden
630
+ // ---------------------------------------------------------------
631
+
632
+ it("free-offer CTA calls subscribe-free, not Stripe checkout (6vk)", async () => {
633
+ const onPlanChange = vi.fn();
634
+ const fetcher = mockFetchRouter({
635
+ "/billing/subscription/current/": null,
636
+ "/billing/products/": mockProduct,
637
+ });
638
+ const Wrapper = createWrapper(fetcher);
639
+
640
+ render(
641
+ <Wrapper>
642
+ <SubscriptionManager
643
+ productId="raise-simpli"
644
+ returnUrl="https://t/s"
645
+ onPlanChange={onPlanChange}
646
+ />
647
+ </Wrapper>
648
+ );
649
+
650
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
651
+
652
+ // Click the Free card's CTA (slug "starter-monthly", isFree=true)
653
+ (await screen.findByTestId("upgrade-cta-starter-monthly")).click();
654
+
655
+ // subscribe-free is hit (server fetcher saw the POST)
656
+ await waitFor(() => {
657
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
658
+ expect(calls.some((u) => u.includes("/billing/subscribe-free/"))).toBe(true);
659
+ });
660
+ // onPlanChange (the Stripe checkout path) was NOT called
661
+ expect(onPlanChange).not.toHaveBeenCalled();
662
+ });
663
+
664
+ it("paid-offer CTA delegates to onPlanChange (Stripe path)", async () => {
665
+ const onPlanChange = vi.fn();
666
+ const fetcher = mockFetchRouter({
667
+ "/billing/subscription/current/": null,
668
+ "/billing/products/": mockProduct,
669
+ });
670
+ const Wrapper = createWrapper(fetcher);
671
+
672
+ render(
673
+ <Wrapper>
674
+ <SubscriptionManager
675
+ productId="raise-simpli"
676
+ returnUrl="https://t/s"
677
+ onPlanChange={onPlanChange}
678
+ />
679
+ </Wrapper>
680
+ );
681
+
682
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
683
+
684
+ // Click the Pro card's CTA (slug "pro-monthly", isFree=false)
685
+ (await screen.findByTestId("upgrade-cta-pro-monthly")).click();
686
+
687
+ await waitFor(() => {
688
+ expect(onPlanChange).toHaveBeenCalledTimes(1);
689
+ });
690
+ expect(onPlanChange.mock.calls[0][0].slug).toBe("pro-monthly");
691
+ });
692
+
693
+ it("free-tier current plan shows 'Upgrade to <PaidName>' CTA (b1z)", async () => {
694
+ // User subscribed to the free Starter offer
695
+ const freeSub: SubscriptionInfo = {
696
+ ...mockSubscriptionActive,
697
+ offer: mockProduct.offers[0], // Starter, isFree=true
698
+ };
699
+ const fetcher = mockFetchRouter({
700
+ "/billing/subscription/current/": freeSub,
701
+ "/billing/products/": mockProduct,
702
+ });
703
+ const Wrapper = createWrapper(fetcher);
704
+
705
+ render(
706
+ <Wrapper>
707
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
708
+ </Wrapper>
709
+ );
710
+
711
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
712
+
713
+ const upgrade = screen.getByTestId("upgrade-cta-current-plan");
714
+ expect(upgrade.textContent).toMatch(/Upgrade to Pro/i);
715
+
716
+ // And the portal button is hidden (no Stripe customer on a free sub)
717
+ expect(screen.queryByTestId("portal-button")).toBeNull();
718
+ });
719
+
720
+ it("paid-tier current plan does NOT render the Upgrade CTA (b1z)", async () => {
721
+ // mockSubscriptionActive uses the Pro offer (isFree=false)
722
+ const fetcher = mockFetchRouter({
723
+ "/billing/subscription/current/": mockSubscriptionActive,
724
+ "/billing/products/": mockProduct,
725
+ });
726
+ const Wrapper = createWrapper(fetcher);
727
+
728
+ render(
729
+ <Wrapper>
730
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
731
+ </Wrapper>
732
+ );
733
+
734
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
735
+ expect(screen.queryByTestId("upgrade-cta-current-plan")).toBeNull();
736
+ // Portal button IS visible for the paid sub
737
+ expect(screen.queryByTestId("portal-button")).not.toBeNull();
738
+ });
739
+
740
+ it("skips boolean-false features (you don't get to brag about what you DON'T have)", async () => {
741
+ const productWithFalse = {
742
+ ...mockProduct,
743
+ offers: [
744
+ {
745
+ ...mockProduct.offers[0],
746
+ features: [
747
+ { key: "audit", name: "Audit log", value: true },
748
+ { key: "sso", name: "SSO", value: false },
749
+ ],
750
+ },
751
+ // Second offer needed so the Change Plan grid actually renders
752
+ // (single-offer products hide it after startsim-ft2).
753
+ mockProduct.offers[1],
754
+ ],
755
+ };
756
+ const fetcher = mockFetchRouter({
757
+ "/billing/subscription/current/": null,
758
+ "/billing/products/": productWithFalse,
759
+ });
760
+ const Wrapper = createWrapper(fetcher);
761
+
762
+ render(
763
+ <Wrapper>
764
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
765
+ </Wrapper>
766
+ );
767
+
768
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
769
+ expect(screen.queryByText(/audit log/i)).not.toBeNull();
770
+ expect(screen.queryByText(/^SSO$/i)).toBeNull();
771
+ });
772
+
773
+ // ───────────────────────────────────────────────────────────────
774
+ // PlanCard CTA × subscription-state matrix — startsim-21f, -1f1, -yc4
775
+ // ───────────────────────────────────────────────────────────────
776
+ describe("per-card CTA matrix vs current subscription", () => {
777
+ type Case = {
778
+ label: string;
779
+ subscription: SubscriptionInfo | null;
780
+ cardSlug: string; // which card we inspect
781
+ expectedCta: RegExp;
782
+ shouldBeDisabled: boolean;
783
+ shouldShowCurrentBadge: boolean;
784
+ shouldShowTrialCopy: boolean;
785
+ };
786
+
787
+ const cases: Case[] = [
788
+ // No subscription (legacy / pre-auto-enrol): every card is an "upgrade"
789
+ { label: "no-sub · Starter card → 'Start Free' (upgrade)",
790
+ subscription: null, cardSlug: "starter-monthly",
791
+ expectedCta: /start free/i, shouldBeDisabled: false,
792
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
793
+ { label: "no-sub · Pro card → 'Start Trial' (upgrade)",
794
+ subscription: null, cardSlug: "pro-monthly",
795
+ expectedCta: /start trial/i, shouldBeDisabled: false,
796
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: true },
797
+
798
+ // FREE current sub
799
+ { label: "free · Starter card → 'Current Plan' disabled badge",
800
+ subscription: { ...mockSubscriptionActive, offer: mockProduct.offers[0] },
801
+ cardSlug: "starter-monthly",
802
+ expectedCta: /current plan/i, shouldBeDisabled: true,
803
+ shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
804
+ { label: "free · Pro card → upgrade CTA (existing ctaText)",
805
+ subscription: { ...mockSubscriptionActive, offer: mockProduct.offers[0] },
806
+ cardSlug: "pro-monthly",
807
+ expectedCta: /start trial/i, shouldBeDisabled: false,
808
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: true },
809
+ { label: "free · Enterprise card → upgrade CTA",
810
+ subscription: { ...mockSubscriptionActive, offer: mockProduct.offers[0] },
811
+ cardSlug: "enterprise-monthly",
812
+ expectedCta: /contact sales/i, shouldBeDisabled: false,
813
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
814
+
815
+ // PRO current sub (this is the exact scenario the user flagged)
816
+ { label: "pro · Starter card → 'Downgrade to Starter', NOT 'Start Free'",
817
+ subscription: mockSubscriptionActive, // Pro
818
+ cardSlug: "starter-monthly",
819
+ expectedCta: /downgrade to starter/i, shouldBeDisabled: false,
820
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
821
+ { label: "pro · Pro card → 'Current Plan' disabled badge",
822
+ subscription: mockSubscriptionActive,
823
+ cardSlug: "pro-monthly",
824
+ expectedCta: /current plan/i, shouldBeDisabled: true,
825
+ shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
826
+ { label: "pro · Enterprise card → upgrade CTA, trial copy if applicable",
827
+ subscription: mockSubscriptionActive,
828
+ cardSlug: "enterprise-monthly",
829
+ expectedCta: /contact sales/i, shouldBeDisabled: false,
830
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
831
+
832
+ // TRIALING Pro current sub
833
+ { label: "trialing-pro · Pro card → 'Current Plan' badge",
834
+ subscription: mockSubscriptionTrialing,
835
+ cardSlug: "pro-monthly",
836
+ expectedCta: /current plan/i, shouldBeDisabled: true,
837
+ shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
838
+ { label: "trialing-pro · Starter card → 'Downgrade to Starter'",
839
+ subscription: mockSubscriptionTrialing,
840
+ cardSlug: "starter-monthly",
841
+ expectedCta: /downgrade to starter/i, shouldBeDisabled: false,
842
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
843
+
844
+ // ENTERPRISE current sub (downgrade to Pro)
845
+ { label: "enterprise · Pro card → 'Downgrade to Pro' (both paid)",
846
+ subscription: { ...mockSubscriptionActive, offer: mockProduct.offers[2] },
847
+ cardSlug: "pro-monthly",
848
+ expectedCta: /downgrade to pro/i, shouldBeDisabled: false,
849
+ shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
850
+ { label: "enterprise · Enterprise card → 'Current Plan'",
851
+ subscription: { ...mockSubscriptionActive, offer: mockProduct.offers[2] },
852
+ cardSlug: "enterprise-monthly",
853
+ expectedCta: /current plan/i, shouldBeDisabled: true,
854
+ shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
855
+ ];
856
+
857
+ it.each(cases)("$label", async ({ subscription, cardSlug, expectedCta, shouldBeDisabled, shouldShowCurrentBadge, shouldShowTrialCopy }) => {
858
+ const fetcher = mockFetchRouter({
859
+ "/billing/subscription/current/": subscription,
860
+ "/billing/products/": mockProduct,
861
+ });
862
+ const Wrapper = createWrapper(fetcher);
863
+
864
+ render(
865
+ <Wrapper>
866
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
867
+ </Wrapper>
868
+ );
869
+
870
+ await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
871
+
872
+ const cta = await screen.findByTestId(`upgrade-cta-${cardSlug}`);
873
+ expect(cta.textContent).toMatch(expectedCta);
874
+ if (shouldBeDisabled) {
875
+ expect((cta as HTMLButtonElement).disabled).toBe(true);
876
+ } else {
877
+ expect((cta as HTMLButtonElement).disabled).toBe(false);
878
+ }
879
+
880
+ const badge = screen.queryByTestId(`current-plan-badge-${cardSlug}`);
881
+ if (shouldShowCurrentBadge) {
882
+ expect(badge).not.toBeNull();
883
+ } else {
884
+ expect(badge).toBeNull();
885
+ }
886
+
887
+ // Trial copy: only show on the upgrade direction for trialDays > 0 offers
888
+ const card = screen.getByTestId(`plan-card-${cardSlug}`);
889
+ const trialCopy = card.querySelector('[data-testid="trial-copy"]');
890
+ if (shouldShowTrialCopy) {
891
+ expect(trialCopy).not.toBeNull();
892
+ } else {
893
+ expect(trialCopy).toBeNull();
894
+ }
895
+ });
896
+ });
897
+
898
+ // ───────────────────────────────────────────────────────────────
899
+ // startsim-7j4 — Active Pro next-billing-date polish
900
+ // startsim-ngw — ?status=success: success-sync fires + banner + refetch
901
+ // startsim-0l2 — ?status=cancelled: banner visible, no state change
902
+ // ───────────────────────────────────────────────────────────────
903
+
904
+ it("active paid sub shows 'Next billing date' (not 'Billing period') — startsim-7j4", async () => {
905
+ const fetcher = mockFetchRouter({
906
+ "/billing/subscription/current/": mockSubscriptionActive, // Pro / active
907
+ "/billing/products/": mockProduct,
908
+ });
909
+ const Wrapper = createWrapper(fetcher);
910
+
911
+ render(
912
+ <Wrapper>
913
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
914
+ </Wrapper>
915
+ );
916
+
917
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
918
+
919
+ const nextBilling = screen.getByTestId("next-billing-date");
920
+ expect(nextBilling.textContent).toMatch(/next billing date/i);
921
+ expect(nextBilling.textContent).toMatch(/2026-02-01/);
922
+ // The verbose "Billing period:" line is hidden on active paid subs.
923
+ expect(screen.queryByText(/^Billing period:/i)).toBeNull();
924
+ });
925
+
926
+ it("free sub falls back to billing-period (no next-billing-date) — startsim-7j4", async () => {
927
+ const freeSub: SubscriptionInfo = { ...mockSubscriptionActive, offer: mockProduct.offers[0] };
928
+ const fetcher = mockFetchRouter({
929
+ "/billing/subscription/current/": freeSub,
930
+ "/billing/products/": mockProduct,
931
+ });
932
+ const Wrapper = createWrapper(fetcher);
933
+
934
+ render(
935
+ <Wrapper>
936
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
937
+ </Wrapper>
938
+ );
939
+
940
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
941
+ expect(screen.queryByTestId("next-billing-date")).toBeNull();
942
+ });
943
+
944
+ it("status=success: POSTs to /success-sync/ once and refetches sub — startsim-ngw", async () => {
945
+ const fetcher = mockFetchRouter({
946
+ "/billing/subscription/current/": null,
947
+ "/billing/products/": mockProduct,
948
+ });
949
+ const Wrapper = createWrapper(fetcher);
950
+
951
+ render(
952
+ <Wrapper>
953
+ <SubscriptionManager
954
+ productId="raise-simpli"
955
+ returnUrl="https://t/s"
956
+ statusMessage="success"
957
+ />
958
+ </Wrapper>
959
+ );
960
+
961
+ // Banner renders
962
+ await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
963
+
964
+ // success-sync was hit
965
+ await waitFor(() => {
966
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
967
+ expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(true);
968
+ });
969
+
970
+ // Subscription was refetched (≥2 hits to subscription/current: initial + post-sync)
971
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
972
+ const subCalls = calls.filter((u) => u.includes("/billing/subscription/current/")).length;
973
+ expect(subCalls).toBeGreaterThanOrEqual(2);
974
+ });
975
+
976
+ it("status=success: success-sync fires exactly once on rapid re-renders — startsim-ngw", async () => {
977
+ const fetcher = mockFetchRouter({
978
+ "/billing/subscription/current/": null,
979
+ "/billing/products/": mockProduct,
980
+ });
981
+ const Wrapper = createWrapper(fetcher);
982
+
983
+ const { rerender } = render(
984
+ <Wrapper>
985
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" statusMessage="success" />
986
+ </Wrapper>
987
+ );
988
+
989
+ await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
990
+ rerender(
991
+ <Wrapper>
992
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" statusMessage="success" />
993
+ </Wrapper>
994
+ );
995
+
996
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
997
+ const syncCalls = calls.filter((u) => u.includes("/billing/success-sync/")).length;
998
+ expect(syncCalls).toBe(1);
999
+ });
1000
+
1001
+ it("status=cancelled: banner shown, no success-sync POST, no extra refetch — startsim-0l2", async () => {
1002
+ const fetcher = mockFetchRouter({
1003
+ "/billing/subscription/current/": null,
1004
+ "/billing/products/": mockProduct,
1005
+ });
1006
+ const Wrapper = createWrapper(fetcher);
1007
+
1008
+ render(
1009
+ <Wrapper>
1010
+ <SubscriptionManager
1011
+ productId="raise-simpli"
1012
+ returnUrl="https://t/s"
1013
+ statusMessage="cancelled"
1014
+ />
1015
+ </Wrapper>
1016
+ );
1017
+
1018
+ await waitFor(() => expect(screen.getByTestId("status-banner-cancelled")).toBeDefined());
1019
+
1020
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
1021
+ expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(false);
1022
+ });
1023
+
1024
+ // ───────────────────────────────────────────────────────────────
1025
+ // startsim-z6f — skeleton during loading (matches final layout)
1026
+ // startsim-mtv — partial load: sub-ready/product-still-loading shouldn't crash
1027
+ // startsim-c0r — product fetch error doesn't blank the page (inline + retry)
1028
+ // startsim-ft2 — single-offer products hide the Change Plan grid entirely
1029
+ // startsim-y34 — multi-tier: Upgrade CTA picks the NEXT tier by sortOrder
1030
+ // ───────────────────────────────────────────────────────────────
1031
+
1032
+ it("dual-loading renders skeleton matching the final layout (z6f)", () => {
1033
+ // Never-resolving fetcher → both sub + product stay loading.
1034
+ const fetcher = vi.fn().mockReturnValue(new Promise(() => {})) as unknown as typeof fetch;
1035
+ const Wrapper = createWrapper(fetcher);
1036
+
1037
+ render(
1038
+ <Wrapper>
1039
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1040
+ </Wrapper>
1041
+ );
1042
+
1043
+ expect(screen.getByTestId("skeleton-current-plan")).toBeDefined();
1044
+ expect(screen.getAllByTestId("skeleton-plan-card").length).toBeGreaterThanOrEqual(2);
1045
+ // The old bare "Loading subscription..." text is gone.
1046
+ expect(screen.queryByText(/loading subscription/i)).toBeNull();
1047
+ });
1048
+
1049
+ it("sub-ready + product-still-loading renders current-plan + grid skeletons (mtv)", async () => {
1050
+ // Sub resolves; product hangs forever.
1051
+ const fetcher = vi.fn((url: string) => {
1052
+ if (url.includes("/billing/subscription/current/")) {
1053
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockSubscriptionActive) });
1054
+ }
1055
+ return new Promise(() => {}); // product never resolves
1056
+ }) as unknown as typeof fetch;
1057
+ const Wrapper = createWrapper(fetcher);
1058
+
1059
+ render(
1060
+ <Wrapper>
1061
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1062
+ </Wrapper>
1063
+ );
1064
+
1065
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
1066
+ // Grid skeletons render while product is in flight; available-plans absent.
1067
+ expect(screen.getByTestId("available-plans-loading")).toBeDefined();
1068
+ expect(screen.queryByTestId("available-plans")).toBeNull();
1069
+ });
1070
+
1071
+ it("product fetch error renders inline error + retry (c0r)", async () => {
1072
+ let productCalls = 0;
1073
+ const fetcher = vi.fn((url: string) => {
1074
+ if (url.includes("/billing/subscription/current/")) {
1075
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockSubscriptionActive) });
1076
+ }
1077
+ productCalls += 1;
1078
+ return Promise.resolve({
1079
+ ok: false, status: 500,
1080
+ json: () => Promise.resolve({ detail: "kaboom" }),
1081
+ });
1082
+ }) as unknown as typeof fetch;
1083
+ const Wrapper = createWrapper(fetcher);
1084
+
1085
+ render(
1086
+ <Wrapper>
1087
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1088
+ </Wrapper>
1089
+ );
1090
+
1091
+ // Current plan still visible (page didn't blank).
1092
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
1093
+ const banner = await screen.findByTestId("error-product");
1094
+ expect(banner.textContent).toMatch(/couldn.?t load plans/i);
1095
+
1096
+ const initial = productCalls;
1097
+ (screen.getByTestId("retry-product") as HTMLButtonElement).click();
1098
+ await waitFor(() => expect(productCalls).toBeGreaterThan(initial));
1099
+ });
1100
+
1101
+ it("single-offer product (Free-only app) hides the Change Plan grid (ft2)", async () => {
1102
+ const singleOffer = {
1103
+ ...mockProduct,
1104
+ offers: [mockProduct.offers[0]], // Just Starter
1105
+ };
1106
+ const fetcher = mockFetchRouter({
1107
+ "/billing/subscription/current/": null,
1108
+ "/billing/products/": singleOffer,
1109
+ });
1110
+ const Wrapper = createWrapper(fetcher);
1111
+
1112
+ render(
1113
+ <Wrapper>
1114
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1115
+ </Wrapper>
1116
+ );
1117
+
1118
+ // Wait for product to resolve.
1119
+ await new Promise((r) => setTimeout(r, 30));
1120
+ expect(screen.queryByTestId("available-plans")).toBeNull();
1121
+ expect(screen.queryByTestId("available-plans-loading")).toBeNull();
1122
+ });
1123
+
1124
+ // ───────────────────────────────────────────────────────────────
1125
+ // startsim-wo9 — non-owner team member sees read-only billing view
1126
+ // ───────────────────────────────────────────────────────────────
1127
+
1128
+ it("non-owner: shows read-only notice + disables every action (wo9)", async () => {
1129
+ const onPlanChange = vi.fn();
1130
+ const nonOwnerSub: SubscriptionInfo = {
1131
+ ...mockSubscriptionActive,
1132
+ viewerIsOwner: false,
1133
+ ownerEmail: "owner@example.com",
1134
+ };
1135
+ const fetcher = mockFetchRouter({
1136
+ "/billing/subscription/current/": nonOwnerSub,
1137
+ "/billing/products/": mockProduct,
1138
+ });
1139
+ const Wrapper = createWrapper(fetcher);
1140
+
1141
+ render(
1142
+ <Wrapper>
1143
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" onPlanChange={onPlanChange} />
1144
+ </Wrapper>
1145
+ );
1146
+
1147
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
1148
+
1149
+ // Read-only notice with owner email surfaced.
1150
+ const notice = screen.getByTestId("non-owner-billing-notice");
1151
+ expect(notice.textContent).toMatch(/owner@example\.com/);
1152
+
1153
+ // Manage Billing button hidden (paid sub but viewer is not owner).
1154
+ expect(screen.queryByTestId("portal-button")).toBeNull();
1155
+
1156
+ // Per-card CTAs are present (for visibility) but disabled.
1157
+ const starterCta = screen.getByTestId("upgrade-cta-starter-monthly") as HTMLButtonElement;
1158
+ expect(starterCta.disabled).toBe(true);
1159
+ starterCta.click();
1160
+ expect(onPlanChange).not.toHaveBeenCalled();
1161
+ });
1162
+
1163
+ it("owner (default): notice hidden, actions enabled (wo9)", async () => {
1164
+ const ownerSub: SubscriptionInfo = {
1165
+ ...mockSubscriptionActive,
1166
+ viewerIsOwner: true,
1167
+ offer: mockProduct.offers[0], // Free so the Upgrade CTA renders
1168
+ };
1169
+ const fetcher = mockFetchRouter({
1170
+ "/billing/subscription/current/": ownerSub,
1171
+ "/billing/products/": mockProduct,
1172
+ });
1173
+ const Wrapper = createWrapper(fetcher);
1174
+
1175
+ render(
1176
+ <Wrapper>
1177
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1178
+ </Wrapper>
1179
+ );
1180
+
1181
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
1182
+ expect(screen.queryByTestId("non-owner-billing-notice")).toBeNull();
1183
+ // Upgrade CTA visible for free-tier owner.
1184
+ expect(screen.getByTestId("upgrade-cta-current-plan")).toBeDefined();
1185
+ });
1186
+
1187
+ it("multi-paid-tier: Pro user's Upgrade CTA points at Enterprise (y34)", async () => {
1188
+ // Pro (sortOrder 1) is the current sub; Enterprise (sortOrder 2) is the
1189
+ // next tier up. Free (sortOrder 0) and Pro itself are NOT the upgrade target.
1190
+ const proCurrent: SubscriptionInfo = {
1191
+ ...mockSubscriptionActive,
1192
+ offer: { ...mockProduct.offers[1], isFree: true /* force-current-is-free to expose the CTA path */ },
1193
+ };
1194
+ // Re-use freeSub flow but pretend Pro is free so the Upgrade CTA actually
1195
+ // renders (it only renders for free-tier current subs by design).
1196
+ const fetcher = mockFetchRouter({
1197
+ "/billing/subscription/current/": proCurrent,
1198
+ "/billing/products/": mockProduct,
1199
+ });
1200
+ const Wrapper = createWrapper(fetcher);
1201
+
1202
+ render(
1203
+ <Wrapper>
1204
+ <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" />
1205
+ </Wrapper>
1206
+ );
1207
+
1208
+ await waitFor(() => expect(screen.getByTestId("current-plan")).toBeDefined());
1209
+ const upgrade = screen.queryByTestId("upgrade-cta-current-plan");
1210
+ expect(upgrade).not.toBeNull();
1211
+ // The CTA text reads "Upgrade to <NextTier>" — Enterprise is sortOrder 2,
1212
+ // strictly above proCurrent.offer.sortOrder=1.
1213
+ expect(upgrade?.textContent).toMatch(/upgrade to enterprise/i);
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
+ });
530
1282
  });