Skip to content
Merged
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
3 changes: 3 additions & 0 deletions components/Account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -104,6 +105,8 @@ const Account = () => {
</div>
</div>

<BillingSection />

{/* Actions Section */}
<div className="w-full space-y-3 mt-2">
<button
Expand Down
38 changes: 38 additions & 0 deletions components/Account/BillingSection.tsx
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}

Copy link
Copy Markdown

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 disabled only checks the query/mutation states. Since useAutoRecharge skips the query until accountId exists but still lets the mutation call updateAutoRechargeSetting(accountId as string, ...), an early toggle can PATCH /api/accounts/undefined/auto-recharge and show a failed update. Consider disabling the switch until the hook has a real account id, or have the hook expose an isReady/canUpdate state and guard the mutation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Account/BillingSection.tsx, line 29:

<comment>The automatic top-up switch can be interactive before the account has finished loading, because `disabled` only checks the query/mutation states. Since `useAutoRecharge` skips the query until `accountId` exists but still lets the mutation call `updateAutoRechargeSetting(accountId as string, ...)`, an early toggle can PATCH `/api/accounts/undefined/auto-recharge` and show a failed update. Consider disabling the switch until the hook has a real account id, or have the hook expose an `isReady`/`canUpdate` state and guard the mutation.</comment>

<file context>
@@ -0,0 +1,38 @@
+        <Switch
+          id="auto-topup"
+          checked={enabled}
+          disabled={isLoading || isUpdating}
+          onCheckedChange={setEnabled}
+          className="mt-0.5"
</file context>

onCheckedChange={setEnabled}
className="mt-0.5"
/>
</div>
</div>
);
};

export default BillingSection;
55 changes: 55 additions & 0 deletions hooks/useAutoRecharge.ts
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,

Copy link
Copy Markdown

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 toggle can appear checked before the live Stripe-read setting has loaded (or if loading fails), because enabled falls back to true when query.data is missing. Since this is a consent-style setting, it would be safer not to render an opt-in state unless the API has actually returned enabled: true.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useAutoRecharge.ts, line 44:

<comment>The automatic top-up toggle can appear checked before the live Stripe-read setting has loaded (or if loading fails), because `enabled` falls back to `true` when `query.data` is missing. Since this is a consent-style setting, it would be safer not to render an opt-in state unless the API has actually returned `enabled: true`.</comment>

<file context>
@@ -0,0 +1,51 @@
+  });
+
+  return {
+    enabled: query.data?.enabled ?? true,
+    isLoading: query.isLoading,
+    isUpdating: mutation.isPending,
</file context>

isLoading: query.isLoading,
isUpdating: mutation.isPending,
setEnabled: mutation.mutate,
};
};

export default useAutoRecharge;
46 changes: 46 additions & 0 deletions lib/recoup/__tests__/getAutoRechargeSetting.test.ts
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");
});
});
53 changes: 53 additions & 0 deletions lib/recoup/__tests__/updateAutoRechargeSetting.test.ts
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");
});
});
33 changes: 33 additions & 0 deletions lib/recoup/getAutoRechargeSetting.ts
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;
33 changes: 33 additions & 0 deletions lib/recoup/updateAutoRechargeSetting.ts
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;
Loading