refactor(saas): remove the legacy credits engine (FE + Java) (#6687)

Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.

## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.

## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).

**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.

## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).

## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.

## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.

Targets `feat/desktop-cloud-saas-reuse`.
This commit is contained in:
ConnorYoh
2026-06-17 14:11:06 +00:00
committed by GitHub
parent 4f26fdeb5c
commit 20c88feabb
50 changed files with 134 additions and 9700 deletions
@@ -2756,7 +2756,6 @@ tooltip = "Pick colour from screen"
title = "Choose colour"
[common]
available = "available"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -2774,7 +2773,6 @@ previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
used = "used"
[compare]
clearSelected = "Clear selected"
@@ -3020,9 +3018,7 @@ title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account"
[config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation"
@@ -3030,14 +3026,9 @@ docsTitle = "API Documentation"
generateError = "We couldn't generate your API key."
goToAccount = "Go to Account"
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
includedCredits = "Included credits"
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
label = "API Key"
lastApiUse = "Last API Use"
nextReset = "Next Reset"
overlayMessage = "Generate a key to see credits and available credits"
publicKeyAriaLabel = "Public API key"
purchasedCredits = "Purchased credits"
refreshAriaLabel = "Refresh API key"
schemaLink = "API Schema Reference"
usage = "Include this key in the X-API-KEY header with all API requests."
@@ -2730,7 +2730,6 @@ tooltip = "Pick color from screen"
title = "Choose color"
[common]
available = "available"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -2748,7 +2747,6 @@ previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
used = "used"
[compare]
clearSelected = "Clear selected"
@@ -2994,9 +2992,7 @@ title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account"
[config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation"
@@ -3004,14 +3000,9 @@ docsTitle = "API Documentation"
generateError = "We couldn't generate your API key."
goToAccount = "Go to Account"
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
includedCredits = "Included credits"
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
label = "API Key"
lastApiUse = "Last API Use"
nextReset = "Next Reset"
overlayMessage = "Generate a key to see credits and available credits"
publicKeyAriaLabel = "Public API key"
purchasedCredits = "Purchased credits"
refreshAriaLabel = "Refresh API key"
schemaLink = "API Schema Reference"
usage = "Include this key in the X-API-KEY header with all API requests."
+4 -104
View File
@@ -14,12 +14,6 @@ import type {
User as SupabaseUser,
AuthError,
} from "@supabase/supabase-js";
import {
CreditSummary,
SubscriptionInfo,
CreditCheckResult,
} from "@app/types/credits";
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
import { synchronizeUserUpgrade } from "@app/services/userService";
import {
syncOAuthAvatar,
@@ -70,17 +64,11 @@ interface AuthContextType {
isAnonymous: boolean;
loading: boolean;
error: AuthError | null;
creditBalance: number | null;
subscription: SubscriptionInfo | null;
creditSummary: CreditSummary | null;
isPro: boolean | null;
profilePictureUrl: string | null;
profilePictureMetadata: ProfilePictureMetadata | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult;
updateCredits: (newBalance: number) => void;
refreshCredits: () => Promise<void>;
refreshProStatus: () => Promise<void>;
refreshProfilePicture: () => Promise<void>;
refreshProfilePictureMetadata: () => Promise<void>;
@@ -93,21 +81,11 @@ const AuthContext = createContext<AuthContextType>({
isAnonymous: false,
loading: true,
error: null,
creditBalance: null,
subscription: null,
creditSummary: null,
isPro: null,
profilePictureUrl: null,
profilePictureMetadata: null,
signOut: async () => {},
refreshSession: async () => {},
hasSufficientCredits: () => ({
hasSufficientCredits: false,
currentBalance: 0,
requiredCredits: 0,
}),
updateCredits: () => {},
refreshCredits: async () => {},
refreshProStatus: async () => {},
refreshProfilePicture: async () => {},
refreshProfilePictureMetadata: async () => {},
@@ -117,13 +95,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(null);
const [creditBalance, setCreditBalance] = useState<number | null>(null);
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null,
);
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(
null,
);
const [isPro, setIsPro] = useState<boolean | null>(null);
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
null,
@@ -131,19 +102,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [profilePictureMetadata, setProfilePictureMetadata] =
useState<ProfilePictureMetadata | null>(null);
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
// consumers of useAuth() that still destructure creditBalance / refreshCredits
// compile cleanly; values just stay null forever and refreshCredits is a noop.
// _ underscore on the param keeps the public signature stable for callers.
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
/* legacy credit fetch removed — see comment above */
}, []);
const refreshCredits = useCallback(async () => {
/* legacy credit refresh removed — useWallet() replaces this */
}, []);
const fetchProStatus = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
@@ -289,46 +247,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await fetchProfilePictureMetadata();
}, [fetchProfilePictureMetadata]);
const updateCredits = useCallback(
(newBalance: number) => {
console.debug("[Auth Debug] Updating credit balance:", {
from: creditBalance,
to: newBalance,
});
setCreditBalance(newBalance);
// Also update the creditSummary if it exists
if (creditSummary) {
const updatedSummary: CreditSummary = {
...creditSummary,
creditsRemaining: newBalance,
currentCredits: newBalance,
};
setCreditSummary(updatedSummary);
}
},
[creditSummary],
);
const hasSufficientCredits = useCallback(
(requiredCredits: number): CreditCheckResult => {
const currentBalance = creditBalance ?? 0;
const hasSufficient = currentBalance >= requiredCredits;
console.debug("[Auth Debug] Credit check:", {
requiredCredits,
currentBalance,
hasSufficient,
});
return {
hasSufficientCredits: hasSufficient,
currentBalance,
requiredCredits,
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance,
};
},
[creditBalance],
);
const refreshSession = async () => {
try {
setLoading(true);
@@ -372,11 +290,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
// Set up global credit update callback
useEffect(() => {
setGlobalCreditUpdateCallback(updateCredits);
}, [updateCredits]);
useEffect(() => {
let mounted = true;
@@ -399,7 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
setSession(data.session);
// Fetch credits, pro status, profile picture metadata, and profile picture using the session from the response
// Fetch pro status, profile picture metadata, and profile picture using the session from the response
if (data.session?.user) {
// Sync OAuth avatar in background; fetch the picture once the
// sync settles instead of guessing with a fixed delay.
@@ -413,7 +326,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
.then(() => fetchProfilePicture(data.session));
await fetchCredits(data.session);
await fetchProStatus(data.session);
await fetchProfilePictureMetadata(data.session);
}
@@ -458,10 +370,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Additional handling for specific events
if (event === "SIGNED_OUT") {
console.debug("[Auth Debug] User signed out, clearing session");
// Clear credit data, pro status, profile picture, and metadata on sign out
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
// Clear pro status, profile picture, and metadata on sign out
setIsPro(null);
setProfilePictureUrl(null);
setProfilePictureMetadata(null);
@@ -490,7 +399,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Fetch user data in parallel
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchProfilePictureMetadata(newSession),
]).then(() => {
@@ -506,10 +414,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
} else if (event === "TOKEN_REFRESHED") {
console.debug("[Auth Debug] Token refreshed");
// Optionally refresh credits, pro status, profile picture metadata, and profile picture on token refresh
// Optionally refresh pro status, profile picture metadata, and profile picture on token refresh
if (newSession?.user) {
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession),
@@ -547,10 +454,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
"[Auth Debug] User upgrade synchronized successfully",
);
// Refresh credits, pro status, profile picture metadata, and profile picture after upgrade
// Refresh pro status, profile picture metadata, and profile picture after upgrade
if (newSession?.user) {
return Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession),
@@ -589,17 +495,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAnonymous: Boolean(user?.is_anonymous),
loading,
error,
creditBalance,
subscription,
creditSummary,
isPro,
profilePictureUrl,
profilePictureMetadata,
signOut,
refreshSession,
hasSufficientCredits,
updateCredits,
refreshCredits,
refreshProStatus,
refreshProfilePicture,
refreshProfilePictureMetadata,
+4 -5
View File
@@ -17,19 +17,18 @@ import type { TeamAuth } from "@cloud/auth/teamSession";
export type { TeamAuth } from "@cloud/auth/teamSession";
export function useTeamAuth(): TeamAuth {
const { user, refreshCredits, refreshSession } = useAuth();
const { user, refreshSession } = useAuth();
// Teams require a signed-in, non-anonymous user — anonymous (guest) sessions
// never load or manage teams.
const canUseTeams = !!user && !isUserAnonymous(user);
// After a membership change the user's billing tier may have changed, so
// refresh credits + the Supabase session (mirrors the pre-move accept/leave
// flow in saas SaaSTeamContext).
// refresh the Supabase session (mirrors the pre-move accept/leave flow in
// saas SaaSTeamContext).
const refreshAfterMembershipChange = useCallback(async () => {
await refreshCredits();
await refreshSession();
}, [refreshCredits, refreshSession]);
}, [refreshSession]);
return { canUseTeams, refreshAfterMembershipChange };
}
@@ -24,7 +24,7 @@ interface AppConfigModalProps {
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const isMobile = useMediaQuery("(max-width: 1024px)");
const { signOut, user, creditBalance, refreshCredits } = useAuth();
const { signOut, user } = useAuth();
const { t } = useTranslation();
const [confirmOpen, setConfirmOpen] = useState(false);
const [active, setActive] = useState<NavKey>("overview");
@@ -95,35 +95,6 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
window.removeEventListener("appConfig:overlay", handler as EventListener);
}, []);
// When the modal opens to Plan, proactively refresh credits and log values
useEffect(() => {
if (!opened) return;
if (active !== "plan") return;
console.log(
"[AppConfigModal] Opening Plan section. Current creditBalance:",
creditBalance,
);
(async () => {
try {
await refreshCredits();
} catch (e) {
console.warn(
"[AppConfigModal] Failed to refresh credits on Plan open:",
e,
);
}
})();
}, [opened, active]);
useEffect(() => {
if (!opened) return;
if (active !== "plan") return;
console.log(
"[AppConfigModal] Credit balance updated while viewing Plan:",
creditBalance,
);
}, [opened, active, creditBalance]);
const colors = useMemo(
() => ({
navBg: "var(--modal-nav-bg)",
@@ -1,9 +1,7 @@
import React, { useState } from "react";
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { useTranslation } from "react-i18next";
@@ -17,7 +15,6 @@ export default function ApiKeys() {
const { user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const { data: credits, isLoading: creditsLoading } = useCredits();
const {
apiKey,
isLoading: apiKeyLoading,
@@ -25,7 +22,6 @@ export default function ApiKeys() {
isRefreshing,
error: apiKeyError,
refetch,
hasAttempted,
} = useApiKey();
const copy = async (text: string, tag: string) => {
@@ -53,22 +49,8 @@ export default function ApiKeys() {
);
};
const showUsage = Boolean(credits);
return (
<Stack gap={20} p={0}>
{showUsage && (
<UsageSection
apiUsage={credits!}
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
overlayMessage={t(
"config.apiKeys.overlayMessage",
"Generate a key to see credits and available credits",
)}
loading={creditsLoading}
/>
)}
{!isAnonymous && apiKeyError && (
<Text size="sm" c="red.5">
{t(
@@ -1,159 +0,0 @@
import React from "react";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
import { FractionData } from "@app/types/charts";
import { ApiCredits as ApiUsage } from "@app/types/credits";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { formatUTC } from "@app/components/shared/utils/date";
import { useTranslation } from "react-i18next";
// Using shared ApiCredits type as ApiUsage
interface UsageSectionProps {
apiUsage: ApiUsage;
obscured?: boolean;
overlayMessage?: string;
loading?: boolean;
}
export default function UsageSection({
apiUsage,
obscured,
overlayMessage,
loading,
}: UsageSectionProps) {
const { t } = useTranslation();
const weeklyUsed =
apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
const boughtUsed =
apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
// Totals for overall usage visualization
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
const formatDate = (iso: string, withTime: boolean) =>
formatUTC(iso, withTime);
// Prepare data for the stacked bar chart
const fractions: FractionData[] = [
{
name: t("config.apiKeys.includedCredits", "Included credits"),
numerator: Math.max(0, weeklyUsed),
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
numeratorLabel: t("common.used", "used"),
denominatorLabel: t("common.available", "available"),
color: "var(--usage-weekly-active)",
},
{
name: t("config.apiKeys.purchasedCredits", "Purchased credits"),
numerator: Math.max(0, boughtUsed),
denominator: Math.max(0, apiUsage.totalBoughtCredits),
numeratorLabel: t("common.used", "used"),
denominatorLabel: t("common.available", "available"),
color: "var(--usage-bought-active)",
},
];
return (
<div style={{ position: "relative" }}>
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Stack
gap={12}
style={{
fontFamily:
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
}}
>
<Group justify="space-between">
<Text fw={500}>
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
{loading ? (
<SkeletonLoader type="block" width={40} height={14} />
) : (
totalRemaining
)}
</Text>
</Group>
<StackedBarChart
fractions={fractions}
width={640}
height={22}
showLegend={true}
tooltipPosition="top"
loading={Boolean(loading)}
animate={!loading}
animationDurationMs={900}
ariaLabel={t("config.apiKeys.chartAriaLabel", {
includedUsed: Math.max(0, weeklyUsed),
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
purchasedUsed: Math.max(0, boughtUsed),
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
defaultValue:
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
})}
/>
<Divider my={4} />
<Group justify="space-between" wrap="wrap">
<Group gap="lg">
<Text size="sm" c="dimmed">
{t("config.apiKeys.nextReset", "Next Reset")}:{" "}
{loading ? (
<SkeletonLoader type="block" width={120} height={12} />
) : (
formatDate(apiUsage.weeklyResetDate, false)
)}
</Text>
<Text size="sm" c="dimmed">
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
{loading ? (
<SkeletonLoader type="block" width={160} height={12} />
) : (
formatDate(apiUsage.lastApiUsage, true)
)}
</Text>
</Group>
</Group>
</Stack>
</Paper>
{obscured && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: 16,
color: "var(--mantine-color-text)",
background: "rgba(16, 18, 27, 0.55)",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<Text size="sm" c="dimmed">
{overlayMessage ||
t(
"config.apiKeys.overlayMessage",
"Generate a key to see credits and available credits",
)}
</Text>
</div>
)}
</div>
);
}
@@ -1,40 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@app/auth/UseSession";
import { ApiCredits } from "@app/types/credits";
import { isUserAnonymous } from "@app/auth/supabase";
export function useCredits() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
// hasAttempted toggles, so the rest have no setters.
const [data] = useState<ApiCredits | null>(null);
const [isLoading] = useState<boolean>(false);
const [error] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
// the consumer's usage widget will render its "no data" state.
const fetchCredits = useCallback(async () => {
setHasAttempted(true);
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
setHasAttempted(true);
}
}, [loading, session, hasAttempted, isAnonymous]);
return {
data,
isLoading,
error,
refetch: fetchCredits,
hasAttempted,
} as const;
}
export default useCredits;
@@ -1,46 +0,0 @@
import { useAuth } from "@app/auth/UseSession";
/**
* Hook for credit management and checking in tools.
* Provides easy access to credit balance, subscription info, and validation functions.
*/
export const useCredits = () => {
const {
creditBalance,
subscription,
creditSummary,
isPro,
hasSufficientCredits,
updateCredits,
refreshCredits,
} = useAuth();
/**
* Get user-friendly credit status message
*/
const getCreditStatusMessage = (): string => {
if (creditBalance === 0) {
return "No credits remaining";
}
if (creditBalance === null) {
return "Credits loading...";
}
return `${creditBalance} credits available`;
};
return {
// State
creditBalance,
subscription,
creditSummary,
isPro,
// Actions
refreshCredits,
updateCredits,
// Utilities
getCreditStatusMessage,
hasSufficientCredits,
};
};
+2 -53
View File
@@ -1,24 +1,12 @@
import axios from "axios";
import { supabase } from "@app/auth/supabase";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { alert } from "@app/components/toast";
import { openPlanSettings } from "@app/utils/appSettings";
import {
classifyPaygError,
handlePaygError,
} from "@app/services/paygErrorInterceptor";
import { withBasePath } from "@app/constants/app";
// Global credit update callback - will be set by the AuthProvider
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
// Function to set the global credit update callback
export const setGlobalCreditUpdateCallback = (
callback: (credits: number) => void,
) => {
globalCreditUpdateCallback = callback;
};
// Helper: decode base64url JWT payload safely
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
@@ -46,20 +34,6 @@ const apiClient = axios.create({
responseType: "json",
});
const LOW_CREDIT_THRESHOLD = 10;
function notifyLowCredits(credits: number) {
const title = "Credit balance low";
const body = `You have ${credits} credits remaining.`;
alert({
alertType: "warning",
title,
body,
buttonText: "Top up",
buttonCallback: () => openPlanSettings(),
isPersistentPopup: true,
location: "bottom-right",
});
}
// Request interceptor to add JWT token to all requests
apiClient.interceptors.request.use(
async (config) => {
@@ -136,34 +110,9 @@ function refreshSessionOnce(): ReturnType<typeof supabase.auth.refreshSession> {
return inFlightRefresh;
}
// Response interceptor for handling token refresh and credit updates
// Response interceptor for handling token refresh
apiClient.interceptors.response.use(
(response) => {
// Check for X-Credits-Remaining header and update credits automatically
const creditsRemaining = response.headers["x-credits-remaining"];
if (creditsRemaining && globalCreditUpdateCallback) {
const credits = parseInt(creditsRemaining, 10);
if (!isNaN(credits) && credits >= 0) {
console.debug(
"[API Client] Updating credits from response header:",
credits,
"for URL:",
response.config?.url,
);
globalCreditUpdateCallback(credits);
// Show low-credit toast with top-up button when below threshold
if (credits < LOW_CREDIT_THRESHOLD) {
notifyLowCredits(credits);
}
} else {
console.warn(
"[API Client] Invalid credits value in response header:",
creditsRemaining,
);
}
}
return response;
},
(response) => response,
async (error) => {
const originalRequest = error.config || {};
const status = error.response?.status;
-35
View File
@@ -1,35 +0,0 @@
export interface ApiCredits {
weeklyCreditsRemaining: number;
weeklyCreditsAllocated: number;
boughtCreditsRemaining: number;
totalBoughtCredits: number;
totalAvailableCredits: number;
weeklyResetDate: string;
lastApiUsage: string;
}
export interface CreditSummary {
currentCredits: number;
maxCredits: number;
creditsUsed: number;
creditsRemaining: number;
resetDate: string; // ISO date string
weeklyAllowance: number;
}
export interface SubscriptionInfo {
id?: string;
status: "active" | "inactive" | "cancelled" | "expired";
tier: "free" | "basic" | "premium" | "enterprise";
startDate?: string; // ISO date string
endDate?: string; // ISO date string
creditsPerWeek?: number;
maxCredits?: number;
}
export interface CreditCheckResult {
hasSufficientCredits: boolean;
currentBalance: number;
requiredCredits: number;
shortfall?: number;
}