chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user