Skip to content

Commit 98cd56d

Browse files
committed
First draft billing alerts page
1 parent 5ea6605 commit 98cd56d

File tree

8 files changed

+329
-29
lines changed

8 files changed

+329
-29
lines changed

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
BellAlertIcon,
23
ChartBarIcon,
34
Cog8ToothIcon,
45
CreditCardIcon,
@@ -12,6 +13,7 @@ import {
1213
organizationSettingsPath,
1314
organizationTeamPath,
1415
rootPath,
16+
v3BillingAlertsPath,
1517
v3BillingPath,
1618
v3UsagePath,
1719
} from "~/utils/pathBuilder";
@@ -67,27 +69,34 @@ export function OrganizationSettingsSideMenu({
6769
<SideMenuHeader title="Organization" />
6870
</div>
6971
{isManagedCloud && (
70-
<SideMenuItem
71-
name="Usage"
72-
icon={ChartBarIcon}
73-
activeIconColor="text-indigo-500"
74-
to={v3UsagePath(organization)}
75-
data-action="usage"
76-
/>
77-
)}
78-
{isManagedCloud && (
79-
<SideMenuItem
80-
name="Billing"
81-
icon={CreditCardIcon}
82-
activeIconColor="text-emerald-500"
83-
to={v3BillingPath(organization)}
84-
data-action="billing"
85-
badge={
86-
currentPlan?.v3Subscription?.isPaying ? (
87-
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
88-
) : undefined
89-
}
90-
/>
72+
<>
73+
<SideMenuItem
74+
name="Usage"
75+
icon={ChartBarIcon}
76+
activeIconColor="text-indigo-500"
77+
to={v3UsagePath(organization)}
78+
data-action="usage"
79+
/>
80+
<SideMenuItem
81+
name="Billing"
82+
icon={CreditCardIcon}
83+
activeIconColor="text-emerald-500"
84+
to={v3BillingPath(organization)}
85+
data-action="billing"
86+
badge={
87+
currentPlan?.v3Subscription?.isPaying ? (
88+
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
89+
) : undefined
90+
}
91+
/>
92+
<SideMenuItem
93+
name="Billing alerts"
94+
icon={BellAlertIcon}
95+
activeIconColor="text-rose-500"
96+
to={v3BillingAlertsPath(organization)}
97+
data-action="billing-alerts"
98+
/>
99+
</>
91100
)}
92101
<SideMenuItem
93102
name="Team"
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
2+
import { parse } from "@conform-to/zod";
3+
import { BellAlertIcon, CurrencyDollarIcon, EnvelopeIcon } from "@heroicons/react/20/solid";
4+
import { Form, useActionData, type MetaFunction } from "@remix-run/react";
5+
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
6+
import { Fragment, useRef, useState } from "react";
7+
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
8+
import { z } from "zod";
9+
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
10+
import {
11+
MainHorizontallyCenteredContainer,
12+
PageBody,
13+
PageContainer,
14+
} from "~/components/layout/AppLayout";
15+
import { Button, LinkButton } from "~/components/primitives/Buttons";
16+
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
17+
import { Fieldset } from "~/components/primitives/Fieldset";
18+
import { FormButtons } from "~/components/primitives/FormButtons";
19+
import { FormError } from "~/components/primitives/FormError";
20+
import { FormTitle } from "~/components/primitives/FormTitle";
21+
import { Header2 } from "~/components/primitives/Headers";
22+
import { Input } from "~/components/primitives/Input";
23+
import { InputGroup } from "~/components/primitives/InputGroup";
24+
import { Label } from "~/components/primitives/Label";
25+
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
26+
import { Paragraph } from "~/components/primitives/Paragraph";
27+
import { prisma } from "~/db.server";
28+
import { featuresForRequest } from "~/features.server";
29+
import { useOrganization } from "~/hooks/useOrganizations";
30+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
31+
import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
32+
import { requireUserId } from "~/services/session.server";
33+
import {
34+
OrganizationParamsSchema,
35+
organizationPath,
36+
v3BillingAlertsPath,
37+
} from "~/utils/pathBuilder";
38+
39+
export const meta: MetaFunction = () => {
40+
return [
41+
{
42+
title: `Billing alerts | Trigger.dev`,
43+
},
44+
];
45+
};
46+
47+
export async function loader({ params, request }: LoaderFunctionArgs) {
48+
await requireUserId(request);
49+
const { organizationSlug } = OrganizationParamsSchema.parse(params);
50+
51+
const { isManagedCloud } = featuresForRequest(request);
52+
if (!isManagedCloud) {
53+
return redirect(organizationPath({ slug: organizationSlug }));
54+
}
55+
56+
const organization = await prisma.organization.findUnique({
57+
where: { slug: organizationSlug },
58+
});
59+
60+
if (!organization) {
61+
throw new Response(null, { status: 404, statusText: "Organization not found" });
62+
}
63+
64+
const alerts = await getBillingAlerts(organization.id);
65+
if (!alerts) {
66+
throw new Response(null, { status: 404, statusText: "Plans not found" });
67+
}
68+
69+
return typedjson({
70+
alerts: {
71+
...alerts,
72+
amount: alerts.amount / 100,
73+
},
74+
});
75+
}
76+
77+
const schema = z.object({
78+
amount: z
79+
.number({ invalid_type_error: "Not a valid amount" })
80+
.min(0, "Amount must be greater than 0"),
81+
emails: z.preprocess((i) => {
82+
if (typeof i === "string") return [i];
83+
84+
if (Array.isArray(i)) {
85+
const emails = i.filter((v) => typeof v === "string" && v !== "");
86+
if (emails.length === 0) {
87+
return [""];
88+
}
89+
return emails;
90+
}
91+
92+
return [""];
93+
}, z.string().email().array().nonempty("At least one email is required")),
94+
alertLevels: z.preprocess((i) => {
95+
if (typeof i === "string") return [i];
96+
return i;
97+
}, z.coerce.number().array().nonempty("At least one alert level is required")),
98+
});
99+
100+
export const action: ActionFunction = async ({ request, params }) => {
101+
const userId = await requireUserId(request);
102+
const { organizationSlug } = OrganizationParamsSchema.parse(params);
103+
104+
const formData = await request.formData();
105+
const submission = parse(formData, { schema });
106+
107+
if (!submission.value || submission.intent !== "submit") {
108+
return json(submission);
109+
}
110+
111+
try {
112+
const organization = await prisma.organization.findFirst({
113+
where: { slug: organizationSlug, members: { some: { userId } } },
114+
});
115+
116+
if (!organization) {
117+
return redirectWithErrorMessage(
118+
v3BillingAlertsPath({ slug: organizationSlug }),
119+
request,
120+
"You are not authorized to update billing alerts"
121+
);
122+
}
123+
124+
const updatedAlert = await setBillingAlert(organization.id, {
125+
...submission.value,
126+
amount: submission.value.amount * 100,
127+
});
128+
if (!updatedAlert) {
129+
return redirectWithErrorMessage(
130+
v3BillingAlertsPath({ slug: organizationSlug }),
131+
request,
132+
"Failed to update billing alert"
133+
);
134+
}
135+
136+
return redirectWithSuccessMessage(
137+
v3BillingAlertsPath({ slug: organizationSlug }),
138+
request,
139+
"Billing alert updated"
140+
);
141+
} catch (error: any) {
142+
return json({ errors: { body: error.message } }, { status: 400 });
143+
}
144+
};
145+
146+
export default function Page() {
147+
const { alerts } = useTypedLoaderData<typeof loader>();
148+
const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2));
149+
const organization = useOrganization();
150+
151+
const lastSubmission = useActionData();
152+
153+
const [form, { emails, amount, alertLevels }] = useForm({
154+
id: "invite-members",
155+
// TODO: type this
156+
lastSubmission: lastSubmission as any,
157+
onValidate({ formData }) {
158+
return parse(formData, { schema });
159+
},
160+
defaultValue: {
161+
emails: [""],
162+
},
163+
});
164+
165+
const fieldValues = useRef<string[]>([""]);
166+
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
167+
168+
const checkboxLevels = [0.75, 0.9, 1.0];
169+
170+
return (
171+
<PageContainer>
172+
<NavBar>
173+
<PageTitle title="Billing alerts" />
174+
<PageAccessories>
175+
<AdminDebugTooltip />
176+
</PageAccessories>
177+
</NavBar>
178+
<PageBody scrollable={true}>
179+
<MainHorizontallyCenteredContainer>
180+
<div>
181+
<Header2 spacing>Billing alerts</Header2>
182+
<Paragraph spacing variant="small">
183+
Receive emails when your compute spend crosses the thresholds set below.
184+
</Paragraph>
185+
<Form method="post" {...form.props}>
186+
<Fieldset>
187+
<InputGroup>
188+
<Label htmlFor={amount.id}>Amount</Label>
189+
<Input
190+
{...conform.input(amount, { type: "number" })}
191+
value={dollarAmount}
192+
onChange={(e) => {
193+
const numberValue = Number(e.target.value);
194+
if (numberValue < 0) {
195+
setDollarAmount("");
196+
return;
197+
}
198+
setDollarAmount(e.target.value);
199+
}}
200+
step={0.01}
201+
min={0}
202+
placeholder="Enter an amount"
203+
icon={<span className="-mt-0.5 block pl-0.5 text-sm text-text-dimmed">$</span>}
204+
className="pl-px"
205+
/>
206+
<FormError id={amount.errorId}>{amount.error}</FormError>
207+
</InputGroup>
208+
<InputGroup>
209+
<Label htmlFor={alertLevels.id}>Alert me when I reach</Label>
210+
{checkboxLevels.map((level) => (
211+
<CheckboxWithLabel
212+
name={alertLevels.name}
213+
id={`level_${level}`}
214+
value={level.toString()}
215+
variant="simple/small"
216+
label={`${level * 100}% ($${(Number(dollarAmount) * level).toFixed(2)})`}
217+
defaultChecked
218+
className="pr-0"
219+
/>
220+
))}
221+
<FormError id={alertLevels.errorId}>{alertLevels.error}</FormError>
222+
</InputGroup>
223+
<InputGroup>
224+
<Label htmlFor={emails.id}>Email addresses</Label>
225+
{emailFields.map((email, index) => (
226+
<Fragment key={email.key}>
227+
<Input
228+
{...conform.input(email, { type: "email" })}
229+
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
230+
icon={EnvelopeIcon}
231+
autoFocus={index === 0}
232+
onChange={(e) => {
233+
fieldValues.current[index] = e.target.value;
234+
if (
235+
emailFields.length === fieldValues.current.length &&
236+
fieldValues.current.every((v) => v !== "")
237+
) {
238+
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
239+
}
240+
}}
241+
/>
242+
<FormError id={email.errorId}>{email.error}</FormError>
243+
</Fragment>
244+
))}
245+
</InputGroup>
246+
<FormButtons
247+
confirmButton={
248+
<Button type="submit" variant={"primary/small"}>
249+
Update
250+
</Button>
251+
}
252+
/>
253+
</Fieldset>
254+
</Form>
255+
</div>
256+
</MainHorizontallyCenteredContainer>
257+
</PageBody>
258+
</PageContainer>
259+
);
260+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid";
22
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
3-
import { type PlanDefinition } from "@trigger.dev/platform/v3";
3+
import { type PlanDefinition } from "@trigger.dev/platform";
44
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
55
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
66
import { LinkButton } from "~/components/primitives/Buttons";

apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
Plans,
1818
SetPlanBody,
1919
SubscriptionResult,
20-
} from "@trigger.dev/platform/v3";
20+
} from "@trigger.dev/platform";
2121
import React, { useEffect, useState } from "react";
2222
import { z } from "zod";
2323
import { DefinitionTip } from "~/components/DefinitionTooltip";

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
defaultMachine as defaultMachineFromPlatform,
99
machines as machinesFromPlatform,
1010
type MachineCode,
11-
} from "@trigger.dev/platform/v3";
11+
type UpdateBillingAlertsRequest,
12+
type BillingAlertsResult,
13+
} from "@trigger.dev/platform";
1214
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
1315
import { MemoryStore } from "@unkey/cache/stores";
1416
import { redirect } from "remix-typedjson";
@@ -467,6 +469,31 @@ export async function projectCreated(organization: Organization, project: Projec
467469
}
468470
}
469471

472+
export async function getBillingAlerts(
473+
organizationId: string
474+
): Promise<BillingAlertsResult | undefined> {
475+
if (!client) return undefined;
476+
const result = await client.getBillingAlerts(organizationId);
477+
if (!result.success) {
478+
logger.error("Error getting billing alert", { error: result.error, organizationId });
479+
throw new Error("Error getting billing alert");
480+
}
481+
return result;
482+
}
483+
484+
export async function setBillingAlert(
485+
organizationId: string,
486+
alert: UpdateBillingAlertsRequest
487+
): Promise<BillingAlertsResult | undefined> {
488+
if (!client) return undefined;
489+
const result = await client.updateBillingAlerts(organizationId, alert);
490+
if (!result.success) {
491+
logger.error("Error setting billing alert", { error: result.error, organizationId });
492+
throw new Error("Error setting billing alert");
493+
}
494+
return result;
495+
}
496+
470497
function isCloud(): boolean {
471498
const acceptableHosts = [
472499
"https://cloud.trigger.dev",

0 commit comments

Comments
 (0)