diff --git a/.server-changes/billing-limits.md b/.server-changes/billing-limits.md new file mode 100644 index 00000000000..9327cc9f54f --- /dev/null +++ b/.server-changes/billing-limits.md @@ -0,0 +1,9 @@ +--- +area: webapp +type: feature +--- + +Add billing limits. Customers set a spend cap; when usage crosses it, billable +environments pause for a grace period, new triggers are rejected once it ends, +and a recovery flow resumes or cancels the queued backlog. Reconciliation keeps +the webapp converged to billing's state. diff --git a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx new file mode 100644 index 00000000000..b0f11b7eba3 --- /dev/null +++ b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx @@ -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 ( + + {show ? ( + +
+ + + {children} + +
+ {action} +
+ ) : null} +
+ ); +} diff --git a/apps/webapp/app/components/billing/BillingAlertsSection.tsx b/apps/webapp/app/components/billing/BillingAlertsSection.tsx new file mode 100644 index 00000000000..41a050bffb6 --- /dev/null +++ b/apps/webapp/app/components/billing/BillingAlertsSection.tsx @@ -0,0 +1,390 @@ +import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react"; +import { parse } 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)) + .filter((n) => Number.isFinite(n)); + }, z.number().array().refine(thresholdValuesAreUnique, "Each alert must be unique")), +}); + +export type { BillingAlertsFormData } from "~/components/billing/billingAlertsFormat"; + +type BillingAlertsActionData = { + formIntent: "billing-alerts"; + submission: ReturnType>; +}; + +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(() => + toThresholdRows(savedThresholds) + ); + const [emailValues, setEmailValues] = useState( + savedEmails.length > 0 ? [...savedEmails, ""] : [""] + ); + const actionData = useActionData(); + 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({ + id: "billing-alerts", + lastSubmission: lastSubmission as any, + shouldRevalidate: "onInput", + onValidate({ formData }) { + return parse(formData, { schema: billingAlertsSchema }); + }, + defaultValue: { + emails: emailValues, + alertLevels: savedThresholds.map(String), + }, + }); + + const emailFields = useFieldList(form.ref, emails); + + 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 ( +
+
+ Billing alerts + + Receive an email when your compute spend crosses different thresholds. You can also learn + how to{" "} + reduce your compute spend. + +
+
+ +
+ setShowResetBanner(false)} + > + Billing alerts were reset because they no longer match the selected billing limit + configuration. + + +
+ + +
+ {thresholdRows.map((row, index) => { + const parsed = Number(row.value); + const isDuplicate = isDuplicateThresholdRow(thresholdRows, index); + + return ( +
+
+ {isPercentageMode ? ( + <> + 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} + /> + % + + ( + {formatCurrency( + previewDollarAmountForPercent( + Number.isFinite(parsed) ? parsed : 0, + alertPreviewLimitCents + ), + false + )} + ) + + + ) : ( + updateThresholdRow(index, e.target.value)} + min={0.01} + step={0.01} + placeholder="100" + icon={ + + $ + + } + className="max-w-xs pl-px" + fullWidth={false} + /> + )} + +
+ {isDuplicate && This alert threshold is already in use} +
+ ); + })} +
+ + {alertLevels.error} + + {canAddThreshold && ( + + )} +
+ + + + {emailFields.map((email, index) => { + const { defaultValue: _emailDefaultValue, ...emailInputProps } = conform.input( + email, + { type: "email" } + ); + + return ( + + { + const nextValue = e.target.value; + setEmailValues((current) => { + const next = [...current]; + next[index] = nextValue; + if ( + emailFields.length === next.length && + next.every((value) => value !== "") + ) { + requestIntent(form.ref.current ?? undefined, list.append(emails.name)); + return [...next, ""]; + } + return next; + }); + }} + fullWidth + /> + {email.error} + + ); + })} + + + + Update alerts + + } + /> +
+
+
+ ); +} diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx new file mode 100644 index 00000000000..1737bca74b1 --- /dev/null +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -0,0 +1,335 @@ +import { conform, useForm, type Submission } from "@conform-to/react"; + +import { parse } from "@conform-to/zod"; +import { Form, useActionData } from "@remix-run/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { z } from "zod"; +import { AnimatedCallout } from "~/components/primitives/AnimatedCallout"; +import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat"; +import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat"; +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 { cn } from "~/utils/cn"; +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 BillingLimitFormValue = z.infer; + +type BillingLimitActionData = { + formIntent: "billing-limit"; + submission: Submission; +}; + +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(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(); + 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", + lastSubmission: lastSubmission as any, + shouldRevalidate: "onInput", + onValidate({ formData }) { + return parse(formData, { schema: billingLimitFormSchema }); + }, + defaultValue: { + mode: savedMode, + }, + }); + + useEffect(() => { + form.ref.current?.dispatchEvent(new Event("input", { bubbles: true })); + }, [customAmount, form.ref, mode]); + + const planLimitLabel = formatCurrency(planLimitCents / 100, false); + const showPlanInfoCallout = mode === "plan"; + const showCustomInfoCallout = mode === "custom"; + const showNoneWarningCallout = mode === "none"; + + return ( +
+
+ Billing limit + + 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. + +
+ +
+ +
+ + + +
+ {`My plan limit (${planLimitLabel})`} + } + description={`Pause billable environments when monthly spend reaches ${planLimitLabel}.`} + /> +
+ + + +
+
+ +
+
+ + {mode === "custom" && ( + + setCustomAmount(e.target.value)} + placeholder="Custom limit amount" + icon={ + + $ + + } + className="pl-px" + fullWidth + /> + {fields.amount && ( + {fields.amount.error} + )} + + )} +
+
+ + + +
+
+ +
+ +
+ + Without a billing limit, runs will continue even if usage spikes unexpectedly. You + may have to pay higher fees before you notice. + +
+
+
+ + {mode !== "none" && ( + + )} + + Save billing limit + + } + /> +
+
+
+ ); +} + +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{" "} + + terms + {" "} + for refund policy details. + {cancelInProgressRuns ? ( + <> In-progress runs will be cancelled when the limit is hit. + ) : null} + + ); +} diff --git a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx new file mode 100644 index 00000000000..4f634717e53 --- /dev/null +++ b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx @@ -0,0 +1,240 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } 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: ReturnType>; +}; + +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(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(); + const recoverySubmission = + actionData?.formIntent === "billing-limit-resolve" ? actionData.submission : undefined; + + const [form, fields] = useForm({ + id: "billing-limit-resolve", + lastSubmission: recoverySubmission as any, + shouldRevalidate: "onInput", + onValidate({ formData }) { + return parse(formData, { schema: billingLimitRecoveryFormSchema }); + }, + defaultValue: { + action: "increase", + newAmount: suggestedNewLimitDollars, + resumeMode: "queue", + }, + }); + + useEffect(() => { + form.ref.current?.dispatchEvent(new Event("input", { bubbles: true })); + }, [action, form.ref, 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 ( +
+
+ Action required + + + {isGrace ? ( + <> + Your organization has reached its billing limit. Processing is paused and new runs + are queuing. Without action, new triggers block on{" "} + . + + ) : ( + "Your organization has exceeded its billing limit. New triggers are blocked until you resolve this." + )} + + +
+ +
+ + Current usage: {formatCurrency(currentSpendCents / 100, false)} + + {billingLimit.effectiveAmountCents !== null && ( + + Current limit: {formatCurrency(billingLimit.effectiveAmountCents / 100, false)} + + )} + {queuedRunsLabel && {queuedRunsLabel}} + + {isRejected && queuedRunsLabel && ( + New triggers are currently blocked. + )} +
+ +
+ + + + +
+
+ + How would you like to resolve this? + + +
+ + {action === "increase" && ( + + setNewAmount(e.target.value)} + placeholder="New limit amount" + icon={ + + $ + + } + /> + + )} + {action === "increase" && fields.newAmount?.error && ( + {fields.newAmount.error} + )} +
+ + +
+
+ +
+ + What should happen to queued runs? + + setResumeMode(value as typeof resumeMode)} + className="flex flex-col gap-2" + > + + + +
+ + + {isSubmitting ? "Resolving…" : "Resolve"} + + } + /> +
+
+
+ ); +} diff --git a/apps/webapp/app/components/billing/BillingLimitResolveProgress.tsx b/apps/webapp/app/components/billing/BillingLimitResolveProgress.tsx new file mode 100644 index 00000000000..2873a066813 --- /dev/null +++ b/apps/webapp/app/components/billing/BillingLimitResolveProgress.tsx @@ -0,0 +1,27 @@ +import { AnimatedCallout } from "~/components/primitives/AnimatedCallout"; + +export function BillingLimitResolveProgress({ + show, + cancellingQueuedRuns, +}: { + show: boolean; + cancellingQueuedRuns: boolean; +}) { + if (!show) { + return null; + } + + return ( +
+ + Billing limit resolved. Environments are being unpaused — this usually takes a few + seconds. + + {cancellingQueuedRuns && ( + + Cancelling queued runs across billable environments… + + )} +
+ ); +} diff --git a/apps/webapp/app/components/billing/OrgBanner.tsx b/apps/webapp/app/components/billing/OrgBanner.tsx new file mode 100644 index 00000000000..dd0fc619d3f --- /dev/null +++ b/apps/webapp/app/components/billing/OrgBanner.tsx @@ -0,0 +1,209 @@ +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, +} 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"; + +function getUpgradeResetDate(): Date { + const nextMonth = new Date(); + nextMonth.setUTCMonth(nextMonth.getMonth() + 1); + nextMonth.setUTCDate(1); + nextMonth.setUTCHours(0, 0, 0, 0); + 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 !== "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 : ; + case OrgBannerKind.LimitGrace: + return hideBillingLimitBanner ? null : ; + case OrgBannerKind.NoLimitConfigured: + return hideBillingLimitBanner ? null : ; + case OrgBannerKind.Upgrade: + return organization ? : null; + case OrgBannerKind.EnvironmentWarning: + return isArchived ? ( + + ) : ( + + ); + default: + return null; + } +} + +function LimitRejectedBanner() { + const organization = useOrganization(); + + return ( + + Resolve + + } + > + Billing limit exceeded — New triggers are currently + blocked. + + ); +} + +function LimitGraceBanner() { + const organization = useOrganization(); + const billingLimit = useBillingLimit(); + + const graceEndsAt = + billingLimit?.isConfigured && billingLimit.limitState.status === "grace" + ? billingLimit.limitState.graceEndsAt + : null; + + return ( + + Resolve + + } + > + Billing limit reached — Queues have been paused. New runs + will continue to queue until . + + ); +} + +function NoLimitConfiguredBanner() { + const organization = useOrganization(); + + return ( + + Configure billing limit + + } + > + Protect your organization from unexpected usage spikes. + + ); +} + +function UpgradeBanner() { + const organization = useOrganization(); + const plan = useCurrentPlan(); + const freeCreditsDollars = (plan?.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100; + + return ( + + Upgrade + + } + > + You have exceeded the monthly ${freeCreditsDollars} free credits. Existing runs will be queued + and new runs won't be created until{" "} + , or you upgrade. + + ); +} + +function PausedEnvironmentBanner({ hideButton }: { hideButton: boolean }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + Manage + + ) + } + > + {environmentFullTitle(environment)} environment paused. No new runs will be dequeued and + executed. + + ); +} + +function ArchivedEnvironmentBanner() { + const environment = useEnvironment(); + + return ( + + "{environment.branchName}" branch is archived and is read-only. No new runs will be dequeued + and executed. + + ); +} diff --git a/apps/webapp/app/components/billing/UpgradePrompt.tsx b/apps/webapp/app/components/billing/UpgradePrompt.tsx deleted file mode 100644 index 8a3e098ba42..00000000000 --- a/apps/webapp/app/components/billing/UpgradePrompt.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; -import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations"; -import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; -import { v3BillingPath } from "~/utils/pathBuilder"; -import { LinkButton } from "../primitives/Buttons"; -import { Icon } from "../primitives/Icon"; -import { Paragraph } from "../primitives/Paragraph"; -import { DateTime } from "~/components/primitives/DateTime"; - -export function UpgradePrompt() { - const organization = useOrganization(); - const plan = useCurrentPlan(); - - if (!plan || !plan.v3Usage.hasExceededFreeTier) { - return null; - } - - const nextMonth = new Date(); - nextMonth.setUTCMonth(nextMonth.getMonth() + 1); - nextMonth.setUTCDate(1); - nextMonth.setUTCHours(0, 0, 0, 0); - - return ( -
-
- - - You have exceeded the monthly $ - {(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. Existing - runs will be queued and new runs won't be created until{" "} - , or you upgrade. - -
- - Upgrade - -
- ); -} - -export function useShowUpgradePrompt(organization?: MatchedOrganization) { - const currentPlan = useCurrentPlan(); - const shouldShow = currentPlan?.v3Usage.hasExceededFreeTier === true; - return { shouldShow }; -} diff --git a/apps/webapp/app/components/billing/billingAlertsFormat.ts b/apps/webapp/app/components/billing/billingAlertsFormat.ts new file mode 100644 index 00000000000..fb231fad0a1 --- /dev/null +++ b/apps/webapp/app/components/billing/billingAlertsFormat.ts @@ -0,0 +1,373 @@ +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 "plan"; + } + 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); +} + +function isAbsoluteDollarAlertLevels(levels: number[]): boolean { + if (levels.length === 0) { + return false; + } + + return !usesFractionAlertLevelFormat(levels); +} + +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[]; +}): 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; + + // Legacy percentage alerts sometimes stored plan dollars directly (e.g. 100 for $100). + // Never apply to absolute dollar alerts — those use a fixed $1 base (100 cents). + if ( + rawAmount !== ABSOLUTE_ALERT_BASE_CENTS && + Number.isFinite(rawAmount) && + rawAmount >= 10 && + rawAmount / 100 < 10 && + alertLevels.length > 0 + ) { + 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 []; + } + + // Legacy percentage alerts keep their saved base amount even if billing limit changed. + if ( + percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents) || + 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); +} diff --git a/apps/webapp/app/components/billing/billingLimitFormat.ts b/apps/webapp/app/components/billing/billingLimitFormat.ts new file mode 100644 index 00000000000..9a463e5b9c4 --- /dev/null +++ b/apps/webapp/app/components/billing/billingLimitFormat.ts @@ -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; +} diff --git a/apps/webapp/app/components/billing/selectOrgBanner.ts b/apps/webapp/app/components/billing/selectOrgBanner.ts new file mode 100644 index 00000000000..ed8f57a4e20 --- /dev/null +++ b/apps/webapp/app/components/billing/selectOrgBanner.ts @@ -0,0 +1,47 @@ +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; + } + } else if (billingLimit && !billingLimit.isConfigured && showSelfServe) { + return OrgBannerKind.NoLimitConfigured; + } + + if (hasExceededFreeTier) { + return OrgBannerKind.Upgrade; + } + + if (showEnvironmentWarning) { + return OrgBannerKind.EnvironmentWarning; + } + + return OrgBannerKind.None; +} diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index 3da2bfbb5a5..41b95450d83 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from "react"; import { cn } from "~/utils/cn"; /** This container is used to surround the entire app, it correctly places the nav bar */ @@ -34,17 +35,17 @@ export function PageContainer({ ); } -export function PageBody({ - children, - scrollable = true, - className, -}: { - children: React.ReactNode; - scrollable?: boolean; - className?: string; -}) { +export const PageBody = forwardRef< + HTMLDivElement, + { + children: React.ReactNode; + scrollable?: boolean; + className?: string; + } +>(function PageBody({ children, scrollable = true, className }, ref) { return (
); -} +}); export function MainCenteredContainer({ children, diff --git a/apps/webapp/app/components/navigation/EnvironmentBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx deleted file mode 100644 index 2a34b9e434d..00000000000 --- a/apps/webapp/app/components/navigation/EnvironmentBanner.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { useLocation } from "@remix-run/react"; -import { AnimatePresence, motion } from "framer-motion"; -import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment"; -import { useOptionalOrganization, useOrganization } from "~/hooks/useOrganizations"; -import { useOptionalProject, useProject } from "~/hooks/useProject"; -import { v3QueuesPath } from "~/utils/pathBuilder"; -import { environmentFullTitle } from "../environments/EnvironmentLabel"; -import { LinkButton } from "../primitives/Buttons"; -import { Icon } from "../primitives/Icon"; -import { Paragraph } from "../primitives/Paragraph"; - -export function EnvironmentBanner() { - const organization = useOptionalOrganization(); - const project = useOptionalProject(); - const environment = useOptionalEnvironment(); - - const isPaused = organization && project && environment && environment.paused; - const isArchived = organization && project && environment && environment.archivedAt; - - return ( - - {isArchived ? : isPaused ? : null} - - ); -} - -function PausedBanner() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - - const location = useLocation(); - const hideButton = location.pathname.endsWith("/queues"); - - return ( - -
- - - {environmentFullTitle(environment)} environment paused. No new runs will be dequeued and - executed. - -
- {hideButton ? null : ( -
- - Manage - -
- )} -
- ); -} - -function ArchivedBranchBanner() { - const environment = useEnvironment(); - - return ( - -
- - - "{environment.branchName}" branch is archived and is read-only. No new runs will be - dequeued and executed. - -
-
- ); -} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index e0ab1bf3b72..3573cec9564 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -20,7 +20,7 @@ import { organizationTeamPath, organizationVercelIntegrationPath, rootPath, - v3BillingAlertsPath, + v3BillingLimitsPath, v3BillingPath, v3PrivateConnectionsPath, v3UsagePath, @@ -109,12 +109,12 @@ export function OrganizationSettingsSideMenu({ /> {showSelfServe ? ( ) : null} diff --git a/apps/webapp/app/components/primitives/AnimatedCallout.tsx b/apps/webapp/app/components/primitives/AnimatedCallout.tsx new file mode 100644 index 00000000000..2391f893e99 --- /dev/null +++ b/apps/webapp/app/components/primitives/AnimatedCallout.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from "react"; +import { Callout, type CalloutVariant } from "~/components/primitives/Callout"; +import { cn } from "~/utils/cn"; + +const CALLOUT_ANIMATION_MS = 300; + +type AnimatedCalloutProps = { + show: boolean; + variant: CalloutVariant; + className?: string; + children: React.ReactNode; + /** When set, the callout auto-hides after this many milliseconds. */ + autoHideMs?: number; + onAutoHide?: () => void; + onHidden?: () => void; +}; + +export function AnimatedCallout({ + show, + variant, + className, + children, + autoHideMs, + onAutoHide, + onHidden, +}: AnimatedCalloutProps) { + const [rendered, setRendered] = useState(show); + const [autoDismissed, setAutoDismissed] = useState(false); + const onAutoHideRef = useRef(onAutoHide); + const onHiddenRef = useRef(onHidden); + + useEffect(() => { + onAutoHideRef.current = onAutoHide; + }, [onAutoHide]); + + useEffect(() => { + onHiddenRef.current = onHidden; + }, [onHidden]); + + const shouldShow = show && !autoDismissed; + + useEffect(() => { + if (!show) { + setAutoDismissed(false); + } + }, [show]); + + useEffect(() => { + if (shouldShow) { + setRendered(true); + return; + } + + if (!rendered) { + return; + } + + const hideTimer = window.setTimeout(() => { + setRendered(false); + onHiddenRef.current?.(); + }, CALLOUT_ANIMATION_MS); + + return () => window.clearTimeout(hideTimer); + }, [shouldShow, rendered]); + + useEffect(() => { + if (!shouldShow || autoHideMs === undefined) { + return; + } + + const closeTimer = window.setTimeout(() => { + setAutoDismissed(true); + onAutoHideRef.current?.(); + }, autoHideMs); + return () => window.clearTimeout(closeTimer); + }, [shouldShow, autoHideMs]); + + if (!rendered) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index 1b5e3be5579..2c61988e3c6 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -1,13 +1,11 @@ import { Link, useNavigation } from "@remix-run/react"; import { type ReactNode } from "react"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; -import { useOptionalOrganization } from "~/hooks/useOrganizations"; -import { UpgradePrompt, useShowUpgradePrompt } from "../billing/UpgradePrompt"; +import { OrgBanner } from "../billing/OrgBanner"; import { BreadcrumbIcon } from "./BreadcrumbIcon"; import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; import { SimpleTooltip } from "./Tooltip"; -import { EnvironmentBanner } from "../navigation/EnvironmentBanner"; type WithChildren = { children: React.ReactNode; @@ -15,9 +13,6 @@ type WithChildren = { }; export function NavBar({ children }: WithChildren) { - const organization = useOptionalOrganization(); - const showUpgradePrompt = useShowUpgradePrompt(organization); - const navigation = useNavigation(); const isLoading = navigation.state === "loading" || navigation.state === "submitting"; @@ -27,7 +22,7 @@ export function NavBar({ children }: WithChildren) {
{children}
- {showUpgradePrompt.shouldShow && organization ? : } + ); } diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 72191c1d0c5..d3fd6bf7a3b 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -10,6 +10,7 @@ import { PassThrough } from "stream"; import * as Worker from "~/services/worker.server"; import { initMollifierDrainerWorker } from "~/v3/mollifierDrainerWorker.server"; import { initMollifierStaleSweepWorker } from "~/v3/mollifierStaleSweepWorker.server"; +import "~/v3/billingLimitWorker.server"; import { bootstrap } from "./bootstrap"; import { LocaleContextProvider } from "./components/primitives/LocaleProvider"; import { diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index dcb4eb11dba..5e80c0dde5a 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1471,6 +1471,37 @@ const EnvironmentSchema = z ALERTS_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"), ALERTS_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"), + BILLING_LIMIT_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), + BILLING_LIMIT_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), + BILLING_LIMIT_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10), + BILLING_LIMIT_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), + BILLING_LIMIT_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50), + BILLING_LIMIT_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(20), + BILLING_LIMIT_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(60_000), + BILLING_LIMIT_WORKER_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), + BILLING_LIMIT_RECONCILE_INTERVAL_MS: z.coerce.number().int().default(90_000), + BILLING_LIMIT_WORKER_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + BILLING_LIMIT_WORKER_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + BILLING_LIMIT_WORKER_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + BILLING_LIMIT_WORKER_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + BILLING_LIMIT_WORKER_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + SCHEDULE_ENGINE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"), SCHEDULE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"), SCHEDULE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2), diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index 1aa81b11048..c8d19c40f11 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -87,3 +87,11 @@ export function useWidgetLimitPerDashboard(matches?: UIMatch[]) { }); return data?.widgetLimitPerDashboard ?? 16; } + +export function useBillingLimit(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + return data?.billingLimit; +} diff --git a/apps/webapp/app/hooks/useScrollContainerToTop.ts b/apps/webapp/app/hooks/useScrollContainerToTop.ts new file mode 100644 index 00000000000..43db10b58ba --- /dev/null +++ b/apps/webapp/app/hooks/useScrollContainerToTop.ts @@ -0,0 +1,14 @@ +import { useLocation } from "@remix-run/react"; +import { useEffect, useRef } from "react"; + +/** Scroll a page body container back to the top when navigating to a route. */ +export function useScrollContainerToTop() { + const ref = useRef(null); + const location = useLocation(); + + useEffect(() => { + ref.current?.scrollTo(0, 0); + }, [location.key]); + + return ref; +} diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 944d14505a4..c10ae173310 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -15,6 +15,10 @@ import { featuresForUrl } from "~/features.server"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; import { enqueueAttioWorkspaceSync } from "~/services/attio.server"; +import { + applyBillingLimitPauseAfterEnvCreate, + getInitialEnvPauseStateForBillingLimit, +} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server"; export type { Organization }; const nanoid = customAlphabet("1234567890abcdef", 4); @@ -139,8 +143,9 @@ export async function createEnvironment({ const shortcode = createShortcode().join("-"); const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type); + const billingPause = await getInitialEnvPauseStateForBillingLimit(organization.id, type); - return await prismaClient.runtimeEnvironment.create({ + const environment = await prismaClient.runtimeEnvironment.create({ data: { slug, apiKey, @@ -148,6 +153,8 @@ export async function createEnvironment({ shortcode, autoEnableInternalSources: type !== "DEVELOPMENT", maximumConcurrencyLimit: limit, + paused: billingPause.paused, + pauseSource: billingPause.pauseSource, organization: { connect: { id: organization.id, @@ -162,7 +169,15 @@ export async function createEnvironment({ type, isBranchableEnvironment, }, + include: { + organization: true, + project: true, + }, }); + + await applyBillingLimitPauseAfterEnvCreate(environment); + + return environment; } function createShortcode() { diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 99ced5e3efb..c170f6278dd 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -79,6 +79,7 @@ export class OrganizationsPresenter { type: true, slug: true, paused: true, + pauseSource: true, isBranchableEnvironment: true, branchName: true, parentEnvironmentId: true, @@ -207,6 +208,7 @@ export class OrganizationsPresenter { | "type" | "branchName" | "paused" + | "pauseSource" | "parentEnvironmentId" | "isBranchableEnvironment" | "archivedAt" diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 720bfda8db7..9c4766d3d8b 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,6 +1,6 @@ import { $replica, PrismaClient, PrismaReplicaClient, prisma } from "~/db.server"; -import { Project } from "~/models/project.server"; -import { User } from "~/models/user.server"; +import type { Project } from "~/models/project.server"; +import type { User } from "~/models/user.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository"; import { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index b6afe46be4f..0788d6de13c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -10,6 +10,7 @@ import { import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { EnvironmentPauseSource } from "@trigger.dev/database"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import type { QueueItem } from "@trigger.dev/core/v3/schemas"; import { useEffect, useState } from "react"; @@ -184,11 +185,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (action) { case "environment-pause": const pauseService = new PauseEnvironmentService(); - await pauseService.call(environment, "paused"); + { + const result = await pauseService.call(environment, "paused"); + if (!result.success) { + return redirectWithErrorMessage(redirectPath, request, result.error); + } + } return redirectWithSuccessMessage(redirectPath, request, "Environment paused"); case "environment-resume": const resumeService = new PauseEnvironmentService(); - await resumeService.call(environment, "resumed"); + { + const result = await resumeService.call(environment, "resumed"); + if (!result.success) { + return redirectWithErrorMessage(redirectPath, request, result.error); + } + } return redirectWithSuccessMessage(redirectPath, request, "Environment resumed"); case "queue-pause": case "queue-resume": { @@ -346,7 +357,9 @@ export default function Page() { animate accessory={
- {environment.runsEnabled ? : null} + {environment.runsEnabled && env.pauseSource !== EnvironmentPauseSource.BILLING_LIMIT ? ( + + ) : null} { - return [ - { - title: `Billing alerts | Trigger.dev`, - }, - ]; -}; - -export const loader = dashboardLoader( - { - params: OrganizationParamsSchema, - context: async (params) => { - const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); - return organizationId ? { organizationId } : {}; - }, - authorization: { - action: "manage", - resource: { type: "billing" }, - message: "With your current role, you can't manage billing alerts.", - }, - }, - async ({ params, request, user }) => { - const userId = user.id; - const { organizationSlug } = params; - - const { isManagedCloud } = featuresForRequest(request); - if (!isManagedCloud) { - return redirect(organizationPath({ slug: organizationSlug })); - } - - const organization = await prisma.organization.findFirst({ - where: { slug: organizationSlug, members: { some: { userId } } }, - }); - - if (!organization) { - throw new Response(null, { status: 404, statusText: "Organization not found" }); - } - - const currentPlan = await getCurrentPlan(organization.id); - if (currentPlan?.v3Subscription?.showSelfServe === false) { - return redirect(v3BillingPath({ slug: organizationSlug })); - } - - const [error, alerts] = await tryCatch(getBillingAlerts(organization.id)); - if (error) { - throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` }); - } - - if (!alerts) { - throw new Response(null, { status: 404, statusText: "Billing alerts not found" }); - } - - return typedjson({ - alerts: { - ...alerts, - amount: alerts.amount / 100, - }, - }); - } -); - -type BillingAlertsData = UseDataFunctionReturn; - -const schema = z.object({ - amount: z - .number({ invalid_type_error: "Not a valid amount" }) - .min(0, "Amount must be greater than 0"), - 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) => { - if (typeof i === "string") return [i]; - return i; - }, z.coerce.number().array().nonempty("At least one alert level is required")), -}); - -export const action = dashboardAction( - { - params: OrganizationParamsSchema, - context: async (params) => { - const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); - return organizationId ? { organizationId } : {}; - }, - authorization: { action: "manage", resource: { type: "billing" } }, - }, - async ({ request, params, user }) => { - const userId = user.id; - const { organizationSlug } = params; - - const organization = await prisma.organization.findFirst({ - where: { slug: organizationSlug, members: { some: { userId } } }, - }); - - if (!organization) { - return redirectWithErrorMessage( - v3BillingPath({ slug: organizationSlug }), - request, - "You are not authorized to update billing alerts" - ); - } - - const currentPlan = await getCurrentPlan(organization.id); - if (currentPlan?.v3Subscription?.showSelfServe === false) { - return redirect(v3BillingPath({ slug: organizationSlug })); - } - - const formData = await request.formData(); - const submission = parse(formData, { schema }); - - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } - - try { - const [error, updatedAlert] = await tryCatch( - setBillingAlert(organization.id, { - ...submission.value, - amount: submission.value.amount * 100, - }) - ); - if (error) { - return redirectWithErrorMessage( - v3BillingAlertsPath({ slug: organizationSlug }), - request, - "Failed to update billing alert" - ); - } - - if (!updatedAlert) { - return redirectWithErrorMessage( - v3BillingAlertsPath({ slug: organizationSlug }), - request, - "Failed to update billing alert" - ); - } - - return redirectWithSuccessMessage( - v3BillingAlertsPath({ slug: organizationSlug }), - request, - "Billing alert updated" - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); - } - } -); - -export default function Page() { - const loaderData = useTypedLoaderData(); - - return ; -} - -function BillingAlerts({ alerts }: { alerts: BillingAlertsData["alerts"] }) { - const plan = useCurrentPlan(); - const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2)); - - const lastSubmission = useActionData(); - - const [form, { emails, amount, alertLevels }] = useForm({ - id: "invite-members", - // TODO: type this - lastSubmission: lastSubmission as any, - onValidate({ formData }) { - return parse(formData, { schema }); - }, - defaultValue: { - emails: [""], - }, - }); - - const fieldValues = useRef(alerts.emails); - const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails }); - - const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0]; - - const spikeAlertLevels = [10.0, 20.0, 50.0, 100.0]; - - useEffect(() => { - if (alerts.emails.length > 0) { - requestIntent(form.ref.current ?? undefined, list.append(emails.name)); - } - }, [emails.name, form.ref]); - const isFree = !plan?.v3Subscription?.isPaying; - - return ( - - - - - - - - - -
-
- Billing alerts - - Receive an email when your compute spend crosses different thresholds. You can also - learn how to{" "} - - reduce your compute spend - - . - -
-
-
- - - {isFree ? ( - <> - - ${dollarAmount} - - - - ) : ( - { - const numberValue = Number(e.target.value); - if (numberValue < 0) { - setDollarAmount(""); - return; - } - setDollarAmount(e.target.value); - }} - step={0.01} - min={0} - placeholder="Enter an amount" - icon={ - $ - } - className="pl-px" - fullWidth - readOnly={isFree} - /> - )} - {amount.error} - - - - {checkboxLevels.map((level) => ( - - {level * 100}%{" "} - - ({formatCurrency(Number(dollarAmount) * level, false)}) - - - } - defaultChecked={alerts.alertLevels.includes(level)} - className="pr-0" - readOnly={level === 1.0} - /> - ))} - {alertLevels.error} - - -
- - -
- {spikeAlertLevels.map((level) => ( - - {formatNumber(level * 100)}%{" "} - - ({formatCurrency(Number(dollarAmount) * level, false)}) - - - } - defaultChecked={ - alerts.alertLevels.includes(level) || - !spikeAlertLevels.some((l) => alerts.alertLevels.includes(l)) - } - className="pr-0" - /> - ))} -
- - - {emailFields.map((email, index) => ( - - { - fieldValues.current[index] = e.target.value; - if ( - emailFields.length === fieldValues.current.length && - fieldValues.current.every((v) => v !== "") - ) { - requestIntent(form.ref.current ?? undefined, list.append(emails.name)); - } - }} - fullWidth - /> - {email.error} - - ))} - - - Update - - } - /> -
-
-
-
-
-
- ); +export async function loader({ params }: LoaderFunctionArgs) { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + return redirect(v3BillingLimitsPath({ slug: organizationSlug }), 302); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation.ts new file mode 100644 index 00000000000..13b7f1c5f2c --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation.ts @@ -0,0 +1,12 @@ +/** Revalidate org layout (billingLimit banner) after billing limits settings forms submit. */ +export function isBillingLimitSettingsFormSubmission( + formMethod: string | undefined, + formData: FormData | undefined +): boolean { + if (!formMethod || !formData || formMethod.toLowerCase() !== "post") { + return false; + } + + const intent = formData.get("intent"); + return intent === "billing-limit" || intent === "billing-alerts" || intent === "billing-limit-resolve"; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server.ts new file mode 100644 index 00000000000..dd51df7e388 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server.ts @@ -0,0 +1,37 @@ +import type { BillingLimitResult } from "~/services/billingLimit.schemas"; +import type { ResolveBillingLimitRequest } from "~/services/billingLimit.schemas"; + +export function isEnforcementActive(billingLimit: BillingLimitResult): boolean { + return ( + billingLimit.isConfigured && + (billingLimit.limitState.status === "grace" || billingLimit.limitState.status === "rejected") + ); +} + +export function getAlertsResetRequested(request: Request): boolean { + return new URL(request.url).searchParams.get("alertsReset") === "1"; +} + +export function getEffectiveLimitCentsAfterLimitSave( + mode: "plan" | "custom" | "none", + planLimitCents: number, + customAmountDollars?: number +): number { + if (mode === "custom") { + return Math.round((customAmountDollars ?? 0) * 100); + } + + return planLimitCents; +} + +export function getResolveSubmitted(request: Request): boolean { + return new URL(request.url).searchParams.get("resolved") === "1"; +} + +export function getSubmittedResumeMode(request: Request): ResolveBillingLimitRequest["resumeMode"] | null { + const value = new URL(request.url).searchParams.get("resumeMode"); + if (value === "queue" || value === "new_only") { + return value; + } + return null; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx new file mode 100644 index 00000000000..6a2b3efd7fa --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx @@ -0,0 +1,533 @@ +import { parse } from "@conform-to/zod"; +import type { MetaFunction } from "@remix-run/react"; +import { + json, + redirect, + type ActionFunction, + type LoaderFunctionArgs, +} from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { + BillingAlertsSection, + billingAlertsSchema, + type BillingAlertsFormData, +} from "~/components/billing/BillingAlertsSection"; +import { + getBillingLimitMode, + getEffectiveLimitCents, + isPercentageAlertMode, + MAX_ABSOLUTE_ALERTS, + MAX_PERCENTAGE_ALERTS, + MAX_PERCENTAGE_THRESHOLD, + normalizeBillingAlertsFromApi, + resetAlertsPayloadForLimitMode, + shouldResetAlertsOnLimitChange, + thresholdsToAlertPayload, + hadSavedAlertsToClearOnLimitChange, + thresholdValuesAreUnique, +} from "~/components/billing/billingAlertsFormat"; +import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat"; +import { + BillingLimitConfigSection, + billingLimitFormSchema, +} from "~/components/billing/BillingLimitConfigSection"; +import { + BillingLimitRecoveryPanel, + billingLimitRecoveryFormSchema, +} from "~/components/billing/BillingLimitRecoveryPanel"; +import { BillingLimitResolveProgress } from "~/components/billing/BillingLimitResolveProgress"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { prisma } from "~/db.server"; +import { featuresForRequest } from "~/features.server"; +import { useScrollContainerToTop } from "~/hooks/useScrollContainerToTop"; +import { + commitSession, + getSession, + redirectWithErrorMessage, + redirectWithSuccessMessage, + setSuccessMessage, +} from "~/models/message.server"; +import { + getBillingAlerts, + getBillingLimit, + getCachedUsage, + getCurrentPlan, + resolveBillingLimit, + setBillingAlert, + setBillingLimit, +} from "~/services/platform.v3.server"; +import type { BillingLimitResult } from "~/services/billingLimit.schemas"; +import { + getAlertsResetRequested, + getEffectiveLimitCentsAfterLimitSave, + getResolveSubmitted, + getSubmittedResumeMode, + isEnforcementActive, +} from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server"; +import { + countBillingLimitPausedEnvironments, + getBillingLimitQueuedRunCount, +} from "~/v3/services/billingLimit/getBillingLimitQueuedRunCount.server"; +import { + OrganizationParamsSchema, + organizationPath, + v3BillingLimitsPath, + v3BillingPath, +} from "~/utils/pathBuilder"; +import { requireUserId } from "~/services/session.server"; + +export const meta: MetaFunction = () => { + return [{ title: `Billing limits | Trigger.dev` }]; +}; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const { isManagedCloud } = featuresForRequest(request); + if (!isManagedCloud) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + const currentPlan = await getCurrentPlan(organization.id); + if (currentPlan?.v3Subscription?.showSelfServe === false) { + return redirect(v3BillingPath({ slug: organizationSlug })); + } + + const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id)); + if (billingLimitError || !billingLimit) { + throw new Response(null, { + status: 404, + statusText: `Billing limit error: ${billingLimitError ?? "not found"}`, + }); + } + + const [alertsError, alerts] = await tryCatch(getBillingAlerts(organization.id)); + if (alertsError || !alerts) { + throw new Response(null, { + status: 404, + statusText: `Billing alerts error: ${alertsError ?? "not found"}`, + }); + } + + const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500; + const alertsResetRequested = getAlertsResetRequested(request); + const resolveSubmitted = getResolveSubmitted(request); + const submittedResumeMode = getSubmittedResumeMode(request); + + const firstDayOfMonth = new Date(); + firstDayOfMonth.setUTCDate(1); + firstDayOfMonth.setUTCHours(0, 0, 0, 0); + + const firstDayOfNextMonth = new Date(); + firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1); + firstDayOfNextMonth.setUTCDate(1); + firstDayOfNextMonth.setUTCHours(0, 0, 0, 0); + + const [usage, queuedRunCount, billingLimitPauseEnvCount] = await Promise.all([ + getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }), + isEnforcementActive(billingLimit) + ? getBillingLimitQueuedRunCount(organization.id) + : Promise.resolve(0), + countBillingLimitPausedEnvironments(organization.id), + ]); + + const currentSpendCents = usage?.cents ?? 0; + const suggestedNewLimitDollars = isEnforcementActive(billingLimit) + ? getSuggestedRecoveryLimitDollars( + billingLimit.isConfigured ? billingLimit.effectiveAmountCents : null, + currentSpendCents + ) + : 0; + + const alertsFormData = normalizeBillingAlertsFromApi(alerts); + + return typedjson({ + billingLimit, + alerts: alertsFormData, + planLimitCents, + isRecoveryMode: isEnforcementActive(billingLimit), + alertsResetRequested, + currentSpendCents, + queuedRunCount, + billingLimitPauseEnvCount, + resolveSubmitted, + submittedResumeMode, + suggestedNewLimitDollars, + }); +} + +type LoaderData = { + billingLimit: BillingLimitResult; + alerts: BillingAlertsFormData; + planLimitCents: number; + isRecoveryMode: boolean; + alertsResetRequested: boolean; + currentSpendCents: number; + queuedRunCount: number; + billingLimitPauseEnvCount: number; + resolveSubmitted: boolean; + submittedResumeMode: "queue" | "new_only" | null; + suggestedNewLimitDollars: number; +}; + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3BillingPath({ slug: organizationSlug }), + request, + "You are not authorized to update billing settings" + ); + } + + const currentPlan = await getCurrentPlan(organization.id); + if (currentPlan?.v3Subscription?.showSelfServe === false) { + return redirect(v3BillingPath({ slug: organizationSlug })); + } + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "billing-alerts") { + const submission = parse(formData, { schema: billingAlertsSchema }); + if (!submission.value || submission.intent !== "submit") { + return json({ formIntent: "billing-alerts", submission }); + } + + const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id)); + if (billingLimitError || !billingLimit) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Failed to load billing limit for alerts" + ); + } + + const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500; + const billingLimitMode = getBillingLimitMode(billingLimit); + const maxAlerts = isPercentageAlertMode(billingLimitMode) + ? MAX_PERCENTAGE_ALERTS + : MAX_ABSOLUTE_ALERTS; + + if (submission.value.alertLevels.length > maxAlerts) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + `You can add at most ${maxAlerts} alerts` + ); + } + + if ( + submission.value.alertLevels.some((threshold) => !Number.isFinite(threshold) || threshold <= 0) + ) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Each alert must be greater than 0" + ); + } + + if (!thresholdValuesAreUnique(submission.value.alertLevels)) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Each alert must be unique" + ); + } + + if ( + isPercentageAlertMode(billingLimitMode) && + submission.value.alertLevels.some((threshold) => threshold > MAX_PERCENTAGE_THRESHOLD) + ) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Alerts cannot exceed 100% of your billing limit" + ); + } + + const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents); + const alertPayload = thresholdsToAlertPayload( + submission.value.alertLevels, + billingLimitMode, + effectiveLimitCents + ); + + const [error] = await tryCatch( + setBillingAlert(organization.id, { + emails: submission.value.emails, + ...alertPayload, + }) + ); + + if (error) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Failed to update billing alerts" + ); + } + + return redirectWithSuccessMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Billing alerts updated" + ); + } + + if (intent === "billing-limit") { + const submission = parse(formData, { schema: billingLimitFormSchema }); + if (!submission.value || submission.intent !== "submit") { + return json({ formIntent: "billing-limit", submission }); + } + + const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id)); + if (billingLimitError || !billingLimit) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Failed to load billing limit" + ); + } + + if (isEnforcementActive(billingLimit)) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Resolve the active billing limit before changing settings" + ); + } + + const cancelInProgressRuns = + submission.value.mode === "none" ? false : (submission.value.cancelInProgressRuns ?? false); + const previousMode = getBillingLimitMode(billingLimit); + const resettingAlerts = shouldResetAlertsOnLimitChange(previousMode, submission.value.mode); + const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500; + + try { + if (submission.value.mode === "custom") { + await setBillingLimit(organization.id, { + mode: "custom", + amountCents: Math.round(submission.value.amount * 100), + cancelInProgressRuns, + }); + } else if (submission.value.mode === "plan") { + await setBillingLimit(organization.id, { + mode: "plan", + cancelInProgressRuns, + }); + } else { + await setBillingLimit(organization.id, { + mode: "none", + cancelInProgressRuns: false, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update billing limit"; + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + message + ); + } + + if (resettingAlerts) { + const [alertsError, existingAlerts] = await tryCatch(getBillingAlerts(organization.id)); + if (alertsError || !existingAlerts) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Billing limit updated, but failed to clear billing alerts" + ); + } + + const existingAlertsFormData = normalizeBillingAlertsFromApi(existingAlerts); + + const shouldClearSavedAlerts = hadSavedAlertsToClearOnLimitChange( + existingAlertsFormData, + billingLimit, + planLimitCents + ); + + if (shouldClearSavedAlerts) { + const effectiveLimitCents = getEffectiveLimitCentsAfterLimitSave( + submission.value.mode, + planLimitCents, + submission.value.mode === "custom" ? submission.value.amount : undefined + ); + + const [clearAlertsError] = await tryCatch( + setBillingAlert( + organization.id, + resetAlertsPayloadForLimitMode( + submission.value.mode, + effectiveLimitCents, + existingAlerts.emails ?? [] + ) + ) + ); + + if (clearAlertsError) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Billing limit updated, but failed to clear billing alerts" + ); + } + + const session = await getSession(request.headers.get("cookie")); + setSuccessMessage(session, "Billing limit updated"); + + return redirect(`${v3BillingLimitsPath({ slug: organizationSlug })}?alertsReset=1`, { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + } + + const session = await getSession(request.headers.get("cookie")); + setSuccessMessage(session, "Billing limit updated"); + + return redirect(v3BillingLimitsPath({ slug: organizationSlug }), { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + + if (intent === "billing-limit-resolve") { + const submission = parse(formData, { schema: billingLimitRecoveryFormSchema }); + if (!submission.value || submission.intent !== "submit") { + return json({ formIntent: "billing-limit-resolve", submission }); + } + + const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id)); + if (billingLimitError || !billingLimit) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Failed to load billing limit" + ); + } + + if (!isEnforcementActive(billingLimit)) { + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + "Billing limit is not in an enforced state" + ); + } + + const resolvePayload = + submission.value.action === "increase" + ? { + action: "increase" as const, + newAmountCents: Math.round((submission.value.newAmount ?? 0) * 100), + resumeMode: submission.value.resumeMode, + } + : { + action: "remove" as const, + resumeMode: submission.value.resumeMode, + }; + + const [error] = await tryCatch(resolveBillingLimit(organization.id, resolvePayload)); + + if (error) { + const message = + error instanceof Error ? error.message : "Failed to resolve billing limit"; + return redirectWithErrorMessage( + v3BillingLimitsPath({ slug: organizationSlug }), + request, + message + ); + } + + const session = await getSession(request.headers.get("cookie")); + setSuccessMessage(session, "Billing limit resolved"); + + const resumeModeParam = submission.value.resumeMode; + return redirect( + `${v3BillingLimitsPath({ slug: organizationSlug })}?resolved=1&resumeMode=${resumeModeParam}`, + { + headers: { + "Set-Cookie": await commitSession(session), + }, + } + ); + } + + return json({ error: "Unknown form intent" }, { status: 400 }); +}; + +export default function Page() { + const { + billingLimit, + alerts, + planLimitCents, + isRecoveryMode, + alertsResetRequested, + currentSpendCents, + queuedRunCount, + billingLimitPauseEnvCount, + resolveSubmitted, + submittedResumeMode, + suggestedNewLimitDollars, + } = useTypedLoaderData(); + + const showResolveProgress = resolveSubmitted && billingLimitPauseEnvCount > 0; + const pageBodyRef = useScrollContainerToTop(); + + return ( + + + + + + + + +
+ + {isRecoveryMode && billingLimit.isConfigured ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx index a430a2b5023..6d609c9cc86 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx @@ -6,6 +6,7 @@ import { Suspense, useMemo } from "react"; import { redirect, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { URL } from "url"; import { UsageBar } from "~/components/billing/UsageBar"; +import { getUsageBarBillingLimitDollars } from "~/components/billing/billingAlertsFormat"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Card } from "~/components/primitives/charts/Card"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; @@ -30,6 +31,7 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server"; import { requireUserId } from "~/services/session.server"; import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter"; +import { useBillingLimit } from "~/hooks/useOrganizations"; import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; @@ -96,6 +98,11 @@ const monthDateFormatter = new Intl.DateTimeFormat("en-US", { export default function Page() { const { usage, tasks, months, isCurrentMonth } = useTypedLoaderData(); const currentPlan = useCurrentPlan(); + const billingLimit = useBillingLimit(); + const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0; + const billingLimitDollars = isCurrentMonth + ? getUsageBarBillingLimitDollars(billingLimit, planLimitCents) + : undefined; const { value, replace } = useSearchParams(); const month = value("month") ?? months[0].toISOString(); @@ -156,10 +163,9 @@ export default function Page() { current={usage.overall.current} isPaying={currentPlan?.v3Subscription?.isPaying ?? false} tierLimit={ - isCurrentMonth - ? (currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0) / 100 - : undefined + isCurrentMonth ? planLimitCents / 100 : undefined } + billingLimit={billingLimitDollars} />
)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 1ad36854dcf..6d8a54d9bdb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -9,11 +9,12 @@ import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server"; import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server"; import { getImpersonationId } from "~/services/impersonation.server"; -import { getCachedUsage, getCurrentPlan } from "~/services/platform.v3.server"; +import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server"; import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; import { organizationPath } from "~/utils/pathBuilder"; import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route"; +import { isBillingLimitSettingsFormSubmission } from "../_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation"; const ParamsSchema = z.object({ organizationSlug: z.string(), @@ -53,6 +54,10 @@ export const shouldRevalidate: ShouldRevalidateFunction = (params) => { return true; } + if (isBillingLimitSettingsFormSubmission(params.formMethod, params.formData)) { + return true; + } + // This prevents revalidation when there are search params changes // IMPORTANT: If the loader function depends on search params, this should be updated return params.currentUrl.pathname !== params.nextUrl.pathname; @@ -92,9 +97,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const shouldLoadRegions = !!projectParam && !!environment && environment.type !== "DEVELOPMENT"; - const [plan, usage, customDashboards, regions] = await Promise.all([ + const [plan, usage, billingLimit, customDashboards, regions] = await Promise.all([ getCurrentPlan(organization.id), getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }), + getBillingLimit(organization.id), prisma.metricsDashboard.findMany({ where: { organizationId: organization.id }, select: { @@ -160,6 +166,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { regions, isImpersonating: !!impersonationId, currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } }, + billingLimit, customDashboards: customDashboardsWithWidgetCount, dashboardLimits: { used: customDashboards.length, diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.hit.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.hit.ts new file mode 100644 index 00000000000..cbda4ee1dc7 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.hit.ts @@ -0,0 +1,53 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { BillingLimitHitWebhookBodySchema } from "~/services/billingLimit.schemas"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; +import { bustBillingLimitCaches } from "~/services/platform.v3.server"; +import { + enqueueBillingLimitCancelInProgressRuns, + enqueueBillingLimitConverge, +} from "~/v3/billingLimitWorker.server"; +import { BillingLimitConvergeEnvironmentsService } from "~/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server"; +import { processBillingLimitHit } from "~/v3/services/billingLimit/billingLimitHit.server"; + +const ParamsSchema = z.object({ + organizationId: z.string(), +}); + +/** Billing platform webhook: org entered billing limit grace. Idempotent — returns 202. */ +export async function action({ request, params }: ActionFunctionArgs) { + await requireAdminApiRequest(request); + + if (request.method.toLowerCase() !== "post") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const { organizationId } = ParamsSchema.parse(params); + const body = BillingLimitHitWebhookBodySchema.parse(await request.json()); + + const organization = await prisma.organization.findFirst({ + where: { id: organizationId }, + select: { id: true }, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + await processBillingLimitHit( + { + organizationId, + hitAt: body.hitAt, + cancelInProgressRuns: body.cancelInProgressRuns, + }, + { + bustCaches: bustBillingLimitCaches, + seedReconcileQueue: BillingLimitConvergeEnvironmentsService.seedReconcileQueue, + enqueueConverge: enqueueBillingLimitConverge, + enqueueCancelInProgressRuns: enqueueBillingLimitCancelInProgressRuns, + } + ); + + return json({ success: true, accepted: true }, { status: 202 }); +} diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.reject.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.reject.ts new file mode 100644 index 00000000000..de4362e5ef2 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.reject.ts @@ -0,0 +1,37 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; +import { bustBillingLimitCaches } from "~/services/platform.v3.server"; +import { BillingLimitConvergeEnvironmentsService } from "~/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server"; +import { enqueueBillingLimitConverge } from "~/v3/billingLimitWorker.server"; + +const ParamsSchema = z.object({ + organizationId: z.string(), +}); + +/** Billing platform webhook: org billing limit grace expired. Idempotent — returns 202. */ +export async function action({ request, params }: ActionFunctionArgs) { + await requireAdminApiRequest(request); + + if (request.method.toLowerCase() !== "post") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const { organizationId } = ParamsSchema.parse(params); + + const organization = await prisma.organization.findFirst({ + where: { id: organizationId }, + select: { id: true }, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + bustBillingLimitCaches(organizationId); + await BillingLimitConvergeEnvironmentsService.seedReconcileQueue(organizationId); + await enqueueBillingLimitConverge(organizationId, "rejected"); + + return json({ success: true, accepted: true }, { status: 202 }); +} diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.resolve.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.resolve.ts new file mode 100644 index 00000000000..c4163bcdea5 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.billing-limit.resolve.ts @@ -0,0 +1,69 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; +import { completeBillingLimitResolve } from "~/services/platform.v3.server"; +import { logger } from "~/services/logger.server"; +import { + convergeBillingLimitResolve, + type PendingBillingLimitResolve, +} from "~/v3/services/billingLimit/billingLimitConvergeResolve.server"; + +const ParamsSchema = z.object({ + organizationId: z.string(), +}); + +const BodySchema = z.object({ + resumeMode: z.enum(["queue", "new_only"]), + resolvedAt: z.string(), +}); + +/** Billing platform webhook: org resolved billing limit to ok. Idempotent — returns 202. */ +export async function action({ request, params }: ActionFunctionArgs) { + await requireAdminApiRequest(request); + + if (request.method.toLowerCase() !== "post") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const { organizationId } = ParamsSchema.parse(params); + + const organization = await prisma.organization.findFirst({ + where: { id: organizationId }, + select: { id: true }, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + let pending: PendingBillingLimitResolve; + try { + const body = await request.json(); + pending = { + organizationId, + ...BodySchema.parse(body), + }; + } catch (error) { + logger.error("Invalid billing limit resolve webhook payload", { + error, + organizationId, + }); + return json({ error: "Invalid request body" }, { status: 400 }); + } + + try { + await convergeBillingLimitResolve(pending); + await completeBillingLimitResolve(organizationId); + } catch (error) { + logger.error("Billing limit resolve webhook failed", { + error, + organizationId, + resumeMode: pending.resumeMode, + resolvedAt: pending.resolvedAt, + }); + return json({ error: "Failed to process billing limit resolve" }, { status: 500 }); + } + + return json({ success: true, accepted: true }, { status: 202 }); +} diff --git a/apps/webapp/app/routes/storybook.callout/route.tsx b/apps/webapp/app/routes/storybook.callout/route.tsx index d5e3464daea..35386d7d4ca 100644 --- a/apps/webapp/app/routes/storybook.callout/route.tsx +++ b/apps/webapp/app/routes/storybook.callout/route.tsx @@ -1,11 +1,23 @@ import { EnvelopeIcon } from "@heroicons/react/20/solid"; +import { useState } from "react"; +import { AnimatedCallout } from "~/components/primitives/AnimatedCallout"; +import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Header2 } from "~/components/primitives/Headers"; export default function Story() { + const [showAnimatedCallout, setShowAnimatedCallout] = useState(true); + return (
+ Animated callout + + + This callout fades in and out + Callouts This is an info callout This is a warning callout diff --git a/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts b/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts index ab6e46521ab..97e98d1aeb0 100644 --- a/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts +++ b/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts @@ -1,7 +1,7 @@ import { MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server"; import { logger } from "~/services/logger.server"; import { getEntitlement } from "~/services/platform.v3.server"; -import { MAX_ATTEMPTS, OutOfEntitlementError } from "~/v3/services/triggerTask.server"; +import { MAX_ATTEMPTS } from "~/v3/services/triggerTask.server"; import { isFinalRunStatus } from "~/v3/taskStatus"; import type { EntitlementValidationParams, @@ -12,6 +12,7 @@ import type { TriggerTaskValidator, ValidationResult, } from "../types"; +import { validateProductionEntitlement } from "./validateProductionEntitlement.server"; import { ServiceValidationError } from "~/v3/services/common.server"; export class DefaultTriggerTaskValidator implements TriggerTaskValidator { @@ -41,22 +42,7 @@ export class DefaultTriggerTaskValidator implements TriggerTaskValidator { async validateEntitlement( params: EntitlementValidationParams ): Promise { - const { environment } = params; - - if (environment.type === "DEVELOPMENT") { - return { ok: true }; - } - - const result = await getEntitlement(environment.organizationId); - - if (result && result.hasAccess === false) { - return { - ok: false, - error: new OutOfEntitlementError(), - }; - } - - return { ok: true, plan: result?.plan }; + return validateProductionEntitlement(params, getEntitlement); } validateMaxAttempts(params: MaxAttemptsValidationParams): ValidationResult { diff --git a/apps/webapp/app/runEngine/validators/validateProductionEntitlement.server.ts b/apps/webapp/app/runEngine/validators/validateProductionEntitlement.server.ts new file mode 100644 index 00000000000..04751b4a47d --- /dev/null +++ b/apps/webapp/app/runEngine/validators/validateProductionEntitlement.server.ts @@ -0,0 +1,29 @@ +import type { EntitlementResult } from "~/services/billingLimit.schemas"; +import { OutOfEntitlementError } from "~/v3/outOfEntitlementError.server"; +import type { EntitlementValidationParams, EntitlementValidationResult } from "../types"; + +export type GetEntitlementFn = ( + organizationId: string +) => Promise; + +export async function validateProductionEntitlement( + params: EntitlementValidationParams, + getEntitlementFn: GetEntitlementFn +): Promise { + const { environment } = params; + + if (environment.type === "DEVELOPMENT") { + return { ok: true }; + } + + const result = await getEntitlementFn(environment.organizationId); + + if (result && result.hasAccess === false) { + return { + ok: false, + error: new OutOfEntitlementError(), + }; + } + + return { ok: true, plan: result?.plan }; +} diff --git a/apps/webapp/app/services/billingLimit.schemas.ts b/apps/webapp/app/services/billingLimit.schemas.ts new file mode 100644 index 00000000000..415d9b8de12 --- /dev/null +++ b/apps/webapp/app/services/billingLimit.schemas.ts @@ -0,0 +1,181 @@ +import { BillingClient } from "@trigger.dev/platform"; +import { z } from "zod"; + +/** + * Billing limit API schemas for the billing platform service. + * + * These mirror the planned @trigger.dev/platform types and are used via + * BillingClient.fetch until the platform package is published with native + * BillingClient methods. + */ + +export const BillingLimitStateSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + }), + z.object({ + status: z.literal("grace"), + hitAt: z.string(), + graceEndsAt: z.string(), + }), + z.object({ + status: z.literal("rejected"), + hitAt: z.string(), + graceEndsAt: z.string(), + }), +]); + +export type BillingLimitState = z.infer; + +export const BillingLimitConfigSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("none"), + }), + z.object({ + mode: z.literal("plan"), + }), + z.object({ + mode: z.literal("custom"), + amountCents: z.number().int().positive(), + }), +]); + +export type BillingLimitConfig = z.infer; + +export const BillingLimitUnconfiguredSchema = z.object({ + isConfigured: z.literal(false), + gracePeriodMs: z.number().int().nonnegative(), +}); + +const billingLimitConfiguredFields = { + isConfigured: z.literal(true), + cancelInProgressRuns: z.boolean(), + limitState: BillingLimitStateSchema, + effectiveAmountCents: z.number().int().nonnegative().nullable(), + gracePeriodMs: z.number().int().nonnegative(), +}; + +export const BillingLimitConfiguredNoneSchema = z.object({ + ...billingLimitConfiguredFields, + mode: z.literal("none"), +}); + +export const BillingLimitConfiguredPlanSchema = z.object({ + ...billingLimitConfiguredFields, + mode: z.literal("plan"), +}); + +export const BillingLimitConfiguredCustomSchema = z.object({ + ...billingLimitConfiguredFields, + mode: z.literal("custom"), + amountCents: z.number().int().positive(), +}); + +export const BillingLimitConfiguredSchema = z.discriminatedUnion("mode", [ + BillingLimitConfiguredNoneSchema, + BillingLimitConfiguredPlanSchema, + BillingLimitConfiguredCustomSchema, +]); + +export const BillingLimitResultSchema = z.union([ + BillingLimitUnconfiguredSchema, + BillingLimitConfiguredNoneSchema, + BillingLimitConfiguredPlanSchema, + BillingLimitConfiguredCustomSchema, +]); + +export type BillingLimitResult = z.infer; + +export const UpdateBillingLimitRequestSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("none"), + cancelInProgressRuns: z.boolean(), + }), + z.object({ + mode: z.literal("plan"), + cancelInProgressRuns: z.boolean(), + }), + z.object({ + mode: z.literal("custom"), + amountCents: z.number().int().positive(), + cancelInProgressRuns: z.boolean(), + }), +]); + +export type UpdateBillingLimitRequest = z.infer; + +export const ResolveBillingLimitRequestSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("increase"), + newAmountCents: z.number().int().positive(), + resumeMode: z.enum(["queue", "new_only"]), + }), + z.object({ + action: z.literal("remove"), + resumeMode: z.enum(["queue", "new_only"]), + }), +]); + +export type ResolveBillingLimitRequest = z.infer; + +export const BillingLimitActiveOrgSchema = z.object({ + orgId: z.string(), + limitState: z.enum(["grace", "rejected"]), +}); + +export const BillingLimitsActiveResultSchema = z.object({ + orgs: z.array(BillingLimitActiveOrgSchema), +}); + +export type BillingLimitsActiveResult = z.infer; + +export const BillingLimitPendingResolveOrgSchema = z.object({ + organizationId: z.string(), + resumeMode: z.enum(["queue", "new_only"]), + resolvedAt: z.string(), +}); + +export const BillingLimitsPendingResolvesResultSchema = z.object({ + orgs: z.array(BillingLimitPendingResolveOrgSchema), +}); + +export type BillingLimitsPendingResolvesResult = z.infer< + typeof BillingLimitsPendingResolvesResultSchema +>; + +export const BillingLimitHitWebhookBodySchema = z.object({ + hitAt: z.string(), + cancelInProgressRuns: z.boolean(), + limitState: z.literal("grace"), +}); + +export type BillingLimitHitWebhookBody = z.infer; + +/** Entitlement response — mirrors ReportUsageResult with billing limit fields until platform ships native types. */ +export const EntitlementResultSchema = z.object({ + hasAccess: z.boolean(), + balance: z.number().optional(), + usage: z.number().optional(), + overage: z.number().optional(), + plan: z + .object({ + type: z.string(), + code: z.string(), + isPaying: z.boolean(), + }) + .optional(), + limitState: z.literal("grace").optional(), + reason: z.enum(["free_tier_exceeded", "billing_limit"]).optional(), +}); + +export type EntitlementResult = z.infer; + +export type BillingLimitPageData = BillingLimitResult & { + queuedRunCount: number; + currentSpendCents: number; +}; + +/** Bridge webapp Zod schemas to BillingClient.fetch (separate Zod type instances). */ +export function asPlatformSchema(schema: z.ZodTypeAny) { + return schema as unknown as Parameters[1]; +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index fdc709fb1b1..d10636dfe73 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -11,13 +11,27 @@ import { type PrivateLinkConnection, type PrivateLinkConnectionList, type PrivateLinkRegionsResult, - type ReportUsageResult, type SetPlanBody, type UpdateBillingAlertsRequest, type UsageResult, type UsageSeriesParams, type CurrentPlan, } from "@trigger.dev/platform"; +import { + BillingLimitResultSchema, + BillingLimitsActiveResultSchema, + BillingLimitsPendingResolvesResultSchema, + EntitlementResultSchema, + ResolveBillingLimitRequestSchema, + UpdateBillingLimitRequestSchema, + asPlatformSchema, + type BillingLimitResult, + type BillingLimitsActiveResult, + type BillingLimitsPendingResolvesResult, + type EntitlementResult, + type ResolveBillingLimitRequest, + type UpdateBillingLimitRequest, +} from "~/services/billingLimit.schemas"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { createLRUMemoryStore } from "@internal/cache"; import { existsSync, readFileSync } from "node:fs"; @@ -99,11 +113,16 @@ function initializePlatformCache() { fresh: 60_000 * 5, // 5 minutes stale: 60_000 * 10, // 10 minutes }), - entitlement: new Namespace(ctx, { + entitlement: new Namespace(ctx, { stores: [memory, redisCacheStore], fresh: 60_000, // serve without revalidation for 60s stale: 120_000, // total TTL — fresh 0-60s, stale-revalidate 60-120s }), + billingLimit: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, + stale: 120_000, + }), }); return cache; @@ -111,6 +130,15 @@ function initializePlatformCache() { const platformCache = singleton("platformCache", initializePlatformCache); +function invalidateBillingLimitCaches(organizationId: string) { + platformCache.billingLimit.remove(organizationId).catch(() => {}); + platformCache.entitlement.remove(organizationId).catch(() => {}); +} + +export function bustBillingLimitCaches(organizationId: string) { + invalidateBillingLimitCaches(organizationId); +} + type Machines = typeof machinesFromPlatform; const MachineOverrideValues = z.object({ @@ -604,7 +632,7 @@ export async function reportComputeUsage(request: Request) { export async function getEntitlement( organizationId: string -): Promise { +): Promise { if (!client) return undefined; // Errors must be caught inside the loader — @unkey/cache passes the loader @@ -616,7 +644,10 @@ export async function getEntitlement( // SWR call so it never becomes a cached access decision. const result = await platformCache.entitlement.swr(organizationId, async () => { try { - const response = await client.getEntitlement(organizationId); + const response = await client.fetch( + `/api/v1/orgs/${organizationId}/usage/entitlement`, + asPlatformSchema(EntitlementResultSchema) + ); if (!response.success) { recordPlatformFailure("getEntitlement", "no_success"); return undefined; @@ -637,6 +668,153 @@ export async function getEntitlement( return result.val; } +export async function getBillingLimit( + organizationId: string +): Promise { + if (!client) return undefined; + + const result = await platformCache.billingLimit.swr(organizationId, async () => { + try { + const response = await client.fetch( + `/api/v1/orgs/${organizationId}/billing-limit`, + asPlatformSchema(BillingLimitResultSchema) + ); + if (!response.success) { + recordPlatformFailure("getBillingLimit", "no_success"); + return undefined; + } + return response; + } catch (e) { + recordPlatformFailure("getBillingLimit", "caught"); + return undefined; + } + }); + + if (result.err || result.val === undefined) { + return undefined; + } + + return result.val; +} + +export async function setBillingLimit( + organizationId: string, + config: UpdateBillingLimitRequest +): Promise { + if (!client) return undefined; + + const response = await client.fetch( + `/api/v1/orgs/${organizationId}/billing-limit`, + asPlatformSchema(BillingLimitResultSchema), + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + } + ); + + if (!response.success) { + recordPlatformFailure("setBillingLimit", "no_success"); + throw new Error(response.error ?? "Error setting billing limit"); + } + + invalidateBillingLimitCaches(organizationId); + return response; +} + +export async function resolveBillingLimit( + organizationId: string, + payload: ResolveBillingLimitRequest +): Promise { + if (!client) return undefined; + + const response = await client.fetch( + `/api/v1/orgs/${organizationId}/billing-limit/resolve`, + asPlatformSchema(BillingLimitResultSchema), + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + } + ); + + if (!response.success) { + recordPlatformFailure("resolveBillingLimit", "no_success"); + throw new Error(response.error ?? "Error resolving billing limit"); + } + + invalidateBillingLimitCaches(organizationId); + return response; +} + +/** Admin: orgs currently in grace or rejected — used by reconciliation worker (Phase 2). */ +export async function getActiveBillingLimits(): Promise { + if (!client) return undefined; + + try { + const response = await client.fetch( + `/api/v1/billing-limits/active`, + asPlatformSchema(BillingLimitsActiveResultSchema) + ); + if (!response.success) { + recordPlatformFailure("getActiveBillingLimits", "no_success"); + return undefined; + } + return response; + } catch (e) { + recordPlatformFailure("getActiveBillingLimits", "caught"); + return undefined; + } +} + +/** Admin: orgs with pending resolve side effects — used by reconciliation worker. */ +export async function getPendingBillingLimitResolves(): Promise< + BillingLimitsPendingResolvesResult | undefined +> { + if (!client) return undefined; + + try { + const response = await client.fetch( + `/api/v1/billing-limits/pending-resolves`, + asPlatformSchema(BillingLimitsPendingResolvesResultSchema) + ); + if (!response.success) { + recordPlatformFailure("getPendingBillingLimitResolves", "no_success"); + return undefined; + } + return response; + } catch (e) { + recordPlatformFailure("getPendingBillingLimitResolves", "caught"); + return undefined; + } +} + +/** Admin: mark billing limit resolve side effects as completed after webapp convergence. */ +export async function completeBillingLimitResolve( + organizationId: string +): Promise<{ completed: boolean } | undefined> { + if (!client) return undefined; + + const response = await client.fetch( + `/api/v1/orgs/${organizationId}/billing-limit/resolve-complete`, + asPlatformSchema(z.object({ completed: z.boolean() })), + { + method: "POST", + } + ); + + if (!response.success) { + recordPlatformFailure("completeBillingLimitResolve", "no_success"); + throw new Error(response.error ?? "Error completing billing limit resolve"); + } + + return response; +} + export async function getBillingAlerts( organizationId: string ): Promise { @@ -801,6 +979,17 @@ export async function triggerInitialDeployment( } } +export type { + BillingLimitConfig, + BillingLimitPageData, + BillingLimitResult, + BillingLimitState, + BillingLimitsActiveResult, + EntitlementResult, + ResolveBillingLimitRequest, + UpdateBillingLimitRequest, +} from "~/services/billingLimit.schemas"; + export function isCloud(): boolean { const acceptableHosts = [ "https://cloud.trigger.dev", diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index e13f5d244c8..06b3e869956 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -6,6 +6,10 @@ import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.p import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { logger } from "./logger.server"; import { getCurrentPlan, getLimit } from "./platform.v3.server"; +import { + applyBillingLimitPauseAfterEnvCreate, + getInitialEnvPauseStateForBillingLimit, +} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server"; export class UpsertBranchService { #prismaClient: PrismaClient; @@ -103,6 +107,10 @@ export class UpsertBranchService { const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); const shortcode = branchSlug; + const billingPause = await getInitialEnvPauseStateForBillingLimit( + parentEnvironment.organization.id, + parentEnvironment.type + ); const now = new Date(); @@ -119,6 +127,8 @@ export class UpsertBranchService { pkApiKey, shortcode, maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, + paused: billingPause.paused, + pauseSource: billingPause.pauseSource, organization: { connect: { id: parentEnvironment.organization.id, @@ -137,10 +147,18 @@ export class UpsertBranchService { update: { git: git ?? undefined, }, + include: { + organization: true, + project: true, + }, }); const alreadyExisted = branch.createdAt < now; + if (!alreadyExisted) { + await applyBillingLimitPauseAfterEnvCreate(branch); + } + return { success: true as const, alreadyExisted: alreadyExisted, diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 48a74caade3..30a9913f9d3 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -755,8 +755,13 @@ export function v3BillingPath(organization: OrgForPath, message?: string) { }`; } +export function v3BillingLimitsPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/billing-limits`; +} + +/** @deprecated Use v3BillingLimitsPath — redirects from billing-alerts are preserved */ export function v3BillingAlertsPath(organization: OrgForPath) { - return `${organizationPath(organization)}/settings/billing-alerts`; + return v3BillingLimitsPath(organization); } export function v3PrivateConnectionsPath(organization: OrgForPath) { diff --git a/apps/webapp/app/v3/billingLimitWorker.server.ts b/apps/webapp/app/v3/billingLimitWorker.server.ts new file mode 100644 index 00000000000..8cf4637c6dc --- /dev/null +++ b/apps/webapp/app/v3/billingLimitWorker.server.ts @@ -0,0 +1,122 @@ +import { Logger } from "@trigger.dev/core/logger"; +import { Worker as RedisWorker } from "@trigger.dev/redis-worker"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { singleton } from "~/utils/singleton"; +import { BillingLimitConvergeEnvironmentsService } from "./services/billingLimit/billingLimitConvergeEnvironmentsService.server"; +import type { BillingLimitConvergeTargetState } from "./services/billingLimit/billingLimitConstants"; +import { buildBillingLimitInProgressCancelJobId } from "./services/billingLimit/billingLimitConstants"; +import { runBillingLimitCancelInProgressRuns } from "./services/billingLimit/billingLimitCancelInProgressRuns.server"; + +function initializeWorker() { + const redisOptions = { + keyPrefix: "billing-limit:worker:", + host: env.BILLING_LIMIT_WORKER_REDIS_HOST, + port: env.BILLING_LIMIT_WORKER_REDIS_PORT, + username: env.BILLING_LIMIT_WORKER_REDIS_USERNAME, + password: env.BILLING_LIMIT_WORKER_REDIS_PASSWORD, + enableAutoPipelining: true, + ...(env.BILLING_LIMIT_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }; + + logger.debug(`👨‍🏭 Initializing billing limit worker at host ${env.BILLING_LIMIT_WORKER_REDIS_HOST}`); + + const worker = new RedisWorker({ + name: "billing-limit-worker", + redisOptions, + catalog: { + "billingLimit.convergeEnvironments": { + schema: z.object({ + organizationId: z.string(), + targetState: z.enum(["grace", "rejected", "ok"]), + }), + visibilityTimeoutMs: 60_000 * 10, + retry: { + maxAttempts: 5, + }, + }, + "billingLimit.reconcileTick": { + schema: z.object({}), + visibilityTimeoutMs: 60_000 * 5, + retry: { + maxAttempts: 3, + }, + }, + "billingLimit.cancelInProgressRuns": { + schema: z.object({ + organizationId: z.string(), + hitAt: z.string(), + }), + visibilityTimeoutMs: 60_000 * 10, + retry: { + maxAttempts: 5, + }, + }, + }, + concurrency: { + workers: env.BILLING_LIMIT_WORKER_CONCURRENCY_WORKERS, + tasksPerWorker: env.BILLING_LIMIT_WORKER_CONCURRENCY_TASKS_PER_WORKER, + limit: env.BILLING_LIMIT_WORKER_CONCURRENCY_LIMIT, + }, + pollIntervalMs: env.BILLING_LIMIT_WORKER_POLL_INTERVAL, + immediatePollIntervalMs: env.BILLING_LIMIT_WORKER_IMMEDIATE_POLL_INTERVAL, + shutdownTimeoutMs: env.BILLING_LIMIT_WORKER_SHUTDOWN_TIMEOUT_MS, + logger: new Logger("BillingLimitWorker", env.BILLING_LIMIT_WORKER_LOG_LEVEL), + jobs: { + "billingLimit.convergeEnvironments": async ({ payload }) => { + await BillingLimitConvergeEnvironmentsService.runConverge(payload); + }, + "billingLimit.reconcileTick": async () => { + await BillingLimitConvergeEnvironmentsService.runReconcileTick(); + await scheduleBillingLimitReconcileTick(worker); + }, + "billingLimit.cancelInProgressRuns": async ({ payload }) => { + await runBillingLimitCancelInProgressRuns(payload.organizationId, payload.hitAt); + }, + }, + }); + + if (env.BILLING_LIMIT_WORKER_ENABLED === "true") { + logger.debug( + `👨‍🏭 Starting billing limit worker at host ${env.BILLING_LIMIT_WORKER_REDIS_HOST}, reconcileIntervalMs = ${env.BILLING_LIMIT_RECONCILE_INTERVAL_MS}` + ); + worker.start(); + void scheduleBillingLimitReconcileTick(worker); + } + + return worker; +} + +async function scheduleBillingLimitReconcileTick(worker: ReturnType) { + await worker.enqueue({ + id: "billingLimit.reconcileTick", + job: "billingLimit.reconcileTick", + payload: {}, + availableAt: new Date(Date.now() + env.BILLING_LIMIT_RECONCILE_INTERVAL_MS), + }); +} + +export const billingLimitWorker = singleton("billingLimitWorker", initializeWorker); + +export async function enqueueBillingLimitConverge( + organizationId: string, + targetState: BillingLimitConvergeTargetState +) { + return billingLimitWorker.enqueue({ + id: `billingLimit.converge:${organizationId}:${targetState}`, + job: "billingLimit.convergeEnvironments", + payload: { organizationId, targetState }, + }); +} + +export async function enqueueBillingLimitCancelInProgressRuns( + organizationId: string, + hitAt: string +) { + return billingLimitWorker.enqueue({ + id: buildBillingLimitInProgressCancelJobId(organizationId, hitAt), + job: "billingLimit.cancelInProgressRuns", + payload: { organizationId, hitAt }, + }); +} diff --git a/apps/webapp/app/v3/outOfEntitlementError.server.ts b/apps/webapp/app/v3/outOfEntitlementError.server.ts new file mode 100644 index 00000000000..b5142c2562b --- /dev/null +++ b/apps/webapp/app/v3/outOfEntitlementError.server.ts @@ -0,0 +1,6 @@ +export class OutOfEntitlementError extends Error { + constructor() { + super("You can't trigger a task because you have run out of credits."); + this.name = "OutOfEntitlementError"; + } +} diff --git a/apps/webapp/app/v3/services/billingLimit/BillingLimitBulkCancelService.server.ts b/apps/webapp/app/v3/services/billingLimit/BillingLimitBulkCancelService.server.ts new file mode 100644 index 00000000000..d535a10d807 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/BillingLimitBulkCancelService.server.ts @@ -0,0 +1,177 @@ +import { BulkActionId } from "@trigger.dev/core/v3/isomorphic"; +import { + BulkActionNotificationType, + BulkActionType, + Prisma, + type PrismaClient, + type TaskRunStatus, +} from "@trigger.dev/database"; +import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; +import { prisma } from "~/db.server"; +import type { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { commonWorker } from "~/v3/commonWorker.server"; +import { + countInProgressRunsForBillableEnvironment, + countQueuedRunsForBillableEnvironment, + createBillingLimitRunsRepository, + getBillableEnvironmentsForBillingLimit, +} from "./billingLimitQueuedRuns.server"; + +export const BILLING_LIMIT_RESOLVE_CANCEL_SOURCE = "billing_limit_resolve_new_only"; +export const BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE = "billing_limit_in_progress"; + +type BulkCancelSource = + | typeof BILLING_LIMIT_RESOLVE_CANCEL_SOURCE + | typeof BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE; + +export type BillingLimitBulkCancelDeps = { + prismaClient?: PrismaClient; + createRunsRepository?: (organizationId: string) => Promise; + enqueueProcessBulkAction?: (bulkActionId: string) => Promise; +}; + +function resolveBulkCancelDeps(deps?: BillingLimitBulkCancelDeps) { + return { + prismaClient: deps?.prismaClient ?? prisma, + createRunsRepository: deps?.createRunsRepository ?? createBillingLimitRunsRepository, + enqueueProcessBulkAction: + deps?.enqueueProcessBulkAction ?? + (async (bulkActionId: string) => { + await commonWorker.enqueue({ + id: `processBulkAction-${bulkActionId}`, + job: "processBulkAction", + payload: { bulkActionId }, + }); + }), + }; +} + +export class BillingLimitBulkCancelService { + static async cancelQueuedRuns( + organizationId: string, + options?: { dedupeKey?: string }, + deps?: BillingLimitBulkCancelDeps + ): Promise<{ bulkActionIds: string[] }> { + return this.cancelRunsForBillableEnvironments( + organizationId, + { + source: BILLING_LIMIT_RESOLVE_CANCEL_SOURCE, + statuses: [...QUEUED_STATUSES], + name: "Billing limit resolve — cancel queued runs", + countRuns: countQueuedRunsForBillableEnvironment, + dedupeKey: options?.dedupeKey, + }, + deps + ); + } + + static async cancelInProgressRuns( + organizationId: string, + options: { hitAt: string }, + deps?: BillingLimitBulkCancelDeps + ): Promise<{ bulkActionIds: string[] }> { + return this.cancelRunsForBillableEnvironments( + organizationId, + { + source: BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE, + statuses: [...RUNNING_STATUSES], + name: "Billing limit hit — cancel in-progress runs", + countRuns: countInProgressRunsForBillableEnvironment, + dedupeKey: options.hitAt, + }, + deps + ); + } + + private static async cancelRunsForBillableEnvironments( + organizationId: string, + options: { + source: BulkCancelSource; + statuses: TaskRunStatus[]; + name: string; + countRuns: typeof countQueuedRunsForBillableEnvironment; + dedupeKey?: string; + }, + deps?: BillingLimitBulkCancelDeps + ): Promise<{ bulkActionIds: string[] }> { + const { prismaClient, createRunsRepository, enqueueProcessBulkAction } = + resolveBulkCancelDeps(deps); + + const environments = await getBillableEnvironmentsForBillingLimit( + organizationId, + prismaClient + ); + + if (environments.length === 0) { + return { bulkActionIds: [] }; + } + + const runsRepository = await createRunsRepository(organizationId); + const bulkActionIds: string[] = []; + + for (const environment of environments) { + if (options.dedupeKey) { + const existing = await prismaClient.bulkActionGroup.findFirst({ + where: { + environmentId: environment.id, + type: BulkActionType.CANCEL, + AND: [ + { + params: { + path: ["source"], + equals: options.source, + }, + }, + { + params: { + path: ["dedupeKey"], + equals: options.dedupeKey, + }, + }, + ], + }, + select: { friendlyId: true }, + }); + + if (existing) { + bulkActionIds.push(existing.friendlyId); + continue; + } + } + + const count = await options.countRuns(runsRepository, organizationId, environment); + + if (count === 0) { + continue; + } + + const { id, friendlyId } = BulkActionId.generate(); + + await prismaClient.bulkActionGroup.create({ + data: { + id, + friendlyId, + projectId: environment.projectId, + environmentId: environment.id, + name: options.name, + type: BulkActionType.CANCEL, + params: { + statuses: options.statuses, + finalizeRun: true, + source: options.source, + ...(options.dedupeKey ? { dedupeKey: options.dedupeKey } : {}), + } as Prisma.InputJsonValue, + queryName: "bulk_action_v1", + totalCount: count, + completionNotification: BulkActionNotificationType.NONE, + }, + }); + + await enqueueProcessBulkAction(id); + + bulkActionIds.push(friendlyId); + } + + return { bulkActionIds }; + } +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitCancelInProgressRuns.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitCancelInProgressRuns.server.ts new file mode 100644 index 00000000000..ea3ac5f1f75 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitCancelInProgressRuns.server.ts @@ -0,0 +1,8 @@ +import { BillingLimitBulkCancelService } from "./BillingLimitBulkCancelService.server"; + +export async function runBillingLimitCancelInProgressRuns( + organizationId: string, + hitAt: string +): Promise<{ bulkActionIds: string[] }> { + return BillingLimitBulkCancelService.cancelInProgressRuns(organizationId, { hitAt }); +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitConstants.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitConstants.ts new file mode 100644 index 00000000000..79b2d1c460f --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitConstants.ts @@ -0,0 +1,31 @@ +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; + +export const BILLABLE_ENVIRONMENT_TYPES = [ + "PRODUCTION", + "STAGING", + "PREVIEW", +] as const satisfies RuntimeEnvironmentType[]; + +export type BillableEnvironmentType = (typeof BILLABLE_ENVIRONMENT_TYPES)[number]; + +export const BILLING_LIMIT_CONVERGE_BATCH_SIZE = 50; + +export type BillingLimitConvergeTargetState = "grace" | "rejected" | "ok"; + +export function isBillableEnvironmentType(type: RuntimeEnvironmentType): boolean { + return (BILLABLE_ENVIRONMENT_TYPES as readonly RuntimeEnvironmentType[]).includes(type); +} + +export function buildBillingLimitResolveDedupeKey( + organizationId: string, + resolvedAt: string +): string { + return `billing-limit-resolve:${organizationId}:${resolvedAt}`; +} + +export function buildBillingLimitInProgressCancelJobId( + organizationId: string, + hitAt: string +): string { + return `billingLimit.cancelInProgress:${organizationId}:${hitAt}`; +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironments.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironments.server.ts new file mode 100644 index 00000000000..a0331e9e18a --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironments.server.ts @@ -0,0 +1,185 @@ +import { + EnvironmentPauseSource, + type Organization, + type PrismaClient, + type Project, + type RuntimeEnvironment, +} from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; +import { + BILLABLE_ENVIRONMENT_TYPES, + BILLING_LIMIT_CONVERGE_BATCH_SIZE, + type BillingLimitConvergeTargetState, +} from "./billingLimitConstants"; + +export type ConvergeOrgResult = { + paused: number; + unpaused: number; +}; + +type EnvironmentWithRelations = RuntimeEnvironment & { + organization: Organization; + project: Project; +}; + +type UpdateEnvConcurrency = ( + environment: EnvironmentWithRelations, + maximumConcurrencyLimit?: number +) => Promise; + +export async function convergeBillingLimitEnvironmentsForOrg( + organizationId: string, + targetState: BillingLimitConvergeTargetState, + options?: { + batchSize?: number; + prismaClient?: PrismaClient; + updateConcurrency?: UpdateEnvConcurrency; + } +): Promise { + const db = options?.prismaClient ?? prisma; + const batchSize = options?.batchSize ?? BILLING_LIMIT_CONVERGE_BATCH_SIZE; + const updateConcurrency = options?.updateConcurrency ?? updateEnvConcurrencyLimits; + + if (targetState === "ok") { + return unpauseBillingLimitEnvironments(organizationId, db, batchSize, updateConcurrency); + } + + return pauseBillingLimitEnvironments(organizationId, db, batchSize, updateConcurrency); +} + +async function pauseBillingLimitEnvironments( + organizationId: string, + db: PrismaClient, + batchSize: number, + updateConcurrency: UpdateEnvConcurrency +): Promise { + let paused = 0; + let cursor: string | undefined; + + while (true) { + const environments = await db.runtimeEnvironment.findMany({ + where: { + organizationId, + type: { in: [...BILLABLE_ENVIRONMENT_TYPES] }, + paused: false, + }, + take: batchSize, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + orderBy: { id: "asc" }, + include: { + organization: true, + project: true, + }, + }); + + if (environments.length === 0) { + break; + } + + for (const environment of environments) { + await pauseEnvironmentForBillingLimit(environment, db, updateConcurrency); + paused++; + } + + cursor = environments[environments.length - 1]?.id; + if (environments.length < batchSize) { + break; + } + } + + logger.info("Billing limit converge paused environments", { + organizationId, + paused, + }); + + return { paused, unpaused: 0 }; +} + +async function unpauseBillingLimitEnvironments( + organizationId: string, + db: PrismaClient, + batchSize: number, + updateConcurrency: UpdateEnvConcurrency +): Promise { + let unpaused = 0; + let cursor: string | undefined; + + while (true) { + const environments = await db.runtimeEnvironment.findMany({ + where: { + organizationId, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }, + take: batchSize, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + orderBy: { id: "asc" }, + include: { + organization: true, + project: true, + }, + }); + + if (environments.length === 0) { + break; + } + + for (const environment of environments) { + await resumeEnvironmentFromBillingLimit(environment, db, updateConcurrency); + unpaused++; + } + + cursor = environments[environments.length - 1]?.id; + if (environments.length < batchSize) { + break; + } + } + + logger.info("Billing limit converge unpaused environments", { + organizationId, + unpaused, + }); + + return { paused: 0, unpaused }; +} + +async function pauseEnvironmentForBillingLimit( + environment: EnvironmentWithRelations, + db: PrismaClient, + updateConcurrency: UpdateEnvConcurrency +) { + const updated = await db.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { + paused: true, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }, + include: { + organization: true, + project: true, + }, + }); + + await updateConcurrency(updated, 0); +} + +async function resumeEnvironmentFromBillingLimit( + environment: EnvironmentWithRelations, + db: PrismaClient, + updateConcurrency: UpdateEnvConcurrency +) { + const updated = await db.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { + paused: false, + pauseSource: null, + }, + include: { + organization: true, + project: true, + }, + }); + + await updateConcurrency(updated); +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server.ts new file mode 100644 index 00000000000..732fb88a6ae --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironmentsService.server.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { convergeBillingLimitEnvironmentsForOrg } from "./billingLimitConvergeEnvironments.server"; +import { runBillingLimitReconcileTick } from "./runBillingLimitReconcileTick.server"; +import { seedBillingLimitReconcileQueue } from "./billingLimitReconcileQueue.server"; + +const ConvergePayloadSchema = z.object({ + organizationId: z.string(), + targetState: z.enum(["grace", "rejected", "ok"]), +}); + +export class BillingLimitConvergeEnvironmentsService { + static async seedReconcileQueue(organizationId: string) { + await seedBillingLimitReconcileQueue(organizationId); + } + + static async runConverge(payload: z.infer) { + const parsed = ConvergePayloadSchema.parse(payload); + return convergeBillingLimitEnvironmentsForOrg(parsed.organizationId, parsed.targetState); + } + + static async runReconcileTick() { + await runBillingLimitReconcileTick(); + } +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeResolve.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeResolve.server.ts new file mode 100644 index 00000000000..190392de1ba --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitConvergeResolve.server.ts @@ -0,0 +1,34 @@ +import { + bustBillingLimitCaches, +} from "~/services/platform.v3.server"; +import { logger } from "~/services/logger.server"; +import { BillingLimitBulkCancelService } from "./BillingLimitBulkCancelService.server"; +import { buildBillingLimitResolveDedupeKey } from "./billingLimitConstants"; +import { convergeBillingLimitEnvironmentsForOrg } from "./billingLimitConvergeEnvironments.server"; +import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types"; + +export type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types"; + +export async function convergeBillingLimitResolve( + pending: PendingBillingLimitResolve +): Promise { + const { organizationId, resumeMode, resolvedAt } = pending; + + bustBillingLimitCaches(organizationId); + + if (resumeMode === "new_only") { + await BillingLimitBulkCancelService.cancelQueuedRuns(organizationId, { + dedupeKey: buildBillingLimitResolveDedupeKey(organizationId, resolvedAt), + }); + } + + await convergeBillingLimitEnvironmentsForOrg(organizationId, "ok"); + + logger.info("Converged billing limit resolve", { + organizationId, + resumeMode, + resolvedAt, + }); +} + +export { runPendingBillingLimitResolves } from "./billingLimitPendingResolveCoordinator.server"; diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitHit.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitHit.server.ts new file mode 100644 index 00000000000..062c6203460 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitHit.server.ts @@ -0,0 +1,26 @@ +export type BillingLimitHitPayload = { + organizationId: string; + hitAt: string; + cancelInProgressRuns: boolean; +}; + +export type BillingLimitHitDeps = { + bustCaches: (organizationId: string) => void; + seedReconcileQueue: (organizationId: string) => Promise; + enqueueConverge: (organizationId: string, targetState: "grace") => Promise; + enqueueCancelInProgressRuns: (organizationId: string, hitAt: string) => Promise; +}; + +/** Process billing limit grace hit from the billing platform webhook. */ +export async function processBillingLimitHit( + payload: BillingLimitHitPayload, + deps: BillingLimitHitDeps +): Promise { + deps.bustCaches(payload.organizationId); + await deps.seedReconcileQueue(payload.organizationId); + await deps.enqueueConverge(payload.organizationId, "grace"); + + if (payload.cancelInProgressRuns) { + await deps.enqueueCancelInProgressRuns(payload.organizationId, payload.hitAt); + } +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolve.types.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolve.types.ts new file mode 100644 index 00000000000..faaada4ce9b --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolve.types.ts @@ -0,0 +1,5 @@ +export type PendingBillingLimitResolve = { + organizationId: string; + resumeMode: "queue" | "new_only"; + resolvedAt: string; +}; diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolveCoordinator.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolveCoordinator.server.ts new file mode 100644 index 00000000000..84e727f4060 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolveCoordinator.server.ts @@ -0,0 +1,56 @@ +import { logger } from "~/services/logger.server"; +import { classifyPendingBillingLimitResolveConvergeFailure } from "./billingLimitPendingResolveFailure.server"; +import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types"; + +export type RunPendingBillingLimitResolveDeps = { + converge?: (pending: PendingBillingLimitResolve) => Promise; + complete?: (organizationId: string) => Promise<{ completed: boolean } | undefined>; +}; + +export async function runPendingBillingLimitResolves( + pendingResolves: PendingBillingLimitResolve[], + deps: RunPendingBillingLimitResolveDeps = {} +): Promise> { + const converge = + deps.converge ?? + (await import("./billingLimitConvergeResolve.server")).convergeBillingLimitResolve; + const complete = + deps.complete ?? + (await import("~/services/platform.v3.server")).completeBillingLimitResolve; + + const stillPendingOrgIds = new Set(); + + for (const pending of pendingResolves) { + try { + await converge(pending); + } catch (error) { + logger.error("Failed to converge pending billing limit resolve", { + failureClass: classifyPendingBillingLimitResolveConvergeFailure(pending.resumeMode), + error, + organizationId: pending.organizationId, + resumeMode: pending.resumeMode, + resolvedAt: pending.resolvedAt, + }); + stillPendingOrgIds.add(pending.organizationId); + continue; + } + + try { + const completion = await complete(pending.organizationId); + if (!completion) { + throw new Error("Billing platform client unavailable"); + } + } catch (error) { + logger.error("Failed to ack pending billing limit resolve", { + failureClass: "ack-only", + error, + organizationId: pending.organizationId, + resumeMode: pending.resumeMode, + resolvedAt: pending.resolvedAt, + }); + stillPendingOrgIds.add(pending.organizationId); + } + } + + return stillPendingOrgIds; +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolveFailure.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolveFailure.server.ts new file mode 100644 index 00000000000..6b194863785 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitPendingResolveFailure.server.ts @@ -0,0 +1,11 @@ +export type PendingBillingLimitResolveFailureClass = + | "cancel-failing" + | "converge-failing" + | "ack-only"; + +/** Used in converge logs to classify stuck pending resolves. */ +export function classifyPendingBillingLimitResolveConvergeFailure( + resumeMode: "queue" | "new_only" +): Exclude { + return resumeMode === "new_only" ? "cancel-failing" : "converge-failing"; +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitQueuedRuns.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitQueuedRuns.server.ts new file mode 100644 index 00000000000..f0992ed2b2c --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitQueuedRuns.server.ts @@ -0,0 +1,99 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import type { TaskRunStatus } from "@trigger.dev/database"; +import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus"; +import { prisma } from "~/db.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { BILLABLE_ENVIRONMENT_TYPES } from "./billingLimitConstants"; + +export type BillableEnvironmentRef = { + id: string; + projectId: string; +}; + +export async function getBillableEnvironmentsForBillingLimit( + organizationId: string, + prismaClient: PrismaClient = prisma +): Promise { + return prismaClient.runtimeEnvironment.findMany({ + where: { + organizationId, + type: { in: [...BILLABLE_ENVIRONMENT_TYPES] }, + }, + select: { + id: true, + projectId: true, + }, + }); +} + +export async function createBillingLimitRunsRepository(organizationId: string) { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + organizationId, + "standard" + ); + + return new RunsRepository({ + clickhouse, + prisma: prisma as PrismaClient, + }); +} + +export async function countQueuedRunsForBillableEnvironment( + runsRepository: RunsRepository, + organizationId: string, + environment: BillableEnvironmentRef +): Promise { + return countRunsForBillableEnvironment(runsRepository, organizationId, environment, [ + ...QUEUED_STATUSES, + ]); +} + +export async function countInProgressRunsForBillableEnvironment( + runsRepository: RunsRepository, + organizationId: string, + environment: BillableEnvironmentRef +): Promise { + return countRunsForBillableEnvironment(runsRepository, organizationId, environment, [ + ...RUNNING_STATUSES, + ]); +} + +async function countRunsForBillableEnvironment( + runsRepository: RunsRepository, + organizationId: string, + environment: BillableEnvironmentRef, + statuses: TaskRunStatus[] +): Promise { + return runsRepository.countRuns({ + organizationId, + projectId: environment.projectId, + environmentId: environment.id, + statuses, + }); +} + +/** Same source as BillingLimitBulkCancelService — ClickHouse countRuns(QUEUED_STATUSES). */ +export async function countBillableQueuedRunsForOrganization( + organizationId: string +): Promise { + const environments = await getBillableEnvironmentsForBillingLimit(organizationId); + + if (environments.length === 0) { + return 0; + } + + const runsRepository = await createBillingLimitRunsRepository(organizationId); + + let total = 0; + + for (const environment of environments) { + total += await countQueuedRunsForBillableEnvironment( + runsRepository, + organizationId, + environment + ); + } + + return total; +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitReconcileQueue.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitReconcileQueue.server.ts new file mode 100644 index 00000000000..b213aa76c26 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitReconcileQueue.server.ts @@ -0,0 +1,33 @@ +import { env } from "~/env.server"; +import { createRedisClient } from "~/redis.server"; +import { singleton } from "~/utils/singleton"; + +const RECONCILE_QUEUE_KEY = "billing-limit:reconcile-queue"; + +function createQueueRedis() { + return createRedisClient("billing-limit:reconcile", { + keyPrefix: "", + host: env.BILLING_LIMIT_WORKER_REDIS_HOST, + port: env.BILLING_LIMIT_WORKER_REDIS_PORT, + username: env.BILLING_LIMIT_WORKER_REDIS_USERNAME, + password: env.BILLING_LIMIT_WORKER_REDIS_PASSWORD, + tlsDisabled: env.BILLING_LIMIT_WORKER_REDIS_TLS_DISABLED === "true", + }); +} + +const queueRedis = singleton("billingLimitReconcileQueueRedis", createQueueRedis); + +export async function seedBillingLimitReconcileQueue(organizationId: string): Promise { + await queueRedis.sadd(RECONCILE_QUEUE_KEY, organizationId); +} + +export async function readBillingLimitReconcileQueue(): Promise { + return queueRedis.smembers(RECONCILE_QUEUE_KEY); +} + +export async function removeFromBillingLimitReconcileQueue(organizationIds: string[]): Promise { + if (organizationIds.length === 0) { + return; + } + await queueRedis.srem(RECONCILE_QUEUE_KEY, ...organizationIds); +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitReconcileTarget.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitReconcileTarget.server.ts new file mode 100644 index 00000000000..a464a388b58 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitReconcileTarget.server.ts @@ -0,0 +1,20 @@ +import type { BillingLimitConvergeTargetState } from "./billingLimitConstants"; +import type { OrgReconcileTarget } from "./billingLimitReconciliation.server"; + +export async function reconcileBillingLimitTarget( + target: OrgReconcileTarget, + deps: { + bustCaches: (organizationId: string) => void; + enqueueConverge: ( + organizationId: string, + targetState: BillingLimitConvergeTargetState + ) => Promise; + } +) { + // Safety net when webhooks are lost: bust stale entitlement after reject or resolve. + if (target.targetState === "rejected" || target.targetState === "ok") { + deps.bustCaches(target.organizationId); + } + + await deps.enqueueConverge(target.organizationId, target.targetState); +} diff --git a/apps/webapp/app/v3/services/billingLimit/billingLimitReconciliation.server.ts b/apps/webapp/app/v3/services/billingLimit/billingLimitReconciliation.server.ts new file mode 100644 index 00000000000..52c2ddcb9b7 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/billingLimitReconciliation.server.ts @@ -0,0 +1,103 @@ +import { EnvironmentPauseSource } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import type { BillingLimitResult } from "~/services/billingLimit.schemas"; +import { getActiveBillingLimits, getBillingLimit } from "~/services/platform.v3.server"; +import type { BillingLimitConvergeTargetState } from "./billingLimitConstants"; +import { + readBillingLimitReconcileQueue, + removeFromBillingLimitReconcileQueue, +} from "./billingLimitReconcileQueue.server"; + +export type OrgReconcileTarget = { + organizationId: string; + targetState: BillingLimitConvergeTargetState; +}; + +export function resolveConvergeTargetFromBillingLimit( + billingLimit: BillingLimitResult | undefined +): BillingLimitConvergeTargetState { + if (!billingLimit?.isConfigured) { + return "ok"; + } + + if (billingLimit.limitState.status === "grace") { + return "grace"; + } + + if (billingLimit.limitState.status === "rejected") { + return "rejected"; + } + + return "ok"; +} + +export async function getOrgIdsWithBillingPauseSource(): Promise { + const rows = await prisma.runtimeEnvironment.findMany({ + where: { + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }, + select: { + organizationId: true, + }, + distinct: ["organizationId"], + }); + + return rows.map((row) => row.organizationId); +} + +export async function collectOrgsToReconcile(options?: { + excludeOrgIds?: Set; +}): Promise<{ + targets: OrgReconcileTarget[]; + queuedOrgIds: string[]; +}> { + const excludeOrgIds = options?.excludeOrgIds ?? new Set(); + const targetByOrgId = new Map(); + + const activeLimits = await getActiveBillingLimits(); + if (activeLimits) { + for (const org of activeLimits.orgs) { + if (excludeOrgIds.has(org.orgId)) { + continue; + } + targetByOrgId.set(org.orgId, org.limitState); + } + } + + const staleOrgIds = await getOrgIdsWithBillingPauseSource(); + for (const organizationId of staleOrgIds) { + if (excludeOrgIds.has(organizationId) || targetByOrgId.has(organizationId)) { + continue; + } + + const billingLimit = await getBillingLimit(organizationId); + targetByOrgId.set(organizationId, resolveConvergeTargetFromBillingLimit(billingLimit)); + } + + const queuedOrgIds = await readBillingLimitReconcileQueue(); + for (const organizationId of queuedOrgIds) { + if (excludeOrgIds.has(organizationId) || targetByOrgId.has(organizationId)) { + continue; + } + + const billingLimit = await getBillingLimit(organizationId); + targetByOrgId.set(organizationId, resolveConvergeTargetFromBillingLimit(billingLimit)); + } + + return { + targets: Array.from(targetByOrgId.entries()).map(([organizationId, targetState]) => ({ + organizationId, + targetState, + })), + queuedOrgIds, + }; +} + +export async function clearProcessedReconcileQueueEntries( + queuedOrgIds: string[], + processedOrgIds: string[] +): Promise { + const processed = new Set(processedOrgIds); + const toRemove = queuedOrgIds.filter((orgId) => processed.has(orgId)); + await removeFromBillingLimitReconcileQueue(toRemove); +} diff --git a/apps/webapp/app/v3/services/billingLimit/getBillingLimitQueuedRunCount.server.ts b/apps/webapp/app/v3/services/billingLimit/getBillingLimitQueuedRunCount.server.ts new file mode 100644 index 00000000000..2689a3dc9bb --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/getBillingLimitQueuedRunCount.server.ts @@ -0,0 +1,16 @@ +import { EnvironmentPauseSource } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { countBillableQueuedRunsForOrganization } from "./billingLimitQueuedRuns.server"; + +export async function getBillingLimitQueuedRunCount(organizationId: string): Promise { + return countBillableQueuedRunsForOrganization(organizationId); +} + +export async function countBillingLimitPausedEnvironments(organizationId: string): Promise { + return prisma.runtimeEnvironment.count({ + where: { + organizationId, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }, + }); +} diff --git a/apps/webapp/app/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server.ts b/apps/webapp/app/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server.ts new file mode 100644 index 00000000000..379f6e58d5d --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server.ts @@ -0,0 +1,49 @@ +import { EnvironmentPauseSource, type RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { Organization, Project, RuntimeEnvironment } from "@trigger.dev/database"; +import type { BillingLimitResult } from "~/services/billingLimit.schemas"; +import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; +import { isBillableEnvironmentType } from "./billingLimitConstants"; +import { resolveConvergeTargetFromBillingLimit } from "./billingLimitReconciliation.server"; + +export type InitialEnvPauseState = { + paused: boolean; + pauseSource: typeof EnvironmentPauseSource.BILLING_LIMIT | null; +}; + +export type GetInitialEnvPauseStateDeps = { + getBillingLimit?: (organizationId: string) => Promise; +}; + +export async function getInitialEnvPauseStateForBillingLimit( + organizationId: string, + type: RuntimeEnvironmentType, + deps: GetInitialEnvPauseStateDeps = {} +): Promise { + if (!isBillableEnvironmentType(type)) { + return { paused: false, pauseSource: null }; + } + + const billingLimit = deps.getBillingLimit + ? await deps.getBillingLimit(organizationId) + : await (await import("~/services/platform.v3.server")).getBillingLimit(organizationId); + const targetState = resolveConvergeTargetFromBillingLimit(billingLimit); + + if (targetState === "grace" || targetState === "rejected") { + return { + paused: true, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }; + } + + return { paused: false, pauseSource: null }; +} + +export async function applyBillingLimitPauseAfterEnvCreate( + environment: RuntimeEnvironment & { organization: Organization; project: Project } +): Promise { + if (!environment.paused || environment.pauseSource !== EnvironmentPauseSource.BILLING_LIMIT) { + return; + } + + await updateEnvConcurrencyLimits(environment, 0); +} diff --git a/apps/webapp/app/v3/services/billingLimit/manualPauseEnvironmentGuard.server.ts b/apps/webapp/app/v3/services/billingLimit/manualPauseEnvironmentGuard.server.ts new file mode 100644 index 00000000000..0ee7405c647 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/manualPauseEnvironmentGuard.server.ts @@ -0,0 +1,32 @@ +import { EnvironmentPauseSource } from "@trigger.dev/database"; +import type { PauseStatus } from "~/v3/services/pauseEnvironment.server"; + +export function getManualPauseEnvironmentResult( + action: PauseStatus, + pauseSource: EnvironmentPauseSource | null | undefined +): + | { proceed: true } + | { proceed: false; success: true; state: PauseStatus } + | { proceed: false; success: false; error: string } { + if ( + action === "resumed" && + pauseSource === EnvironmentPauseSource.BILLING_LIMIT + ) { + return { + proceed: false, + success: false, + error: + "This environment is paused because your organization reached its billing limit. Resolve the limit on the billing limits settings page to resume.", + }; + } + + if (action === "paused" && pauseSource === EnvironmentPauseSource.BILLING_LIMIT) { + return { + proceed: false, + success: true, + state: "paused", + }; + } + + return { proceed: true }; +} diff --git a/apps/webapp/app/v3/services/billingLimit/runBillingLimitReconcileTick.server.ts b/apps/webapp/app/v3/services/billingLimit/runBillingLimitReconcileTick.server.ts new file mode 100644 index 00000000000..96ec1f3b978 --- /dev/null +++ b/apps/webapp/app/v3/services/billingLimit/runBillingLimitReconcileTick.server.ts @@ -0,0 +1,72 @@ +import type { BillingLimitsPendingResolvesResult } from "~/services/billingLimit.schemas"; +import { runPendingBillingLimitResolves } from "./billingLimitPendingResolveCoordinator.server"; +import type { PendingBillingLimitResolve } from "./billingLimitPendingResolve.types"; +import type { OrgReconcileTarget } from "./billingLimitReconciliation.server"; +import type { reconcileBillingLimitTarget } from "./billingLimitReconcileTarget.server"; + +export type RunBillingLimitReconcileTickDeps = { + getPendingResolves?: () => Promise; + runPendingResolves?: ( + pendingResolves: PendingBillingLimitResolve[] + ) => Promise>; + collectOrgs?: (options?: { excludeOrgIds?: Set }) => Promise<{ + targets: OrgReconcileTarget[]; + queuedOrgIds: string[]; + }>; + reconcileTarget?: typeof reconcileBillingLimitTarget; + clearProcessedQueue?: ( + queuedOrgIds: string[], + processedOrgIds: string[] + ) => Promise; + bustCaches?: (organizationId: string) => void; + enqueueConverge?: ( + organizationId: string, + targetState: OrgReconcileTarget["targetState"] + ) => Promise; +}; + +export async function runBillingLimitReconcileTick( + deps: RunBillingLimitReconcileTickDeps = {} +): Promise { + const getPendingResolves = + deps.getPendingResolves ?? + (await import("~/services/platform.v3.server")).getPendingBillingLimitResolves; + const runPendingResolves = deps.runPendingResolves ?? runPendingBillingLimitResolves; + const collectOrgs = + deps.collectOrgs ?? + (await import("./billingLimitReconciliation.server")).collectOrgsToReconcile; + const reconcileTarget = + deps.reconcileTarget ?? + (await import("./billingLimitReconcileTarget.server")).reconcileBillingLimitTarget; + const clearProcessedQueue = + deps.clearProcessedQueue ?? + (await import("./billingLimitReconciliation.server")).clearProcessedReconcileQueueEntries; + const bustCaches = + deps.bustCaches ?? (await import("~/services/platform.v3.server")).bustBillingLimitCaches; + + const pendingResolves = (await getPendingResolves())?.orgs ?? []; + const stillPendingOrgIds = await runPendingResolves(pendingResolves); + + const { targets, queuedOrgIds } = await collectOrgs({ + excludeOrgIds: stillPendingOrgIds, + }); + + const enqueueConverge = + deps.enqueueConverge ?? + (async (organizationId, targetState) => { + const { enqueueBillingLimitConverge } = await import("~/v3/billingLimitWorker.server"); + await enqueueBillingLimitConverge(organizationId, targetState); + }); + + for (const target of targets) { + await reconcileTarget(target, { + bustCaches, + enqueueConverge, + }); + } + + await clearProcessedQueue( + queuedOrgIds, + targets.map((target) => target.organizationId) + ); +} diff --git a/apps/webapp/app/v3/services/pauseEnvironment.server.ts b/apps/webapp/app/v3/services/pauseEnvironment.server.ts index 99e588ca7df..12266348772 100644 --- a/apps/webapp/app/v3/services/pauseEnvironment.server.ts +++ b/apps/webapp/app/v3/services/pauseEnvironment.server.ts @@ -1,6 +1,7 @@ import { type PrismaClientOrTransaction } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; +import { getManualPauseEnvironmentResult } from "~/v3/services/billingLimit/manualPauseEnvironmentGuard.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; import { WithRunEngine } from "./baseService.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; @@ -40,6 +41,27 @@ export class PauseEnvironmentService extends WithRunEngine { throw new Error("Organization not found"); } + const runtimeEnvironment = await this._prisma.runtimeEnvironment.findFirst({ + where: { id: environment.id }, + select: { + pauseSource: true, + }, + }); + + const manualPauseGuard = getManualPauseEnvironmentResult( + action, + runtimeEnvironment?.pauseSource + ); + if (!manualPauseGuard.proceed) { + if (manualPauseGuard.success) { + return { + success: true, + state: manualPauseGuard.state, + }; + } + throw new Error(manualPauseGuard.error); + } + if (!org.runsEnabled && action === "resumed") { throw new Error( "Runs are disabled for this organization. Your free plan has probably been exceeded. If not please contact support." @@ -52,6 +74,7 @@ export class PauseEnvironmentService extends WithRunEngine { }, data: { paused: action === "paused", + pauseSource: action === "resumed" ? null : undefined, }, }); diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 7bbaa0dd99b..2d2ca0c4d66 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -37,11 +37,7 @@ export type TriggerTaskServiceOptions = { triggerAction?: string; }; -export class OutOfEntitlementError extends Error { - constructor() { - super("You can't trigger a task because you have run out of credits."); - } -} +export { OutOfEntitlementError } from "../outOfEntitlementError.server"; export type TriggerTaskServiceResult = { run: TaskRun; diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 964c13c5625..9d8646b4ca8 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -103,6 +103,10 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { // Remix fingerprints its assets so we can cache forever. app.use("/build", express.static("public/build", { immutable: true, maxAge: "1y" })); + // Stale dev builds can request an old hashed manifest; don't fall through to Remix. + app.use("/build", (_req, res) => { + res.status(404).end(); + }); // Everything else (like favicon.ico) is cached for an hour. You may want to be // more aggressive with this caching. diff --git a/apps/webapp/test/billingAlertsFormat.test.ts b/apps/webapp/test/billingAlertsFormat.test.ts new file mode 100644 index 00000000000..cc22655da20 --- /dev/null +++ b/apps/webapp/test/billingAlertsFormat.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from "vitest"; +import { + clearedAlertsPayload, + emailsMatchSaved, + getAlertPreviewLimitCents, + getBillingLimitMode, + getConfiguredBillingLimitCents, + getUsageBarBillingLimitDollars, + hadSavedAlertsToClearOnLimitChange, + hasConfiguredAlerts, + hasLegacySpikeAlertLevels, + normalizeBillingAlertsFromApi, + percentageAlertLevelsToUiThresholds, + previewDollarAmountForPercent, + resetAlertsPayloadForLimitMode, + shouldClearAlertsOnLimitChange, + shouldResetAlertsOnLimitChange, + storedAlertsToThresholds, + thresholdsMatchSaved, + thresholdsToAlertPayload, + thresholdValuesAreUnique, + ABSOLUTE_ALERT_BASE_CENTS, +} from "~/components/billing/billingAlertsFormat"; + +const legacyDefaultLevels = [0.75, 0.9, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0]; + +describe("billingAlertsFormat", () => { + it("uses percentage thresholds saved in the new format", () => { + expect( + storedAlertsToThresholds( + { amount: 50, emails: [], alertLevels: [0.75, 0.9, 1.0] }, + "plan", + 5000, + 5000 + ) + ).toEqual([75, 90, 100]); + }); + + it("filters legacy spike multipliers above 100%", () => { + expect( + storedAlertsToThresholds( + { amount: 50, emails: [], alertLevels: legacyDefaultLevels }, + "plan", + 5000, + 5000 + ) + ).toEqual([75, 90, 100]); + + expect( + storedAlertsToThresholds( + { amount: 50, emails: [], alertLevels: legacyDefaultLevels }, + "none", + 5000, + 5000 + ) + ).toEqual([]); + }); + + it("reads legacy alerts saved against plan included usage", () => { + expect( + storedAlertsToThresholds( + { amount: 100, emails: [], alertLevels: [0.1, 0.5, 0.8, 2.0] }, + "plan", + 25_000, + 10_000 + ) + ).toEqual([10, 50, 80]); + + expect( + storedAlertsToThresholds( + { amount: 100, emails: [], alertLevels: [10, 50, 80, 200] }, + "plan", + 25_000, + 10_000 + ) + ).toEqual([10, 50, 80]); + + expect(getAlertPreviewLimitCents({ amount: 100, emails: [], alertLevels: [] }, 25_000, 10_000)).toBe( + 10_000 + ); + }); + + it("normalizes legacy API alerts with dollar amount field and whole percents", () => { + expect( + normalizeBillingAlertsFromApi({ + amount: 10_000, + emails: ["a@example.com"], + alertLevels: [10, 50, 80, 200], + }) + ).toEqual({ + amount: 100, + emails: ["a@example.com"], + alertLevels: [10, 50, 80, 200], + }); + + expect(percentageAlertLevelsToUiThresholds([10, 50, 80, 200])).toEqual([10, 50, 80]); + }); + + it("normalizes platform API alerts stored in cents", () => { + expect( + normalizeBillingAlertsFromApi({ + amount: 10_000, + emails: [], + alertLevels: [0.75, 0.9], + }) + ).toEqual({ + amount: 100, + emails: [], + alertLevels: [0.75, 0.9], + }); + }); + + it("returns no default thresholds when alerts are empty", () => { + expect( + storedAlertsToThresholds({ amount: 50, emails: [], alertLevels: [] }, "plan", 5000, 5000) + ).toEqual([]); + expect( + storedAlertsToThresholds({ amount: 1, emails: [], alertLevels: [] }, "none", 5000, 5000) + ).toEqual([]); + }); + + it("uses dollar thresholds for none mode with absolute base", () => { + expect( + storedAlertsToThresholds( + { amount: 1, emails: [], alertLevels: [100, 250] }, + "none", + 5000, + 5000 + ) + ).toEqual([100, 250]); + }); + + it("loads absolute dollar alerts after save with unlimited billing limit", () => { + const normalized = normalizeBillingAlertsFromApi({ + amount: ABSOLUTE_ALERT_BASE_CENTS, + emails: ["a@example.com"], + alertLevels: [100], + }); + + expect(normalized).toEqual({ + amount: 1, + emails: ["a@example.com"], + alertLevels: [100], + }); + + expect(storedAlertsToThresholds(normalized, "none", 5000, 5000)).toEqual([100]); + }); + + it("converts percentage UI values to API payload", () => { + expect(thresholdsToAlertPayload([75, 90], "plan", 5000)).toEqual({ + amount: 5000, + alertLevels: [0.75, 0.9], + }); + }); + + it("converts absolute UI values to API payload", () => { + expect(thresholdsToAlertPayload([100, 250], "none", 5000)).toEqual({ + amount: 100, + alertLevels: [100, 250], + }); + }); + + it("previews dollar amount from percentage and limit", () => { + expect(previewDollarAmountForPercent(75, 5000)).toBe(37.5); + expect(previewDollarAmountForPercent(10, 10_000)).toBe(10); + }); + + it("defaults unconfigured billing limit to plan mode", () => { + expect(getBillingLimitMode({ isConfigured: false, gracePeriodMs: 86_400_000 })).toBe("plan"); + }); + + it("detects configured alerts for the current billing limit mode", () => { + const billingLimit = { + isConfigured: true, + mode: "plan" as const, + effectiveAmountCents: 5000, + gracePeriodMs: 86_400_000, + }; + + expect( + hasConfiguredAlerts( + { amount: 50, emails: [], alertLevels: [0.75, 0.9] }, + billingLimit, + 5000 + ) + ).toBe(true); + + expect(hasConfiguredAlerts({ amount: 50, emails: [], alertLevels: [] }, billingLimit, 5000)).toBe( + false + ); + }); + + it("clears percentage alerts when switching from plan or custom to none", () => { + expect(shouldClearAlertsOnLimitChange("plan", "none")).toBe(true); + expect(shouldClearAlertsOnLimitChange("custom", "none")).toBe(true); + expect(shouldClearAlertsOnLimitChange("none", "none")).toBe(false); + expect(shouldClearAlertsOnLimitChange("plan", "custom")).toBe(false); + }); + + it("resets alerts when switching between percentage and dollar alert modes", () => { + expect(shouldResetAlertsOnLimitChange("none", "plan")).toBe(true); + expect(shouldResetAlertsOnLimitChange("none", "custom")).toBe(true); + expect(shouldResetAlertsOnLimitChange("plan", "none")).toBe(true); + expect(shouldResetAlertsOnLimitChange("plan", "custom")).toBe(false); + }); + + it("builds a cleared alerts payload for none mode", () => { + expect(clearedAlertsPayload(["a@example.com"])).toEqual({ + amount: 100, + alertLevels: [], + emails: ["a@example.com"], + }); + }); + + it("detects legacy spike alert levels above 100%", () => { + expect( + hasLegacySpikeAlertLevels( + { amount: 50, emails: [], alertLevels: [0.75, 0.9, 1.0, 2.0] }, + "plan", + 5000, + 5000 + ) + ).toBe(true); + + expect( + hasLegacySpikeAlertLevels( + { amount: 100, emails: [], alertLevels: [0.1, 0.5, 0.8, 2.0] }, + "plan", + 25_000, + 10_000 + ) + ).toBe(true); + + expect( + hasLegacySpikeAlertLevels({ amount: 50, emails: [], alertLevels: [0.75, 0.9] }, "plan", 5000, 5000) + ).toBe(false); + + expect( + hasLegacySpikeAlertLevels({ amount: 1, emails: [], alertLevels: [100, 250] }, "none", 5000, 5000) + ).toBe(false); + }); + + it("detects when saved alerts should be cleared on a limit format change", () => { + const billingLimit = { + isConfigured: true, + mode: "plan" as const, + effectiveAmountCents: 5000, + gracePeriodMs: 86_400_000, + }; + + expect( + hadSavedAlertsToClearOnLimitChange( + { amount: 50, emails: [], alertLevels: [0.75, 0.9] }, + billingLimit, + 5000 + ) + ).toBe(true); + + expect( + hadSavedAlertsToClearOnLimitChange( + { amount: 50, emails: ["a@example.com"], alertLevels: [] }, + billingLimit, + 5000 + ) + ).toBe(false); + }); + + it("compares threshold and email values for dirty form state", () => { + expect(thresholdsMatchSaved([90, 75], [75, 90])).toBe(true); + expect(thresholdsMatchSaved([75], [75, 90])).toBe(false); + expect(emailsMatchSaved(["a@example.com", ""], ["a@example.com"])).toBe(true); + expect(emailsMatchSaved(["b@example.com"], ["a@example.com"])).toBe(false); + }); + + it("detects duplicate alert thresholds", () => { + expect(thresholdValuesAreUnique([75, 90, 100])).toBe(true); + expect(thresholdValuesAreUnique([75, 75])).toBe(false); + expect(thresholdValuesAreUnique([100, 250, 100])).toBe(false); + }); + + it("returns configured billing limit cents for plan and custom modes", () => { + expect( + getConfiguredBillingLimitCents( + { + isConfigured: true, + mode: "custom", + amountCents: 25_000, + cancelInProgressRuns: false, + limitState: { status: "ok" }, + effectiveAmountCents: 25_000, + gracePeriodMs: 86_400_000, + }, + 5_000 + ) + ).toBe(25_000); + + expect( + getConfiguredBillingLimitCents( + { + isConfigured: true, + mode: "none", + cancelInProgressRuns: false, + limitState: { status: "ok" }, + effectiveAmountCents: null, + gracePeriodMs: 86_400_000, + }, + 5_000 + ) + ).toBeUndefined(); + }); + + it("maps usage bar billing limit dollars and hides when same as plan limit", () => { + const customLimit = { + isConfigured: true as const, + mode: "custom" as const, + amountCents: 25_000, + cancelInProgressRuns: false, + limitState: { status: "ok" as const }, + effectiveAmountCents: 25_000, + gracePeriodMs: 86_400_000, + }; + + expect(getUsageBarBillingLimitDollars(customLimit, 5_000)).toBe(250); + expect(getUsageBarBillingLimitDollars(customLimit, 25_000)).toBeUndefined(); + expect(getUsageBarBillingLimitDollars(undefined, 5_000)).toBeUndefined(); + }); +}); diff --git a/apps/webapp/test/billingLimit.schemas.test.ts b/apps/webapp/test/billingLimit.schemas.test.ts new file mode 100644 index 00000000000..597661cd6a9 --- /dev/null +++ b/apps/webapp/test/billingLimit.schemas.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + BillingLimitResultSchema, + BillingLimitsPendingResolvesResultSchema, + EntitlementResultSchema, + ResolveBillingLimitRequestSchema, +} from "~/services/billingLimit.schemas"; + +describe("billingLimit.schemas", () => { + it("parses unconfigured billing limit", () => { + const result = BillingLimitResultSchema.parse({ + isConfigured: false, + gracePeriodMs: 86_400_000, + }); + + expect(result.isConfigured).toBe(false); + expect(result.gracePeriodMs).toBe(86_400_000); + }); + + it("parses configured mode none with limitState ok — not the same as unconfigured", () => { + const result = BillingLimitResultSchema.parse({ + isConfigured: true, + mode: "none", + cancelInProgressRuns: false, + effectiveAmountCents: null, + gracePeriodMs: 86_400_000, + limitState: { status: "ok" }, + }); + + expect(result.isConfigured).toBe(true); + if (result.isConfigured) { + expect(result.mode).toBe("none"); + expect(result.limitState.status).toBe("ok"); + expect(result.effectiveAmountCents).toBeNull(); + } + + // UI must use !isConfigured for the no-limit org banner — not mode === "none". + const unconfigured = BillingLimitResultSchema.parse({ + isConfigured: false, + gracePeriodMs: 86_400_000, + }); + expect(unconfigured.isConfigured).toBe(false); + expect(result.isConfigured).not.toBe(unconfigured.isConfigured); + }); + + it("parses configured billing limit in grace", () => { + const result = BillingLimitResultSchema.parse({ + isConfigured: true, + mode: "custom", + amountCents: 50_000, + cancelInProgressRuns: false, + effectiveAmountCents: 50_000, + gracePeriodMs: 86_400_000, + limitState: { + status: "grace", + hitAt: "2026-06-14T12:00:00.000Z", + graceEndsAt: "2026-06-15T12:00:00.000Z", + }, + }); + + expect(result.isConfigured).toBe(true); + if (result.isConfigured) { + expect(result.mode).toBe("custom"); + expect(result.limitState.status).toBe("grace"); + } + }); + + it("parses entitlement with billing_limit reason", () => { + const result = EntitlementResultSchema.parse({ + hasAccess: false, + reason: "billing_limit", + }); + + expect(result.hasAccess).toBe(false); + expect(result.reason).toBe("billing_limit"); + }); + + it("parses entitlement with free_tier_exceeded reason", () => { + const result = EntitlementResultSchema.parse({ + hasAccess: false, + reason: "free_tier_exceeded", + balance: 0, + usage: 100, + overage: 10, + }); + + expect(result.hasAccess).toBe(false); + expect(result.reason).toBe("free_tier_exceeded"); + }); + + it("parses entitlement with grace limit state", () => { + const result = EntitlementResultSchema.parse({ + hasAccess: true, + limitState: "grace", + }); + + expect(result.hasAccess).toBe(true); + expect(result.limitState).toBe("grace"); + }); + + it("parses resolve payload", () => { + const result = ResolveBillingLimitRequestSchema.parse({ + action: "increase", + newAmountCents: 150_000, + resumeMode: "queue", + }); + + expect(result.action).toBe("increase"); + expect(result.newAmountCents).toBe(150_000); + }); + + it("parses pending billing limit resolves from billing platform", () => { + const result = BillingLimitsPendingResolvesResultSchema.parse({ + orgs: [ + { + organizationId: "org_123", + resumeMode: "new_only", + resolvedAt: "2026-06-17T12:00:00.000Z", + }, + ], + }); + + expect(result.orgs).toHaveLength(1); + expect(result.orgs[0]?.resumeMode).toBe("new_only"); + }); +}); diff --git a/apps/webapp/test/billingLimitBulkCancelInProgress.test.ts b/apps/webapp/test/billingLimitBulkCancelInProgress.test.ts new file mode 100644 index 00000000000..7cd29788614 --- /dev/null +++ b/apps/webapp/test/billingLimitBulkCancelInProgress.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, vi } from "vitest"; +import { setTimeout } from "node:timers/promises"; +import { postgresTest, replicationContainerTest } from "@internal/testcontainers"; +import { BulkActionType } from "@trigger.dev/database"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { + BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE, + BillingLimitBulkCancelService, +} from "~/v3/services/billingLimit/BillingLimitBulkCancelService.server"; +import { countInProgressRunsForBillableEnvironment } from "~/v3/services/billingLimit/billingLimitQueuedRuns.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; +import { setupClickhouseReplication } from "./utils/replicationUtils"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("BillingLimitBulkCancelService.cancelInProgressRuns", () => { + postgresTest("dedupes bulk cancel by hitAt per environment", async ({ prisma }) => { + const { organization, project } = await createTestOrgProjectWithMember(prisma); + const productionEnv = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + + const hitAt = "2026-06-16T12:00:00.000Z"; + + await prisma.bulkActionGroup.create({ + data: { + id: "bulk_existing", + friendlyId: "bulk_existing", + projectId: project.id, + environmentId: productionEnv.id, + name: "Existing in-progress cancel", + type: BulkActionType.CANCEL, + params: { + statuses: ["EXECUTING"], + finalizeRun: true, + source: BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE, + dedupeKey: hitAt, + }, + queryName: "bulk_action_v1", + totalCount: 1, + }, + }); + + const result = await BillingLimitBulkCancelService.cancelInProgressRuns( + organization.id, + { hitAt }, + { prismaClient: prisma, enqueueProcessBulkAction: async () => undefined } + ); + + expect(result.bulkActionIds).toEqual(["bulk_existing"]); + + const groups = await prisma.bulkActionGroup.findMany({ + where: { environmentId: productionEnv.id, type: BulkActionType.CANCEL }, + }); + + expect(groups).toHaveLength(1); + }); + + replicationContainerTest( + "creates bulk cancel for in-progress runs in billable environments", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { title: "billing-limit-in-progress-runs", slug: "billing-limit-in-progress-runs" }, + }); + + const project = await prisma.project.create({ + data: { + name: "billing-limit-in-progress-runs", + slug: "billing-limit-in-progress-runs", + organizationId: organization.id, + externalRef: "billing-limit-in-progress-runs", + }, + }); + + const productionEnv = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: "prod", + pkApiKey: "prod", + shortcode: "prod", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_executing_prod", + taskIdentifier: "running-task", + status: "EXECUTING", + payload: JSON.stringify({}), + traceId: "trace", + spanId: "span", + queue: "main", + runtimeEnvironmentId: productionEnv.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "PRODUCTION", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ prisma, clickhouse }); + + const count = await countInProgressRunsForBillableEnvironment( + runsRepository, + organization.id, + { id: productionEnv.id, projectId: project.id } + ); + + expect(count).toBe(1); + + const hitAt = "2026-06-16T12:00:00.000Z"; + const enqueuedBulkActionIds: string[] = []; + + const result = await BillingLimitBulkCancelService.cancelInProgressRuns( + organization.id, + { hitAt }, + { + prismaClient: prisma, + createRunsRepository: async () => runsRepository, + enqueueProcessBulkAction: async (bulkActionId) => { + enqueuedBulkActionIds.push(bulkActionId); + }, + } + ); + + expect(result.bulkActionIds).toHaveLength(1); + expect(enqueuedBulkActionIds).toHaveLength(1); + + const group = await prisma.bulkActionGroup.findFirst({ + where: { + environmentId: productionEnv.id, + type: BulkActionType.CANCEL, + }, + }); + + expect(group?.name).toBe("Billing limit hit — cancel in-progress runs"); + expect(group?.params).toMatchObject({ + source: BILLING_LIMIT_IN_PROGRESS_CANCEL_SOURCE, + dedupeKey: hitAt, + finalizeRun: true, + }); + } + ); +}); diff --git a/apps/webapp/test/billingLimitConvergeEnvironments.test.ts b/apps/webapp/test/billingLimitConvergeEnvironments.test.ts new file mode 100644 index 00000000000..ec4fc460282 --- /dev/null +++ b/apps/webapp/test/billingLimitConvergeEnvironments.test.ts @@ -0,0 +1,88 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { EnvironmentPauseSource } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import { convergeBillingLimitEnvironmentsForOrg } from "~/v3/services/billingLimit/billingLimitConvergeEnvironments.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, +})); + +import { postgresTest } from "@internal/testcontainers"; + +vi.setConfig({ testTimeout: 60_000 }); + +async function createBillingPausedProductionEnv(prisma: PrismaClient) { + const { organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { + paused: true, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }, + }); + + return { organization, environment }; +} + +describe("convergeBillingLimitEnvironmentsForOrg", () => { + postgresTest("unpauses billable environments paused by billing limit", async ({ prisma }) => { + const { organization, environment } = await createBillingPausedProductionEnv(prisma); + + const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "ok", { + prismaClient: prisma, + updateConcurrency: async () => undefined, + }); + + expect(result).toEqual({ paused: 0, unpaused: 1 }); + + const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({ + where: { id: environment.id }, + }); + expect(envAfter.paused).toBe(false); + expect(envAfter.pauseSource).toBeNull(); + }); + + postgresTest("does not unpause environments paused for other reasons", async ({ prisma }) => { + const { organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { + paused: true, + pauseSource: null, + }, + }); + + const result = await convergeBillingLimitEnvironmentsForOrg(organization.id, "ok", { + prismaClient: prisma, + updateConcurrency: async () => undefined, + }); + + expect(result).toEqual({ paused: 0, unpaused: 0 }); + + const envAfter = await prisma.runtimeEnvironment.findUniqueOrThrow({ + where: { id: environment.id }, + }); + expect(envAfter.paused).toBe(true); + expect(envAfter.pauseSource).toBeNull(); + }); +}); diff --git a/apps/webapp/test/billingLimitConvergeEnvironmentsService.test.ts b/apps/webapp/test/billingLimitConvergeEnvironmentsService.test.ts new file mode 100644 index 00000000000..8a6759438c5 --- /dev/null +++ b/apps/webapp/test/billingLimitConvergeEnvironmentsService.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { reconcileBillingLimitTarget } from "~/v3/services/billingLimit/billingLimitReconcileTarget.server"; + +describe("reconcileBillingLimitTarget", () => { + it("busts billing limit caches for rejected targets before enqueueing converge", async () => { + const bustedOrgIds: string[] = []; + const enqueued: Array<{ organizationId: string; targetState: string }> = []; + + await reconcileBillingLimitTarget( + { organizationId: "org_123", targetState: "rejected" }, + { + bustCaches: (organizationId) => { + bustedOrgIds.push(organizationId); + }, + enqueueConverge: async (organizationId, targetState) => { + enqueued.push({ organizationId, targetState }); + }, + } + ); + + expect(bustedOrgIds).toEqual(["org_123"]); + expect(enqueued).toEqual([{ organizationId: "org_123", targetState: "rejected" }]); + }); + + it("busts billing limit caches for ok targets before enqueueing converge", async () => { + const bustedOrgIds: string[] = []; + const enqueued: Array<{ organizationId: string; targetState: string }> = []; + + await reconcileBillingLimitTarget( + { organizationId: "org_123", targetState: "ok" }, + { + bustCaches: (organizationId) => { + bustedOrgIds.push(organizationId); + }, + enqueueConverge: async (organizationId, targetState) => { + enqueued.push({ organizationId, targetState }); + }, + } + ); + + expect(bustedOrgIds).toEqual(["org_123"]); + expect(enqueued).toEqual([{ organizationId: "org_123", targetState: "ok" }]); + }); + + it("does not bust caches for grace targets", async () => { + const bustedOrgIds: string[] = []; + + await reconcileBillingLimitTarget( + { organizationId: "org_123", targetState: "grace" }, + { + bustCaches: (organizationId) => { + bustedOrgIds.push(organizationId); + }, + enqueueConverge: async () => undefined, + } + ); + + expect(bustedOrgIds).toEqual([]); + }); +}); diff --git a/apps/webapp/test/billingLimitConvergeResolve.test.ts b/apps/webapp/test/billingLimitConvergeResolve.test.ts new file mode 100644 index 00000000000..3147a2b8261 --- /dev/null +++ b/apps/webapp/test/billingLimitConvergeResolve.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { buildBillingLimitResolveDedupeKey } from "~/v3/services/billingLimit/billingLimitConstants"; +import { classifyPendingBillingLimitResolveConvergeFailure } from "~/v3/services/billingLimit/billingLimitPendingResolveFailure.server"; +import { runPendingBillingLimitResolves } from "~/v3/services/billingLimit/billingLimitPendingResolveCoordinator.server"; +import type { PendingBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitPendingResolve.types"; + +describe("billingLimitConvergeResolve", () => { + it("builds a stable dedupe key from org id and resolvedAt", () => { + expect( + buildBillingLimitResolveDedupeKey("org_123", "2026-06-16T12:00:00.000Z") + ).toBe("billing-limit-resolve:org_123:2026-06-16T12:00:00.000Z"); + }); + + it("classifies converge failures for ops triage", () => { + expect(classifyPendingBillingLimitResolveConvergeFailure("new_only")).toBe("cancel-failing"); + expect(classifyPendingBillingLimitResolveConvergeFailure("queue")).toBe("converge-failing"); + }); +}); + +describe("runPendingBillingLimitResolves", () => { + const pending: PendingBillingLimitResolve = { + organizationId: "org_123", + resumeMode: "new_only", + resolvedAt: "2026-06-17T12:00:00.000Z", + }; + + it("keeps org pending and skips ack when converge throws (cancel-failing path)", async () => { + const completeCalls: string[] = []; + + const stillPending = await runPendingBillingLimitResolves([pending], { + converge: async () => { + throw new Error("bulk cancel failed"); + }, + complete: async (organizationId) => { + completeCalls.push(organizationId); + return { completed: true }; + }, + }); + + expect(stillPending).toEqual(new Set(["org_123"])); + expect(completeCalls).toEqual([]); + }); + + it("keeps org pending when converge succeeds but ack throws (ack-only path)", async () => { + let convergeCalls = 0; + let completeCalls = 0; + + const stillPending = await runPendingBillingLimitResolves( + [{ ...pending, resumeMode: "queue" }], + { + converge: async () => { + convergeCalls += 1; + }, + complete: async () => { + completeCalls += 1; + throw new Error("resolve-complete unavailable"); + }, + } + ); + + expect(stillPending).toEqual(new Set(["org_123"])); + expect(convergeCalls).toBe(1); + expect(completeCalls).toBe(1); + }); + + it("clears org from pending set when converge and ack both succeed", async () => { + const stillPending = await runPendingBillingLimitResolves([pending], { + converge: async () => undefined, + complete: async () => ({ completed: true }), + }); + + expect(stillPending).toEqual(new Set()); + }); + + it("retries ack on a later tick after a transient ack failure", async () => { + let ackAttempts = 0; + + const deps = { + converge: async () => undefined, + complete: async () => { + ackAttempts += 1; + if (ackAttempts === 1) { + throw new Error("resolve-complete unavailable"); + } + return { completed: true }; + }, + }; + + expect(await runPendingBillingLimitResolves([pending], deps)).toEqual(new Set(["org_123"])); + expect(await runPendingBillingLimitResolves([pending], deps)).toEqual(new Set()); + expect(ackAttempts).toBe(2); + }); +}); diff --git a/apps/webapp/test/billingLimitEnvCreatePause.test.ts b/apps/webapp/test/billingLimitEnvCreatePause.test.ts new file mode 100644 index 00000000000..319fad59500 --- /dev/null +++ b/apps/webapp/test/billingLimitEnvCreatePause.test.ts @@ -0,0 +1,70 @@ +import { EnvironmentPauseSource } from "@trigger.dev/database"; +import { describe, expect, it } from "vitest"; +import type { BillingLimitResult } from "~/services/billingLimit.schemas"; +import { getInitialEnvPauseStateForBillingLimit } from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server"; + +function configuredLimit( + status: "grace" | "rejected" | "ok" +): BillingLimitResult { + const hitAt = "2026-06-16T12:00:00.000Z"; + const graceEndsAt = "2026-06-17T12:00:00.000Z"; + + return { + isConfigured: true, + mode: "custom", + amountCents: 50_000, + cancelInProgressRuns: false, + effectiveAmountCents: 50_000, + gracePeriodMs: 86_400_000, + limitState: + status === "ok" + ? { status: "ok" } + : { status, hitAt, graceEndsAt }, + }; +} + +describe("getInitialEnvPauseStateForBillingLimit", () => { + it("pauses billable environments when org is in grace", async () => { + const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", { + getBillingLimit: async () => configuredLimit("grace"), + }); + + expect(result).toEqual({ + paused: true, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }); + }); + + it("pauses billable environments when org is rejected", async () => { + const result = await getInitialEnvPauseStateForBillingLimit("org_123", "STAGING", { + getBillingLimit: async () => configuredLimit("rejected"), + }); + + expect(result).toEqual({ + paused: true, + pauseSource: EnvironmentPauseSource.BILLING_LIMIT, + }); + }); + + it("does not pause development environments", async () => { + const result = await getInitialEnvPauseStateForBillingLimit("org_123", "DEVELOPMENT", { + getBillingLimit: async () => configuredLimit("rejected"), + }); + + expect(result).toEqual({ + paused: false, + pauseSource: null, + }); + }); + + it("does not pause when billing limit is ok", async () => { + const result = await getInitialEnvPauseStateForBillingLimit("org_123", "PRODUCTION", { + getBillingLimit: async () => configuredLimit("ok"), + }); + + expect(result).toEqual({ + paused: false, + pauseSource: null, + }); + }); +}); diff --git a/apps/webapp/test/billingLimitHit.test.ts b/apps/webapp/test/billingLimitHit.test.ts new file mode 100644 index 00000000000..d0ef4f645ce --- /dev/null +++ b/apps/webapp/test/billingLimitHit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { BillingLimitHitWebhookBodySchema } from "~/services/billingLimit.schemas"; +import { + type BillingLimitHitDeps, + processBillingLimitHit, +} from "~/v3/services/billingLimit/billingLimitHit.server"; + +describe("billingLimitHit", () => { + it("busts caches, seeds reconcile, and enqueues grace converge", async () => { + const calls: string[] = []; + + const deps: BillingLimitHitDeps = { + bustCaches: (organizationId) => { + calls.push(`bust:${organizationId}`); + }, + seedReconcileQueue: async (organizationId) => { + calls.push(`seed:${organizationId}`); + }, + enqueueConverge: async (organizationId, targetState) => { + calls.push(`converge:${organizationId}:${targetState}`); + }, + enqueueCancelInProgressRuns: async () => { + calls.push("cancel"); + }, + }; + + await processBillingLimitHit( + { + organizationId: "org_123", + hitAt: "2026-06-16T12:00:00.000Z", + cancelInProgressRuns: false, + }, + deps + ); + + expect(calls).toEqual(["bust:org_123", "seed:org_123", "converge:org_123:grace"]); + }); + + it("enqueues in-progress cancel when cancelInProgressRuns is true", async () => { + const cancelCalls: Array<{ organizationId: string; hitAt: string }> = []; + + const deps: BillingLimitHitDeps = { + bustCaches: () => {}, + seedReconcileQueue: async () => {}, + enqueueConverge: async () => {}, + enqueueCancelInProgressRuns: async (organizationId, hitAt) => { + cancelCalls.push({ organizationId, hitAt }); + }, + }; + + await processBillingLimitHit( + { + organizationId: "org_123", + hitAt: "2026-06-16T12:00:00.000Z", + cancelInProgressRuns: true, + }, + deps + ); + + expect(cancelCalls).toEqual([ + { organizationId: "org_123", hitAt: "2026-06-16T12:00:00.000Z" }, + ]); + }); +}); + +describe("BillingLimitHitWebhookBodySchema", () => { + it("parses the hit webhook body", () => { + expect( + BillingLimitHitWebhookBodySchema.parse({ + hitAt: "2026-06-16T12:00:00.000Z", + cancelInProgressRuns: true, + limitState: "grace", + }) + ).toEqual({ + hitAt: "2026-06-16T12:00:00.000Z", + cancelInProgressRuns: true, + limitState: "grace", + }); + }); +}); diff --git a/apps/webapp/test/billingLimitPauseEnvironment.test.ts b/apps/webapp/test/billingLimitPauseEnvironment.test.ts new file mode 100644 index 00000000000..27eb71eb576 --- /dev/null +++ b/apps/webapp/test/billingLimitPauseEnvironment.test.ts @@ -0,0 +1,26 @@ +import { EnvironmentPauseSource } from "@trigger.dev/database"; +import { describe, expect, it } from "vitest"; +import { getManualPauseEnvironmentResult } from "~/v3/services/billingLimit/manualPauseEnvironmentGuard.server"; + +describe("manualPauseEnvironmentGuard", () => { + it("blocks resume and no-ops pause for billing-paused environments", () => { + expect( + getManualPauseEnvironmentResult("resumed", EnvironmentPauseSource.BILLING_LIMIT) + ).toEqual({ + proceed: false, + success: false, + error: expect.stringContaining("billing limit"), + }); + + expect( + getManualPauseEnvironmentResult("paused", EnvironmentPauseSource.BILLING_LIMIT) + ).toEqual({ + proceed: false, + success: true, + state: "paused", + }); + + expect(getManualPauseEnvironmentResult("resumed", null)).toEqual({ proceed: true }); + expect(getManualPauseEnvironmentResult("paused", null)).toEqual({ proceed: true }); + }); +}); diff --git a/apps/webapp/test/billingLimitQueuedRuns.test.ts b/apps/webapp/test/billingLimitQueuedRuns.test.ts new file mode 100644 index 00000000000..c7a036abbb4 --- /dev/null +++ b/apps/webapp/test/billingLimitQueuedRuns.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, vi } from "vitest"; +import { setTimeout } from "node:timers/promises"; +import { replicationContainerTest } from "@internal/testcontainers"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { + countQueuedRunsForBillableEnvironment, +} from "~/v3/services/billingLimit/billingLimitQueuedRuns.server"; +import { setupClickhouseReplication } from "./utils/replicationUtils"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("billingLimitQueuedRuns", () => { + replicationContainerTest( + "counts queued runs via RunsRepository.countRuns (same source as bulk cancel)", + async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { + const { clickhouse } = await setupClickhouseReplication({ + prisma, + databaseUrl: postgresContainer.getConnectionUri(), + clickhouseUrl: clickhouseContainer.getConnectionUrl(), + redisOptions, + }); + + const organization = await prisma.organization.create({ + data: { title: "billing-limit-queued", slug: "billing-limit-queued" }, + }); + + const project = await prisma.project.create({ + data: { + name: "billing-limit-queued", + slug: "billing-limit-queued", + organizationId: organization.id, + externalRef: "billing-limit-queued", + }, + }); + + const productionEnv = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: "prod", + pkApiKey: "prod", + shortcode: "prod", + }, + }); + + const developmentEnv = await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + projectId: project.id, + organizationId: organization.id, + apiKey: "dev", + pkApiKey: "dev", + shortcode: "dev", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_queued_prod", + taskIdentifier: "queued-task", + status: "PENDING", + payload: JSON.stringify({}), + traceId: "trace", + spanId: "span", + queue: "main", + runtimeEnvironmentId: productionEnv.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "PRODUCTION", + engine: "V2", + }, + }); + + await prisma.taskRun.create({ + data: { + friendlyId: "run_queued_dev", + taskIdentifier: "queued-task", + status: "PENDING", + payload: JSON.stringify({}), + traceId: "trace", + spanId: "span", + queue: "main", + runtimeEnvironmentId: developmentEnv.id, + projectId: project.id, + organizationId: organization.id, + environmentType: "DEVELOPMENT", + engine: "V2", + }, + }); + + await setTimeout(1000); + + const runsRepository = new RunsRepository({ prisma, clickhouse }); + + const productionCount = await countQueuedRunsForBillableEnvironment( + runsRepository, + organization.id, + { id: productionEnv.id, projectId: project.id } + ); + + const developmentCount = await countQueuedRunsForBillableEnvironment( + runsRepository, + organization.id, + { id: developmentEnv.id, projectId: project.id } + ); + + expect(productionCount).toBe(1); + expect(developmentCount).toBe(1); + } + ); +}); diff --git a/apps/webapp/test/billingLimitReconcileTick.test.ts b/apps/webapp/test/billingLimitReconcileTick.test.ts new file mode 100644 index 00000000000..3f86ccb665a --- /dev/null +++ b/apps/webapp/test/billingLimitReconcileTick.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { runBillingLimitReconcileTick } from "~/v3/services/billingLimit/runBillingLimitReconcileTick.server"; +import type { PendingBillingLimitResolve } from "~/v3/services/billingLimit/billingLimitPendingResolve.types"; + +describe("runBillingLimitReconcileTick", () => { + const pending: PendingBillingLimitResolve = { + organizationId: "org_pending", + resumeMode: "queue", + resolvedAt: "2026-06-17T12:00:00.000Z", + }; + + it("runs pending resolves before collecting orgs and excludes still-pending orgs", async () => { + const order: string[] = []; + + await runBillingLimitReconcileTick({ + getPendingResolves: async () => ({ orgs: [pending] }), + runPendingResolves: async (pendingResolves) => { + order.push(`pending:${pendingResolves.map((row) => row.organizationId).join(",")}`); + return new Set(["org_pending"]); + }, + collectOrgs: async (options) => { + order.push(`collect:${[...options?.excludeOrgIds ?? []].join(",")}`); + return { + targets: [{ organizationId: "org_active", targetState: "grace" }], + queuedOrgIds: ["org_active"], + }; + }, + reconcileTarget: async (target) => { + order.push(`reconcile:${target.organizationId}:${target.targetState}`); + }, + clearProcessedQueue: async (queuedOrgIds, processedOrgIds) => { + order.push(`clear:${queuedOrgIds.join(",")}:${processedOrgIds.join(",")}`); + }, + bustCaches: () => {}, + enqueueConverge: async () => undefined, + }); + + expect(order).toEqual([ + "pending:org_pending", + "collect:org_pending", + "reconcile:org_active:grace", + "clear:org_active:org_active", + ]); + }); + + it("reconciles collected targets when no pending resolves remain", async () => { + const reconciled: Array<{ organizationId: string; targetState: string }> = []; + + await runBillingLimitReconcileTick({ + getPendingResolves: async () => ({ orgs: [] }), + runPendingResolves: async () => new Set(), + collectOrgs: async () => ({ + targets: [ + { organizationId: "org_grace", targetState: "grace" }, + { organizationId: "org_ok", targetState: "ok" }, + ], + queuedOrgIds: ["org_grace", "org_ok"], + }), + reconcileTarget: async (target, deps) => { + reconciled.push(target); + await deps.enqueueConverge(target.organizationId, target.targetState); + }, + clearProcessedQueue: async () => undefined, + bustCaches: () => {}, + enqueueConverge: async (organizationId, targetState) => { + reconciled.push({ organizationId, targetState: `enqueued:${targetState}` }); + }, + }); + + expect(reconciled).toEqual([ + { organizationId: "org_grace", targetState: "grace" }, + { organizationId: "org_grace", targetState: "enqueued:grace" }, + { organizationId: "org_ok", targetState: "ok" }, + { organizationId: "org_ok", targetState: "enqueued:ok" }, + ]); + }); +}); diff --git a/apps/webapp/test/billingLimitReconciliation.test.ts b/apps/webapp/test/billingLimitReconciliation.test.ts new file mode 100644 index 00000000000..4eac7b00096 --- /dev/null +++ b/apps/webapp/test/billingLimitReconciliation.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { BillingLimitResult } from "~/services/billingLimit.schemas"; +import { resolveConvergeTargetFromBillingLimit } from "~/v3/services/billingLimit/billingLimitReconciliation.server"; + +const graceLimit: BillingLimitResult = { + isConfigured: true, + mode: "custom", + amountCents: 10_000, + cancelInProgressRuns: false, + limitState: { status: "grace", hitAt: "2026-01-01T00:00:00.000Z", graceEndsAt: "2026-01-02T00:00:00.000Z" }, + effectiveAmountCents: 10_000, + gracePeriodMs: 86_400_000, +}; + +describe("billingLimitReconciliation", () => { + it("maps grace and rejected limits to converge targets", () => { + expect(resolveConvergeTargetFromBillingLimit(graceLimit)).toBe("grace"); + expect( + resolveConvergeTargetFromBillingLimit({ + ...graceLimit, + limitState: { + status: "rejected", + hitAt: "2026-01-01T00:00:00.000Z", + graceEndsAt: "2026-01-02T00:00:00.000Z", + }, + }) + ).toBe("rejected"); + expect( + resolveConvergeTargetFromBillingLimit({ + ...graceLimit, + limitState: { status: "ok" }, + }) + ).toBe("ok"); + expect(resolveConvergeTargetFromBillingLimit(undefined)).toBe("ok"); + expect( + resolveConvergeTargetFromBillingLimit({ isConfigured: false, gracePeriodMs: 86_400_000 }) + ).toBe("ok"); + }); +}); diff --git a/apps/webapp/test/billingLimitTriggerEntitlement.test.ts b/apps/webapp/test/billingLimitTriggerEntitlement.test.ts new file mode 100644 index 00000000000..39dc2cdd5ba --- /dev/null +++ b/apps/webapp/test/billingLimitTriggerEntitlement.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { validateProductionEntitlement } from "~/runEngine/validators/validateProductionEntitlement.server"; + +const productionEnv = { + type: "PRODUCTION" as const, + organizationId: "org_123", +}; + +const developmentEnv = { + type: "DEVELOPMENT" as const, + organizationId: "org_123", +}; + +describe("validateProductionEntitlement", () => { + it("allows development environments without checking entitlement", async () => { + const result = await validateProductionEntitlement( + { environment: developmentEnv as never }, + async () => ({ hasAccess: false, reason: "billing_limit" }) + ); + + expect(result).toEqual({ ok: true }); + }); + + it("rejects production triggers when entitlement has billing_limit denial", async () => { + const result = await validateProductionEntitlement( + { environment: productionEnv as never }, + async () => ({ + hasAccess: false, + reason: "billing_limit", + plan: { type: "paid", code: "pro", isPaying: true }, + }) + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.name).toBe("OutOfEntitlementError"); + } + }); + + it("allows production triggers when entitlement grants access", async () => { + const plan = { type: "paid" as const, code: "pro", isPaying: true }; + + const result = await validateProductionEntitlement( + { environment: productionEnv as never }, + async () => ({ + hasAccess: true, + plan, + }) + ); + + expect(result).toEqual({ ok: true, plan }); + }); +}); diff --git a/apps/webapp/test/billingLimitsRoute.test.ts b/apps/webapp/test/billingLimitsRoute.test.ts new file mode 100644 index 00000000000..0380ff4f9a5 --- /dev/null +++ b/apps/webapp/test/billingLimitsRoute.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "@conform-to/zod"; +import { billingAlertsSchema } from "~/components/billing/BillingAlertsSection"; +import { + billingLimitFormSchema, + getBillingLimitFormLastSubmission, + isBillingLimitFormDirty, +} from "~/components/billing/BillingLimitConfigSection"; +import { billingLimitRecoveryFormSchema } from "~/components/billing/BillingLimitRecoveryPanel"; +import { isBillingLimitSettingsFormSubmission } from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRevalidation"; +import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat"; +import { + getAlertsResetRequested, + getEffectiveLimitCentsAfterLimitSave, + getResolveSubmitted, + getSubmittedResumeMode, + isEnforcementActive, +} from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server"; +import { loader as billingAlertsRedirectLoader } from "~/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route"; + +function billingLimitsRequest(search = ""): Request { + return new Request(`http://localhost:3030/orgs/acme/settings/billing-limits${search}`); +} + +describe("billingLimitsRoute.server", () => { + describe("isEnforcementActive", () => { + it("returns true in grace", () => { + expect( + isEnforcementActive({ + isConfigured: true, + mode: "plan", + cancelInProgressRuns: false, + limitState: { status: "grace", hitAt: "t", graceEndsAt: "t" }, + effectiveAmountCents: 5000, + gracePeriodMs: 86_400_000, + }) + ).toBe(true); + }); + + it("returns true when rejected", () => { + expect( + isEnforcementActive({ + isConfigured: true, + mode: "custom", + amountCents: 2500, + cancelInProgressRuns: false, + limitState: { status: "rejected", hitAt: "t", graceEndsAt: "t" }, + effectiveAmountCents: 2500, + gracePeriodMs: 86_400_000, + }) + ).toBe(true); + }); + + it("returns false when unconfigured", () => { + expect( + isEnforcementActive({ + isConfigured: false, + gracePeriodMs: 86_400_000, + }) + ).toBe(false); + }); + + it("returns false when configured and ok", () => { + expect( + isEnforcementActive({ + isConfigured: true, + mode: "none", + cancelInProgressRuns: false, + limitState: { status: "ok" }, + effectiveAmountCents: null, + gracePeriodMs: 86_400_000, + }) + ).toBe(false); + }); + }); + + describe("getAlertsResetRequested", () => { + it("returns true when alertsReset=1 is present", () => { + expect(getAlertsResetRequested(billingLimitsRequest("?alertsReset=1"))).toBe(true); + }); + + it("returns false when the param is absent", () => { + expect(getAlertsResetRequested(billingLimitsRequest())).toBe(false); + }); + + it("returns false for other param values", () => { + expect(getAlertsResetRequested(billingLimitsRequest("?alertsReset=true"))).toBe(false); + }); + }); + + describe("getResolveSubmitted", () => { + it("returns true when resolved=1 is present", () => { + expect(getResolveSubmitted(billingLimitsRequest("?resolved=1"))).toBe(true); + }); + + it("returns false when the param is absent", () => { + expect(getResolveSubmitted(billingLimitsRequest())).toBe(false); + }); + }); + + describe("getSubmittedResumeMode", () => { + it("parses resumeMode from the query string", () => { + expect(getSubmittedResumeMode(billingLimitsRequest("?resumeMode=new_only"))).toBe( + "new_only" + ); + }); + + it("returns null for invalid values", () => { + expect(getSubmittedResumeMode(billingLimitsRequest("?resumeMode=invalid"))).toBeNull(); + }); + }); + + describe("getSuggestedRecoveryLimitDollars", () => { + it("uses max(limit + $50, limit × 1.25, spend × 1.25) rounded up to $50", () => { + expect(getSuggestedRecoveryLimitDollars(5_000, 4_500)).toBe(100); + expect(getSuggestedRecoveryLimitDollars(50_000, 48_000)).toBe(650); + expect(getSuggestedRecoveryLimitDollars(50_000, 60_000)).toBe(750); + expect(getSuggestedRecoveryLimitDollars(1_000_000, 950_000)).toBe(12_500); + }); + + it("falls back to spend × 1.25 when there is no effective limit", () => { + expect(getSuggestedRecoveryLimitDollars(null, 4_500)).toBe(100); + }); + }); + + describe("isBillingLimitSettingsFormSubmission", () => { + it("returns true for billing-limit POST", () => { + const formData = new FormData(); + formData.set("intent", "billing-limit"); + expect(isBillingLimitSettingsFormSubmission("post", formData)).toBe(true); + }); + + it("returns true for billing-alerts POST", () => { + const formData = new FormData(); + formData.set("intent", "billing-alerts"); + expect(isBillingLimitSettingsFormSubmission("POST", formData)).toBe(true); + }); + + it("returns true for billing-limit-resolve POST", () => { + const formData = new FormData(); + formData.set("intent", "billing-limit-resolve"); + expect(isBillingLimitSettingsFormSubmission("post", formData)).toBe(true); + }); + + it("returns false for unrelated POST", () => { + const formData = new FormData(); + formData.set("intent", "other"); + expect(isBillingLimitSettingsFormSubmission("post", formData)).toBe(false); + }); + + it("returns false without form data", () => { + expect(isBillingLimitSettingsFormSubmission("post", undefined)).toBe(false); + }); + }); + + describe("getEffectiveLimitCentsAfterLimitSave", () => { + it("uses custom amount in cents for custom mode", () => { + expect(getEffectiveLimitCentsAfterLimitSave("custom", 5000, 42.5)).toBe(4250); + }); + + it("uses plan limit cents for plan mode", () => { + expect(getEffectiveLimitCentsAfterLimitSave("plan", 5000)).toBe(5000); + }); + + it("uses plan limit cents for none mode", () => { + expect(getEffectiveLimitCentsAfterLimitSave("none", 5000)).toBe(5000); + }); + }); +}); + +describe("billing-alerts redirect route", () => { + it("redirects to billing-limits", async () => { + const response = await billingAlertsRedirectLoader({ + params: { organizationSlug: "acme" }, + request: new Request("http://localhost:3030/orgs/acme/settings/billing-alerts"), + context: {}, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/orgs/acme/settings/billing-limits"); + }); +}); + +describe("billing-limits form validation", () => { + it("rejects duplicate alert thresholds", () => { + const formData = new FormData(); + formData.set("intent", "billing-alerts"); + formData.append("emails", "a@example.com"); + formData.append("alertLevels", "75"); + formData.append("alertLevels", "75"); + + const submission = parse(formData, { schema: billingAlertsSchema }); + expect(submission.error?.alertLevels).toBeTruthy(); + }); + + it("accepts a valid billing limit custom submission", () => { + const formData = new FormData(); + formData.set("intent", "billing-limit"); + formData.set("mode", "custom"); + formData.set("amount", "100"); + formData.set("cancelInProgressRuns", "on"); + + const submission = parse(formData, { schema: billingLimitFormSchema }); + expect(submission.value).toEqual({ + mode: "custom", + amount: 100, + cancelInProgressRuns: true, + }); + }); + + it("parses none mode with cancelInProgressRuns from the form", () => { + const formData = new FormData(); + formData.set("mode", "none"); + formData.set("cancelInProgressRuns", "on"); + + const submission = parse(formData, { schema: billingLimitFormSchema }); + expect(submission.value?.mode).toBe("none"); + expect(submission.value?.cancelInProgressRuns).toBe(true); + }); + + it("accepts a valid billing limit resolve submission", () => { + const formData = new FormData(); + formData.set("intent", "billing-limit-resolve"); + formData.set("action", "increase"); + formData.set("newAmount", "1500"); + formData.set("resumeMode", "queue"); + + const submission = parse(formData, { schema: billingLimitRecoveryFormSchema }); + expect(submission.value).toEqual({ + action: "increase", + newAmount: 1500, + resumeMode: "queue", + }); + }); + + it("accepts remove resolve with new_only resume mode", () => { + const formData = new FormData(); + formData.set("action", "remove"); + formData.set("resumeMode", "new_only"); + + const submission = parse(formData, { schema: billingLimitRecoveryFormSchema }); + expect(submission.value).toEqual({ + action: "remove", + resumeMode: "new_only", + }); + }); +}); + +describe("isBillingLimitFormDirty", () => { + const unconfiguredLimit = { isConfigured: false as const, gracePeriodMs: 86_400_000 }; + const configuredPlanLimit = { + isConfigured: true as const, + mode: "plan" as const, + cancelInProgressRuns: false, + limitState: { status: "ok" as const }, + effectiveAmountCents: 5000, + gracePeriodMs: 86_400_000, + }; + + it("is dirty when billing limit has never been saved", () => { + expect( + isBillingLimitFormDirty({ + billingLimit: unconfiguredLimit, + mode: "plan", + customAmount: "", + cancelInProgressRuns: false, + }) + ).toBe(true); + }); + + it("is clean when configured values match saved state", () => { + expect( + isBillingLimitFormDirty({ + billingLimit: configuredPlanLimit, + mode: "plan", + customAmount: "", + cancelInProgressRuns: false, + }) + ).toBe(false); + }); + + it("is dirty when configured mode changes", () => { + expect( + isBillingLimitFormDirty({ + billingLimit: configuredPlanLimit, + mode: "none", + customAmount: "", + cancelInProgressRuns: false, + }) + ).toBe(true); + }); +}); + +describe("getBillingLimitFormLastSubmission", () => { + it("drops amount errors when the selected mode is not custom", () => { + const submission = parse( + (() => { + const formData = new FormData(); + formData.set("mode", "custom"); + formData.set("amount", "0"); + return formData; + })(), + { schema: billingLimitFormSchema } + ); + + expect(getBillingLimitFormLastSubmission(submission, "plan", true)?.error?.amount).toBeUndefined(); + }); + + it("keeps amount errors while custom mode is selected", () => { + const submission = parse( + (() => { + const formData = new FormData(); + formData.set("mode", "custom"); + formData.set("amount", "0"); + return formData; + })(), + { schema: billingLimitFormSchema } + ); + + expect(getBillingLimitFormLastSubmission(submission, "custom", true)?.error?.amount).toBeTruthy(); + }); + + it("returns undefined when the form is clean", () => { + const submission = parse( + (() => { + const formData = new FormData(); + formData.set("mode", "custom"); + formData.set("amount", "0"); + return formData; + })(), + { schema: billingLimitFormSchema } + ); + + expect(getBillingLimitFormLastSubmission(submission, "custom", false)).toBeUndefined(); + }); +}); diff --git a/apps/webapp/test/orgBanner.test.ts b/apps/webapp/test/orgBanner.test.ts new file mode 100644 index 00000000000..6270b79ec3c --- /dev/null +++ b/apps/webapp/test/orgBanner.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner"; + +describe("selectOrgBanner", () => { + it("prioritizes limit-rejected over grace and no-limit", () => { + expect( + selectOrgBanner({ + billingLimit: { + isConfigured: true, + mode: "plan", + cancelInProgressRuns: false, + limitState: { status: "rejected", hitAt: "t", graceEndsAt: "t" }, + effectiveAmountCents: 1000, + gracePeriodMs: 86_400_000, + }, + hasExceededFreeTier: true, + showEnvironmentWarning: true, + }) + ).toBe(OrgBannerKind.LimitRejected); + }); + + it("shows no-limit when unconfigured and self-serve", () => { + expect( + selectOrgBanner({ + billingLimit: { isConfigured: false, gracePeriodMs: 86_400_000 }, + hasExceededFreeTier: true, + showSelfServe: true, + }) + ).toBe(OrgBannerKind.NoLimitConfigured); + }); + + it("hides no-limit when unconfigured but not self-serve", () => { + expect( + selectOrgBanner({ + billingLimit: { isConfigured: false, gracePeriodMs: 86_400_000 }, + hasExceededFreeTier: true, + showSelfServe: false, + }) + ).toBe(OrgBannerKind.Upgrade); + }); + + it("hides no-limit prompt when configured with mode none", () => { + expect( + selectOrgBanner({ + billingLimit: { + isConfigured: true, + mode: "none", + cancelInProgressRuns: false, + limitState: { status: "ok" }, + effectiveAmountCents: null, + gracePeriodMs: 86_400_000, + }, + }) + ).toBe(OrgBannerKind.None); + }); +}); diff --git a/docs/how-to-reduce-your-spend.mdx b/docs/how-to-reduce-your-spend.mdx index c070745d433..13ec16821b3 100644 --- a/docs/how-to-reduce-your-spend.mdx +++ b/docs/how-to-reduce-your-spend.mdx @@ -16,21 +16,28 @@ Monitor your usage dashboard to understand your spending patterns. You can see: You can view your usage page by clicking the "Organization" menu in the top left of the dashboard and then clicking "Usage". -## Create billing alerts +## Set billing limits and alerts -Configure billing alerts in your dashboard to get notified when you approach spending thresholds. This helps you: +Configure billing limits and alerts in your dashboard to protect against unexpected usage and get notified when you approach spending thresholds. This helps you: +- Set a monthly compute spend limit for your organization - Catch unexpected cost increases early - Identify runaway tasks before they become expensive -The billing alerts page includes two types of alerts: +The **Billing limits** settings page has two sections: -- **Standard alerts**: Get notified at 75%, 90%, 100%, 200%, and 500% of your monthly budget -- **Spike alerts**: Catch runaway usage from bugs or errors with alerts at 10x (1000%), 20x (2000%), 50x (5000%), and 100x (10000%) of your monthly budget. We recommend keeping these enabled as a safety net. +- **Billing limit**: Choose your plan limit, a custom amount, or no limit. When a limit is reached, billable environments (`production`, `staging`, and `preview`) enter a **grace period** — queues pause and new runs queue without starting. After grace expires, new triggers are rejected until you increase or remove the limit. +- **Billing alerts**: Add email alerts at specific spend thresholds (% of your limit when a limit is set, or dollar amounts when no limit is configured). Alerts notify you only; they do **not** pause environments or reject triggers. -![Billing alerts](./images/billing-alerts-ui.png) +**Limits vs alerts:** A billing limit enforces spend (grace → reject). Billing alerts are optional notifications at thresholds you choose. -You can view your billing alerts page by clicking the "Organization" menu in the top left of the dashboard and then clicking "Settings". +**Soft limits:** Billing limits are not instantaneous hard caps. Usage is evaluated on a short delay, so spend can briefly exceed your limit before enforcement applies. Queued runs during grace incur no compute cost until they start. See our [terms](https://trigger.dev/terms) for refund policy details. + +On the **Usage** page, when you have a custom billing limit (or a plan limit that differs from included usage), a **Billing limit** marker appears on the usage bar alongside your current spend and plan included usage. + +![Billing limits and alerts settings](./images/billing-alerts-ui.png) + +You can open the page from the **Organization** menu in the top left of the dashboard, then **Settings** → **Billing limits**. ## Reduce your machine sizes diff --git a/internal-packages/database/prisma/migrations/20260614120000_add_environment_pause_source_billing_limit/migration.sql b/internal-packages/database/prisma/migrations/20260614120000_add_environment_pause_source_billing_limit/migration.sql new file mode 100644 index 00000000000..d34b00e5689 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260614120000_add_environment_pause_source_billing_limit/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "EnvironmentPauseSource" AS ENUM ('BILLING_LIMIT'); + +-- AlterTable +ALTER TABLE "RuntimeEnvironment" ADD COLUMN "pauseSource" "EnvironmentPauseSource"; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index bb80da3a7ec..6067ff78779 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -342,6 +342,7 @@ model RuntimeEnvironment { maximumConcurrencyLimit Int @default(5) concurrencyLimitBurstFactor Decimal @default("2.00") @db.Decimal(4, 2) paused Boolean @default(false) + pauseSource EnvironmentPauseSource? autoEnableInternalSources Boolean @default(true) @@ -421,6 +422,10 @@ enum RuntimeEnvironmentType { PREVIEW } +enum EnvironmentPauseSource { + BILLING_LIMIT +} + model Project { id String @id @default(cuid()) slug String @unique