Skip to content
Open
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
9 changes: 9 additions & 0 deletions apps/web/features/admin/api/billing-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import type {
BillingPlanResponse,
BillingPriceResponse,
CreateRedemptionCodeRequest,
ExpiringCreditGrantDataResponse,
ExpiringCreditResponse,
GrantExpiringCreditRequest,
ModelPricingDataResponse,
ModelPricingResponse,
NativeToolPricingResponse,
Expand Down Expand Up @@ -85,6 +88,12 @@ export type AdminBillingAccountData = Omit<BillingAccountDataResponse, "account"

export type UpdateAdminBillingAccountBalanceRequest = UpdateBillingAccountBalanceRequest;

export type GrantAdminExpiringCreditRequest = GrantExpiringCreditRequest;

export type AdminExpiringCreditDTO = ExpiringCreditResponse;

export type AdminExpiringCreditGrantData = ExpiringCreditGrantDataResponse;

export type AdminRedemptionCodeDTO = Omit<
RedemptionCodeResponse,
"code"
Expand Down
14 changes: 14 additions & 0 deletions apps/web/features/admin/api/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AdminBillingAccountData,
AdminBillingPlanDTO,
AdminBillingPlanData,
AdminExpiringCreditGrantData,
AdminRedemptionCodeDTO,
AdminRedemptionCodeBatchDeleteData,
AdminRedemptionCodeBatchDeleteRequest,
Expand All @@ -17,6 +18,7 @@ import type {
AdminModelPricingPage,
AdminOfficialPricingCatalogData,
CreateAdminRedemptionCodeRequest,
GrantAdminExpiringCreditRequest,
UpdateAdminRedemptionCodeRequest,
UpdateAdminBillingConfigRequest,
UpdateAdminBillingPlanRequest,
Expand Down Expand Up @@ -77,6 +79,18 @@ export async function updateAdminBillingAccountBalance(
);
}

export async function grantAdminExpiringCredit(
accessToken: string,
userID: number,
payload: GrantAdminExpiringCreditRequest,
): Promise<AdminExpiringCreditGrantData> {
return authedRequest<AdminExpiringCreditGrantData>(
`/api/v1/admin/billing/accounts/${userID}/expiring-credits`,
{ method: "POST", accessToken, body: payload },
true,
);
}

export async function listAdminRedemptionCodes(
accessToken: string,
options: ListAdminRedemptionCodeOptions = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ function RedemptionRecordsDialogBody({ code }: { code: AdminRedemptionCodeDTO })
? `${planName} · ${tLogs("redemptions.rewards.durationDays", { count: item.durationDays })}`
: planName;
}
return `${tLogs("redemptions.rewards.balance")} +${formatCreditUSD(item.creditUSD)}`;
const duration = item.balanceDurationSeconds > 0
? ` · ${tLogs("redemptions.rewards.durationDays", { count: item.balanceDurationSeconds / 86400 })}`
: "";
return `${tLogs("redemptions.rewards.balance")} +${formatCreditUSD(item.creditUSD)}${duration}`;
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function createRedemptionFormState(mode: AdminBillingMode, planID = ""): Redempt
creditUSD: "20",
planID,
durationDays: "30",
balanceDurationDays: "",
maxRedemptions: "1",
perUserLimit: "1",
expiresAt: "",
Expand All @@ -72,6 +73,7 @@ function redemptionFormFromCode(item: AdminRedemptionCodeDTO): RedemptionFormSta
creditUSD: String(item.creditUSD || 0),
planID: item.planID ? String(item.planID) : "",
durationDays: String(item.durationDays || 0),
balanceDurationDays: item.balanceDurationSeconds > 0 ? String(item.balanceDurationSeconds / 86400) : "",
maxRedemptions: item.maxRedemptions == null ? "" : String(item.maxRedemptions),
perUserLimit: String(item.perUserLimit || 1),
expiresAt: redemptionExpiresFormValue(item.expiresAt),
Expand Down Expand Up @@ -250,6 +252,9 @@ export function BillingRedemptionSection({ plans, billingMode, loading }: Billin
const planLabel = planNameByID.get(item.planID) || t("redemption.unknownPlan");
return t("redemption.periodReward", { plan: planLabel, days: item.durationDays || 0 });
}
if (item.balanceDurationSeconds > 0) {
return t("redemption.usageRewardExpiring", { amount: formatCreditUSD(item.creditUSD), days: item.balanceDurationSeconds / 86400 });
}
return t("redemption.usageReward", { amount: formatCreditUSD(item.creditUSD) });
}

Expand Down Expand Up @@ -597,8 +602,7 @@ export function BillingRedemptionSection({ plans, billingMode, loading }: Billin
<form onSubmit={(event) => void saveRedemptionCode(event)} className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-2">
{!redemptionDialogForm.id ? (
<div className="grid grid-cols-2 gap-5">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-5"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">{t("redemption.code")}</p>
<Input
id="redemption-code"
Expand Down Expand Up @@ -710,6 +714,24 @@ export function BillingRedemptionSection({ plans, billingMode, loading }: Billin
</div>
)}

{redemptionDialogForm.mode === "usage" ? (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t("redemption.creditDurationDays")}</p>
<Input
id="redemption-credit-duration"
type="number"
min={0.1}
max={3650}
step="any"
value={redemptionDialogForm.balanceDurationDays}
placeholder={t("redemption.permanent")}
disabled={redemptionSaving || Boolean(redemptionDialogForm.id)}
onChange={(event) => setRedemptionForm((current) => current ? { ...current, balanceDurationDays: event.target.value } : current)}
/>
<p className="text-xs text-muted-foreground">{t("redemption.creditDurationHint")}</p>
</div>
) : null}

<div className="grid grid-cols-2 gap-5">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t("redemption.maxRedemptions")}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export function LogDetailSheet({
<DetailBlock title={t("blocks.payment")}>
<DetailRow label={t("fields.amount")} value={`${formatMoneyCents(detail.item.payAmountCents, detail.item.payCurrency)} / ${formatMoneyCents(detail.item.baseAmountCents, detail.item.baseCurrency)}`} mono />
<DetailRow label={t("fields.credit")} value={formatTooltipUsageCost(detail.item.creditUSD, billingDisplay)} mono />

<DetailRow label={t("fields.interval")} value={`${detail.item.billingInterval || "-"} x ${detail.item.cycles || 0}`} />
<DetailRow label={t("fields.externalPaymentID")} value={detail.item.externalPaymentID || "-"} mono />
<DetailRow label={t("fields.externalCheckoutID")} value={detail.item.externalCheckoutID || "-"} mono />
Expand Down Expand Up @@ -386,6 +387,9 @@ export function LogDetailSheet({
) : (
<>
<DetailRow label={t("fields.credit")} value={formatTooltipUsageCost(detail.item.creditUSD, billingDisplay)} mono />
{detail.item.balanceDurationSeconds > 0 ? (
<DetailRow label={t("fields.durationDays")} value={t("rewardDurationDays", { count: detail.item.balanceDurationSeconds / 86400 })} />
) : null}
<DetailRow
label={t("fields.balanceBefore")}
value={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ export function RedemptionRecordTable({
? `${planName} · ${t("redemptions.rewards.durationDays", { count: item.durationDays })}`
: planName;
}
return `${t("redemptions.rewards.balance")} +${formatTooltipUsageCost(item.creditUSD, billingDisplay)}`;
const duration = item.balanceDurationSeconds > 0
? ` · ${t("redemptions.rewards.durationDays", { count: item.balanceDurationSeconds / 86400 })}`
: "";
return `${t("redemptions.rewards.balance")} +${formatTooltipUsageCost(item.creditUSD, billingDisplay)}${duration}`;
},
[billingDisplay, t],
);
Expand Down
27 changes: 27 additions & 0 deletions apps/web/features/admin/components/sections/users/users-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,33 @@ export function EditUserSheet({
</div>
<ReadOnlyField label={t("editor.billingStatus")} value={resolveBillingAccountStatusLabel(editDialogTarget?.billingAccountStatus || "active")} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs font-normal text-muted-foreground">{t("editor.expiringCreditAmount")}</Label>
<Input
type="number"
min="0"
step="0.000001"
value={editPayload.expiringCreditUSD}
placeholder="20"
onChange={(event) => setEditPayload((current) => ({ ...current, expiringCreditUSD: event.target.value }))}
disabled={pending}
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-normal text-muted-foreground">{t("editor.expiringCreditDays")}</Label>
<Input
type="number"
min="0.1"
max="3650"
step="0.1"
value={editPayload.expiringCreditDurationDays}
onChange={(event) => setEditPayload((current) => ({ ...current, expiringCreditDurationDays: event.target.value }))}
disabled={pending}
/>
</div>
<p className="text-xs text-muted-foreground md:col-span-2">{t("editor.expiringCreditHelp")}</p>
</div>
</SheetSection>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type RedemptionFormState = {
creditUSD: string;
planID: string;
durationDays: string;
balanceDurationDays: string;
maxRedemptions: string;
perUserLimit: string;
expiresAt: string;
Expand Down Expand Up @@ -245,12 +246,17 @@ export function useAdminBillingRedemptionCodes({ billingMode }: UseAdminBillingR
const data = redemptionForm.mode === "usage"
? await (async () => {
const creditUSD = Number(redemptionForm.creditUSD);
const balanceDurationDays = Number(redemptionForm.balanceDurationDays);
if (!Number.isFinite(balanceDurationDays) || (balanceDurationDays !== 0 && (balanceDurationDays < 0.1 || balanceDurationDays > 3650))) {
throw new Error(t("toast.redemptionInvalidDuration"));
}
if (!Number.isFinite(creditUSD) || creditUSD <= 0) {
throw new Error(t("toast.redemptionInvalidCredit"));
}
return createAdminRedemptionCodes(token, {
...payload,
creditUSD,
balanceDurationDays: balanceDurationDays || undefined,
});
})()
: await (async () => {
Expand Down
46 changes: 45 additions & 1 deletion apps/web/features/admin/hooks/use-admin-users-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createAdminUser,
deleteAdminUser,
getAdminBillingConfig,
grantAdminExpiringCredit,
listAdminBillingPlans,
patchAdminUser,
resetAdminUserPassword,
Expand Down Expand Up @@ -173,6 +174,8 @@ function createEditPayload(user: AdminUserDTO, fallbackSubscriptionTier = "free"
subscriptionTier,
subscriptionExpiresAt: resolveSubscriptionExpiryInputValue(user.subscriptionExpiresAt),
billingBalanceUSD: String(user.billingBalanceUSD ?? 0),
expiringCreditUSD: "",
expiringCreditDurationDays: "2",
profilePreferences: user.profilePreferences,
reason: "",
};
Expand Down Expand Up @@ -270,9 +273,12 @@ export function useAdminUsersPage({
subscriptionTier: "free",
subscriptionExpiresAt: "",
billingBalanceUSD: "0",
expiringCreditUSD: "",
expiringCreditDurationDays: "2",
profilePreferences: "",
reason: "",
});
const expiringGrantRef = React.useRef<{ fingerprint: string; key: string } | null>(null);
const [resetPasswordDraft, setResetPasswordDraft] = React.useState("");
const [billingMode, setBillingMode] = React.useState<AdminBillingMode>("self");
const [billingDisplay, setBillingDisplay] = React.useState<BillingDisplayOptions>({ currency: "USD" });
Expand Down Expand Up @@ -371,6 +377,7 @@ export function useAdminUsersPage({
return;
}
const fallbackSubscriptionTier = billingPlans[0]?.code || "free";
expiringGrantRef.current = null;
setEditDialogTarget(user);
setEditPayload(createEditPayload(user, fallbackSubscriptionTier));
}, [billingPlans, canManageUser]);
Expand Down Expand Up @@ -562,6 +569,10 @@ export function useAdminUsersPage({
const nextLocale = editPayload.locale.trim() || "en-US";
const nextSubscriptionTier = editPayload.subscriptionTier.trim();
const nextBillingBalance = Number(editPayload.billingBalanceUSD);
const expiringCreditRequested = editPayload.expiringCreditUSD.trim() !== "";
const expiringCreditUSD = Number(editPayload.expiringCreditUSD);
const roundedExpiringCreditUSD = roundBillingBalance(expiringCreditUSD);
const expiringCreditDurationDays = Number(editPayload.expiringCreditDurationDays);
const nextProfilePreferences = editPayload.profilePreferences.trim();
const patchPayload: AdminUserPatchPayload = {};

Expand Down Expand Up @@ -641,7 +652,19 @@ export function useAdminUsersPage({
return;
}

if (!hasPatchChanges(patchPayload) && !billingBalanceChanged) {
if (
expiringCreditRequested &&
(!Number.isFinite(expiringCreditUSD) ||
roundedExpiringCreditUSD <= 0 ||
!Number.isFinite(expiringCreditDurationDays) ||
expiringCreditDurationDays < 0.1 ||
expiringCreditDurationDays > 3650)
) {
toast.error(t("toast.editFailed"), { description: t("validation.invalidExpiringCredit") });
return;
}

if (!hasPatchChanges(patchPayload) && !billingBalanceChanged && !expiringCreditRequested) {
setEditDialogTarget(null);
return;
}
Expand Down Expand Up @@ -671,8 +694,29 @@ export function useAdminUsersPage({
billingAccountStatus: response.account.status,
};
}
if (expiringCreditRequested) {
const fingerprint = JSON.stringify([editDialogTarget.id, roundedExpiringCreditUSD, expiringCreditDurationDays]);
if (expiringGrantRef.current?.fingerprint !== fingerprint) {
expiringGrantRef.current = { fingerprint, key: crypto.randomUUID() };
}
// Preserve the confirmed balance after a partial save, so a retry cannot
// reset the account and revoke a grant whose response was lost.
setEditDialogTarget(nextUser);
const response = await grantAdminExpiringCredit(token, editDialogTarget.id, {
idempotencyKey: expiringGrantRef.current.key,
creditUSD: roundedExpiringCreditUSD,
durationDays: expiringCreditDurationDays,
description: t("toast.expiringCreditDescription", { days: expiringCreditDurationDays }),
});
nextUser = {
...nextUser,
billingBalanceUSD: response.account.balanceUSD,
billingAccountStatus: response.account.status,
};
}
onSetUsers((current) => replaceByID(current, editDialogTarget.id, (user) => user.id, nextUser));
toast.success(t("toast.userUpdated"));
expiringGrantRef.current = null;
setEditDialogTarget(null);
} catch (error) {
toast.error(t("toast.editFailed"), { description: resolveAdminErrorMessage(error) });
Expand Down
2 changes: 2 additions & 0 deletions apps/web/features/admin/types/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export type EditUserPayload = {
subscriptionTier: UserTier;
subscriptionExpiresAt: string;
billingBalanceUSD: string;
expiringCreditUSD: string;
expiringCreditDurationDays: string;
profilePreferences: string;
reason: string;
};
Expand Down
5 changes: 5 additions & 0 deletions apps/web/i18n/messages/en-US/admin-billing.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@
"active": "Active",
"inactive": "Inactive",
"creditUSD": "Redemption amount",
"creditDurationDays": "Credit validity (days)",
"creditDurationHint": "Starts when the user redeems the code. Decimals are supported (for example, 0.1 day); leave blank for permanent credit. Only the unused remainder is reclaimed at expiration.",
"plan": "Plan",
"planPlaceholder": "Select plan",
"durationDays": "Duration days",
Expand All @@ -137,8 +139,10 @@
"copyAll": "Copy all",
"unlimited": "Unlimited",
"never": "Never",
"permanent": "Permanent",
"unknownPlan": "Unknown plan",
"usageReward": "{amount} credit",
"usageRewardExpiring": "{amount} credit · {days} days",
"periodReward": "{plan} · {days} days",
"empty": "No redemption codes",
"columns": {
Expand Down Expand Up @@ -349,6 +353,7 @@
"redemptionInvalidCodeFormat": "Code must be 3-64 letters, numbers, hyphens, or underscores.",
"redemptionInvalidQuantity": "Quantity must be a positive integer.",
"redemptionInvalidCredit": "Credit amount must be greater than 0.",
"redemptionInvalidCreditDuration": "Credit validity must be blank or between 0.1 and 3650 days; decimals are supported.",
"redemptionInvalidPlan": "Choose an active non-Free plan.",
"redemptionInvalidDuration": "Duration days must be greater than 0.",
"redemptionInvalidMaxRedemptions": "Total uses must be a positive integer, or empty for unlimited.",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/i18n/messages/en-US/admin-users.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@
"reason": "Reason",
"reasonPlaceholder": "Record the reason for this status change (optional)",
"accountBalance": "Account balance (USD)",
"expiringCreditAmount": "New expiring credit (USD)",
"expiringCreditDays": "Valid for (days)",
"expiringCreditHelp": "Credited immediately on save. Decimal durations are supported (for example, 0.1 day). Usage consumes the earliest-expiring credit first, and any unused amount is reclaimed at expiration. Leave the amount blank to skip.",
"billingStatus": "Billing status",
"twoFactor": "Two-factor authentication",
"twoFactorEnabled": "Enabled (recovery codes {count})",
Expand All @@ -260,6 +263,7 @@
"invalidSubscriptionExpiry": "Enter a valid subscription expiration time.",
"selectSubscriptionPlan": "Select a subscription plan.",
"invalidUsageBalance": "Enter a valid usage balance.",
"invalidExpiringCredit": "Enter an expiring credit greater than 0 and a duration from 0.1 to 3650 days; decimals are supported.",
"passwordMinLength": "The new password must be at least 8 characters and not digits only.",
"usernameLength": "Username must be 3-16 characters.",
"displayNameLength": "Display name must be 3-16 characters."
Expand All @@ -276,6 +280,7 @@
"avatarUpdateFailed": "Failed to update avatar",
"editFailed": "Failed to edit user",
"balanceAdjustmentDescription": "Balance set by an administrator in user management",
"expiringCreditDescription": "Administrator granted a {days}-day expiring credit",
"userUpdated": "User updated",
"resetPasswordFailed": "Failed to reset password",
"passwordReset": "Password reset",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/i18n/messages/en-US/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
"invalidDailyUsageDays": "Invalid usage day count.",
"invalidModelPricing": "Invalid model pricing.",
"invalidAccountBalance": "Invalid billing account balance.",
"invalidExpiringCredit": "Enter a positive credit amount and a duration from 0.1 to 3650 days.",
"expiringCreditConflict": "This grant key was already used with different details. Restore the original request or start a new grant.",
"invalidNativeToolPricing": "Invalid native tool pricing.",
"subscribeFailed": "Subscription failed. Please try again later.",
"subscriptionEntitlementActive": "A paid subscription entitlement is still active, so Free cannot be selected.",
Expand Down
Loading
Loading