@tribe-nest/forge 3.54.0 → 3.57.0
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 +1 -1
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +12 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +74 -0
- package/src/i18n/en.json +74 -0
- package/src/server/index.ts +74 -0
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/types/models.ts +127 -0
- package/src/ui/headless/index.ts +23 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +9 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/styled/AccountDashboard.tsx +160 -2
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useMembershipCheckout } from "../useMembershipCheckout";
|
|
5
|
+
import type { MembershipApplication, MembershipTier } from "../../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Curated access in the headless checkout: an application tier APPLIES instead
|
|
9
|
+
* of subscribing (free lodges it, paid saves a card through a SetupIntent and
|
|
10
|
+
* confirms), a `payment_failed` application completes through the ordinary
|
|
11
|
+
* paid subscribe, and an invite token rides on the tier list and on both
|
|
12
|
+
* subscribe calls. Every seam is mocked at the module boundary.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const auth = vi.hoisted(() => ({
|
|
16
|
+
user: { id: "account-1", email: "fan@example.com", membership: undefined as unknown },
|
|
17
|
+
refetchUser: vi.fn(async () => {}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const api = vi.hoisted(() => ({
|
|
21
|
+
tiersOptions: [] as unknown[],
|
|
22
|
+
apply: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
23
|
+
confirm: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
24
|
+
createFree: vi.fn(async (_body: Record<string, unknown>) => ({})),
|
|
25
|
+
createPaid: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
let tiers: MembershipTier[] = [];
|
|
29
|
+
|
|
30
|
+
vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
31
|
+
...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
|
|
32
|
+
usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
37
|
+
useGetMembershipTiers: (opts: unknown) => {
|
|
38
|
+
api.tiersOptions.push(opts);
|
|
39
|
+
return { data: tiers, isLoading: false };
|
|
40
|
+
},
|
|
41
|
+
useApplyForMembership: () => ({ mutateAsync: api.apply, isPending: false }),
|
|
42
|
+
useConfirmMembershipApplication: () => ({ mutateAsync: api.confirm, isPending: false }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
46
|
+
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
47
|
+
useCreateSubscription: () => ({ mutateAsync: api.createPaid, isPending: false }),
|
|
48
|
+
useCreateFreeSubscription: () => ({ mutateAsync: api.createFree, isPending: false }),
|
|
49
|
+
useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const tier = (overrides: Partial<MembershipTier>): MembershipTier => ({
|
|
53
|
+
id: "tier-1",
|
|
54
|
+
name: "Inner Circle",
|
|
55
|
+
description: "",
|
|
56
|
+
payWhatYouWant: false,
|
|
57
|
+
benefits: [],
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const application = (overrides: Partial<MembershipApplication> = {}): MembershipApplication => ({
|
|
62
|
+
id: "app-1",
|
|
63
|
+
profileId: "profile-1",
|
|
64
|
+
membershipTierId: "tier-1",
|
|
65
|
+
accountId: "account-1",
|
|
66
|
+
status: "pending",
|
|
67
|
+
billingCycle: null,
|
|
68
|
+
amount: null,
|
|
69
|
+
currency: null,
|
|
70
|
+
message: null,
|
|
71
|
+
instrumentLabel: null,
|
|
72
|
+
decidedAt: null,
|
|
73
|
+
decisionNote: null,
|
|
74
|
+
closedReason: null,
|
|
75
|
+
paymentWindowExpiresAt: null,
|
|
76
|
+
membershipId: null,
|
|
77
|
+
createdAt: "2026-09-17T00:00:00.000Z",
|
|
78
|
+
updatedAt: "2026-09-17T00:00:00.000Z",
|
|
79
|
+
...overrides,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
api.tiersOptions.length = 0;
|
|
84
|
+
api.apply.mockReset();
|
|
85
|
+
api.confirm.mockReset();
|
|
86
|
+
api.createFree.mockReset();
|
|
87
|
+
api.createFree.mockResolvedValue({});
|
|
88
|
+
api.createPaid.mockReset();
|
|
89
|
+
auth.refetchUser.mockClear();
|
|
90
|
+
window.history.replaceState(null, "", "/i/membership/checkout");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const onComplete = vi.fn();
|
|
94
|
+
|
|
95
|
+
describe("application tiers: subscribe() applies", () => {
|
|
96
|
+
it("free tier: lodges the application and shows it received, no subscription", async () => {
|
|
97
|
+
tiers = [tier({ id: "tier-free", priceMonthly: 0, joinMode: "application" })];
|
|
98
|
+
api.apply.mockResolvedValue({ application: application({ membershipTierId: "tier-free" }), requiresCard: false, clientSecret: null });
|
|
99
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-free", onComplete }));
|
|
100
|
+
|
|
101
|
+
expect(result.current.isApplication).toBe(true);
|
|
102
|
+
// The free-text note is gone from the checkout: the tier's questions replaced it.
|
|
103
|
+
expect(result.current).not.toHaveProperty("setApplicationMessage");
|
|
104
|
+
await act(async () => {
|
|
105
|
+
await result.current.subscribe();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(api.apply).toHaveBeenCalledTimes(1);
|
|
109
|
+
const body = api.apply.mock.calls[0][0];
|
|
110
|
+
expect(body).toMatchObject({ membershipTierId: "tier-free" });
|
|
111
|
+
expect(body).not.toHaveProperty("message");
|
|
112
|
+
// A tier without questions sends none.
|
|
113
|
+
expect(body).not.toHaveProperty("questionnaire");
|
|
114
|
+
// A free tier sends no cycle and no amount.
|
|
115
|
+
expect(body).not.toHaveProperty("billingCycle");
|
|
116
|
+
expect(body).not.toHaveProperty("amount");
|
|
117
|
+
expect(api.createFree).not.toHaveBeenCalled();
|
|
118
|
+
expect(api.createPaid).not.toHaveBeenCalled();
|
|
119
|
+
expect(result.current.applicationOutcome).toBe("received");
|
|
120
|
+
expect(result.current.step).toBe("select");
|
|
121
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("paid tier: saves the card through a SetupIntent, then confirms, then shows it received", async () => {
|
|
125
|
+
tiers = [tier({ priceMonthly: 12, joinMode: "application" })];
|
|
126
|
+
api.apply.mockResolvedValue({
|
|
127
|
+
application: application({ status: "card_pending", billingCycle: "month", amount: 12, currency: "USD" }),
|
|
128
|
+
requiresCard: true,
|
|
129
|
+
clientSecret: "seti_123_secret_abc",
|
|
130
|
+
});
|
|
131
|
+
api.confirm.mockResolvedValue({
|
|
132
|
+
application: application({ status: "pending", billingCycle: "month", amount: 12, currency: "USD" }),
|
|
133
|
+
});
|
|
134
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
135
|
+
|
|
136
|
+
await act(async () => {
|
|
137
|
+
await result.current.subscribe();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(api.apply.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", billingCycle: "month", amount: 12 });
|
|
141
|
+
expect(api.createPaid).not.toHaveBeenCalled();
|
|
142
|
+
expect(result.current.step).toBe("payment");
|
|
143
|
+
expect(result.current.clientSecret).toBe("seti_123_secret_abc");
|
|
144
|
+
expect(result.current.paymentKind).toBe("setup");
|
|
145
|
+
// The card step is still ahead: nothing to announce yet.
|
|
146
|
+
expect(result.current.applicationOutcome).toBeNull();
|
|
147
|
+
expect(result.current.applicationReturnUrl).toContain("confirmApplication=app-1");
|
|
148
|
+
|
|
149
|
+
await act(async () => {
|
|
150
|
+
await result.current.confirmApplication();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(api.confirm).toHaveBeenCalledWith({ applicationId: "app-1" });
|
|
154
|
+
expect(result.current.application?.status).toBe("pending");
|
|
155
|
+
expect(result.current.applicationOutcome).toBe("received");
|
|
156
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("surfaces a server refusal in the error slot", async () => {
|
|
160
|
+
tiers = [tier({ priceMonthly: 0, joinMode: "application" })];
|
|
161
|
+
api.apply.mockRejectedValue({ response: { data: { message: "This membership is full." } } });
|
|
162
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
163
|
+
|
|
164
|
+
await act(async () => {
|
|
165
|
+
await result.current.subscribe();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.current.error).toBe("This membership is full.");
|
|
169
|
+
expect(result.current.applicationOutcome).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("closes as tier_full when the last seat went while the card was saved", async () => {
|
|
173
|
+
tiers = [tier({ priceMonthly: 12, joinMode: "application" })];
|
|
174
|
+
api.apply.mockResolvedValue({
|
|
175
|
+
application: application({ status: "card_pending" }),
|
|
176
|
+
requiresCard: true,
|
|
177
|
+
clientSecret: "seti_1_secret_2",
|
|
178
|
+
});
|
|
179
|
+
api.confirm.mockResolvedValue({ application: application({ status: "withdrawn", closedReason: "tier_full" }) });
|
|
180
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
181
|
+
|
|
182
|
+
await act(async () => {
|
|
183
|
+
await result.current.subscribe();
|
|
184
|
+
});
|
|
185
|
+
await act(async () => {
|
|
186
|
+
await result.current.confirmApplication();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(result.current.applicationOutcome).toBe("tier_full");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("EXCEPTION: a payment_failed application runs the ordinary paid subscribe", async () => {
|
|
193
|
+
tiers = [
|
|
194
|
+
tier({
|
|
195
|
+
priceMonthly: 12,
|
|
196
|
+
joinMode: "application",
|
|
197
|
+
myApplication: { id: "app-1", status: "payment_failed", paymentWindowExpiresAt: "2026-09-24T00:00:00.000Z" },
|
|
198
|
+
}),
|
|
199
|
+
];
|
|
200
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
|
|
201
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
202
|
+
|
|
203
|
+
expect(result.current.isApplication).toBe(false);
|
|
204
|
+
await act(async () => {
|
|
205
|
+
await result.current.subscribe();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(api.apply).not.toHaveBeenCalled();
|
|
209
|
+
expect(api.createPaid).toHaveBeenCalledTimes(1);
|
|
210
|
+
expect(result.current.paymentKind).toBe("payment");
|
|
211
|
+
expect(result.current.clientSecret).toBe("pi_1_secret_2");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("invite tokens", () => {
|
|
216
|
+
it("reads the invite off the url and forwards it to the tier list and the paid subscribe", async () => {
|
|
217
|
+
window.history.replaceState(null, "", "/i/membership/checkout?membershipTierId=tier-1&invite=tok-abc");
|
|
218
|
+
tiers = [tier({ priceMonthly: 12, joinMode: "invite" })];
|
|
219
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
|
|
220
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
221
|
+
|
|
222
|
+
expect(result.current.inviteToken).toBe("tok-abc");
|
|
223
|
+
expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: "tok-abc" });
|
|
224
|
+
await act(async () => {
|
|
225
|
+
await result.current.subscribe();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(api.createPaid.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", inviteToken: "tok-abc" });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("forwards an explicit token on a free join", async () => {
|
|
232
|
+
tiers = [tier({ priceMonthly: 0, joinMode: "invite" })];
|
|
233
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", inviteToken: "tok-free", onComplete }));
|
|
234
|
+
|
|
235
|
+
await act(async () => {
|
|
236
|
+
await result.current.subscribe();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(api.createFree.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", inviteToken: "tok-free" });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("sends no token when there is none", async () => {
|
|
243
|
+
tiers = [tier({ priceMonthly: 0 })];
|
|
244
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
await result.current.subscribe();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: null });
|
|
251
|
+
expect(api.createFree.mock.calls[0][0]).not.toHaveProperty("inviteToken");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useMembershipCheckout } from "../useMembershipCheckout";
|
|
5
|
+
import { buildQuestionnaireAnswers, firstMissingAnswer, tierQuestionnaire } from "../membershipQuestionnaire";
|
|
6
|
+
import type { MembershipTier, QuestionnaireQuestion } from "../../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tier questionnaires in the headless checkout: every join mode (open free,
|
|
10
|
+
* open paid, application) asks the tier's questions, refuses a blank required
|
|
11
|
+
* answer before any request, and sends the answers in the body. Completing a
|
|
12
|
+
* `payment_failed` application asks nothing and sends nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const auth = vi.hoisted(() => ({
|
|
16
|
+
user: { id: "account-1", email: "fan@example.com", membership: undefined as unknown },
|
|
17
|
+
refetchUser: vi.fn(async () => {}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const api = vi.hoisted(() => ({
|
|
21
|
+
apply: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
22
|
+
createFree: vi.fn(async (_body: Record<string, unknown>) => ({})),
|
|
23
|
+
createPaid: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
let tiers: MembershipTier[] = [];
|
|
27
|
+
|
|
28
|
+
vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
29
|
+
...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
|
|
30
|
+
usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
34
|
+
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
35
|
+
useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
|
|
36
|
+
useApplyForMembership: () => ({ mutateAsync: api.apply, isPending: false }),
|
|
37
|
+
useConfirmMembershipApplication: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
41
|
+
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
42
|
+
useCreateSubscription: () => ({ mutateAsync: api.createPaid, isPending: false }),
|
|
43
|
+
useCreateFreeSubscription: () => ({ mutateAsync: api.createFree, isPending: false }),
|
|
44
|
+
useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const questions: QuestionnaireQuestion[] = [
|
|
48
|
+
{ id: "q-why", question: "Why do you want to join?", type: "textarea" },
|
|
49
|
+
{ id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"] },
|
|
50
|
+
{ id: "q-handle", question: "Instagram handle", type: "text", optional: true },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Exactly what the server is sent when the fan answers the two required questions. */
|
|
54
|
+
const expectedAnswers = [
|
|
55
|
+
{ id: "q-why", question: "Why do you want to join?", type: "textarea", answer: "Long-time listener" },
|
|
56
|
+
{ id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"], answer: "Lagos" },
|
|
57
|
+
{ id: "q-handle", question: "Instagram handle", type: "text", answer: "" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const tier = (overrides: Partial<MembershipTier>): MembershipTier => ({
|
|
61
|
+
id: "tier-1",
|
|
62
|
+
name: "Inner Circle",
|
|
63
|
+
description: "",
|
|
64
|
+
payWhatYouWant: false,
|
|
65
|
+
benefits: [],
|
|
66
|
+
questionnaire: questions,
|
|
67
|
+
...overrides,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const onComplete = vi.fn();
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
api.apply.mockReset();
|
|
74
|
+
api.createFree.mockReset();
|
|
75
|
+
api.createFree.mockResolvedValue({});
|
|
76
|
+
api.createPaid.mockReset();
|
|
77
|
+
onComplete.mockClear();
|
|
78
|
+
window.history.replaceState(null, "", "/i/membership/checkout");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function answerRequired(result: { current: ReturnType<typeof useMembershipCheckout> }) {
|
|
82
|
+
act(() => result.current.setQuestionnaireAnswer("q-why", " Long-time listener "));
|
|
83
|
+
act(() => result.current.setQuestionnaireAnswer("q-city", "Lagos"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("answers are sent on every join call", () => {
|
|
87
|
+
it("open free tier: /subscriptions/free carries the answers", async () => {
|
|
88
|
+
tiers = [tier({ priceMonthly: 0 })];
|
|
89
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
90
|
+
|
|
91
|
+
expect(result.current.questionnaire).toEqual(questions);
|
|
92
|
+
answerRequired(result);
|
|
93
|
+
expect(result.current.questionnaireAnswers).toEqual({ "q-why": " Long-time listener ", "q-city": "Lagos" });
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await result.current.subscribe();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(api.createFree).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(api.createFree.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1" });
|
|
100
|
+
expect(api.createFree.mock.calls[0][0].questionnaire).toEqual(expectedAnswers);
|
|
101
|
+
expect(result.current.questionnaireRefusal).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("open paid tier: /subscriptions carries the answers", async () => {
|
|
105
|
+
tiers = [tier({ priceMonthly: 12 })];
|
|
106
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
|
|
107
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
108
|
+
|
|
109
|
+
answerRequired(result);
|
|
110
|
+
await act(async () => {
|
|
111
|
+
await result.current.subscribe();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(api.createPaid).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(api.createPaid.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", amount: 12, billingCycle: "month" });
|
|
116
|
+
expect(api.createPaid.mock.calls[0][0].questionnaire).toEqual(expectedAnswers);
|
|
117
|
+
expect(result.current.step).toBe("payment");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("invite tier: /subscriptions carries the answers and the token", async () => {
|
|
121
|
+
tiers = [tier({ priceMonthly: 12, joinMode: "invite" })];
|
|
122
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
|
|
123
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", inviteToken: "tok-1" }));
|
|
124
|
+
|
|
125
|
+
answerRequired(result);
|
|
126
|
+
await act(async () => {
|
|
127
|
+
await result.current.subscribe();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(api.createPaid.mock.calls[0][0]).toMatchObject({ inviteToken: "tok-1", questionnaire: expectedAnswers });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("application tier: /membership-applications carries the answers and no message", async () => {
|
|
134
|
+
tiers = [tier({ priceMonthly: 0, joinMode: "application" })];
|
|
135
|
+
api.apply.mockResolvedValue({
|
|
136
|
+
application: { id: "app-1", status: "pending", closedReason: null },
|
|
137
|
+
requiresCard: false,
|
|
138
|
+
clientSecret: null,
|
|
139
|
+
});
|
|
140
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
141
|
+
|
|
142
|
+
answerRequired(result);
|
|
143
|
+
await act(async () => {
|
|
144
|
+
await result.current.subscribe();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(api.apply).toHaveBeenCalledTimes(1);
|
|
148
|
+
const body = api.apply.mock.calls[0][0];
|
|
149
|
+
expect(body.questionnaire).toEqual(expectedAnswers);
|
|
150
|
+
expect(body).not.toHaveProperty("message");
|
|
151
|
+
expect(result.current.applicationOutcome).toBe("received");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("a blank required answer is refused before any request", () => {
|
|
156
|
+
it.each([
|
|
157
|
+
["open free", { priceMonthly: 0 }],
|
|
158
|
+
["open paid", { priceMonthly: 12 }],
|
|
159
|
+
["application", { priceMonthly: 12, joinMode: "application" as const }],
|
|
160
|
+
])("%s: nothing is sent and the first blank question is named", async (_label, overrides) => {
|
|
161
|
+
tiers = [tier(overrides)];
|
|
162
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
163
|
+
|
|
164
|
+
// Whitespace is blank.
|
|
165
|
+
act(() => result.current.setQuestionnaireAnswer("q-why", " "));
|
|
166
|
+
expect(result.current.questionnaireRefusal).toBeNull();
|
|
167
|
+
await act(async () => {
|
|
168
|
+
await result.current.subscribe();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(api.createFree).not.toHaveBeenCalled();
|
|
172
|
+
expect(api.createPaid).not.toHaveBeenCalled();
|
|
173
|
+
expect(api.apply).not.toHaveBeenCalled();
|
|
174
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
175
|
+
expect(result.current.questionnaireRefusal?.id).toBe("q-why");
|
|
176
|
+
|
|
177
|
+
// The refusal follows the answers: it moves on, then clears.
|
|
178
|
+
act(() => result.current.setQuestionnaireAnswer("q-why", "Because"));
|
|
179
|
+
expect(result.current.questionnaireRefusal?.id).toBe("q-city");
|
|
180
|
+
act(() => result.current.setQuestionnaireAnswer("q-city", "Berlin"));
|
|
181
|
+
expect(result.current.questionnaireRefusal).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("completing an approved application whose card was declined", () => {
|
|
186
|
+
it("asks nothing and sends no questionnaire", async () => {
|
|
187
|
+
tiers = [
|
|
188
|
+
tier({
|
|
189
|
+
priceMonthly: 12,
|
|
190
|
+
joinMode: "application",
|
|
191
|
+
myApplication: { id: "app-1", status: "payment_failed", paymentWindowExpiresAt: "2026-09-24T00:00:00.000Z" },
|
|
192
|
+
}),
|
|
193
|
+
];
|
|
194
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
|
|
195
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
196
|
+
|
|
197
|
+
expect(result.current.questionnaire).toEqual([]);
|
|
198
|
+
await act(async () => {
|
|
199
|
+
await result.current.subscribe();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(api.createPaid).toHaveBeenCalledTimes(1);
|
|
203
|
+
expect(api.createPaid.mock.calls[0][0]).not.toHaveProperty("questionnaire");
|
|
204
|
+
expect(result.current.questionnaireRefusal).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("changing tier", () => {
|
|
209
|
+
it("starts the next tier's answers blank", () => {
|
|
210
|
+
tiers = [
|
|
211
|
+
tier({ id: "tier-1", priceMonthly: 12 }),
|
|
212
|
+
tier({ id: "tier-2", priceMonthly: 20, questionnaire: [{ id: "q-why", question: "Why?", type: "text" }] }),
|
|
213
|
+
];
|
|
214
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
215
|
+
|
|
216
|
+
act(() => result.current.setQuestionnaireAnswer("q-why", "An answer for tier one"));
|
|
217
|
+
act(() => result.current.selectTier(tiers[1]));
|
|
218
|
+
|
|
219
|
+
expect(result.current.selectedTierId).toBe("tier-2");
|
|
220
|
+
expect(result.current.questionnaire).toEqual([{ id: "q-why", question: "Why?", type: "text" }]);
|
|
221
|
+
expect(result.current.questionnaireAnswers).toEqual({});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("pure helpers", () => {
|
|
226
|
+
it("a tier with no questions asks none", () => {
|
|
227
|
+
expect(tierQuestionnaire(undefined)).toEqual([]);
|
|
228
|
+
expect(tierQuestionnaire({ questionnaire: null })).toEqual([]);
|
|
229
|
+
expect(tierQuestionnaire({ questionnaire: [] })).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("optional questions are never missing", () => {
|
|
233
|
+
expect(firstMissingAnswer([questions[2]], {})).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("builds every question in order with trimmed answers", () => {
|
|
237
|
+
expect(buildQuestionnaireAnswers(questions, { "q-why": " Long-time listener ", "q-city": "Lagos" })).toEqual(
|
|
238
|
+
expectedAnswers,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useMembershipCheckout } from "../useMembershipCheckout";
|
|
5
|
+
import type { MembershipTier } from "../../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Free trials in the headless checkout: the selected tier's trial is exposed
|
|
9
|
+
* only when the fan is eligible, and a paid subscribe that answers with `trial`
|
|
10
|
+
* and a SetupIntent secret routes the payment step to a card SAVE that finishes
|
|
11
|
+
* like a paid subscription (to the return page's confirm-latest), never to the
|
|
12
|
+
* application confirm.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const auth = vi.hoisted(() => ({
|
|
16
|
+
user: { id: "account-1", email: "fan@example.com", membership: undefined as unknown },
|
|
17
|
+
refetchUser: vi.fn(async () => {}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const api = vi.hoisted(() => ({
|
|
21
|
+
confirm: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
22
|
+
createPaid: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
let tiers: MembershipTier[] = [];
|
|
26
|
+
|
|
27
|
+
vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
28
|
+
...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
|
|
29
|
+
usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
33
|
+
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
34
|
+
useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
|
|
35
|
+
useApplyForMembership: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
36
|
+
useConfirmMembershipApplication: () => ({ mutateAsync: api.confirm, isPending: false }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
40
|
+
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
41
|
+
useCreateSubscription: () => ({ mutateAsync: api.createPaid, isPending: false }),
|
|
42
|
+
useCreateFreeSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
43
|
+
useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const tier = (overrides: Partial<MembershipTier>): MembershipTier => ({
|
|
47
|
+
id: "tier-1",
|
|
48
|
+
name: "Inner Circle",
|
|
49
|
+
description: "",
|
|
50
|
+
payWhatYouWant: false,
|
|
51
|
+
benefits: [],
|
|
52
|
+
priceMonthly: 12,
|
|
53
|
+
...overrides,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const onComplete = vi.fn();
|
|
57
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
api.confirm.mockReset();
|
|
61
|
+
api.createPaid.mockReset();
|
|
62
|
+
onComplete.mockReset();
|
|
63
|
+
auth.user.membership = undefined;
|
|
64
|
+
window.history.replaceState(null, "", "/i/membership/checkout");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("useMembershipCheckout: trial exposure", () => {
|
|
68
|
+
it("exposes trialDays and a first-charge date of today + trialDays for an eligible fan", () => {
|
|
69
|
+
tiers = [tier({ trialDays: 14, trialEligible: true })];
|
|
70
|
+
const before = Date.now();
|
|
71
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
72
|
+
|
|
73
|
+
expect(result.current.trialDays).toBe(14);
|
|
74
|
+
expect(result.current.trial).toBeNull();
|
|
75
|
+
const ends = new Date(result.current.trialEndsAt as string).getTime();
|
|
76
|
+
expect(ends).toBeGreaterThanOrEqual(before + 14 * DAY);
|
|
77
|
+
expect(ends).toBeLessThanOrEqual(Date.now() + 14 * DAY);
|
|
78
|
+
// The consent line's other inputs.
|
|
79
|
+
expect(result.current.amount).toBe(12);
|
|
80
|
+
expect(result.current.billingCycle).toBe("month");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("exposes no trial to an ineligible fan, on a tier without one, or on a free tier", () => {
|
|
84
|
+
tiers = [
|
|
85
|
+
tier({ id: "used", trialDays: 14, trialEligible: false }),
|
|
86
|
+
tier({ id: "none", trialDays: null }),
|
|
87
|
+
tier({ id: "free", priceMonthly: 0, trialDays: 14 }),
|
|
88
|
+
];
|
|
89
|
+
for (const id of ["used", "none", "free"]) {
|
|
90
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: id, onComplete }));
|
|
91
|
+
expect(result.current.trialDays, id).toBe(0);
|
|
92
|
+
expect(result.current.trialEndsAt, id).toBeNull();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("exposes no trial to a member changing tier", () => {
|
|
97
|
+
auth.user.membership = { paymentProviderSubscriptionId: "sub_1", status: "active", membershipTierId: "other" };
|
|
98
|
+
tiers = [tier({ trialDays: 14 })];
|
|
99
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
100
|
+
expect(result.current.isChange).toBe(true);
|
|
101
|
+
expect(result.current.trialDays).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("useMembershipCheckout: a trial's SetupIntent secret", () => {
|
|
106
|
+
it("routes a seti_ secret from the PAID subscribe to a setup, and finishes like a paid subscription", async () => {
|
|
107
|
+
tiers = [tier({ trialDays: 14 })];
|
|
108
|
+
api.createPaid.mockResolvedValue({
|
|
109
|
+
membershipId: "m-1",
|
|
110
|
+
subscriptionId: "s-1",
|
|
111
|
+
requiresPayment: true,
|
|
112
|
+
clientSecret: "seti_trial_secret_abc",
|
|
113
|
+
trial: { days: 14, endsAt: "2026-10-01T00:00:00.000Z" },
|
|
114
|
+
});
|
|
115
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await result.current.subscribe();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(api.createPaid).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(result.current.step).toBe("payment");
|
|
123
|
+
expect(result.current.clientSecret).toBe("seti_trial_secret_abc");
|
|
124
|
+
expect(result.current.paymentKind).toBe("setup");
|
|
125
|
+
expect(result.current.setupPurpose).toBe("trial");
|
|
126
|
+
expect(result.current.trial).toEqual({ days: 14, endsAt: "2026-10-01T00:00:00.000Z" });
|
|
127
|
+
// The server's date wins over the local estimate.
|
|
128
|
+
expect(result.current.trialEndsAt).toBe("2026-10-01T00:00:00.000Z");
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
await result.current.onPaymentSucceeded();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(onComplete).toHaveBeenCalledWith({ requiresConfirmation: true });
|
|
135
|
+
expect(api.confirm).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keys setup off the SECRET: a seti_ secret with no trial field still confirms as a setup", async () => {
|
|
139
|
+
tiers = [tier({})];
|
|
140
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "seti_x_secret_y" });
|
|
141
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
142
|
+
await act(async () => {
|
|
143
|
+
await result.current.subscribe();
|
|
144
|
+
});
|
|
145
|
+
expect(result.current.paymentKind).toBe("setup");
|
|
146
|
+
expect(result.current.setupPurpose).toBe("trial");
|
|
147
|
+
expect(result.current.trial).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("keeps an ordinary paid subscription on the payment confirm", async () => {
|
|
151
|
+
tiers = [tier({})];
|
|
152
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2", trial: null });
|
|
153
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await result.current.subscribe();
|
|
156
|
+
});
|
|
157
|
+
expect(result.current.paymentKind).toBe("payment");
|
|
158
|
+
expect(result.current.setupPurpose).toBeNull();
|
|
159
|
+
expect(result.current.trial).toBeNull();
|
|
160
|
+
await act(async () => {
|
|
161
|
+
await result.current.onPaymentSucceeded();
|
|
162
|
+
});
|
|
163
|
+
expect(onComplete).toHaveBeenCalledWith({ requiresConfirmation: true });
|
|
164
|
+
});
|
|
165
|
+
});
|