@startsimpli/billing 0.4.9 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.9",
3
+ "version": "0.4.12",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,20 +32,20 @@
32
32
  "react-dom": "^18.0.0 || ^19.0.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@testing-library/jest-dom": "^6.0.0",
36
- "@testing-library/react": "^16.0.0",
35
+ "@testing-library/jest-dom": "^6.9.1",
36
+ "@testing-library/react": "^16.3.2",
37
37
  "@testing-library/user-event": "^14.6.1",
38
- "@types/node": "^20.17.14",
39
- "@types/react": "^19.2.0",
40
- "@types/react-dom": "^19.2.0",
41
- "@vitest/ui": "^3.0.0",
42
- "jsdom": "^25.0.0",
43
- "next": "^15.5.12",
44
- "react": "^19.2.4",
45
- "react-dom": "^19.2.4",
38
+ "@types/node": "^20.19.39",
39
+ "@types/react": "^19.2.14",
40
+ "@types/react-dom": "^19.2.3",
41
+ "@vitest/ui": "^4.1.5",
42
+ "jsdom": "^29.0.2",
43
+ "next": "^15.5.15",
44
+ "react": "^19.2.5",
45
+ "react-dom": "^19.2.5",
46
46
  "tsup": "^8.5.1",
47
- "typescript": "^5.7.3",
48
- "vitest": "^3.0.0"
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.5"
49
49
  },
50
50
  "keywords": [
51
51
  "billing",
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Contract tests for PlanGate (bead my0e).
3
+ *
4
+ * PlanGate renders children only when the current user has an active paid
5
+ * subscription. Optimistic render while loading (no layout shift), then
6
+ * either the children or the fallback once the subscription resolves.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import { render, screen, waitFor } from "@testing-library/react";
11
+ import React from "react";
12
+ import { BillingProvider } from "../hooks/BillingProvider";
13
+ import { PlanGate } from "./PlanGate";
14
+ import type { SubscriptionInfo } from "../types";
15
+
16
+ function makeSub(overrides: Partial<SubscriptionInfo> = {}): SubscriptionInfo {
17
+ return {
18
+ id: "sub-1",
19
+ offer: {
20
+ id: "offer-1",
21
+ name: "Pro",
22
+ slug: "pro",
23
+ unitPrice: "19.00",
24
+ currency: "usd",
25
+ pricingModel: "flat",
26
+ intervalUnit: "month",
27
+ intervalCount: 1,
28
+ intervalDisplay: "monthly",
29
+ isRecurring: true,
30
+ isFree: false,
31
+ features: [],
32
+ isActive: true,
33
+ isFeatured: false,
34
+ trialDays: 0,
35
+ },
36
+ status: "active",
37
+ currentPeriodStart: null,
38
+ currentPeriodEnd: null,
39
+ cancelAtPeriodEnd: false,
40
+ trialEnd: null,
41
+ ...overrides,
42
+ } as SubscriptionInfo;
43
+ }
44
+
45
+ /**
46
+ * Render PlanGate inside a BillingProvider with a controllable fetcher.
47
+ * Returns a `resolveSubscription` fn the test can call to settle the fetch.
48
+ */
49
+ function renderWithSubscription(
50
+ planGate: React.ReactElement,
51
+ responseBuilder: () => Promise<SubscriptionInfo | null>
52
+ ) {
53
+ const fetcher = vi.fn(async () => ({
54
+ ok: true,
55
+ status: 200,
56
+ async json() {
57
+ return await responseBuilder();
58
+ },
59
+ })) as unknown as typeof fetch;
60
+
61
+ return render(
62
+ <BillingProvider apiBaseUrl="/api" fetcher={fetcher}>
63
+ {planGate}
64
+ </BillingProvider>
65
+ );
66
+ }
67
+
68
+ describe("PlanGate", () => {
69
+ beforeEach(() => {
70
+ vi.restoreAllMocks();
71
+ });
72
+
73
+ it("optimistically renders children while subscription is loading", () => {
74
+ let resolveFn!: (v: SubscriptionInfo | null) => void;
75
+ const pending = new Promise<SubscriptionInfo | null>((resolve) => {
76
+ resolveFn = resolve;
77
+ });
78
+
79
+ renderWithSubscription(
80
+ <PlanGate>
81
+ <span>feature content</span>
82
+ </PlanGate>,
83
+ () => pending
84
+ );
85
+
86
+ // Content renders immediately during the loading window
87
+ expect(screen.getByText("feature content")).toBeDefined();
88
+
89
+ // Resolve the promise so the test doesn't leak
90
+ resolveFn(null);
91
+ });
92
+
93
+ it("renders fallback once resolved with a free/no plan", async () => {
94
+ renderWithSubscription(
95
+ <PlanGate fallback={<span>upgrade</span>}>
96
+ <span>feature content</span>
97
+ </PlanGate>,
98
+ async () => null
99
+ );
100
+
101
+ await waitFor(() => expect(screen.getByText("upgrade")).toBeDefined());
102
+ expect(screen.queryByText("feature content")).toBeNull();
103
+ });
104
+
105
+ it("renders children once resolved with an active paid subscription", async () => {
106
+ renderWithSubscription(
107
+ <PlanGate fallback={<span>upgrade</span>}>
108
+ <span>feature content</span>
109
+ </PlanGate>,
110
+ async () => makeSub({ status: "active" })
111
+ );
112
+
113
+ await waitFor(() =>
114
+ expect(screen.getByText("feature content")).toBeDefined()
115
+ );
116
+ expect(screen.queryByText("upgrade")).toBeNull();
117
+ });
118
+
119
+ it("renders default upgrade nudge when no fallback prop is provided", async () => {
120
+ renderWithSubscription(
121
+ <PlanGate>
122
+ <span>feature content</span>
123
+ </PlanGate>,
124
+ async () => null
125
+ );
126
+
127
+ // Default nudge mentions "paid plan" and has an upgrade affordance.
128
+ // Exact copy is allowed to evolve but the semantics must remain.
129
+ await waitFor(() =>
130
+ expect(screen.getAllByText(/paid plan|upgrade/i).length).toBeGreaterThan(0)
131
+ );
132
+ });
133
+ });
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useSubscription } from "../hooks/useSubscription";
5
+ import { isPaidSubscription } from "../utils/plan";
6
+
7
+ export interface PlanGateProps {
8
+ children: React.ReactNode;
9
+ /**
10
+ * Element rendered in place of children when user is not on a paid plan.
11
+ * Defaults to a minimal upgrade nudge pointing at /pricing.
12
+ */
13
+ fallback?: React.ReactNode;
14
+ /**
15
+ * Path to send users to when they click the default nudge. Ignored if
16
+ * `fallback` is provided.
17
+ */
18
+ upgradeHref?: string;
19
+ }
20
+
21
+ function DefaultUpgradeNudge({ upgradeHref }: { upgradeHref: string }) {
22
+ return (
23
+ <div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
24
+ <span>This feature requires a paid plan.</span>
25
+ <a href={upgradeHref} className="font-medium underline hover:no-underline">
26
+ Upgrade
27
+ </a>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Renders children only when the current user has an active paid subscription.
34
+ * While loading, renders children optimistically to avoid layout shift.
35
+ * On free/no plan, renders the upgrade nudge (or a custom fallback).
36
+ *
37
+ * Must be used inside a `<BillingProvider>`.
38
+ */
39
+ export function PlanGate({
40
+ children,
41
+ fallback,
42
+ upgradeHref = "/pricing",
43
+ }: PlanGateProps) {
44
+ const { subscription, loading } = useSubscription();
45
+
46
+ if (loading) return <>{children}</>;
47
+
48
+ if (isPaidSubscription(subscription)) {
49
+ return <>{children}</>;
50
+ }
51
+
52
+ return <>{fallback ?? <DefaultUpgradeNudge upgradeHref={upgradeHref} />}</>;
53
+ }
@@ -10,3 +10,5 @@ export { ManageSubscription } from "./ManageSubscription";
10
10
  export type { ManageSubscriptionProps } from "./ManageSubscription";
11
11
  export { SubscriptionManager } from "./SubscriptionManager";
12
12
  export type { SubscriptionManagerProps } from "./SubscriptionManager";
13
+ export { PlanGate } from "./PlanGate";
14
+ export type { PlanGateProps } from "./PlanGate";
package/src/index.ts CHANGED
@@ -36,7 +36,7 @@ export type {
36
36
  } from "./types";
37
37
 
38
38
  // Components
39
- export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager } from "./components";
39
+ export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate } from "./components";
40
40
  export type {
41
41
  PricingPageProps,
42
42
  PricingSectionProps,
@@ -44,7 +44,11 @@ export type {
44
44
  UpgradeModalProps,
45
45
  ManageSubscriptionProps,
46
46
  SubscriptionManagerProps,
47
+ PlanGateProps,
47
48
  } from "./components";
48
49
 
49
50
  // API client (for advanced usage)
50
51
  export { BillingApiClient } from "./utils/api";
52
+
53
+ // Pure helpers
54
+ export { isPaidSubscription } from "./utils/plan";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Contract tests for isPaidSubscription (bead my0e).
3
+ *
4
+ * Derives the "is the user on a paid plan" boolean from a SubscriptionInfo.
5
+ * Pure function — no React/hooks coupling.
6
+ *
7
+ * Semantics chosen to match the definition raise-simpli has been using:
8
+ * - Must have a subscription at all
9
+ * - Must have an offer
10
+ * - Offer must not be free
11
+ * - Status must be active or trialing (past_due / cancelled / etc. don't count)
12
+ */
13
+ import { describe, it, expect } from 'vitest'
14
+ import { isPaidSubscription } from './plan'
15
+ import type { ProductOffer, SubscriptionInfo } from '../types'
16
+
17
+ function makeOffer(overrides: Partial<ProductOffer> = {}): ProductOffer {
18
+ return {
19
+ id: 'offer-1',
20
+ name: 'Pro',
21
+ slug: 'pro',
22
+ unitPrice: '19.00',
23
+ currency: 'usd',
24
+ pricingModel: 'flat',
25
+ intervalUnit: 'month',
26
+ intervalCount: 1,
27
+ intervalDisplay: 'monthly',
28
+ isRecurring: true,
29
+ isFree: false,
30
+ features: [],
31
+ isActive: true,
32
+ isFeatured: false,
33
+ trialDays: 0,
34
+ ...overrides,
35
+ } as ProductOffer
36
+ }
37
+
38
+ function makeSub(overrides: Partial<SubscriptionInfo> = {}): SubscriptionInfo {
39
+ return {
40
+ id: 'sub-1',
41
+ offer: makeOffer(),
42
+ status: 'active',
43
+ currentPeriodStart: null,
44
+ currentPeriodEnd: null,
45
+ cancelAtPeriodEnd: false,
46
+ trialEnd: null,
47
+ ...overrides,
48
+ } as SubscriptionInfo
49
+ }
50
+
51
+ describe('isPaidSubscription', () => {
52
+ it('returns false for null subscription (no plan at all)', () => {
53
+ expect(isPaidSubscription(null)).toBe(false)
54
+ })
55
+
56
+ it('returns false for undefined', () => {
57
+ expect(isPaidSubscription(undefined)).toBe(false)
58
+ })
59
+
60
+ it('returns true for active + paid offer', () => {
61
+ expect(isPaidSubscription(makeSub({ status: 'active' }))).toBe(true)
62
+ })
63
+
64
+ it('returns true for trialing + paid offer', () => {
65
+ expect(isPaidSubscription(makeSub({ status: 'trialing' }))).toBe(true)
66
+ })
67
+
68
+ it('returns false for active + free offer', () => {
69
+ expect(
70
+ isPaidSubscription(makeSub({ status: 'active', offer: makeOffer({ isFree: true }) }))
71
+ ).toBe(false)
72
+ })
73
+
74
+ const deadStatuses = ['past_due', 'cancelled', 'incomplete', 'incomplete_expired', 'unpaid'] as const
75
+ for (const status of deadStatuses) {
76
+ it(`returns false for status=${status} even with a paid offer`, () => {
77
+ expect(isPaidSubscription(makeSub({ status }))).toBe(false)
78
+ })
79
+ }
80
+
81
+ it('returns false when offer is missing entirely', () => {
82
+ // Offer is typed non-nullable on SubscriptionInfo but real backend responses
83
+ // have been seen without it during bad states; be defensive.
84
+ const sub = { ...makeSub(), offer: null as unknown as ProductOffer }
85
+ expect(isPaidSubscription(sub)).toBe(false)
86
+ })
87
+ })
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Plan-status derivations.
3
+ *
4
+ * Pure helpers for reasoning about a user's subscription state without
5
+ * coupling to any React context. Callers either already have a
6
+ * SubscriptionInfo (from useSubscription or their own fetcher) or know
7
+ * they don't.
8
+ */
9
+
10
+ import type { SubscriptionInfo } from "../types";
11
+
12
+ const PAID_STATUSES: ReadonlyArray<SubscriptionInfo["status"]> = ["active", "trialing"];
13
+
14
+ /**
15
+ * True iff the user is currently on an active (or trialing) paid plan.
16
+ *
17
+ * Returns false for:
18
+ * - no subscription at all (null / undefined)
19
+ * - subscriptions without an offer (bad backend state)
20
+ * - free offers (even if status is active — a free tier is not "paid")
21
+ * - lapsed / cancelled / past_due / incomplete / unpaid statuses
22
+ */
23
+ export function isPaidSubscription(
24
+ sub: SubscriptionInfo | null | undefined
25
+ ): boolean {
26
+ if (!sub) return false;
27
+ if (!sub.offer) return false;
28
+ if (sub.offer.isFree) return false;
29
+ return PAID_STATUSES.includes(sub.status);
30
+ }