diff --git a/frontend/__tests__/components/features/payment/payment-form.test.tsx b/frontend/__tests__/components/features/payment/payment-form.test.tsx new file mode 100644 index 000000000000..815d0530d103 --- /dev/null +++ b/frontend/__tests__/components/features/payment/payment-form.test.tsx @@ -0,0 +1,166 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; +import OpenHands from "#/api/open-hands"; +import { PaymentForm } from "#/components/features/payment/payment-form"; + +describe("PaymentForm", () => { + const getBalanceSpy = vi.spyOn(OpenHands, "getBalance"); + const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession"); + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + + const renderPaymentForm = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + beforeEach(() => { + // useBalance hook will return the balance only if the APP_MODE is "saas" + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the users current balance", async () => { + getBalanceSpy.mockResolvedValue("100.50"); + renderPaymentForm(); + + await waitFor(() => { + const balance = screen.getByTestId("user-balance"); + expect(balance).toHaveTextContent("$100.50"); + }); + }); + + it("should render the users current balance to two decimal places", async () => { + getBalanceSpy.mockResolvedValue("100"); + renderPaymentForm(); + + await waitFor(() => { + const balance = screen.getByTestId("user-balance"); + expect(balance).toHaveTextContent("$100.00"); + }); + }); + + test("the user can top-up a specific amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "50.12"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12); + }); + + it("should round the top-up amount to two decimal places", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "50.125456"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13); + }); + + it("should render the payment method link", async () => { + renderPaymentForm(); + + screen.getByTestId("payment-methods-link"); + }); + + it("should disable the top-up button if the user enters an invalid amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpButton = screen.getByText("Add credit"); + expect(topUpButton).toBeDisabled(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, " "); + + expect(topUpButton).toBeDisabled(); + }); + + it("should disable the top-up button after submission", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "50.12"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(topUpButton).toBeDisabled(); + }); + + describe("prevent submission if", () => { + test("user enters a negative amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "-50.12"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + test("user enters an empty string", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, " "); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + test("user enters a non-numeric value", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "abc"); + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + test("user enters less than the minimum amount", async () => { + const user = userEvent.setup(); + renderPaymentForm(); + + const topUpInput = await screen.findByTestId("top-up-input"); + await user.type(topUpInput, "20"); // test assumes the minimum is 25 + + const topUpButton = screen.getByText("Add credit"); + await user.click(topUpButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/components/settings/settings-input.test.tsx b/frontend/__tests__/components/settings/settings-input.test.tsx index 6009a2409e83..c2c6f650679d 100644 --- a/frontend/__tests__/components/settings/settings-input.test.tsx +++ b/frontend/__tests__/components/settings/settings-input.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import { SettingsInput } from "#/components/features/settings/settings-input"; describe("SettingsInput", () => { @@ -85,4 +86,24 @@ describe("SettingsInput", () => { expect(screen.getByText("Start Content")).toBeInTheDocument(); }); + + it("should call onChange with the input value", async () => { + const onChangeMock = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const input = screen.getByTestId("test-input"); + await user.type(input, "Test"); + + expect(onChangeMock).toHaveBeenCalledTimes(4); + expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test"); + }); }); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx new file mode 100644 index 000000000000..69f9b8c364b1 --- /dev/null +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -0,0 +1,83 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoutesStub } from "react-router"; +import { renderWithProviders } from "test-utils"; +import OpenHands from "#/api/open-hands"; +import SettingsScreen from "#/routes/settings"; +import { PaymentForm } from "#/components/features/payment/payment-form"; +import * as FeatureFlags from "#/utils/feature-flags"; + +describe("Settings Billing", () => { + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true); + + const RoutesStub = createRoutesStub([ + { + Component: SettingsScreen, + path: "/settings", + children: [ + { + Component: () => , + path: "/settings/billing", + }, + ], + }, + ]); + + const renderSettingsScreen = () => + renderWithProviders(); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should not render the navbar if OSS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const navbar = screen.queryByTestId("settings-navbar"); + expect(navbar).not.toBeInTheDocument(); + }); + }); + + it("should render the navbar if SaaS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const navbar = screen.getByTestId("settings-navbar"); + within(navbar).getByText("Account"); + within(navbar).getByText("Credits"); + }); + }); + + it("should render the billing settings if clicking the credits item", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + const credits = within(navbar).getByText("Credits"); + await user.click(credits); + + const billingSection = await screen.findByTestId("billing-settings"); + within(billingSection).getByText("Manage Credits"); + }); +}); diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index 6ce0458bdd3b..f307e4fb4b17 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -10,6 +10,7 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { PostApiSettings } from "#/types/settings"; import * as ConsentHandlers from "#/utils/handle-capture-consent"; +import AccountSettings from "#/routes/account-settings"; const toggleAdvancedSettings = async (user: UserEvent) => { const advancedSwitch = await screen.findByTestId("advanced-settings-switch"); @@ -36,6 +37,7 @@ describe("Settings Screen", () => { { Component: SettingsScreen, path: "/settings", + children: [{ Component: AccountSettings, path: "/settings" }], }, ]); diff --git a/frontend/__tests__/utils/amount-is-valid.test.ts b/frontend/__tests__/utils/amount-is-valid.test.ts new file mode 100644 index 000000000000..30181e1b48c9 --- /dev/null +++ b/frontend/__tests__/utils/amount-is-valid.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { amountIsValid } from "#/utils/amount-is-valid"; + +describe("amountIsValid", () => { + describe("fails", () => { + test("when the amount is negative", () => { + expect(amountIsValid("-5")).toBe(false); + expect(amountIsValid("-25")).toBe(false); + }); + + test("when the amount is zero", () => { + expect(amountIsValid("0")).toBe(false); + }); + + test("when an empty string is passed", () => { + expect(amountIsValid("")).toBe(false); + expect(amountIsValid(" ")).toBe(false); + }); + + test("when a non-numeric value is passed", () => { + expect(amountIsValid("abc")).toBe(false); + expect(amountIsValid("1abc")).toBe(false); + expect(amountIsValid("abc1")).toBe(false); + }); + + test("when an amount less than the minimum is passed", () => { + // test assumes the minimum is 25 + expect(amountIsValid("24")).toBe(false); + expect(amountIsValid("24.99")).toBe(false); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 168c295a2fa9..f03e27c271f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,8 @@ "@react-router/serve": "^7.1.5", "@react-types/shared": "^3.27.0", "@reduxjs/toolkit": "^2.5.1", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.5.0", "@tanstack/react-query": "^5.66.7", "@vitejs/plugin-react": "^4.3.2", "@xterm/addon-fit": "^0.10.0", @@ -84,6 +86,7 @@ "msw": "^2.6.6", "postcss": "^8.5.2", "prettier": "^3.5.1", + "stripe": "^17.5.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", @@ -5871,6 +5874,29 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz", + "integrity": "sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz", + "integrity": "sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -12324,7 +12350,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -14679,7 +14704,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -14691,7 +14715,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/property-information": { @@ -16572,6 +16595,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.6.0.tgz", + "integrity": "sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/style-to-object": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b597063d6ca..b840a3e51418 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "@react-router/serve": "^7.1.5", "@react-types/shared": "^3.27.0", "@reduxjs/toolkit": "^2.5.1", + "@stripe/react-stripe-js": "^3.1.1", + "@stripe/stripe-js": "^5.5.0", "@tanstack/react-query": "^5.66.7", "@vitejs/plugin-react": "^4.3.2", "@xterm/addon-fit": "^0.10.0", @@ -49,7 +51,8 @@ }, "scripts": { "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev", - "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev", + "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev", + "dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev", "build": "npm run make-i18n && npm run typecheck && react-router build", "start": "npx sirv-cli build/ --single", "test": "vitest run", @@ -111,6 +114,7 @@ "msw": "^2.6.6", "postcss": "^8.5.2", "prettier": "^3.5.1", + "stripe": "^17.5.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index e77d7a5527c2..58abf00956aa 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -274,6 +274,23 @@ class OpenHands { return data.status === 200; } + static async createCheckoutSession(amount: number): Promise { + const { data } = await openHands.post( + "/api/billing/create-checkout-session", + { + amount, + }, + ); + return data.redirect_url; + } + + static async getBalance(): Promise { + const { data } = await openHands.get<{ credits: string }>( + "/api/billing/credits", + ); + return data.credits; + } + static async getGitHubUser(): Promise { const response = await openHands.get("/api/github/user"); diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx new file mode 100644 index 000000000000..e42a9e7894d0 --- /dev/null +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; +import { useBalance } from "#/hooks/query/use-balance"; +import { cn } from "#/utils/utils"; +import MoneyIcon from "#/icons/money.svg?react"; +import { SettingsInput } from "../settings/settings-input"; +import { BrandButton } from "../settings/brand-button"; +import { HelpLink } from "../settings/help-link"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { amountIsValid } from "#/utils/amount-is-valid"; + +export function PaymentForm() { + const { data: balance, isLoading } = useBalance(); + const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession(); + + const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true); + + const billingFormAction = async (formData: FormData) => { + const amount = formData.get("top-up-input")?.toString(); + + if (amount?.trim()) { + if (!amountIsValid(amount)) return; + + const float = parseFloat(amount); + addBalance({ amount: Number(float.toFixed(2)) }); + } + + setButtonIsDisabled(true); + }; + + const handleTopUpInputChange = (value: string) => { + setButtonIsDisabled(!amountIsValid(value)); + }; + + return ( +
+

+ Manage Credits +

+ +
+
+ + Balance +
+ {!isLoading && ( + ${Number(balance).toFixed(2)} + )} + {isLoading && } +
+ +
+ + +
+ + Add credit + + {isPending && } +
+
+ + + + ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx index 5362af09aa28..5d737fd991dd 100644 --- a/frontend/src/components/features/settings/settings-input.tsx +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -12,6 +12,7 @@ interface SettingsInputProps { isDisabled?: boolean; startContent?: React.ReactNode; className?: string; + onChange?: (value: string) => void; } export function SettingsInput({ @@ -25,6 +26,7 @@ export function SettingsInput({ isDisabled, startContent, className, + onChange, }: SettingsInputProps) { return (