@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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Tests for <CurrentPlanCard /> — startsim-brx.
3
+ *
4
+ * Presentational component, so tests don't need a BillingProvider — they pass
5
+ * subscription + usage as props directly.
6
+ */
7
+
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import { render, screen } from "@testing-library/react";
10
+ import React from "react";
11
+ import { CurrentPlanCard } from "./CurrentPlanCard";
12
+ import type { SubscriptionInfo, UsageMap } from "../types";
13
+
14
+ const freeSub: SubscriptionInfo = {
15
+ id: "sub-free",
16
+ offer: {
17
+ id: "o-free",
18
+ name: "Free",
19
+ slug: "vault-free",
20
+ unitPrice: "0.00",
21
+ currency: "USD",
22
+ pricingModel: "flat",
23
+ intervalUnit: "month",
24
+ intervalCount: 1,
25
+ intervalDisplay: "per month",
26
+ isRecurring: true,
27
+ isFree: true,
28
+ features: [
29
+ { key: "environments", name: "Environments", value: 3, limit: 3 },
30
+ { key: "secrets_per_env", name: "Secrets per environment", value: 25, limit: 25 },
31
+ { key: "cli_exchange", name: "simpli CLI exchange", value: true },
32
+ { key: "audit_log", name: "Audit log", value: true },
33
+ ],
34
+ isActive: true,
35
+ isFeatured: false,
36
+ trialDays: 0,
37
+ sortOrder: 0,
38
+ ctaText: "Get Started",
39
+ },
40
+ status: "active",
41
+ currentPeriodStart: null,
42
+ currentPeriodEnd: null,
43
+ cancelAtPeriodEnd: false,
44
+ trialEnd: null,
45
+ };
46
+
47
+ const proSub: SubscriptionInfo = {
48
+ ...freeSub,
49
+ id: "sub-pro",
50
+ offer: {
51
+ ...freeSub.offer,
52
+ id: "o-pro",
53
+ name: "Pro",
54
+ slug: "vault-pro",
55
+ unitPrice: "12.00",
56
+ isFree: false,
57
+ isFeatured: true,
58
+ trialDays: 14,
59
+ features: [
60
+ { key: "environments", name: "Environments", value: -1, limit: -1 },
61
+ { key: "secrets_per_env", name: "Secrets per environment", value: -1, limit: -1 },
62
+ ],
63
+ },
64
+ };
65
+
66
+ describe("<CurrentPlanCard />", () => {
67
+ it("renders the offer name and status badge", () => {
68
+ render(<CurrentPlanCard subscription={freeSub} usage={{}} />);
69
+ // "Free" appears twice (name in h2 + price block) — both expected.
70
+ expect(screen.getAllByText("Free").length).toBeGreaterThanOrEqual(1);
71
+ expect(screen.getByTestId("plan-status").textContent).toMatch(/active/i);
72
+ });
73
+
74
+ it("renders 'Free' (not '$0') when offer.isFree", () => {
75
+ render(<CurrentPlanCard subscription={freeSub} usage={{}} />);
76
+ // Heading shows the offer name. The price block also shows "Free".
77
+ const freeNodes = screen.getAllByText(/^Free$/i);
78
+ expect(freeNodes.length).toBeGreaterThanOrEqual(1);
79
+ expect(screen.queryByText(/\$0/)).toBeNull();
80
+ });
81
+
82
+ it("renders dollar price + interval for paid plans", () => {
83
+ render(<CurrentPlanCard subscription={proSub} usage={{}} />);
84
+ expect(screen.getByText("$12")).toBeDefined();
85
+ expect(screen.getByText(/per month/i)).toBeDefined();
86
+ });
87
+
88
+ it("renders the offer feature list", () => {
89
+ render(<CurrentPlanCard subscription={freeSub} usage={{}} />);
90
+ expect(screen.getByText(/up to 3 environments/i)).toBeDefined();
91
+ expect(screen.getByText(/up to 25 secrets per environment/i)).toBeDefined();
92
+ expect(screen.getByText(/simpli cli exchange/i)).toBeDefined();
93
+ expect(screen.getByText(/audit log/i)).toBeDefined();
94
+ });
95
+
96
+ it("renders 'Unlimited X' for limit -1 (Pro features)", () => {
97
+ render(<CurrentPlanCard subscription={proSub} usage={{}} />);
98
+ expect(screen.getByText(/unlimited environments/i)).toBeDefined();
99
+ expect(screen.getByText(/unlimited secrets per environment/i)).toBeDefined();
100
+ });
101
+
102
+ it("shows usage progress bar + 'used / limit' text when usage data exists for a feature", () => {
103
+ const usage: UsageMap = {
104
+ environments: { used: 2, limit: 3 },
105
+ secrets_per_env: { used: 12, limit: 25 },
106
+ };
107
+ render(<CurrentPlanCard subscription={freeSub} usage={usage} />);
108
+ expect(screen.getByTestId("usage-count-environments").textContent).toMatch(/2\s*\/\s*3/);
109
+ expect(screen.getByTestId("usage-count-secrets_per_env").textContent).toMatch(/12\s*\/\s*25/);
110
+ });
111
+
112
+ it("hides the progress bar for features without a numeric limit (cli_exchange = boolean)", () => {
113
+ const usage: UsageMap = { environments: { used: 1, limit: 3 } };
114
+ render(<CurrentPlanCard subscription={freeSub} usage={usage} />);
115
+ expect(screen.queryByTestId("usage-count-cli_exchange")).toBeNull();
116
+ expect(screen.queryByTestId("usage-count-audit_log")).toBeNull();
117
+ });
118
+
119
+ it("hides the progress bar for unlimited (limit -1) features", () => {
120
+ const usage: UsageMap = { environments: { used: 99, limit: -1 } };
121
+ render(<CurrentPlanCard subscription={proSub} usage={usage} />);
122
+ expect(screen.queryByTestId("usage-count-environments")).toBeNull();
123
+ });
124
+
125
+ it("renders the upgradeCta slot when provided", () => {
126
+ render(
127
+ <CurrentPlanCard
128
+ subscription={freeSub}
129
+ usage={{}}
130
+ upgradeCta={<button data-testid="custom-upgrade">Upgrade Now</button>}
131
+ />,
132
+ );
133
+ expect(screen.getByTestId("custom-upgrade")).toBeDefined();
134
+ });
135
+
136
+ // ───────────────────────────────────────────────────────────────
137
+ // State-driven banners — startsim-br2, -xe5, -dyg
138
+ // ───────────────────────────────────────────────────────────────
139
+
140
+ function withStatus(s: Partial<SubscriptionInfo>): SubscriptionInfo {
141
+ return { ...proSub, ...s };
142
+ }
143
+
144
+ it("trialing: shows countdown banner with days-remaining + 'Cancel trial' (br2)", () => {
145
+ // +7d 5min to avoid Math.floor drift on the exact-7-days boundary.
146
+ const trialEnd = new Date(Date.now() + 7 * 86_400_000 + 5 * 60_000).toISOString();
147
+ const onPortalClick = vi.fn();
148
+ render(
149
+ <CurrentPlanCard
150
+ subscription={withStatus({ status: "trialing", trialEnd })}
151
+ usage={{}}
152
+ onPortalClick={onPortalClick}
153
+ />,
154
+ );
155
+ const banner = screen.getByTestId("banner-trialing");
156
+ expect(banner.textContent).toMatch(/trial ends in 7 days/i);
157
+ expect(banner.textContent).toMatch(/cancel trial/i);
158
+ (banner.querySelector("button") as HTMLButtonElement).click();
159
+ expect(onPortalClick).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it("trialing-ends-tomorrow: 'ends tomorrow' phrasing (br2)", () => {
163
+ const trialEnd = new Date(Date.now() + 1 * 86_400_000 + 5 * 60_000).toISOString();
164
+ render(
165
+ <CurrentPlanCard
166
+ subscription={withStatus({ status: "trialing", trialEnd })}
167
+ usage={{}}
168
+ onPortalClick={() => {}}
169
+ />,
170
+ );
171
+ expect(screen.getByTestId("banner-trialing").textContent).toMatch(/ends tomorrow/i);
172
+ });
173
+
174
+ it("trialing-ends-today: 'ends today' phrasing (br2)", () => {
175
+ const trialEnd = new Date(Date.now() + 1 * 60_000).toISOString(); // just under 1 day
176
+ render(
177
+ <CurrentPlanCard
178
+ subscription={withStatus({ status: "trialing", trialEnd })}
179
+ usage={{}}
180
+ onPortalClick={() => {}}
181
+ />,
182
+ );
183
+ expect(screen.getByTestId("banner-trialing").textContent).toMatch(/ends today/i);
184
+ });
185
+
186
+ it("past_due: red 'Payment failed' banner with portal CTA (xe5)", () => {
187
+ const onPortalClick = vi.fn();
188
+ render(
189
+ <CurrentPlanCard
190
+ subscription={withStatus({ status: "past_due", currentPeriodEnd: "2026-06-27T00:00:00Z" })}
191
+ usage={{}}
192
+ onPortalClick={onPortalClick}
193
+ />,
194
+ );
195
+ const banner = screen.getByTestId("banner-past-due");
196
+ expect(banner.textContent).toMatch(/payment failed/i);
197
+ expect(banner.textContent).toMatch(/update.*card/i);
198
+ expect(banner.getAttribute("role")).toBe("alert");
199
+ (banner.querySelector("button") as HTMLButtonElement).click();
200
+ expect(onPortalClick).toHaveBeenCalledTimes(1);
201
+ });
202
+
203
+ it("cancelled-in-period: shows period-end + 'Reactivate' CTA (dyg)", () => {
204
+ const onPortalClick = vi.fn();
205
+ render(
206
+ <CurrentPlanCard
207
+ subscription={withStatus({
208
+ status: "active",
209
+ cancelAtPeriodEnd: true,
210
+ currentPeriodEnd: "2026-06-27T00:00:00Z",
211
+ })}
212
+ usage={{}}
213
+ onPortalClick={onPortalClick}
214
+ />,
215
+ );
216
+ const banner = screen.getByTestId("banner-cancelled-in-period");
217
+ expect(banner.textContent).toMatch(/cancelled/i);
218
+ expect(banner.textContent).toMatch(/Jun 27, 2026/);
219
+ expect(banner.textContent).toMatch(/reactivate/i);
220
+ (banner.querySelector("button") as HTMLButtonElement).click();
221
+ expect(onPortalClick).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it("cancelled-status: same banner as cancel-at-period-end (dyg)", () => {
225
+ render(
226
+ <CurrentPlanCard
227
+ subscription={withStatus({ status: "cancelled", currentPeriodEnd: "2026-06-27T00:00:00Z" })}
228
+ usage={{}}
229
+ onPortalClick={() => {}}
230
+ />,
231
+ );
232
+ expect(screen.getByTestId("banner-cancelled-in-period")).toBeDefined();
233
+ });
234
+
235
+ it("incomplete: 'Finish checkout' banner (bis)", () => {
236
+ const onPortalClick = vi.fn();
237
+ render(
238
+ <CurrentPlanCard
239
+ subscription={withStatus({ status: "incomplete" })}
240
+ usage={{}}
241
+ onPortalClick={onPortalClick}
242
+ />,
243
+ );
244
+ const banner = screen.getByTestId("banner-incomplete");
245
+ expect(banner.textContent).toMatch(/incomplete/i);
246
+ expect(banner.textContent).toMatch(/finish checkout/i);
247
+ (banner.querySelector("button") as HTMLButtonElement).click();
248
+ expect(onPortalClick).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ it("incomplete_expired: 'Start over' banner (bis)", () => {
252
+ const onPortalClick = vi.fn();
253
+ render(
254
+ <CurrentPlanCard
255
+ subscription={withStatus({ status: "incomplete_expired" })}
256
+ usage={{}}
257
+ onPortalClick={onPortalClick}
258
+ />,
259
+ );
260
+ const banner = screen.getByTestId("banner-incomplete-expired");
261
+ expect(banner.textContent).toMatch(/expired/i);
262
+ expect(banner.textContent).toMatch(/start over/i);
263
+ (banner.querySelector("button") as HTMLButtonElement).click();
264
+ expect(onPortalClick).toHaveBeenCalledTimes(1);
265
+ });
266
+
267
+ it("active paid: NO banner rendered", () => {
268
+ render(<CurrentPlanCard subscription={proSub} usage={{}} onPortalClick={() => {}} />);
269
+ expect(screen.queryByTestId("banner-trialing")).toBeNull();
270
+ expect(screen.queryByTestId("banner-past-due")).toBeNull();
271
+ expect(screen.queryByTestId("banner-cancelled-in-period")).toBeNull();
272
+ });
273
+
274
+ it("renders nothing for boolean-false features (no bragging about what you don't have)", () => {
275
+ const subWithFalse: SubscriptionInfo = {
276
+ ...freeSub,
277
+ offer: {
278
+ ...freeSub.offer,
279
+ features: [
280
+ { key: "sso", name: "SSO", value: false },
281
+ { key: "audit_log", name: "Audit log", value: true },
282
+ ],
283
+ },
284
+ };
285
+ render(<CurrentPlanCard subscription={subWithFalse} usage={{}} />);
286
+ expect(screen.queryByText(/^SSO$/i)).toBeNull();
287
+ expect(screen.getByText(/audit log/i)).toBeDefined();
288
+ });
289
+ });
@@ -0,0 +1,317 @@
1
+ /**
2
+ * <CurrentPlanCard /> — startsim-brx.
3
+ *
4
+ * The "you are here" widget on every app's billing page. Replaces the inline
5
+ * price-only block SubscriptionManager used to render and adds:
6
+ * - the offer's features as bullets,
7
+ * - usage progress bars for each numeric-limit feature
8
+ * (used via the `usage` prop, typically wired by useUsage),
9
+ * - a trial-ending banner when the sub is in `trialing` state.
10
+ *
11
+ * Stays presentational: takes the subscription + usage as props so it can be
12
+ * unit-tested without a BillingProvider context. Apps consume this through
13
+ * SubscriptionManager — no app-local copy of this card.
14
+ */
15
+ "use client";
16
+
17
+ import React from "react";
18
+ import type { OfferFeature, SubscriptionInfo, UsageMap } from "../types";
19
+
20
+ export interface CurrentPlanCardProps {
21
+ subscription: SubscriptionInfo;
22
+ usage: UsageMap;
23
+ /** Optional Upgrade CTA slot — SubscriptionManager wires this when the
24
+ * subscription is on a free offer and there's a featured paid offer. */
25
+ upgradeCta?: React.ReactNode;
26
+ /** Called when the user clicks a state-banner CTA that should route
27
+ * through Stripe customer portal — past-due "Update card", cancelled
28
+ * "Reactivate", trial "Cancel trial". SubscriptionManager wires this to
29
+ * usePortal so apps don't reinvent it. startsim-br2 + startsim-xe5 + startsim-dyg. */
30
+ onPortalClick?: () => void;
31
+ }
32
+
33
+ function priceLabel(offer: SubscriptionInfo["offer"]): string {
34
+ if (offer.isFree) return "Free";
35
+ const n = Math.floor(parseFloat(offer.unitPrice) || 0);
36
+ return `$${n}`;
37
+ }
38
+
39
+ function isProgressBarFeature(f: OfferFeature): boolean {
40
+ return typeof f.limit === "number" && f.limit > 0;
41
+ }
42
+
43
+ function featureLabel(f: OfferFeature): string | null {
44
+ if (f.value === false) return null;
45
+ if (typeof f.value === "number" || typeof f.limit === "number") {
46
+ const limit = typeof f.limit === "number" ? f.limit
47
+ : typeof f.value === "number" ? (f.value as number) : null;
48
+ if (limit === -1) return `Unlimited ${f.name.toLowerCase()}`;
49
+ if (limit !== null && limit > 0) return `Up to ${limit} ${f.name.toLowerCase()}`;
50
+ }
51
+ return f.name;
52
+ }
53
+
54
+ function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
55
+ let cls = "inline-block text-xs font-semibold px-3 py-1 rounded-full ";
56
+ switch (status) {
57
+ case "active": cls += "bg-green-50 text-green-700"; break;
58
+ case "trialing": cls += "bg-blue-50 text-blue-700"; break;
59
+ case "past_due": cls += "bg-yellow-50 text-yellow-700 warning"; break;
60
+ case "cancelled": cls += "bg-gray-100 text-gray-600"; break;
61
+ case "incomplete": cls += "bg-yellow-50 text-yellow-700"; break;
62
+ case "incomplete_expired":cls += "bg-red-50 text-red-700"; break;
63
+ case "unpaid": cls += "bg-red-50 text-red-700"; break;
64
+ default: cls += "bg-gray-100 text-gray-600";
65
+ }
66
+ const labels: Record<string, string> = {
67
+ active: "Active",
68
+ trialing: "Trialing",
69
+ past_due: "Past due",
70
+ cancelled: "Cancelled",
71
+ incomplete: "Incomplete",
72
+ incomplete_expired: "Expired",
73
+ unpaid: "Unpaid",
74
+ };
75
+ const readable = labels[status] ?? status;
76
+ return (
77
+ <span
78
+ data-testid="plan-status"
79
+ role="status"
80
+ aria-label={`Subscription status: ${readable}`}
81
+ className={cls}
82
+ >
83
+ {readable}
84
+ {status === "trialing" && trialEnd && (
85
+ <span className="ml-1">until {formatDate(trialEnd)}</span>
86
+ )}
87
+ </span>
88
+ );
89
+ }
90
+
91
+ function formatDate(iso: string): string {
92
+ return new Date(iso).toISOString().slice(0, 10);
93
+ }
94
+
95
+ function ProgressBar({ used, limit }: { used: number; limit: number }) {
96
+ // Cap at 100% so a hard-overage display can't blow out the layout.
97
+ const pct = Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
98
+ const isFull = pct >= 100;
99
+ return (
100
+ <div className="w-full bg-gray-100 rounded-full h-1.5" aria-hidden="true">
101
+ <div
102
+ className={`h-1.5 rounded-full transition-all ${isFull ? "bg-red-500" : "bg-blue-600"}`}
103
+ style={{ width: `${pct}%` }}
104
+ />
105
+ </div>
106
+ );
107
+ }
108
+
109
+ function FeatureRow({ feature, used }: { feature: OfferFeature; used: number | null }) {
110
+ const label = featureLabel(feature);
111
+ if (label === null) return null;
112
+
113
+ const showBar = isProgressBarFeature(feature) && used !== null;
114
+ const limit = feature.limit as number;
115
+
116
+ return (
117
+ <li data-testid={`current-plan-feature-${feature.key}`} className="flex flex-col gap-1">
118
+ <div className="flex items-start gap-2 text-sm text-gray-700">
119
+ <svg className="w-4 h-4 text-green-600 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24"
120
+ stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
121
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
122
+ </svg>
123
+ <span className="flex-1">{label}</span>
124
+ {showBar && (
125
+ <span data-testid={`usage-count-${feature.key}`} className="text-xs text-gray-500 tabular-nums">
126
+ {used} / {limit}
127
+ </span>
128
+ )}
129
+ </div>
130
+ {showBar && <ProgressBar used={used as number} limit={limit} />}
131
+ </li>
132
+ );
133
+ }
134
+
135
+ /** Days until ISO date, rounded down; negative if past. */
136
+ function daysUntil(iso: string): number {
137
+ const ms = new Date(iso).getTime() - Date.now();
138
+ return Math.floor(ms / 86_400_000);
139
+ }
140
+
141
+ /** Pretty "Jun 27, 2026" style — easier to scan than ISO yyyy-mm-dd in banners. */
142
+ function prettyDate(iso: string): string {
143
+ return new Date(iso).toLocaleDateString("en-US", {
144
+ year: "numeric", month: "short", day: "numeric", timeZone: "UTC",
145
+ });
146
+ }
147
+
148
+ interface BannerProps {
149
+ testId: string;
150
+ variant: "info" | "warning" | "danger" | "neutral";
151
+ message: React.ReactNode;
152
+ action?: { label: string; onClick: () => void; variant?: "primary" | "secondary" };
153
+ }
154
+
155
+ function StateBanner({ testId, variant, message, action }: BannerProps) {
156
+ const wrap =
157
+ variant === "danger" ? "bg-red-50 border-red-200 text-red-800"
158
+ : variant === "warning" ? "bg-yellow-50 border-yellow-200 text-yellow-900"
159
+ : variant === "info" ? "bg-blue-50 border-blue-200 text-blue-900"
160
+ : "bg-gray-50 border-gray-200 text-gray-800";
161
+ const btn =
162
+ variant === "danger" ? "bg-red-600 text-white hover:bg-red-700"
163
+ : variant === "warning" ? "bg-yellow-600 text-white hover:bg-yellow-700"
164
+ : variant === "info" ? "bg-blue-600 text-white hover:bg-blue-700"
165
+ : "bg-gray-900 text-white hover:bg-gray-800";
166
+ return (
167
+ <div
168
+ data-testid={testId}
169
+ className={`mb-4 rounded-lg border px-4 py-3 flex items-center justify-between gap-3 ${wrap}`}
170
+ role={variant === "danger" ? "alert" : "status"}
171
+ >
172
+ <p className="text-sm font-medium">{message}</p>
173
+ {action && (
174
+ <button
175
+ onClick={action.onClick}
176
+ className={`shrink-0 text-sm font-semibold px-3 py-1.5 rounded-lg cursor-pointer transition-colors ${btn}`}
177
+ >
178
+ {action.label}
179
+ </button>
180
+ )}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick }: CurrentPlanCardProps) {
186
+ const offer = subscription.offer;
187
+ const features = offer.features ?? [];
188
+
189
+ // State-driven banner choice — at most one banner at the top of the card.
190
+ // Precedence: past_due > cancelled-in-period > trialing > none.
191
+ let stateBanner: React.ReactNode = null;
192
+ if (subscription.status === "past_due" && onPortalClick) {
193
+ // startsim-xe5: red banner with portal CTA so users fix the card fast.
194
+ stateBanner = (
195
+ <StateBanner
196
+ testId="banner-past-due"
197
+ variant="danger"
198
+ message={
199
+ <>
200
+ Payment failed{subscription.currentPeriodEnd ? ` on ${prettyDate(subscription.currentPeriodEnd)}` : ""}.
201
+ {" "}Update your card to keep your subscription active.
202
+ </>
203
+ }
204
+ action={{ label: "Update card", onClick: onPortalClick }}
205
+ />
206
+ );
207
+ } else if (subscription.status === "cancelled" || subscription.cancelAtPeriodEnd) {
208
+ // startsim-dyg: cancelled-in-period — user keeps access until the end of
209
+ // the period, can reactivate via portal.
210
+ const endDate = subscription.currentPeriodEnd ? prettyDate(subscription.currentPeriodEnd) : "the end of your period";
211
+ stateBanner = (
212
+ <StateBanner
213
+ testId="banner-cancelled-in-period"
214
+ variant="neutral"
215
+ message={
216
+ <>
217
+ Your {offer.name} plan is cancelled. You'll keep access until {endDate}.
218
+ </>
219
+ }
220
+ action={onPortalClick ? { label: "Reactivate", onClick: onPortalClick } : undefined}
221
+ />
222
+ );
223
+ } else if ((subscription.status === "incomplete" || subscription.status === "incomplete_expired") && onPortalClick) {
224
+ // startsim-bis — checkout was abandoned mid-flow (incomplete) or expired
225
+ // after 24h. Both states are unrecoverable without going back through
226
+ // Stripe; nudge the user toward picking up where they left off. Re-uses
227
+ // the portal CTA so apps don't reimplement the redirect.
228
+ const expired = subscription.status === "incomplete_expired";
229
+ stateBanner = (
230
+ <StateBanner
231
+ testId={`banner-${subscription.status.replace("_", "-")}`}
232
+ variant="warning"
233
+ message={
234
+ expired
235
+ ? `Your ${offer.name} checkout expired. Start over to activate the plan.`
236
+ : `Your ${offer.name} plan setup is incomplete. Finish checkout to activate.`
237
+ }
238
+ action={{
239
+ label: expired ? "Start over" : "Finish checkout",
240
+ onClick: onPortalClick,
241
+ }}
242
+ />
243
+ );
244
+ } else if (subscription.status === "trialing" && subscription.trialEnd) {
245
+ // startsim-br2: trial countdown — gentle info banner until ≤3 days, then
246
+ // upgrade to warning so users notice.
247
+ const days = daysUntil(subscription.trialEnd);
248
+ const variant: "info" | "warning" = days <= 3 ? "warning" : "info";
249
+ const label = days <= 0
250
+ ? `Your trial ends today (${prettyDate(subscription.trialEnd)}).`
251
+ : days === 1
252
+ ? `Your trial ends tomorrow (${prettyDate(subscription.trialEnd)}).`
253
+ : `Your trial ends in ${days} days (${prettyDate(subscription.trialEnd)}).`;
254
+ stateBanner = (
255
+ <StateBanner
256
+ testId="banner-trialing"
257
+ variant={variant}
258
+ message={label}
259
+ action={onPortalClick ? { label: "Cancel trial", onClick: onPortalClick } : undefined}
260
+ />
261
+ );
262
+ }
263
+
264
+ return (
265
+ <div data-testid="current-plan" className="rounded-2xl border border-gray-200 bg-white shadow-sm p-8">
266
+ {stateBanner}
267
+ <div className="flex items-center justify-between mb-4">
268
+ <h2 className="text-xl font-bold text-gray-900">{offer.name}</h2>
269
+ <StatusBadge status={subscription.status} trialEnd={subscription.trialEnd} />
270
+ </div>
271
+
272
+ <div className="mb-4">
273
+ {offer.isFree ? (
274
+ <span aria-label="Free">
275
+ <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">Free</span>
276
+ </span>
277
+ ) : (
278
+ <span aria-label={`${priceLabel(offer)} ${offer.intervalDisplay}`}>
279
+ <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{priceLabel(offer)}</span>
280
+ <span aria-hidden="true" className="text-base text-gray-500 ml-1">{offer.intervalDisplay}</span>
281
+ </span>
282
+ )}
283
+ </div>
284
+
285
+ {/* Active paid subs surface "Next billing date" prominently — that's
286
+ what users actually want to know. Free + cancelled + no-period subs
287
+ fall back to the period range / nothing. startsim-7j4. */}
288
+ {subscription.currentPeriodEnd && !offer.isFree && subscription.status === "active" && !subscription.cancelAtPeriodEnd ? (
289
+ <p data-testid="next-billing-date" className="text-sm text-gray-600 mb-6">
290
+ Next billing date: {formatDate(subscription.currentPeriodEnd)}
291
+ </p>
292
+ ) : subscription.currentPeriodStart && subscription.currentPeriodEnd ? (
293
+ <p className="text-sm text-gray-500 mb-6">
294
+ Billing period: {formatDate(subscription.currentPeriodStart)} to{" "}
295
+ {formatDate(subscription.currentPeriodEnd)}
296
+ </p>
297
+ ) : null}
298
+
299
+ {features.length > 0 && (
300
+ <ul data-testid="current-plan-features" className="space-y-3 mb-6">
301
+ {features.map((f) => {
302
+ const u = usage[f.key];
303
+ return (
304
+ <FeatureRow
305
+ key={f.key}
306
+ feature={f}
307
+ used={u && typeof u.used === "number" ? u.used : null}
308
+ />
309
+ );
310
+ })}
311
+ </ul>
312
+ )}
313
+
314
+ {upgradeCta && <div className="pt-2">{upgradeCta}</div>}
315
+ </div>
316
+ );
317
+ }