Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SaaS): Billing settings screen #6495

Merged
merged 57 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
73f1b73
Init tests
amanape Jan 7, 2025
20632c8
Display hardcoded balance
amanape Jan 8, 2025
e115d47
Merge branch 'main' into ALL-963/saas-billing
amanape Jan 8, 2025
0ebd48a
Merge branch 'main' into ALL-963/saas-billing
amanape Jan 8, 2025
a99e406
Refactor
amanape Jan 8, 2025
afd285a
Progress
amanape Jan 8, 2025
961204e
Merge branch 'main' into ALL-963/saas-billing
amanape Jan 9, 2025
c286c2b
Refactor and setup stripe checkout form
amanape Jan 9, 2025
7b4bfd0
Merge
amanape Jan 22, 2025
c4b969d
Mock stripe session creation
amanape Jan 22, 2025
159c3b6
Refactor
amanape Jan 22, 2025
3ddd1ab
Create payment option selection
amanape Jan 22, 2025
a168a76
Payment options
amanape Jan 22, 2025
75ef0f7
Refactor
amanape Jan 22, 2025
1da3c61
Add comment
amanape Jan 22, 2025
bf3894c
Merge
amanape Jan 24, 2025
efbd20e
Merge branch 'main' into ALL-963/saas-billing
amanape Jan 27, 2025
7af8bc8
Merge
amanape Jan 28, 2025
fe0140e
Remove woopsie
amanape Jan 28, 2025
81ca982
Dont return PK from config
amanape Jan 28, 2025
c021563
Use stripe hosted page
amanape Jan 28, 2025
6cd910b
Fix tests
amanape Jan 28, 2025
05c9256
Merge
amanape Jan 30, 2025
b964cf7
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 4, 2025
361ef41
Work in progress
tofarr Feb 4, 2025
7cb1975
WIP
tofarr Feb 4, 2025
76b0e9f
WIP.
tofarr Feb 4, 2025
167f08e
WIP
tofarr Feb 4, 2025
beb486e
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 5, 2025
fc7e2a4
Removed billing routes
tofarr Feb 6, 2025
037ad19
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 6, 2025
978f075
Update active conditions
tofarr Feb 6, 2025
bba51eb
Merge branch 'ALL-963/saas-billing' of github.com:All-Hands-AI/OpenHa…
tofarr Feb 6, 2025
33f8213
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 7, 2025
c37fda5
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 11, 2025
f9a38a3
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 12, 2025
e1d412e
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 12, 2025
14771f7
Merge branch 'main' into ALL-963/saas-billing
tofarr Feb 13, 2025
e2cf6c5
Merge
amanape Feb 14, 2025
e1703b5
Payment form setup
amanape Feb 14, 2025
b832d9f
Reorganize main settings form
amanape Feb 14, 2025
ed3f255
Split routes and style billing settings
amanape Feb 14, 2025
7fb7b83
UI synced with settings
amanape Feb 14, 2025
e3397b3
Test external link
amanape Feb 17, 2025
46891cf
Dont allow to redirect to billing if not saas
amanape Feb 17, 2025
bc91ce5
Validate amount
amanape Feb 17, 2025
7185ecb
Merge and resolve
amanape Feb 18, 2025
ad8de3f
Redirect handling
amanape Feb 18, 2025
c4e430a
Remove old stripe test key
amanape Feb 18, 2025
a149363
Remove outdated method
amanape Feb 18, 2025
6a6d0cc
Remove type
amanape Feb 18, 2025
8660f2b
Remove old components
amanape Feb 18, 2025
dc81bbe
Remove unused hook and redirect screen
amanape Feb 18, 2025
12cfcf3
Update billing mocks
amanape Feb 18, 2025
6d38be3
Feature flag
amanape Feb 18, 2025
951d2ae
merge and resolve
amanape Feb 18, 2025
35dacab
merge and resolve
amanape Feb 18, 2025
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
166 changes: 166 additions & 0 deletions frontend/__tests__/components/features/payment/payment-form.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PaymentForm />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});

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();
});
});
});
23 changes: 22 additions & 1 deletion frontend/__tests__/components/settings/settings-input.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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(
<SettingsInput
testId="test-input"
label="Test Input"
type="text"
onChange={onChangeMock}
/>,
);

const input = screen.getByTestId("test-input");
await user.type(input, "Test");

expect(onChangeMock).toHaveBeenCalledTimes(4);
expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
});
});
81 changes: 81 additions & 0 deletions frontend/__tests__/routes/settings-with-payment.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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";

describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");

const RoutesStub = createRoutesStub([
{
Component: SettingsScreen,
path: "/settings",
children: [
{
Component: () => <PaymentForm />,
path: "/settings/billing",
},
],
},
]);

const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);

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");
});
});
2 changes: 2 additions & 0 deletions frontend/__tests__/routes/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -36,6 +37,7 @@ describe("Settings Screen", () => {
{
Component: SettingsScreen,
path: "/settings",
children: [{ Component: AccountSettings, path: "/settings" }],
},
]);

Expand Down
32 changes: 32 additions & 0 deletions frontend/__tests__/utils/amount-is-valid.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading