-
Notifications
You must be signed in to change notification settings - Fork 18
feat(billing): Automatic top-up toggle in profile settings #1862
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { Switch } from "@/components/ui/switch"; | ||
| import useAutoRecharge from "@/hooks/useAutoRecharge"; | ||
|
|
||
| /** | ||
| * Billing section of the account modal. Unlike the form fields above it, the | ||
| * auto top-up switch applies immediately (PATCH on flip) — it is not governed | ||
| * by the form's Save button, so it sits in its own divided section. | ||
| */ | ||
| const BillingSection = () => { | ||
| const { enabled, isLoading, isUpdating, setEnabled } = useAutoRecharge(); | ||
|
|
||
| return ( | ||
| <div className="w-full border-t border-border pt-4 mb-6"> | ||
| <p className="text-sm font-medium text-foreground mb-3">Billing</p> | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <div> | ||
| <label htmlFor="auto-topup" className="text-sm text-foreground"> | ||
| Automatic top-up | ||
| </label> | ||
| <p className="text-xs text-muted-foreground mt-0.5"> | ||
| When your credits run out, automatically charge your saved card $5 | ||
| for 500 credits. Turning this off never removes your card — you can | ||
| still top up manually anytime. | ||
| </p> | ||
| </div> | ||
| <Switch | ||
| id="auto-topup" | ||
| checked={enabled} | ||
| disabled={isLoading || isUpdating} | ||
| onCheckedChange={setEnabled} | ||
| className="mt-0.5" | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default BillingSection; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| import { toast } from "react-toastify"; | ||
| 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); | ||
| }, | ||
| onError: () => { | ||
| toast.error("Couldn't update automatic top-up. Please try again."); | ||
| }, | ||
| }); | ||
|
|
||
| return { | ||
| enabled: query.data?.enabled ?? true, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The automatic top-up toggle can appear checked before the live Stripe-read setting has loaded (or if loading fails), because Prompt for AI agents |
||
| isLoading: query.isLoading, | ||
| isUpdating: mutation.isPending, | ||
| setEnabled: mutation.mutate, | ||
| }; | ||
| }; | ||
|
|
||
| export default useAutoRecharge; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AutoRechargeSetting> { | ||
| 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AutoRechargeSetting> { | ||
| 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The automatic top-up switch can be interactive before the account has finished loading, because
disabledonly checks the query/mutation states. SinceuseAutoRechargeskips the query untilaccountIdexists but still lets the mutation callupdateAutoRechargeSetting(accountId as string, ...), an early toggle can PATCH/api/accounts/undefined/auto-rechargeand show a failed update. Consider disabling the switch until the hook has a real account id, or have the hook expose anisReady/canUpdatestate and guard the mutation.Prompt for AI agents