chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,57 @@
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { Icon } from "~/components/primitives/Icon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { cn } from "~/utils/cn";
type AnimatedOrgBannerBarProps = {
show: boolean;
variant: "warning" | "error";
children: React.ReactNode;
action?: React.ReactNode;
};
export function AnimatedOrgBannerBar({
show,
variant,
children,
action,
}: AnimatedOrgBannerBarProps) {
return (
<AnimatePresence initial={false}>
{show ? (
<motion.div
className={cn(
"flex h-10 items-center justify-between overflow-hidden py-0 pl-3 pr-2",
variant === "warning"
? "border-y border-amber-400/20 bg-warning/20"
: "border border-error bg-repeat"
)}
style={
variant === "error"
? { backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }
: undefined
}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "2.5rem" }}
exit={{ opacity: 0, height: 0 }}
>
<div className="flex items-center gap-2">
<Icon
icon={ExclamationCircleIcon}
className={cn("h-5 w-5", variant === "warning" ? "text-amber-400" : "text-error")}
/>
<Paragraph
variant="small"
className={variant === "warning" ? "text-amber-200" : "text-error"}
>
{children}
</Paragraph>
</div>
{action}
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -0,0 +1,389 @@
import { getFormProps, getInputProps, useForm, type SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useSearchParams } from "@remix-run/react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import {
emailsMatchSaved,
getAlertPreviewLimitCents,
getBillingLimitMode,
getEffectiveLimitCents,
hasLegacySpikeAlertLevels,
isPercentageAlertMode,
MAX_ABSOLUTE_ALERTS,
MAX_PERCENTAGE_ALERTS,
MAX_PERCENTAGE_THRESHOLD,
previewDollarAmountForPercent,
storedAlertsToThresholds,
thresholdsMatchSaved,
thresholdValuesAreUnique,
type BillingAlertsFormData,
} from "~/components/billing/billingAlertsFormat";
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { formatCurrency } from "~/utils/numberFormatter";
import { docsPath } from "~/utils/pathBuilder";
export const billingAlertsSchema = z.object({
emails: z.preprocess((i) => {
if (typeof i === "string") return [i];
if (Array.isArray(i)) {
const emails = i.filter((v) => typeof v === "string" && v !== "");
if (emails.length === 0) {
return [""];
}
return emails;
}
return [""];
}, z.string().email().array().nonempty("At least one email is required")),
alertLevels: z.preprocess(
(i) => {
const values = typeof i === "string" ? [i] : Array.isArray(i) ? i : [];
return values.filter((v) => v !== "").map((v) => Number(v));
},
z.number().array().refine(thresholdValuesAreUnique, "Each alert must be unique")
),
});
export type { BillingAlertsFormData } from "~/components/billing/billingAlertsFormat";
type BillingAlertsActionData = {
formIntent: "billing-alerts";
submission: SubmissionResult;
};
type BillingAlertsSectionProps = {
alerts: BillingAlertsFormData;
billingLimit: BillingLimitResult;
planLimitCents: number;
alertsResetRequested?: boolean;
};
type ThresholdRow = {
id: number;
value: string;
};
function isEmptyThresholdRow(value: string): boolean {
const parsed = Number(value);
return value === "" || !Number.isFinite(parsed) || parsed <= 0;
}
function toThresholdRows(values: number[]): ThresholdRow[] {
return values.map((value, index) => ({ id: index, value: String(value) }));
}
function parseThresholdValues(rows: ThresholdRow[]): number[] {
return rows
.map((row) => Number(row.value))
.filter((value) => Number.isFinite(value) && value > 0);
}
function isDuplicateThresholdRow(rows: ThresholdRow[], index: number): boolean {
const value = rows[index]?.value;
if (!value || isEmptyThresholdRow(value)) {
return false;
}
const parsed = Number(value);
return rows.some(
(row, rowIndex) =>
rowIndex !== index && !isEmptyThresholdRow(row.value) && Number(row.value) === parsed
);
}
export function BillingAlertsSection({
alerts,
billingLimit,
planLimitCents,
alertsResetRequested = false,
}: BillingAlertsSectionProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [showResetBanner, setShowResetBanner] = useState(alertsResetRequested);
useEffect(() => {
if (!alertsResetRequested) {
return;
}
setShowResetBanner(true);
if (searchParams.get("alertsReset") !== "1") {
return;
}
const next = new URLSearchParams(searchParams);
next.delete("alertsReset");
setSearchParams(next, { replace: true });
}, [alertsResetRequested, searchParams, setSearchParams]);
const billingLimitMode = getBillingLimitMode(billingLimit);
const isPercentageMode = isPercentageAlertMode(billingLimitMode);
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
const alertPreviewLimitCents = getAlertPreviewLimitCents(
alerts,
effectiveLimitCents,
planLimitCents
);
const maxAlerts = isPercentageMode ? MAX_PERCENTAGE_ALERTS : MAX_ABSOLUTE_ALERTS;
const savedThresholds = useMemo(
() => storedAlertsToThresholds(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents]
);
const savedEmails = useMemo(() => alerts.emails, [alerts.emails]);
const hasLegacySpikes = useMemo(
() => hasLegacySpikeAlertLevels(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents]
);
const nextThresholdIdRef = useRef(savedThresholds.length);
const [thresholdRows, setThresholdRows] = useState<ThresholdRow[]>(() =>
toThresholdRows(savedThresholds)
);
const [emailValues, setEmailValues] = useState<string[]>(
savedEmails.length > 0 ? [...savedEmails, ""] : [""]
);
const actionData = useActionData<BillingAlertsActionData>();
const alertsSubmission =
actionData?.formIntent === "billing-alerts" ? actionData.submission : undefined;
const currentThresholds = parseThresholdValues(thresholdRows);
const isDirty =
hasLegacySpikes ||
!thresholdsMatchSaved(currentThresholds, savedThresholds) ||
!emailsMatchSaved(emailValues, savedEmails);
const lastSubmission = isDirty ? alertsSubmission : undefined;
const [form, { emails, alertLevels }] = useForm<z.infer<typeof billingAlertsSchema>>({
id: "billing-alerts",
lastResult: lastSubmission as any,
shouldRevalidate: "onInput",
onValidate({ formData }) {
return parseWithZod(formData, { schema: billingAlertsSchema });
},
defaultValue: {
emails: emailValues,
alertLevels: savedThresholds.map(String),
},
});
const emailFields = emails.getFieldList();
useEffect(() => {
nextThresholdIdRef.current = savedThresholds.length;
setThresholdRows(toThresholdRows(savedThresholds));
setEmailValues(savedEmails.length > 0 ? [...savedEmails, ""] : [""]);
}, [savedThresholds, savedEmails]);
function updateThresholdRow(index: number, rawValue: string) {
setThresholdRows((current) =>
current.map((row, rowIndex) => (rowIndex === index ? { ...row, value: rawValue } : row))
);
}
function removeThreshold(index: number) {
setThresholdRows((current) => current.filter((_, rowIndex) => rowIndex !== index));
}
function addThreshold() {
if (thresholdRows.length >= maxAlerts) return;
if (thresholdRows.some((row) => isEmptyThresholdRow(row.value))) return;
nextThresholdIdRef.current += 1;
setThresholdRows((current) => [...current, { id: nextThresholdIdRef.current, value: "" }]);
}
const hasEmptyThreshold = thresholdRows.some((row) => isEmptyThresholdRow(row.value));
const hasDuplicateThresholds = !thresholdValuesAreUnique(currentThresholds);
const canAddThreshold = thresholdRows.length < maxAlerts && !hasEmptyThreshold;
const showAlertsSave = isDirty && !hasDuplicateThresholds;
return (
<div>
<div className="mb-3 border-b border-grid-dimmed pb-3">
<Header2 spacing>Billing alerts</Header2>
<Paragraph variant="small">
Receive an email when your compute spend crosses different thresholds. You can also learn
how to{" "}
<TextLink to={docsPath("how-to-reduce-your-spend")}>reduce your compute spend</TextLink>.
</Paragraph>
</div>
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="intent" value="billing-alerts" />
<Fieldset>
<AnimatedCallout
show={showResetBanner}
variant="warning"
className="mb-4"
autoHideMs={5_000}
onAutoHide={() => setShowResetBanner(false)}
>
Billing alerts were reset because they no longer match the selected billing limit
configuration.
</AnimatedCallout>
<div className="space-y-2">
<Label htmlFor={alertLevels.id}>
{isPercentageMode ? "Alert me when I reach" : "Monthly spend alerts"}
</Label>
<div className="flex flex-col gap-2">
{thresholdRows.map((row, index) => {
const parsed = Number(row.value);
const isDuplicate = isDuplicateThresholdRow(thresholdRows, index);
return (
<div key={row.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{isPercentageMode ? (
<>
<Input
name={`${alertLevels.name}[${index}]`}
id={`${alertLevels.id}-${index}`}
type="number"
value={row.value}
onChange={(e) => updateThresholdRow(index, e.target.value)}
onBlur={(e) => {
if (e.target.value === "") return;
const clamped = Math.min(
MAX_PERCENTAGE_THRESHOLD,
Math.max(1, Number(e.target.value))
);
if (String(clamped) !== e.target.value) {
updateThresholdRow(index, String(clamped));
}
}}
min={1}
max={MAX_PERCENTAGE_THRESHOLD}
step={1}
placeholder="75"
className="w-24"
fullWidth={false}
/>
<span className="text-sm text-text-dimmed">%</span>
<span className="text-sm text-text-dimmed">
(
{formatCurrency(
previewDollarAmountForPercent(
Number.isFinite(parsed) ? parsed : 0,
alertPreviewLimitCents
),
false
)}
)
</span>
</>
) : (
<Input
name={`${alertLevels.name}[${index}]`}
id={`${alertLevels.id}-${index}`}
type="number"
value={row.value}
onChange={(e) => updateThresholdRow(index, e.target.value)}
min={0.01}
step={0.01}
placeholder="100"
icon={
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
$
</span>
}
className="max-w-xs pl-px"
fullWidth={false}
/>
)}
<Button
type="button"
variant="tertiary/small"
LeadingIcon={TrashIcon}
className="shrink-0"
onClick={() => removeThreshold(index)}
aria-label="Remove alert"
/>
</div>
{isDuplicate && <FormError>This alert threshold is already in use</FormError>}
</div>
);
})}
</div>
<FormError id={alertLevels.errorId}>{alertLevels.errors}</FormError>
{canAddThreshold && (
<Button
type="button"
variant="tertiary/small"
LeadingIcon={PlusIcon}
className="mt-2 w-fit"
onClick={addThreshold}
>
Add alert
</Button>
)}
</div>
<InputGroup fullWidth className="mt-4">
<Label htmlFor={emails.id}>Email addresses</Label>
{emailFields.map((email, index) => {
const { defaultValue: _emailDefaultValue, ...emailInputProps } = getInputProps(
email,
{ type: "email" }
);
return (
<Fragment key={email.key}>
<Input
{...emailInputProps}
value={emailValues[index] ?? ""}
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
onChange={(e) => {
const nextValue = e.target.value;
setEmailValues((current) => {
const next = [...current];
next[index] = nextValue;
if (
emailFields.length === next.length &&
next.every((value) => value !== "")
) {
form.insert({ name: emails.name });
return [...next, ""];
}
return next;
});
}}
fullWidth
/>
<FormError id={email.errorId}>{email.errors}</FormError>
</Fragment>
);
})}
</InputGroup>
<FormButtons
className={showAlertsSave ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!showAlertsSave}>
Update alerts
</Button>
}
/>
</Fieldset>
</Form>
</div>
);
}
@@ -0,0 +1,333 @@
import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { formatCurrency } from "~/utils/numberFormatter";
export const billingLimitFormSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("none"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
.optional(),
}),
z.object({
mode: z.literal("plan"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
.optional(),
}),
z.object({
mode: z.literal("custom"),
amount: z.coerce
.number({ invalid_type_error: "Not a valid amount" })
.positive("Amount must be greater than 0"),
cancelInProgressRuns: z
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
.optional(),
}),
]);
type BillingLimitActionData = {
formIntent: "billing-limit";
submission: SubmissionResult;
};
export function isBillingLimitFormDirty(input: {
billingLimit: BillingLimitResult;
mode: "none" | "plan" | "custom";
customAmount: string;
cancelInProgressRuns: boolean;
}): boolean {
const needsInitialSave = !input.billingLimit.isConfigured;
const savedMode = getBillingLimitMode(input.billingLimit);
const savedCustomAmount =
input.billingLimit.isConfigured && input.billingLimit.mode === "custom"
? (input.billingLimit.amountCents / 100).toFixed(2)
: "";
const savedCancelInProgressRuns =
input.billingLimit.isConfigured && input.billingLimit.cancelInProgressRuns;
const isLimitDirty =
input.mode !== savedMode ||
(input.mode === "custom" && input.customAmount !== savedCustomAmount);
return (
needsInitialSave || isLimitDirty || input.cancelInProgressRuns !== savedCancelInProgressRuns
);
}
export function getBillingLimitFormLastSubmission(
submission: BillingLimitActionData["submission"] | undefined,
mode: "none" | "plan" | "custom",
isDirty: boolean
) {
if (!isDirty || !submission) {
return undefined;
}
if (mode !== "custom" && submission.error?.amount) {
const { amount: _amount, ...remainingErrors } = submission.error;
return {
...submission,
error: remainingErrors,
};
}
return submission;
}
type BillingLimitConfigSectionProps = {
billingLimit: BillingLimitResult;
planLimitCents: number;
};
export function BillingLimitConfigSection({
billingLimit,
planLimitCents,
}: BillingLimitConfigSectionProps) {
const gracePeriodLabel = formatGracePeriodMs(billingLimit.gracePeriodMs);
const savedMode = getBillingLimitMode(billingLimit);
const savedCustomAmount =
billingLimit.isConfigured && billingLimit.mode === "custom"
? (billingLimit.amountCents / 100).toFixed(2)
: "";
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
const customAmountInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
setMode(savedMode);
setCustomAmount(savedCustomAmount);
setCancelInProgressRuns(savedCancelInProgressRuns);
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
function handleModeChange(value: string) {
const nextMode = value as typeof mode;
if (mode === "custom" && nextMode !== "custom") {
setCustomAmount(savedCustomAmount);
}
setMode(nextMode);
if (nextMode === "custom") {
window.setTimeout(() => customAmountInputRef.current?.focus(), 0);
}
}
const actionData = useActionData<BillingLimitActionData>();
const limitSubmission =
actionData?.formIntent === "billing-limit" ? actionData.submission : undefined;
const _needsInitialSave = !billingLimit.isConfigured;
const _isLimitDirty =
mode !== savedMode || (mode === "custom" && customAmount !== savedCustomAmount);
const isDirty = isBillingLimitFormDirty({
billingLimit,
mode,
customAmount,
cancelInProgressRuns,
});
const lastSubmission = useMemo(
() => getBillingLimitFormLastSubmission(limitSubmission, mode, isDirty),
[limitSubmission, mode, isDirty]
);
const [form, fields] = useForm({
id: "billing-limit",
lastResult: lastSubmission as any,
shouldRevalidate: "onInput",
onValidate({ formData }) {
return parseWithZod(formData, { schema: billingLimitFormSchema });
},
defaultValue: {
mode: savedMode,
},
});
useEffect(() => {
formRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
}, [customAmount, mode]);
const planLimitLabel = formatCurrency(planLimitCents / 100, false);
const showPlanInfoCallout = mode === "plan";
const showCustomInfoCallout = mode === "custom";
const showNoneWarningCallout = mode === "none";
return (
<div>
<div className="mb-3 border-b border-grid-dimmed pb-3">
<Header2 spacing>Billing limit</Header2>
<Paragraph variant="small">
Set a monthly compute spend limit for your organization. When the limit is reached,
billable environments enter a grace period before new triggers are rejected.
</Paragraph>
</div>
<Form method="post" {...getFormProps(form)} ref={formRef}>
<input type="hidden" name="intent" value="billing-limit" />
<Fieldset>
<input type="hidden" name="mode" value={mode} />
<RadioGroup value={mode} onValueChange={handleModeChange} className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
<RadioGroupItem
id="limit_mode_plan"
value="plan"
variant="description"
label={
<span className="inline-flex items-center">{`My plan limit (${planLimitLabel})`}</span>
}
description={`Pause billable environments when monthly spend reaches ${planLimitLabel}.`}
/>
<div className="relative">
<AnimatedCallout
show={showPlanInfoCallout}
variant="info"
className="absolute w-full text-sm"
>
<LimitReachedCalloutContent
gracePeriodLabel={gracePeriodLabel}
cancelInProgressRuns={cancelInProgressRuns}
/>
</AnimatedCallout>
</div>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
<div className="flex flex-col gap-2">
<RadioGroupItem
id="limit_mode_custom"
value="custom"
variant="description"
label="Custom limit"
description="Set your own monthly spend threshold."
/>
{mode === "custom" && (
<InputGroup fullWidth className="mb-1 mt-0.5">
<Input
ref={customAmountInputRef}
id="custom-amount"
name="amount"
type="number"
step={0.01}
min={0.01}
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="Custom limit amount"
icon={
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
$
</span>
}
className="pl-px"
fullWidth
/>
{fields.amount && (
<FormError id={fields.amount.errorId}>{fields.amount.errors}</FormError>
)}
</InputGroup>
)}
</div>
<div className="relative">
<AnimatedCallout
show={showCustomInfoCallout}
variant="info"
className="absolute w-full text-sm"
>
<LimitReachedCalloutContent
gracePeriodLabel={gracePeriodLabel}
cancelInProgressRuns={cancelInProgressRuns}
/>
</AnimatedCallout>
</div>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
<RadioGroupItem
id="limit_mode_none"
value="none"
variant="description"
label="I don't want a billing limit"
description="Runs continue without protection against runaway usage."
/>
<div className="relative">
<AnimatedCallout
show={showNoneWarningCallout}
variant="warning"
className="absolute w-full"
>
Without a billing limit, runs will continue even if usage spikes unexpectedly. You
may have to pay higher fees before you notice.
</AnimatedCallout>
</div>
</div>
</RadioGroup>
{mode !== "none" && (
<CheckboxWithLabel
className="mt-4"
name="cancelInProgressRuns"
id="cancel_in_progress_runs"
value="on"
variant="simple/small"
label="Cancel in-progress runs when this limit is reached"
defaultChecked={cancelInProgressRuns}
onChange={setCancelInProgressRuns}
/>
)}
<FormButtons
className={isDirty ? undefined : "invisible"}
confirmButton={
<Button type="submit" variant="primary/small" disabled={!isDirty}>
Save billing limit
</Button>
}
/>
</Fieldset>
</Form>
</div>
);
}
function LimitReachedCalloutContent({
gracePeriodLabel,
cancelInProgressRuns,
}: {
gracePeriodLabel: string;
cancelInProgressRuns: boolean;
}) {
return (
<>
When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers
will be rejected until you increase or remove the limit. Limits are enforced with a short
delay, so spend may briefly exceed the limit before grace begins. See our{" "}
<a href="https://trigger.dev/terms" className="underline">
terms
</a>{" "}
for refund policy details.
{cancelInProgressRuns ? (
<> In-progress runs will be cancelled when the limit is hit.</>
) : null}
</>
);
}
@@ -0,0 +1,241 @@
import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Paragraph } from "~/components/primitives/Paragraph";
import { DateTime } from "~/components/primitives/DateTime";
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import { formatCurrency } from "~/utils/numberFormatter";
export const billingLimitRecoveryFormSchema = z
.object({
action: z.enum(["increase", "remove"]),
newAmount: z.coerce
.number({ invalid_type_error: "Not a valid amount" })
.positive("Amount must be greater than 0")
.optional(),
resumeMode: z.enum(["queue", "new_only"]),
})
.superRefine((value, ctx) => {
if (value.action === "increase" && (value.newAmount === undefined || value.newAmount <= 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Amount must be greater than 0",
path: ["newAmount"],
});
}
});
type BillingLimitRecoveryActionData = {
formIntent: "billing-limit-resolve";
submission: SubmissionResult;
};
export function BillingLimitRecoveryPanel({
billingLimit,
currentSpendCents,
queuedRunCount,
suggestedNewLimitDollars,
}: {
billingLimit: BillingLimitResult & { isConfigured: true };
currentSpendCents: number;
queuedRunCount: number;
suggestedNewLimitDollars: number;
}) {
const { limitState } = billingLimit;
const isGrace = limitState.status === "grace";
const isRejected = limitState.status === "rejected";
const [action, setAction] = useState<"increase" | "remove">("increase");
const [newAmount, setNewAmount] = useState(String(suggestedNewLimitDollars));
const [resumeMode, setResumeMode] = useState<"queue" | "new_only">("queue");
const newAmountInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
setNewAmount(String(suggestedNewLimitDollars));
}, [suggestedNewLimitDollars]);
function handleActionChange(value: string) {
const nextAction = value as typeof action;
setAction(nextAction);
if (nextAction === "increase") {
window.setTimeout(() => newAmountInputRef.current?.focus(), 0);
}
}
const actionData = useActionData<BillingLimitRecoveryActionData>();
const recoverySubmission =
actionData?.formIntent === "billing-limit-resolve" ? actionData.submission : undefined;
const [form, fields] = useForm({
id: "billing-limit-resolve",
lastResult: recoverySubmission as any,
shouldRevalidate: "onInput",
onValidate({ formData }) {
return parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
},
defaultValue: {
action: "increase",
newAmount: suggestedNewLimitDollars,
resumeMode: "queue",
},
});
useEffect(() => {
formRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
}, [action, newAmount, resumeMode]);
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const queuedRunsLabel = useMemo(() => {
if (queuedRunCount === 0) {
return null;
}
return `~${queuedRunCount.toLocaleString()} run${
queuedRunCount === 1 ? "" : "s"
} waiting in queue`;
}, [queuedRunCount]);
return (
<div className="space-y-6">
<div className="border-b border-grid-dimmed pb-3">
<Header2 spacing>Action required</Header2>
<Callout variant="warning">
<Paragraph variant="small" className="text-yellow-200">
{isGrace ? (
<>
Your organization has reached its billing limit. Processing is paused and new runs
are queuing. Without action, new triggers block on{" "}
<DateTime date={limitState.graceEndsAt} includeTime />.
</>
) : (
"Your organization has exceeded its billing limit. New triggers are blocked until you resolve this."
)}
</Paragraph>
</Callout>
</div>
<div className="space-y-2">
<Paragraph variant="small">
Current usage: {formatCurrency(currentSpendCents / 100, false)}
</Paragraph>
{billingLimit.effectiveAmountCents !== null && (
<Paragraph variant="small">
Current limit: {formatCurrency(billingLimit.effectiveAmountCents / 100, false)}
</Paragraph>
)}
{queuedRunsLabel && <Paragraph variant="small">{queuedRunsLabel}</Paragraph>}
{isRejected && queuedRunsLabel && (
<Paragraph variant="small">New triggers are currently blocked.</Paragraph>
)}
</div>
<Form method="post" {...getFormProps(form)} ref={formRef}>
<input type="hidden" name="intent" value="billing-limit-resolve" />
<input type="hidden" name="action" value={action} />
<input type="hidden" name="resumeMode" value={resumeMode} />
<Fieldset className="space-y-6">
<div className="space-y-3">
<Header2 spacing className="text-base">
How would you like to resolve this?
</Header2>
<RadioGroup
value={action}
onValueChange={handleActionChange}
className="flex flex-col gap-2"
>
<div className="space-y-2">
<RadioGroupItem
id="resolve_action_increase"
value="increase"
variant="description"
label="Increase billing limit"
description="Continue running with a higher monthly cap."
/>
{action === "increase" && (
<InputGroup fullWidth>
<Input
ref={newAmountInputRef}
id="new-amount"
name="newAmount"
type="number"
step={0.01}
min={0.01}
value={newAmount}
onChange={(e) => setNewAmount(e.target.value)}
placeholder="New limit amount"
icon={
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
$
</span>
}
/>
</InputGroup>
)}
{action === "increase" && fields.newAmount?.errors && (
<FormError id={fields.newAmount?.errorId}>{fields.newAmount.errors}</FormError>
)}
</div>
<RadioGroupItem
id="resolve_action_remove"
value="remove"
variant="description"
label="Remove billing limit"
description="Keep going without a ceiling on usage."
/>
</RadioGroup>
</div>
<div className="space-y-3 border-t border-grid-dimmed pt-6">
<Header2 spacing className="text-base">
What should happen to queued runs?
</Header2>
<RadioGroup
value={resumeMode}
onValueChange={(value) => setResumeMode(value as typeof resumeMode)}
className="flex flex-col gap-2"
>
<RadioGroupItem
id="resume_mode_queue"
value="queue"
variant="description"
label="Resume queued runs"
description="Everything built up during the pause will run in order."
/>
<RadioGroupItem
id="resume_mode_new_only"
value="new_only"
variant="description"
label="Cancel queued runs"
description="Nothing from the pause is kept — only new triggers going forward."
/>
</RadioGroup>
</div>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
{isSubmitting ? "Resolving…" : "Resolve"}
</Button>
}
/>
</Fieldset>
</Form>
</div>
);
}
@@ -0,0 +1,26 @@
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
export function BillingLimitResolveProgress({
show,
cancellingQueuedRuns,
}: {
show: boolean;
cancellingQueuedRuns: boolean;
}) {
if (!show) {
return null;
}
return (
<div className="space-y-3">
<AnimatedCallout show variant="success">
Billing limit resolved. Environments are being unpaused this usually takes a few seconds.
</AnimatedCallout>
{cancellingQueuedRuns && (
<AnimatedCallout show variant="info">
Cancelling queued runs across billable environments
</AnimatedCallout>
)}
</div>
);
}
@@ -0,0 +1,51 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Link } from "@remix-run/react";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { useThemeColor } from "~/hooks/useThemeColor";
import { cn } from "~/utils/cn";
export function FreePlanUsage({ to, percentage }: { to: string; percentage: number }) {
const cappedPercentage = Math.min(percentage, 1);
const widthProgress = useMotionValue(cappedPercentage * 100);
// Resolved to concrete colors - framer-motion can't interpolate var() strings
const successColor = useThemeColor("--color-success", "#28bf5c");
const warningColor = useThemeColor("--color-warning", "#f59e0b");
const errorColor = useThemeColor("--color-error", "#e11d48");
const color = useTransform(
widthProgress,
[0, 74, 75, 95, 100],
[successColor, successColor, warningColor, errorColor, errorColor]
);
const hasHitLimit = cappedPercentage >= 1;
return (
<div
className={cn(
"rounded border border-grid-bright bg-background-hover p-2.5",
hasHitLimit && "border-error/40"
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
<span className="text-2sm text-text-bright">Free Plan</span>
</div>
<Link to={to} className="text-2sm text-text-link focus-custom">
Upgrade
</Link>
</div>
<div className="relative mt-3 h-1 rounded-full bg-background-dimmed">
<motion.div
initial={{ width: 0 }}
animate={{ width: cappedPercentage * 100 + "%" }}
style={{
backgroundColor: color,
}}
transition={{ duration: 1, type: "spring" }}
className={cn("absolute left-0 top-0 h-full rounded-full")}
/>
</div>
</div>
);
}
@@ -0,0 +1,228 @@
import { useLocation } from "@remix-run/react";
import { DateTime } from "~/components/primitives/DateTime";
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner";
import { LinkButton } from "~/components/primitives/Buttons";
import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment";
import {
useOptionalOrganization,
useOrganization,
useBillingLimit,
useCanManageBillingLimits,
} from "~/hooks/useOrganizations";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBuilder";
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
function getUpgradeResetDate(): Date {
const nextMonth = new Date();
nextMonth.setUTCDate(1);
nextMonth.setUTCHours(0, 0, 0, 0);
nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
return nextMonth;
}
export function OrgBanner() {
const organization = useOptionalOrganization();
const project = useOptionalProject();
const environment = useOptionalEnvironment();
const billingLimit = useBillingLimit();
const currentPlan = useCurrentPlan();
const showSelfServe = useShowSelfServe();
const location = useLocation();
// Billing-limit pauses are surfaced by the dedicated limit banners (grace/rejected).
// Don't also raise the generic "environment paused" warning for them — that would
// alarm the user during the async resolve→ok window, when billing is already `ok`
// but converge hasn't finished unpausing envs yet. Manual pauses still warn.
const isPaused = !!(
organization &&
project &&
environment &&
environment.paused &&
environment.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT
);
const isArchived = !!(organization && project && environment && environment.archivedAt);
const bannerKind = selectOrgBanner({
billingLimit,
hasExceededFreeTier: currentPlan?.v3Usage.hasExceededFreeTier === true,
showEnvironmentWarning: isPaused || isArchived,
showSelfServe,
});
const hideQueuesButton = location.pathname.endsWith("/queues");
const hideBillingLimitBanner = location.pathname.endsWith("/settings/billing-limits");
switch (bannerKind) {
case OrgBannerKind.LimitRejected:
return hideBillingLimitBanner ? null : <LimitRejectedBanner />;
case OrgBannerKind.LimitGrace:
return hideBillingLimitBanner ? null : <LimitGraceBanner />;
case OrgBannerKind.NoLimitConfigured:
return hideBillingLimitBanner ? null : <NoLimitConfiguredBanner />;
case OrgBannerKind.Upgrade:
return organization ? <UpgradeBanner /> : null;
case OrgBannerKind.EnvironmentWarning:
return isArchived ? (
<ArchivedEnvironmentBanner />
) : (
<PausedEnvironmentBanner hideButton={hideQueuesButton} />
);
default:
return null;
}
}
function LimitRejectedBanner() {
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
const canManageBillingLimits = useCanManageBillingLimits();
const canResolve = showSelfServe && canManageBillingLimits;
return (
<AnimatedOrgBannerBar
show
variant="error"
action={
canResolve ? (
<LinkButton
variant="danger/small"
leadingIconClassName="px-0"
to={v3BillingLimitsPath(organization)}
>
Resolve
</LinkButton>
) : undefined
}
>
<span className="font-medium">Billing limit exceeded</span> New triggers are currently
blocked.
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
</AnimatedOrgBannerBar>
);
}
function LimitGraceBanner() {
const organization = useOrganization();
const billingLimit = useBillingLimit();
const showSelfServe = useShowSelfServe();
const canManageBillingLimits = useCanManageBillingLimits();
const canResolve = showSelfServe && canManageBillingLimits;
const graceEndsAt =
billingLimit?.isConfigured && billingLimit.limitState.status === "grace"
? billingLimit.limitState.graceEndsAt
: null;
return (
<AnimatedOrgBannerBar
show={graceEndsAt !== null}
variant="error"
action={
canResolve ? (
<LinkButton
variant="danger/small"
leadingIconClassName="px-0"
to={v3BillingLimitsPath(organization)}
>
Resolve
</LinkButton>
) : undefined
}
>
<span className="font-medium">Billing limit reached</span> Queues have been paused. New runs
will continue to queue until <DateTime date={graceEndsAt ?? new Date()} includeTime />.
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
</AnimatedOrgBannerBar>
);
}
function NoLimitConfiguredBanner() {
const organization = useOrganization();
const canManageBillingLimits = useCanManageBillingLimits();
return (
<AnimatedOrgBannerBar
show
variant="warning"
action={
canManageBillingLimits ? (
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
Configure billing limit
</LinkButton>
) : undefined
}
>
{canManageBillingLimits
? "Protect your organization from unexpected usage spikes."
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
</AnimatedOrgBannerBar>
);
}
function UpgradeBanner() {
const organization = useOrganization();
const plan = useCurrentPlan();
const freeCreditsDollars = (plan?.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100;
return (
<AnimatedOrgBannerBar
show={plan?.v3Usage.hasExceededFreeTier === true}
variant="error"
action={
<LinkButton
variant="danger/small"
leadingIconClassName="px-0"
to={v3BillingPath(organization)}
>
Upgrade
</LinkButton>
}
>
You have exceeded the monthly ${freeCreditsDollars} free credits. Existing runs will be queued
and new runs won't be created until{" "}
<DateTime date={getUpgradeResetDate()} includeTime={false} timeZone="utc" />, or you upgrade.
</AnimatedOrgBannerBar>
);
}
function PausedEnvironmentBanner({ hideButton }: { hideButton: boolean }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<AnimatedOrgBannerBar
show
variant="warning"
action={
hideButton ? undefined : (
<LinkButton
variant="tertiary/small"
to={v3QueuesPath(organization, project, environment)}
>
Manage
</LinkButton>
)
}
>
{environmentFullTitle(environment)} environment paused. No new runs will be dequeued and
executed.
</AnimatedOrgBannerBar>
);
}
function ArchivedEnvironmentBanner() {
const environment = useEnvironment();
return (
<AnimatedOrgBannerBar show variant="warning">
"{environment.branchName}" branch is archived and is read-only. No new runs will be dequeued
and executed.
</AnimatedOrgBannerBar>
);
}
@@ -0,0 +1,138 @@
import { cn } from "~/utils/cn";
import { formatCurrency } from "~/utils/numberFormatter";
import { Paragraph } from "../primitives/Paragraph";
import { SimpleTooltip } from "../primitives/Tooltip";
import { motion } from "framer-motion";
type UsageBarProps = {
current: number;
billingLimit?: number;
tierLimit?: number;
isPaying: boolean;
};
const startFactor = 4;
export function UsageBar({ current, billingLimit, tierLimit, isPaying }: UsageBarProps) {
const getLargestNumber = Math.max(current, tierLimit ?? -Infinity, billingLimit ?? -Infinity, 5);
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
const maxRange = Math.round(getLargestNumber * 1.1);
const tierRunLimitPercentage = tierLimit ? Math.round((tierLimit / maxRange) * 100) : 0;
const billingLimitPercentage =
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
const usagePercentage = Math.round((current / maxRange) * 100);
//cap the usagePercentage to the freeRunLimitPercentage
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
return (
<div className="h-fit w-full py-6">
<div className="relative h-3 w-full rounded-sm bg-background-bright">
{billingLimit !== undefined && (
<motion.div
initial={{ width: billingLimitPercentage / startFactor + "%" }}
animate={{ width: billingLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${billingLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Billing limit:"
value={formatCurrency(billingLimit, false)}
position="bottomRow2"
percentage={billingLimitPercentage}
/>
</motion.div>
)}
<motion.div
initial={{ width: usagePercentage / startFactor + "%" }}
animate={{ width: usagePercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usagePercentage}%` }}
className={cn(
"absolute h-3 rounded-l-sm",
tierLimit && current > tierLimit ? "bg-green-700" : "bg-green-600"
)}
>
<Legend
text="Used:"
value={formatCurrency(current, false)}
position="topRow1"
percentage={usagePercentage}
/>
</motion.div>
{tierLimit !== undefined && (
<motion.div
initial={{ width: tierRunLimitPercentage / startFactor + "%" }}
animate={{ width: tierRunLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${tierRunLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm bg-green-900/50"
>
<Legend
text={isPaying ? `Included usage:` : `Tier limit:`}
value={formatCurrency(tierLimit, false)}
position="bottomRow1"
percentage={tierRunLimitPercentage}
/>
</motion.div>
)}
<motion.div
initial={{ width: usageCappedToLimitPercentage / startFactor + "%" }}
animate={{ width: usageCappedToLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usageCappedToLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm bg-green-600"
/>
</div>
</div>
);
}
const positions = {
topRow1: "bottom-0 h-9",
topRow2: "bottom-0 h-14",
bottomRow1: "top-0 h-9 items-end",
bottomRow2: "top-0 h-14 items-end",
};
type LegendProps = {
text: string;
value: number | string;
percentage: number;
position: keyof typeof positions;
tooltipContent?: string;
};
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
const flipLegendPositionValue = 80;
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
return (
<div
className={cn(
"absolute left-full z-10 flex border-border-brightest",
positions[position],
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
)}
>
{tooltipContent ? (
<SimpleTooltip
button={
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
{text}
<span className="ml-1 text-text-dimmed">{value}</span>
</Paragraph>
}
side="top"
content={tooltipContent}
className="z-50 h-fit"
/>
) : (
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
{text}
<span className="ml-1 text-text-dimmed">{value}</span>
</Paragraph>
)}
</div>
);
}
@@ -0,0 +1,413 @@
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
export type BillingAlertsFormData = {
/** Stored base amount in dollars (from API cents / 100). Used when converting legacy alerts. */
amount: number;
emails: string[];
alertLevels: number[];
};
/** $1 base (in cents) for absolute spend alerts when billing limit mode is none. */
export const ABSOLUTE_ALERT_BASE_CENTS = 100;
export const MAX_PERCENTAGE_ALERTS = 5;
export const MAX_ABSOLUTE_ALERTS = 10;
export const MAX_PERCENTAGE_THRESHOLD = 100;
export type BillingLimitMode = "plan" | "custom" | "none";
export function getBillingLimitMode(billingLimit: BillingLimitResult): BillingLimitMode {
if (!billingLimit.isConfigured) {
return "none";
}
return billingLimit.mode;
}
export function isPercentageAlertMode(mode: BillingLimitMode): boolean {
return mode === "plan" || mode === "custom";
}
export function shouldClearAlertsOnLimitChange(
previousMode: BillingLimitMode,
nextMode: BillingLimitMode
): boolean {
return shouldResetAlertsOnLimitChange(previousMode, nextMode);
}
/** Alert format changes when crossing between percentage (plan/custom) and dollar (none) modes. */
export function shouldResetAlertsOnLimitChange(
previousMode: BillingLimitMode | null,
nextMode: BillingLimitMode
): boolean {
if (previousMode === null) {
return false;
}
return isPercentageAlertMode(previousMode) !== isPercentageAlertMode(nextMode);
}
/** Configured billing limit mode before a save; null when billing limit was never configured. */
export function getPreviousBillingLimitModeForAlertSync(
billingLimit: BillingLimitResult
): BillingLimitMode | null {
if (!billingLimit.isConfigured) {
return null;
}
return billingLimit.mode;
}
export function hasConfiguredAlerts(
alerts: BillingAlertsFormData,
billingLimit: BillingLimitResult,
planLimitCents: number
): boolean {
const mode = getBillingLimitMode(billingLimit);
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
return storedAlertsToThresholds(alerts, mode, effectiveLimitCents, planLimitCents).length > 0;
}
export function hasSavedAlertThresholds(alerts: BillingAlertsFormData): boolean {
return alerts.alertLevels.length > 0;
}
/** Saved thresholds that would be cleared when the billing limit alert format changes. */
export function hadSavedAlertsToClearOnLimitChange(
alerts: BillingAlertsFormData,
billingLimit: BillingLimitResult,
planLimitCents: number
): boolean {
return hasConfiguredAlerts(alerts, billingLimit, planLimitCents);
}
export function normalizeThresholdValues(values: number[]): number[] {
return [...values].sort((a, b) => a - b);
}
export function thresholdValuesAreUnique(values: number[]): boolean {
const normalized = normalizeThresholdValues(values);
return new Set(normalized).size === normalized.length;
}
export function normalizeEmailValues(values: string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean);
}
export function thresholdsMatchSaved(current: number[], saved: number[]): boolean {
return (
JSON.stringify(normalizeThresholdValues(current)) ===
JSON.stringify(normalizeThresholdValues(saved))
);
}
export function emailsMatchSaved(current: string[], saved: string[]): boolean {
return (
JSON.stringify(normalizeEmailValues(current)) === JSON.stringify(normalizeEmailValues(saved))
);
}
export function clearedAlertsPayload(emails: string[] = []): {
amount: number;
alertLevels: number[];
emails: string[];
} {
return {
amount: ABSOLUTE_ALERT_BASE_CENTS,
alertLevels: [],
emails,
};
}
export function resetAlertsPayloadForLimitMode(
nextMode: BillingLimitMode,
effectiveLimitCents: number,
emails: string[] = []
): { amount: number; alertLevels: number[]; emails: string[] } {
if (nextMode === "none") {
return clearedAlertsPayload(emails);
}
return {
amount: effectiveLimitCents,
alertLevels: [],
emails,
};
}
export function getEffectiveLimitCents(
billingLimit: BillingLimitResult,
planLimitCents: number
): number {
if (!billingLimit.isConfigured) {
return planLimitCents;
}
if (billingLimit.mode === "custom") {
return billingLimit.amountCents;
}
if (billingLimit.mode === "plan") {
return billingLimit.effectiveAmountCents ?? planLimitCents;
}
return planLimitCents;
}
/** Billing limit in cents when configured (plan/custom); undefined for none or unconfigured. */
export function getConfiguredBillingLimitCents(
billingLimit: BillingLimitResult | undefined,
planLimitCents: number
): number | undefined {
if (!billingLimit?.isConfigured || billingLimit.mode === "none") {
return undefined;
}
return getEffectiveLimitCents(billingLimit, planLimitCents);
}
/** Dollars for UsageBar marker; omitted when no limit or same as plan included usage. */
export function getUsageBarBillingLimitDollars(
billingLimit: BillingLimitResult | undefined,
planLimitCents: number
): number | undefined {
const limitCents = getConfiguredBillingLimitCents(billingLimit, planLimitCents);
if (limitCents === undefined || limitCents === planLimitCents) {
return undefined;
}
return limitCents / 100;
}
function getSavedAlertAmountCents(alerts: BillingAlertsFormData): number {
return Math.round(alerts.amount * 100);
}
function usesFractionAlertLevelFormat(levels: number[]): boolean {
return levels.some((level) => level > 0 && level <= 1);
}
export type NormalizeBillingAlertsOptions = {
planLimitCents: number;
effectiveLimitCents: number;
};
function isAbsoluteDollarAlertAmount(rawAmount: number, alertLevels: number[]): boolean {
if (rawAmount !== ABSOLUTE_ALERT_BASE_CENTS || alertLevels.length === 0) {
return false;
}
// None-mode absolute alerts use the $1 base (100 cents) with dollar thresholds (e.g. 100, 250).
// Legacy percentage alerts at amount=100 use UI percent values, with at least one below 100.
const hasTypicalPercentLevel = alertLevels.some((level) => level > 0 && level < 100);
return !hasTypicalPercentLevel;
}
/** Detect legacy alerts that stored the limit base in dollars with whole-number percents. */
export function isLegacyDollarAmountField(
rawAmount: number,
alertLevels: number[],
options: NormalizeBillingAlertsOptions
): boolean {
if (isAbsoluteDollarAlertAmount(rawAmount, alertLevels)) {
return false;
}
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
return false;
}
if (alertLevels.length === 0 || usesFractionAlertLevelFormat(alertLevels)) {
return false;
}
// Platform migrated $10+ limits to cents (e.g. 10_000 for $100).
if (rawAmount >= 1000) {
return false;
}
if (rawAmount === options.planLimitCents || rawAmount === options.effectiveLimitCents) {
return false;
}
const planDollars = Math.round(options.planLimitCents / 100);
const effectiveDollars = Math.round(options.effectiveLimitCents / 100);
return rawAmount === planDollars || rawAmount === effectiveDollars;
}
export function isAbsoluteSavedAlerts(alerts: BillingAlertsFormData): boolean {
return getSavedAlertAmountCents(alerts) === ABSOLUTE_ALERT_BASE_CENTS;
}
/** Build a cleaned alerts payload when saving billing limits in the same alert format. */
export function buildCleanedAlertsPayloadForLimitSave(
alerts: BillingAlertsFormData,
nextMode: BillingLimitMode,
effectiveLimitCents: number,
planLimitCents: number
): { amount: number; alertLevels: number[]; emails: string[] } | null {
if (alerts.alertLevels.length === 0) {
return null;
}
const thresholds = storedAlertsToThresholds(
alerts,
nextMode,
effectiveLimitCents,
planLimitCents
);
return {
emails: alerts.emails,
...thresholdsToAlertPayload(thresholds, nextMode, effectiveLimitCents),
};
}
/** Convert stored percentage alert levels to UI percent values (10, 50, 80). */
export function percentageAlertLevelsToUiThresholds(levels: number[]): number[] {
const normalized = levels.filter((level) => Number.isFinite(level) && level > 0);
if (normalized.length === 0) {
return [];
}
if (usesFractionAlertLevelFormat(normalized)) {
return normalized.filter((level) => level <= 1).map((level) => Math.round(level * 100));
}
return normalized
.filter((level) => level <= MAX_PERCENTAGE_THRESHOLD)
.map((level) => Math.round(level));
}
export function normalizeBillingAlertsFromApi(
apiAlerts: {
amount: number;
emails?: string[];
alertLevels?: number[];
},
options: NormalizeBillingAlertsOptions
): BillingAlertsFormData {
const rawAmount = Number(apiAlerts.amount);
const alertLevels = (apiAlerts.alertLevels ?? []).map(Number).filter(Number.isFinite);
// Platform API stores amount in cents.
let amountDollars = rawAmount / 100;
if (isLegacyDollarAmountField(rawAmount, alertLevels, options)) {
amountDollars = rawAmount;
}
return {
amount: amountDollars,
emails: apiAlerts.emails ?? [],
alertLevels,
};
}
/** Legacy alerts used plan included usage; new alerts use the billing limit amount. */
function percentageAlertAmountMatches(
amountCents: number,
effectiveLimitCents: number,
planLimitCents: number
): boolean {
return amountCents === effectiveLimitCents || amountCents === planLimitCents;
}
/** Cents base for dollar preview when displaying saved percentage alerts. */
export function getAlertPreviewLimitCents(
alerts: BillingAlertsFormData,
effectiveLimitCents: number,
planLimitCents: number
): number {
const amountCents = getSavedAlertAmountCents(alerts);
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
return amountCents;
}
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
return amountCents;
}
return effectiveLimitCents;
}
/** Convert stored API alerts to UI threshold values (percent or dollars). */
export function storedAlertsToThresholds(
alerts: BillingAlertsFormData,
mode: BillingLimitMode,
effectiveLimitCents: number,
planLimitCents: number
): number[] {
const amountCents = getSavedAlertAmountCents(alerts);
if (mode === "none") {
if (alerts.alertLevels.length === 0) {
return [];
}
// Absolute dollar alerts: API amount is the $1 base marker (100 cents).
if (
amountCents === ABSOLUTE_ALERT_BASE_CENTS ||
alerts.amount === ABSOLUTE_ALERT_BASE_CENTS / 100
) {
return alerts.alertLevels.slice(0, MAX_ABSOLUTE_ALERTS);
}
return [];
}
const uiThresholds = percentageAlertLevelsToUiThresholds(alerts.alertLevels);
if (uiThresholds.length === 0) {
return [];
}
// Saved percentage alerts keep their thresholds whenever a positive base amount is stored.
if (amountCents > 0) {
return uiThresholds.slice(0, MAX_PERCENTAGE_ALERTS);
}
return [];
}
export function thresholdsToAlertPayload(
thresholds: number[],
mode: BillingLimitMode,
effectiveLimitCents: number
): { amount: number; alertLevels: number[] } {
if (mode === "none") {
return {
amount: ABSOLUTE_ALERT_BASE_CENTS,
alertLevels: thresholds,
};
}
return {
amount: effectiveLimitCents,
alertLevels: thresholds.map((percent) => percent / 100),
};
}
export function isEmptyThreshold(value: number): boolean {
return !Number.isFinite(value) || value <= 0;
}
export function previewDollarAmountForPercent(
percent: number,
effectiveLimitCents: number
): number {
if (!Number.isFinite(percent) || percent <= 0) {
return 0;
}
return (effectiveLimitCents * percent) / 100 / 100;
}
/** Legacy percentage alerts may include spike multipliers above 100%. */
export function hasLegacySpikeAlertLevels(
alerts: BillingAlertsFormData,
mode: BillingLimitMode,
effectiveLimitCents: number,
planLimitCents: number
): boolean {
if (!isPercentageAlertMode(mode)) {
return false;
}
if (usesFractionAlertLevelFormat(alerts.alertLevels)) {
return alerts.alertLevels.some((level) => level > 1);
}
return alerts.alertLevels.some((level) => level > MAX_PERCENTAGE_THRESHOLD);
}
@@ -0,0 +1,23 @@
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
/** Format billing grace period from API `gracePeriodMs` (e.g. 24 hours, not 1 day). */
export function formatGracePeriodMs(ms: number): string {
return formatDurationMilliseconds(ms, {
style: "long",
units: ["h", "m", "s"],
maxUnits: 1,
});
}
export function getSuggestedRecoveryLimitDollars(
effectiveAmountCents: number | null,
currentSpendCents: number
): number {
const candidates = [Math.ceil(currentSpendCents * 1.25)];
if (effectiveAmountCents != null) {
candidates.push(effectiveAmountCents + 5_000, Math.ceil(effectiveAmountCents * 1.25));
}
const rawAmount = Math.max(...candidates) / 100;
return Math.ceil(rawAmount / 50) * 50;
}
@@ -0,0 +1,44 @@
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
export enum OrgBannerKind {
LimitRejected = "limit-rejected",
LimitGrace = "limit-grace",
NoLimitConfigured = "no-limit",
Upgrade = "upgrade",
EnvironmentWarning = "env-warning",
None = "none",
}
export function selectOrgBanner(input: {
billingLimit?: BillingLimitResult;
hasExceededFreeTier?: boolean;
showEnvironmentWarning?: boolean;
/** Self-serve billing UI — hide configure-limit prompt for managed customers. */
showSelfServe?: boolean;
}): OrgBannerKind {
const { billingLimit, hasExceededFreeTier, showEnvironmentWarning, showSelfServe = true } = input;
if (billingLimit?.isConfigured) {
const status = billingLimit.limitState.status;
if (status === "rejected") {
return OrgBannerKind.LimitRejected;
}
if (status === "grace") {
return OrgBannerKind.LimitGrace;
}
}
if (hasExceededFreeTier) {
return OrgBannerKind.Upgrade;
}
if (billingLimit && !billingLimit.isConfigured && showSelfServe) {
return OrgBannerKind.NoLimitConfigured;
}
if (showEnvironmentWarning) {
return OrgBannerKind.EnvironmentWarning;
}
return OrgBannerKind.None;
}