@startsimpli/billing 0.4.12 → 0.4.14
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/README.md +285 -87
- package/package.json +3 -2
- package/src/components/CurrentPlanCard.test.tsx +289 -0
- package/src/components/CurrentPlanCard.tsx +317 -0
- package/src/components/LimitNudge.test.tsx +132 -0
- package/src/components/LimitNudge.tsx +84 -0
- package/src/components/SubscriptionManager.test.tsx +704 -19
- package/src/components/SubscriptionManager.tsx +330 -79
- package/src/components/index.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUsage.ts +94 -0
- package/src/index.ts +7 -1
- package/src/mock/__tests__/mock-backend.test.ts +128 -0
- package/src/mock/backend.ts +25 -0
- package/src/mock/index.ts +7 -0
- package/src/mock/mock-backend.ts +103 -0
- package/src/types/index.ts +44 -1
- package/src/utils/api.ts +16 -0
|
@@ -208,7 +208,9 @@ describe("SubscriptionManager", () => {
|
|
|
208
208
|
);
|
|
209
209
|
|
|
210
210
|
expect(screen.getByTestId("subscription-manager")).toBeDefined();
|
|
211
|
-
|
|
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
|
-
//
|
|
235
|
-
|
|
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
|
-
//
|
|
238
|
-
|
|
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
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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, //
|
|
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
|
-
//
|
|
338
|
-
expect(screen.
|
|
339
|
-
expect(screen.
|
|
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
|
|
342
|
-
|
|
343
|
-
expect(
|
|
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,678 @@ 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
|
+
});
|
|
530
1215
|
});
|