From e12b279f83053fca3388b6e17d228bab90972ce9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 8 Jul 2026 12:49:07 -0500 Subject: [PATCH 1/2] feat(billing): Automatic top-up toggle in the profile settings menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an 'Automatic top-up' checkbox item to the user-profile dropdown's settings group, backed by GET/PATCH /api/accounts/{id}/auto-recharge (recoupable/api#769). Renders the live Stripe-read state — never a cached local flag — so the toggle always shows the consent the charge path will honor. Disabling keeps the saved card and manual checkout top-ups working. TDD: fetch-helper tests RED before implementation; 35 files / 130 tests pass; eslint + tsc clean on touched files; production build succeeds. Part of recoupable/chat#1861 (merge order: docs#270 → api#769 → this). Co-Authored-By: Claude Fable 5 --- .../UserProfileDropdown/AutoTopUpMenuItem.tsx | 25 +++++++++ .../UserProfileDropdown/SettingsGroup.tsx | 2 + hooks/useAutoRecharge.ts | 51 ++++++++++++++++++ .../__tests__/getAutoRechargeSetting.test.ts | 46 ++++++++++++++++ .../updateAutoRechargeSetting.test.ts | 53 +++++++++++++++++++ lib/recoup/getAutoRechargeSetting.ts | 33 ++++++++++++ lib/recoup/updateAutoRechargeSetting.ts | 33 ++++++++++++ 7 files changed, 243 insertions(+) create mode 100644 components/Sidebar/UserProfileDropdown/AutoTopUpMenuItem.tsx create mode 100644 hooks/useAutoRecharge.ts create mode 100644 lib/recoup/__tests__/getAutoRechargeSetting.test.ts create mode 100644 lib/recoup/__tests__/updateAutoRechargeSetting.test.ts create mode 100644 lib/recoup/getAutoRechargeSetting.ts create mode 100644 lib/recoup/updateAutoRechargeSetting.ts diff --git a/components/Sidebar/UserProfileDropdown/AutoTopUpMenuItem.tsx b/components/Sidebar/UserProfileDropdown/AutoTopUpMenuItem.tsx new file mode 100644 index 000000000..516aa9ca3 --- /dev/null +++ b/components/Sidebar/UserProfileDropdown/AutoTopUpMenuItem.tsx @@ -0,0 +1,25 @@ +import { DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"; +import useAutoRecharge from "@/hooks/useAutoRecharge"; + +/** + * Toggles automatic credit top-up. Checked = the platform may top up the + * saved card off-session when credits run out; unchecked = topping up stays + * an explicit checkout action. Renders the live setting from the api. + */ +const AutoTopUpMenuItem = () => { + const { enabled, isLoading, isUpdating, setEnabled } = useAutoRecharge(); + + return ( + setEnabled(checked === true)} + onSelect={(event) => event.preventDefault()} + className="cursor-pointer" + > + Automatic top-up + + ); +}; + +export default AutoTopUpMenuItem; diff --git a/components/Sidebar/UserProfileDropdown/SettingsGroup.tsx b/components/Sidebar/UserProfileDropdown/SettingsGroup.tsx index cd45acb2b..935e41344 100644 --- a/components/Sidebar/UserProfileDropdown/SettingsGroup.tsx +++ b/components/Sidebar/UserProfileDropdown/SettingsGroup.tsx @@ -9,6 +9,7 @@ import { import { Check } from "lucide-react"; import { useTheme } from "next-themes"; import ManageSubscriptionButton from "./ManageSubscriptionButton"; +import AutoTopUpMenuItem from "./AutoTopUpMenuItem"; import ConnectorsMenuItem from "./ConnectorsMenuItem"; import ApiKeysMenuItem from "./ApiKeysMenuItem"; import themeLabel from "@/lib/sidebar/themeLabel"; @@ -20,6 +21,7 @@ const SettingsGroup = () => { return ( + diff --git a/hooks/useAutoRecharge.ts b/hooks/useAutoRecharge.ts new file mode 100644 index 000000000..be5dcbbad --- /dev/null +++ b/hooks/useAutoRecharge.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import getAutoRechargeSetting from "@/lib/recoup/getAutoRechargeSetting"; +import updateAutoRechargeSetting from "@/lib/recoup/updateAutoRechargeSetting"; +import { useUserProvider } from "@/providers/UserProvder"; + +/** + * Reads and updates the account's automatic top-up setting. The value is the + * live Stripe-read state from the api (never a cached local flag), so the + * toggle always shows the consent the charge path will actually honor. + */ +const useAutoRecharge = () => { + const { userData } = useUserProvider(); + const { getAccessToken, authenticated } = usePrivy(); + const queryClient = useQueryClient(); + const accountId = userData?.account_id as string | undefined; + + const query = useQuery({ + queryKey: ["auto-recharge", accountId], + queryFn: async () => { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to load the auto top-up setting"); + } + return getAutoRechargeSetting(accountId as string, accessToken); + }, + enabled: authenticated && !!accountId, + }); + + const mutation = useMutation({ + mutationFn: async (enabled: boolean) => { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to update the auto top-up setting"); + } + return updateAutoRechargeSetting(accountId as string, accessToken, enabled); + }, + onSuccess: (data) => { + queryClient.setQueryData(["auto-recharge", accountId], data); + }, + }); + + return { + enabled: query.data?.enabled ?? true, + isLoading: query.isLoading, + isUpdating: mutation.isPending, + setEnabled: mutation.mutate, + }; +}; + +export default useAutoRecharge; diff --git a/lib/recoup/__tests__/getAutoRechargeSetting.test.ts b/lib/recoup/__tests__/getAutoRechargeSetting.test.ts new file mode 100644 index 000000000..36e86e0c2 --- /dev/null +++ b/lib/recoup/__tests__/getAutoRechargeSetting.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import getAutoRechargeSetting from "../getAutoRechargeSetting"; +import { NEW_API_BASE_URL } from "@/lib/consts"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("getAutoRechargeSetting", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches the auto top-up setting with the Privy bearer token", async () => { + const apiResponse = { account_id: "account-123", enabled: false }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => apiResponse, + }); + + const result = await getAutoRechargeSetting("account-123", "test-token"); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/accounts/account-123/auto-recharge`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + expect(result).toEqual(apiResponse); + }); + + it("throws on a non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: "Unauthorized" }), + }); + + await expect( + getAutoRechargeSetting("account-123", "bad-token"), + ).rejects.toThrow("401"); + }); +}); diff --git a/lib/recoup/__tests__/updateAutoRechargeSetting.test.ts b/lib/recoup/__tests__/updateAutoRechargeSetting.test.ts new file mode 100644 index 000000000..5737cb3bd --- /dev/null +++ b/lib/recoup/__tests__/updateAutoRechargeSetting.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import updateAutoRechargeSetting from "../updateAutoRechargeSetting"; +import { NEW_API_BASE_URL } from "@/lib/consts"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("updateAutoRechargeSetting", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("PATCHes the auto top-up setting with the Privy bearer token", async () => { + const apiResponse = { account_id: "account-123", enabled: false }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => apiResponse, + }); + + const result = await updateAutoRechargeSetting( + "account-123", + "test-token", + false, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/accounts/account-123/auto-recharge`, + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }), + body: JSON.stringify({ enabled: false }), + }), + ); + expect(result).toEqual(apiResponse); + }); + + it("throws on a non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: "Forbidden" }), + }); + + await expect( + updateAutoRechargeSetting("account-123", "bad-token", true), + ).rejects.toThrow("403"); + }); +}); diff --git a/lib/recoup/getAutoRechargeSetting.ts b/lib/recoup/getAutoRechargeSetting.ts new file mode 100644 index 000000000..1c9e47f40 --- /dev/null +++ b/lib/recoup/getAutoRechargeSetting.ts @@ -0,0 +1,33 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +export interface AutoRechargeSetting { + account_id: string; + enabled: boolean; +} + +/** + * GET /api/accounts/{id}/auto-recharge on the Recoup API (requires Privy + * bearer). Reads whether automatic top-up is enabled — the setting lives on + * the account's Stripe customer record and is read live by the api. + */ +async function getAutoRechargeSetting( + accountId: string, + accessToken: string, +): Promise { + const response = await fetch( + `${getClientApiBaseUrl()}/api/accounts/${accountId}/auto-recharge`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch auto top-up setting: ${response.status}`); + } + + return response.json(); +} + +export default getAutoRechargeSetting; diff --git a/lib/recoup/updateAutoRechargeSetting.ts b/lib/recoup/updateAutoRechargeSetting.ts new file mode 100644 index 000000000..fc5c8b352 --- /dev/null +++ b/lib/recoup/updateAutoRechargeSetting.ts @@ -0,0 +1,33 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import type { AutoRechargeSetting } from "@/lib/recoup/getAutoRechargeSetting"; + +/** + * PATCH /api/accounts/{id}/auto-recharge on the Recoup API (requires Privy + * bearer). Disabling never removes the saved card — manual checkout top-ups + * keep working and re-enabling requires no card re-entry. + */ +async function updateAutoRechargeSetting( + accountId: string, + accessToken: string, + enabled: boolean, +): Promise { + const response = await fetch( + `${getClientApiBaseUrl()}/api/accounts/${accountId}/auto-recharge`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to update auto top-up setting: ${response.status}`); + } + + return response.json(); +} + +export default updateAutoRechargeSetting; From 45099680efa37f43a43de16463631256199edbe0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Jul 2026 15:53:02 -0500 Subject: [PATCH 2/2] feat(billing): move auto top-up toggle into a Billing section of the account modal Review feedback: the profile dropdown is primarily navigational; a billing- consent control deserves explanation a menu item can't carry. The toggle is now a labeled Switch in a divided Billing section of the Account modal with helper text stating exactly what gets charged ($5 for 500 credits) and that disabling never removes the card. The section is visually separated because it applies instantly via PATCH, unlike the save-to-apply form above it. Also surfaces mutation failures via toast (was silent). useAutoRecharge and the lib/recoup fetchers are unchanged aside from onError; all 130 tests pass. Co-Authored-By: Claude Fable 5 --- components/Account/Account.tsx | 3 ++ components/Account/BillingSection.tsx | 38 +++++++++++++++++++ .../UserProfileDropdown/AutoTopUpMenuItem.tsx | 25 ------------ .../UserProfileDropdown/SettingsGroup.tsx | 2 - hooks/useAutoRecharge.ts | 4 ++ 5 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 components/Account/BillingSection.tsx delete mode 100644 components/Sidebar/UserProfileDropdown/AutoTopUpMenuItem.tsx diff --git a/components/Account/Account.tsx b/components/Account/Account.tsx index 07dcb9f32..45f129914 100644 --- a/components/Account/Account.tsx +++ b/components/Account/Account.tsx @@ -7,6 +7,7 @@ import { useUserProvider } from "@/providers/UserProvder"; import ArtistInstructionTextArea from "./ArtistInstructionTextArea"; import Input from "../Input"; import AccountIdDisplay from "../ArtistSetting/AccountIdDisplay"; +import BillingSection from "./BillingSection"; const Account = () => { const { @@ -104,6 +105,8 @@ const Account = () => { + + {/* Actions Section */}