Skip to main content

Credits & Billing UI — Implementation Notes

Date: 2026-03-22 / 2026-03-23 Scope: Unified Credits tab, BillingConfigPanel fixes, company-to-company credit transfer


1. Unified Credits Tab (userprofile sidebar)

What changed

Previously, "Credits" appeared as a sub-item under the Jobseeker section and under each Company section of the profile sidebar. This made it hard to compare balances across profiles.

New behaviour: Credits lives under Plan / Billing only. The Credits page itself has an inline profile switcher so users can toggle between Personal and each company they belong to.

Files modified

FileChange
components/profile/Sidebar.tsxRemoved credits from jobSeekerSubItems and companySubItems; added it to billingSubItems
app/(shared)/userprofile/page.tsxRemoved || activeTab === "credits" from the branch that passes localCompanyId to the tab component
app/(shared)/userprofile/creditsPage.tsxRemoved localCompanyId prop; added internal profile switcher + global context switching

Profile switcher architecture

The credits page queries GET_MY_COMPANIES independently. A pill-button row lets the user pick Personal or any company. The selection is stored in local profileOverride state:

// null = follow user's current auth context (default)
// { id: null } = user explicitly chose Personal
// { id: "companyId" } = user explicitly chose a company
const [profileOverride, setProfileOverride] = useState<{ id: string | null } | null>(null)
const selectedProfileId = profileOverride !== null
? profileOverride.id
: (user?.selectedCompanyId ?? null)

Because GET_CREDIT_BALANCE, GET_CREDIT_TRANSACTIONS, GET_AI_OPERATION_USAGE_SUMMARY, and GET_PLAN_USAGE_SUMMARY all read from user.selectedCompanyId in the auth session (no explicit companyId variable), the page must update the global user context when the user switches profiles. This is done via UPDATE_PROFILE mutation + refetchUser() only when profileOverride is non-null and differs from the current session value.

The switchingRef guard prevents concurrent switches from stomping each other.


2. BillingConfigPanel — Number Input Snap-Back Fix

Root cause

app/company/credits/page.tsx had React controlled type="number" inputs. When a user pressed Backspace to clear a field, the browser emitted e.target.value = "". React read that as parseInt("") → NaN → -1, which immediately reset the displayed value — making it impossible to type a new number.

Fix

Replaced the single limitValues: Record<BillingConfigLimitField, number> state with:

  • rawValues: Record<BillingConfigLimitField, string> — what the input displays (allows empty / partial strings)
  • parsedValues: Record<BillingConfigLimitField, number> — derived via useMemo; used for validation and save
  • Input changed to type="text" with an onValueChange that only accepts "", "-", or /^-?\d+$/
  • onBlur commits the raw value back to its parsed form, so the field always shows a valid number after the user leaves
<TWInput
type="text"
value={rawValues[field]}
onValueChange={(val) => {
if (val === "" || val === "-" || /^-?\d+$/.test(val)) {
setRawValues((prev) => ({ ...prev, [field]: val }))
}
}}
onBlur={() => {
setRawValues((prev) => ({ ...prev, [field]: String(parsedValues[field]) }))
}}
/>

3. Company-to-Company Credit Transfer

What changed

CreditTransferInput previously only had { toCompanyId, amount }, which supported personal → company transfers only.

Added fromCompanyId?: string so a company balance can be the source.

Files modified

FileChange
lib/types/billingConfig.tsAdded fromCompanyId?: string to CreditTransferInput
graphql/operations/billingConfig/mutations.tsUpdated comment; mutation body unchanged (backend handles the new field)
app/company/credits/page.tsxTransferCreditsModal passes fromCompanyId: currentCompanyId; filters out current company from target list; shows "From: [company] / Available: N credits" info block
app/(shared)/userprofile/creditsPage.tsxSame modal updated — see §4 below

Transfer availability rules

  • Personal view (selectedProfileId === null): Transfer button shown when user has ≥1 company → personal → any company
  • Company view (selectedProfileId !== null): Transfer button shown when user has ≥1 other company → current company → another company

The fromCompanyId prop drives both the info banner label and the mutation payload.


4. creditsPage.tsx — TransferCreditsModal unified API

The TransferCreditsModal component in creditsPage.tsx now handles both transfer directions via a single API:

interface TransferCreditsModalProps {
isOpen: boolean
onClose: () => void
companies: { id: string; companyName: string }[]
balance: number // available balance of the source (personal or company)
fromCompanyId?: string | null // null/undefined = personal source; set = company source
onTransferComplete: () => void
}
  • When fromCompanyId is null/undefined → calls transferCredits({ toCompanyId, amount })
  • When fromCompanyId is set → calls transferCredits({ fromCompanyId, toCompanyId, amount })
  • Target dropdown automatically excludes fromCompanyId
  • Info block shows "From: [Personal / Company Name]" with available balance

5. BuyCreditsModal — "Purchasing for" context

Added a small info row at the top of the modal showing who the credits are being purchased for:

<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<span className="text-xs text-gray-500">Purchasing credits for:</span>
<span className="text-xs font-semibold text-gray-800">
{companyName ? companyName : "Personal account"}
</span>
</div>

BuyCreditsModal now accepts companyName?: string | null. The credits page derives it from the companies list using selectedProfileId.



6. Enterprise Plan — Pay-as-You-Go Display

Enterprise plans bill at end of month (no fixed credit cap). The hero section must not show "X credits remaining" for these accounts.

Detection

const isEnterprise = !!planUsage?.planName?.toLowerCase().includes("enterprise")

planUsage comes from GET_PLAN_USAGE_SUMMARY. On personal view the query is skipped, so isEnterprise is always false there (correct — enterprise is a company plan type).

Behaviour difference

Standard planEnterprise plan
Radial progress ringShown (% used)Hidden
Primary numberRemaining creditsCredits used this period
Subtitle"credits remaining""credits used this period"
Secondary line"X of Y used""Pay as you go · invoiced at month end" (amber)

Both app/(shared)/userprofile/creditsPage.tsx and app/company/credits/page.tsx apply this logic.


GraphQL / Type reference

// lib/types/billingConfig.ts
export interface CreditTransferInput {
toCompanyId: string
amount: number // min 0.01
fromCompanyId?: string // NEW — omit for personal→company; set for company→company
}
# graphql/operations/billingConfig/mutations.ts
mutation TransferCredits($input: CreditTransferInput!) {
transferCredits(input: $input) {
success
message
}
}

The backend resolves fromCompanyId to determine the debit source; no frontend mutation change required.