@startsimpli/billing 0.4.8 → 0.4.10
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 +13 -13
- package/src/components/PlanGate.test.tsx +133 -0
- package/src/components/PlanGate.tsx +53 -0
- package/src/components/index.ts +2 -0
- package/src/index.ts +5 -1
- package/src/server/index.ts +1 -1
- package/src/server/proxy.test.ts +93 -0
- package/src/server/proxy.ts +23 -2
- package/src/utils/plan.test.ts +87 -0
- package/src/utils/plan.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
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.
|
|
36
|
-
"@testing-library/react": "^16.
|
|
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.
|
|
39
|
-
"@types/react": "^19.2.
|
|
40
|
-
"@types/react-dom": "^19.2.
|
|
41
|
-
"@vitest/ui": "^
|
|
42
|
-
"jsdom": "^
|
|
43
|
-
"next": "^15.5.
|
|
44
|
-
"react": "^19.2.
|
|
45
|
-
"react-dom": "^19.2.
|
|
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": "^
|
|
48
|
-
"vitest": "^
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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";
|
package/src/server/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { camelToSnake, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
|
|
1
|
+
export { camelToSnake, snakeToCamel, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
|
|
2
2
|
export type { BillingProxyOptions, DjangoServiceTokenOptions } from './proxy';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { snakeToCamel, camelToSnake, proxyBillingRequest } from './proxy';
|
|
3
|
+
|
|
4
|
+
describe('snakeToCamel', () => {
|
|
5
|
+
it('converts snake_case keys to camelCase recursively', () => {
|
|
6
|
+
const input = {
|
|
7
|
+
unit_price: '9.00',
|
|
8
|
+
is_free: false,
|
|
9
|
+
nested_obj: { interval_display: 'per month', is_recurring: true },
|
|
10
|
+
list_of_things: [{ cta_text: 'Get Started' }],
|
|
11
|
+
};
|
|
12
|
+
expect(snakeToCamel(input)).toEqual({
|
|
13
|
+
unitPrice: '9.00',
|
|
14
|
+
isFree: false,
|
|
15
|
+
nestedObj: { intervalDisplay: 'per month', isRecurring: true },
|
|
16
|
+
listOfThings: [{ ctaText: 'Get Started' }],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('leaves string values untouched even when they contain underscores', () => {
|
|
21
|
+
expect(snakeToCamel({ feature_key: 'patterns_per_month' })).toEqual({
|
|
22
|
+
featureKey: 'patterns_per_month',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is idempotent for camelCase keys', () => {
|
|
27
|
+
const input = { alreadyCamel: true, nested: { stillCamel: 1 } };
|
|
28
|
+
expect(snakeToCamel(input)).toEqual(input);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is the inverse of camelToSnake', () => {
|
|
32
|
+
const camel = { unitPrice: '9.00', isFree: true, nested: { ctaText: 'Go' } };
|
|
33
|
+
expect(snakeToCamel(camelToSnake(camel))).toEqual(camel);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('proxyBillingRequest with fromSnake option', () => {
|
|
38
|
+
const originalFetch = globalThis.fetch;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
globalThis.fetch = vi.fn();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
globalThis.fetch = originalFetch;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('transforms snake_case response to camelCase when fromSnake is true', async () => {
|
|
49
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
50
|
+
new Response(
|
|
51
|
+
JSON.stringify({ unit_price: '9.00', is_free: false, offers: [{ cta_text: 'Go' }] }),
|
|
52
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const response = await proxyBillingRequest(
|
|
57
|
+
'http://django',
|
|
58
|
+
'/api/v1/billing/products/x/',
|
|
59
|
+
undefined,
|
|
60
|
+
{ public: true, fromSnake: true }
|
|
61
|
+
);
|
|
62
|
+
const body = await response.json();
|
|
63
|
+
expect(body).toEqual({
|
|
64
|
+
unitPrice: '9.00',
|
|
65
|
+
isFree: false,
|
|
66
|
+
offers: [{ ctaText: 'Go' }],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('leaves response untouched when no transform option is set', async () => {
|
|
71
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
72
|
+
new Response(JSON.stringify({ alreadyCamel: true }), { status: 200 })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const response = await proxyBillingRequest('http://django', '/path/', 'token');
|
|
76
|
+
const body = await response.json();
|
|
77
|
+
expect(body).toEqual({ alreadyCamel: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('preserves non-OK status and error shape', async () => {
|
|
81
|
+
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
|
82
|
+
new Response('Not found', { status: 404 })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const response = await proxyBillingRequest(
|
|
86
|
+
'http://django',
|
|
87
|
+
'/x/',
|
|
88
|
+
undefined,
|
|
89
|
+
{ public: true, fromSnake: true }
|
|
90
|
+
);
|
|
91
|
+
expect(response.status).toBe(404);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/server/proxy.ts
CHANGED
|
@@ -87,11 +87,31 @@ export function camelToSnake(obj: unknown): unknown {
|
|
|
87
87
|
return obj;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Recursively converts snake_case object keys to camelCase.
|
|
92
|
+
* Used for Django endpoints that bypass the camel-case middleware
|
|
93
|
+
* (e.g. public product endpoints) so consumers always receive camelCase.
|
|
94
|
+
*/
|
|
95
|
+
export function snakeToCamel(obj: unknown): unknown {
|
|
96
|
+
if (Array.isArray(obj)) return obj.map(snakeToCamel);
|
|
97
|
+
if (obj !== null && typeof obj === 'object') {
|
|
98
|
+
return Object.fromEntries(
|
|
99
|
+
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
|
100
|
+
k.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()),
|
|
101
|
+
snakeToCamel(v),
|
|
102
|
+
])
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return obj;
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
export interface BillingProxyOptions {
|
|
91
109
|
method?: string;
|
|
92
110
|
body?: unknown;
|
|
93
111
|
/** If true, apply camelToSnake transform to the response body */
|
|
94
112
|
normalize?: boolean;
|
|
113
|
+
/** If true, apply snakeToCamel transform to the response body */
|
|
114
|
+
fromSnake?: boolean;
|
|
95
115
|
/** If true, skip Authorization header even when accessToken is provided */
|
|
96
116
|
public?: boolean;
|
|
97
117
|
}
|
|
@@ -110,7 +130,7 @@ export async function proxyBillingRequest(
|
|
|
110
130
|
accessToken: string | undefined,
|
|
111
131
|
options: BillingProxyOptions = {}
|
|
112
132
|
): Promise<NextResponse> {
|
|
113
|
-
const { method = 'GET', body, normalize = false } = options;
|
|
133
|
+
const { method = 'GET', body, normalize = false, fromSnake = false } = options;
|
|
114
134
|
const url = `${apiBaseUrl}${path}`;
|
|
115
135
|
|
|
116
136
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
@@ -135,7 +155,8 @@ export async function proxyBillingRequest(
|
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
const data = await response.json();
|
|
138
|
-
|
|
158
|
+
const transformed = normalize ? camelToSnake(data) : fromSnake ? snakeToCamel(data) : data;
|
|
159
|
+
return NextResponse.json(transformed);
|
|
139
160
|
} catch (error) {
|
|
140
161
|
return NextResponse.json(
|
|
141
162
|
{
|
|
@@ -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
|
+
}
|