Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions hooks/useSubscribeClick.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { usePrivy } from "@privy-io/react-auth";
import { useUserProvider } from "@/providers/UserProvder";
import { usePaymentProvider } from "@/providers/PaymentProvider";
import createClientPortalSession from "@/lib/stripe/createClientPortalSession";
import createClientCheckoutSession from "@/lib/stripe/createClientCheckoutSession";

const useSubscribeClick = () => {
const { getAccessToken } = usePrivy();
const { userData } = useUserProvider();
const { isSubscribed } = usePaymentProvider();

const handleClick = () => {
const handleClick = async () => {
if (!userData?.account_id) return;

if (isSubscribed) {
createClientPortalSession(userData.account_id);
void createClientPortalSession(userData.account_id);
return;
}
createClientCheckoutSession(userData.account_id);

const accessToken = await getAccessToken();
if (!accessToken) return;

void createClientCheckoutSession(accessToken, window.location.href);
Comment on lines +12 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't gate checkout on account_id.

The new checkout flow uses a Privy access token, so the early return on userData?.account_id blocks the non-subscribed path entirely for users who no longer have an account ID. Move that guard inside the isSubscribed branch so only the portal flow depends on account_id.

Suggested fix
 const handleClick = async () => {
-  if (!userData?.account_id) return;
-
   if (isSubscribed) {
+    if (!userData?.account_id) return;
     void createClientPortalSession(userData.account_id);
     return;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleClick = async () => {
if (!userData?.account_id) return;
if (isSubscribed) {
createClientPortalSession(userData.account_id);
void createClientPortalSession(userData.account_id);
return;
}
createClientCheckoutSession(userData.account_id);
const accessToken = await getAccessToken();
if (!accessToken) return;
void createClientCheckoutSession(accessToken, window.location.href);
const handleClick = async () => {
if (isSubscribed) {
if (!userData?.account_id) return;
void createClientPortalSession(userData.account_id);
return;
}
const accessToken = await getAccessToken();
if (!accessToken) return;
void createClientCheckoutSession(accessToken, window.location.href);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/useSubscribeClick.ts` around lines 12 - 23, The handleClick function
currently returns early if userData?.account_id is missing, which incorrectly
blocks the checkout path; change the guard so that the account_id check is only
used for the portal flow: inside the isSubscribed branch, verify
userData?.account_id and call createClientPortalSession(userData.account_id) (or
return if missing), while leaving the non-subscribed path to proceed to await
getAccessToken() and call createClientCheckoutSession(accessToken,
window.location.href); update references to isSubscribed, userData.account_id,
createClientPortalSession, getAccessToken, and createClientCheckoutSession
accordingly.

};

return {
Expand Down
71 changes: 71 additions & 0 deletions lib/stripe/__tests__/createClientCheckoutSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import createClientCheckoutSession from "../createClientCheckoutSession";
import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";

vi.mock("@/lib/api/getClientApiBaseUrl", () => ({
getClientApiBaseUrl: vi.fn(),
}));

describe("createClientCheckoutSession", () => {
const mockOpen = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(getClientApiBaseUrl).mockReturnValue("https://api.recoupable.com");
vi.stubGlobal("window", { open: mockOpen } as unknown as Window);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("POSTs only successUrl to recoup-api with Bearer auth and opens checkout url", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
id: "cs_test_1",
url: "https://checkout.stripe.com/test",
}),
}) as unknown as typeof fetch;

await createClientCheckoutSession(
"privy-token",
"https://chat.recoupable.com/billing",
);

expect(fetch).toHaveBeenCalledWith(
"https://api.recoupable.com/api/subscriptions/sessions",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer privy-token",
},
}),
);
const init = vi.mocked(fetch).mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
successUrl: "https://chat.recoupable.com/billing",
});
expect(mockOpen).toHaveBeenCalledWith(
"https://checkout.stripe.com/test",
"_blank",
"noopener,noreferrer",
);
});

it("does not open a window and returns error when response is not ok", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: vi.fn().mockResolvedValue({ error: "Unauthorized" }),
}) as unknown as typeof fetch;

const result = await createClientCheckoutSession("bad-token", "https://ex.com");

expect(mockOpen).not.toHaveBeenCalled();
expect(result).toEqual({ error: expect.any(Error) });
});
});
40 changes: 28 additions & 12 deletions lib/stripe/createClientCheckoutSession.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
const createClientCheckoutSession = async (accountId: string) => {
import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl";

const createClientCheckoutSession = async (
accessToken: string,
successUrl: string,
) => {
try {
const response = await fetch(`/api/stripe/session/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${getClientApiBaseUrl()}/api/subscriptions/sessions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ successUrl }),
},
body: JSON.stringify({
accountId,
successUrl: `${window.location.href}`,
}),
});
);

const data = (await response.json()) as {
id?: string;
url?: string;
error?: string;
};

if (!response.ok || typeof data.url !== "string") {
throw new Error(data.error || "Failed to create checkout session");
Comment on lines +8 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Checkout requests are pointed at the wrong API contract.

app/api/stripe/session/create/route.ts expects { accountId, successUrl } and returns { data: session }, but this client now posts only { successUrl } to /api/subscriptions/sessions and then looks for a top-level url. Unless a matching backend route lands in this PR, the checkout flow will fail at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/stripe/createClientCheckoutSession.ts` around lines 8 - 27, The client is
posting the wrong payload and expecting the wrong response shape; update
createClientCheckoutSession to POST both accountId and successUrl (matching
app/api/stripe/session/create/route.ts) to the correct endpoint
(`/api/stripe/session/create`) via getClientApiBaseUrl(), then parse the
returned JSON as { data: session } and read session.url (or throw using
data.data.error) and still check response.ok; locate the fetch call in
lib/stripe/createClientCheckoutSession.ts and adjust the request body, endpoint
path, and response handling accordingly.

}

const data = await response.json();
window.open(data.data.url, "__blank");
window.open(data.url, "_blank", "noopener,noreferrer");
} catch (error) {
console.error("Error creating checkout session:", error);
return { error };
}
};
Expand Down
Loading