Free flow is working

This commit is contained in:
Matt Aitken
2024-06-26 15:13:46 +01:00
parent 131827518a
commit 1ff809131f
9 changed files with 371 additions and 127 deletions
@@ -25,7 +25,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
return redirectWithErrorMessage(
`${v3PlansPath({ slug: org.slug })}`,
v3PlansPath({ slug: org.slug }),
request,
"You didn't complete your details on Stripe. Please try again."
);
@@ -1,8 +1,9 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { v3SubscribedPath } from "~/utils/pathBuilder";
import { newProjectPath, v3PlansPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
@@ -14,6 +15,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const org = await prisma.organization.findUnique({
select: {
slug: true,
_count: {
select: {
projects: true,
},
},
},
where: {
id: organizationId,
@@ -24,9 +30,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response(null, { status: 404 });
}
return redirectWithSuccessMessage(
`${v3SubscribedPath({ slug: org.slug })}`,
request,
"You are now subscribed to Trigger.dev"
const hasProject = org._count.projects > 0;
if (hasProject) {
return redirectWithSuccessMessage(
v3PlansPath({ slug: org.slug }),
request,
"Your subscription has been successfully activated."
);
}
return redirect(
newProjectPath({ slug: org.slug }, "Your subscription has been successfully activated.")
);
};
@@ -30,5 +30,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
let errorMessage = reason ? decodeURIComponent(reason) : "Subscribing failed to complete";
return redirectWithErrorMessage(`${v3PlansPath({ slug: org.slug })}`, request, errorMessage);
return redirectWithErrorMessage(v3PlansPath({ slug: org.slug }), request, errorMessage);
};
@@ -0,0 +1,44 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { newProjectPath, selectPlanPath, v3PlansPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
_count: {
select: {
projects: true,
},
},
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const hasProject = org._count.projects > 0;
if (hasProject) {
return redirectWithSuccessMessage(
v3PlansPath({ slug: org.slug }),
request,
"Free tier unlocked successfully."
);
}
return redirect(newProjectPath({ slug: org.slug }, "You're on the Free plan."));
};
@@ -16,12 +16,10 @@ import { FormTitle } from "~/components/primitives/FormTitle";
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 { Select, SelectItem } from "~/components/primitives/Select";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { createProject } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
@@ -31,8 +29,6 @@ import {
projectPath,
selectPlanPath,
} from "~/utils/pathBuilder";
import { RequestV3Access } from "../resources.orgs.$organizationSlug.v3-access";
import { featuresForRequest } from "~/features.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -69,6 +65,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}
const url = new URL(request.url);
const message = url.searchParams.get("message");
return typedjson({
organization: {
id: organization.id,
@@ -80,6 +79,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
hasRequestedV3: organization.hasRequestedV3,
},
defaultVersion: url.searchParams.get("version") ?? "v2",
message: message ? decodeURIComponent(message) : undefined,
});
}
@@ -119,7 +119,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const { organization } = useTypedLoaderData<typeof loader>();
const { organization, message } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { v3Enabled, isManagedCloud } = useFeatures();
@@ -144,9 +144,9 @@ export default function Page() {
description={`This will create a new project in your "${organization.title}" organization.`}
/>
<Form method="post" {...form.props}>
{organization.projectsCount === 0 && (
<Callout variant="info" className="mb-4">
Organizations require at least one project, please create one to continue.
{message && (
<Callout variant="success" className="mb-4">
{message}
</Callout>
)}
<Fieldset>
@@ -184,15 +184,9 @@ export default function Page() {
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
</InputGroup>
) : canCreateV3Projects ? (
<>
<Callout variant="info">This will be a v3 project</Callout>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />
</>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />
) : (
<>
<Callout variant="info">This will be a v2 project</Callout>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
</>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
)}
<FormButtons
confirmButton={
@@ -1,14 +1,16 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PricingPlans } from "~/components/billing/v3/PricingPlans";
import { Header1 } from "~/components/primitives/Headers";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.v3.server";
import { requireUserId } from "~/services/session.server";
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
import { prisma } from "~/db.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
@@ -17,27 +19,34 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}
const billingPresenter = new BillingService(isManagedCloud);
const result = await billingPresenter.getPlans();
if (!result) {
const plans = await billingPresenter.getPlans();
if (!plans) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
return typedjson(result);
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const currentPlan = await billingPresenter.currentPlan(organization.id);
return typedjson({ ...plans, ...currentPlan, organizationSlug });
}
export default function ChoosePlanPage() {
const { plans } = useTypedLoaderData<typeof loader>();
const { plans, v3Subscription, organizationSlug } = useTypedLoaderData<typeof loader>();
return (
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
<Header1>Subscribe for full access</Header1>
<PricingPlans
plans={plans}
// organizationSlug={organizationSlug}
// plans={plans}
// showActionText={false}
// freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
subscription={v3Subscription}
organizationSlug={organizationSlug}
/>
</div>
);
@@ -1,11 +1,91 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { FreePlanDefinition, Limits, PaidPlanDefinition, Plans } from "@trigger.dev/billing/v3";
import { CheckIcon, ExclamationTriangleIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useLocation, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import {
FreePlanDefinition,
FreeTierStatus,
Limits,
PaidPlanDefinition,
Plans,
SetPlanBody,
SubscriptionResult,
} from "@trigger.dev/billing/v3";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { DefinitionTip } from "~/components/DefinitionTooltip";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.v3.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
const Params = z.object({
organizationSlug: z.string(),
});
const schema = z.object({
type: z.enum(["free", "paid"]),
planCode: z.string().optional(),
callerPath: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
const { organizationSlug } = Params.parse(params);
const userId = await requireUserId(request);
const formData = Object.fromEntries(await request.formData());
const form = schema.parse(formData);
const { isManagedCloud } = featuresForRequest(request);
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
if (!organization) {
throw redirectWithErrorMessage(form.callerPath, request, "Organization not found");
}
let payload: SetPlanBody;
switch (form.type) {
case "free": {
payload = {
type: "free" as const,
userId,
};
break;
}
case "paid": {
if (form.planCode === undefined) {
throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
}
payload = {
type: "paid" as const,
planCode: form.planCode,
userId,
};
break;
}
}
const billingService = new BillingService(isManagedCloud);
return billingService.setPlan(organization, request, form.callerPath, payload);
}
type PricingPlansProps = {
plans: Plans;
subscription?: SubscriptionResult;
organizationSlug: string;
};
const pricingDefinitions = {
@@ -45,13 +125,17 @@ const pricingDefinitions = {
},
};
export function PricingPlans({ plans }: PricingPlansProps) {
export function PricingPlans({ plans, subscription, organizationSlug }: PricingPlansProps) {
return (
<div className="flex w-full flex-col">
<div className="flex flex-col lg:flex-row">
<TierFree plan={plans.free} />
<TierHobby plan={plans.hobby} />
<TierPro plan={plans.pro} />
<TierFree
plan={plans.free}
status={subscription?.freeTierStatus ?? "requires_connect"}
organizationSlug={organizationSlug}
/>
<TierHobby plan={plans.hobby} organizationSlug={organizationSlug} />
<TierPro plan={plans.pro} organizationSlug={organizationSlug} />
</div>
<div className="mt-4">
<TierEnterprise />
@@ -60,42 +144,88 @@ export function PricingPlans({ plans }: PricingPlansProps) {
);
}
export function TierFree({ plan }: { plan: FreePlanDefinition | PaidPlanDefinition }) {
export function TierFree({
plan,
status,
organizationSlug,
}: {
plan: FreePlanDefinition;
status: FreeTierStatus;
organizationSlug: string;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
return (
<TierContainer>
<PricingHeader title={plan.title} cost={0} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage} free usage
</TierLimit>
<input type="hidden" name="type" value="free" />
<div className="py-6">
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
Unlock free plan
</Button>
</div>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
</ul>
{status === "rejected" ? (
<div>
<hr className="my-6 border-grid-bright" />
<div className="flex flex-col gap-2 rounded-sm border border-warning p-4">
<ExclamationTriangleIcon className="h-6 w-6 text-warning" />
<Paragraph variant="small/bright">
Your Trigger.dev account failed to be verified for the free plan because your GitHub
account is too new. We require verification to prevent scammers and malicious use of
our platform.
</Paragraph>
<Paragraph variant="small/bright">
You can still select a paid plan to continue or if you think this is a mistake, get in
touch.
</Paragraph>
</div>
</div>
) : (
<Form action={formAction} method="post">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage} free usage
</TierLimit>
<div className="py-6">
<Button
variant="tertiary/large"
fullWidth
className="text-md font-medium"
disabled={isLoading}
LeadingIcon={isLoading ? Spinner : undefined}
>
{status === "requires_connect" ? "Unlock free plan" : "Select plan"}
</Button>
</div>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
</ul>
</Form>
)}
</TierContainer>
);
}
export function TierHobby({ plan }: { plan: PaidPlanDefinition }) {
export function TierHobby({
plan,
organizationSlug,
}: {
plan: PaidPlanDefinition;
organizationSlug: string;
}) {
return (
<TierContainer isHighlighted>
<PricingHeader title={plan.title} isHighlighted cost={plan.tierPrice} />
@@ -128,7 +258,13 @@ export function TierHobby({ plan }: { plan: PaidPlanDefinition }) {
);
}
export function TierPro({ plan }: { plan: PaidPlanDefinition }) {
export function TierPro({
plan,
organizationSlug,
}: {
plan: PaidPlanDefinition;
organizationSlug: string;
}) {
return (
<TierContainer>
<PricingHeader title={plan.title} isHighlighted cost={plan.tierPrice} />
+99 -50
View File
@@ -1,8 +1,10 @@
import { BillingClient, SetPlanBody } from "@trigger.dev/billing/v3";
import { redirect } from "remix-typedjson";
import { $replica, PrismaClient, PrismaReplicaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { organizationBillingPath } from "~/utils/pathBuilder";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
export class BillingService {
#billingClient: BillingClient | undefined;
@@ -27,56 +29,55 @@ export class BillingService {
}
}
//todo
// async currentPlan(orgId: string) {
// if (!this.#billingClient) return undefined;
// try {
// const result = await this.#billingClient.currentPlan(orgId);
async currentPlan(orgId: string) {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.currentPlan(orgId);
// const firstDayOfMonth = new Date();
// firstDayOfMonth.setDate(1);
// firstDayOfMonth.setHours(0, 0, 0, 0);
const firstDayOfMonth = new Date();
firstDayOfMonth.setDate(1);
firstDayOfMonth.setHours(0, 0, 0, 0);
// const firstDayOfNextMonth = new Date();
// firstDayOfNextMonth.setDate(1);
// firstDayOfNextMonth.setMonth(firstDayOfNextMonth.getMonth() + 1);
// firstDayOfNextMonth.setHours(0, 0, 0, 0);
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setDate(1);
firstDayOfNextMonth.setMonth(firstDayOfNextMonth.getMonth() + 1);
firstDayOfNextMonth.setHours(0, 0, 0, 0);
// const currentRunCount = await this.#replica.jobRun.count({
// where: {
// organizationId: orgId,
// createdAt: {
// gte: firstDayOfMonth,
// },
// },
// });
const currentRunCount = await this.#replica.jobRun.count({
where: {
organizationId: orgId,
createdAt: {
gte: firstDayOfMonth,
},
},
});
// if (!result.success) {
// logger.error("Error getting current plan", { orgId, error: result.error });
// return undefined;
// }
if (!result.success) {
logger.error("Error getting current plan", { orgId, error: result.error });
return undefined;
}
// const periodStart = firstDayOfMonth;
// const periodEnd = firstDayOfNextMonth;
// const periodRemainingDuration = periodEnd.getTime() - new Date().getTime();
const periodStart = firstDayOfMonth;
const periodEnd = firstDayOfNextMonth;
const periodRemainingDuration = periodEnd.getTime() - new Date().getTime();
// const usage = {
// currentRunCount,
// runCountCap: result.subscription?.plan.runs?.freeAllowance,
// exceededRunCount: result.subscription?.plan.runs?.freeAllowance
// ? currentRunCount > result.subscription?.plan.runs?.freeAllowance
// : false,
// periodStart,
// periodEnd,
// periodRemainingDuration,
// };
const usage = {
currentRunCount,
runCountCap: result.subscription?.plan.runs?.freeAllowance,
exceededRunCount: result.subscription?.plan.runs?.freeAllowance
? currentRunCount > result.subscription?.plan.runs?.freeAllowance
: false,
periodStart,
periodEnd,
periodRemainingDuration,
};
// return { ...result, usage };
// } catch (e) {
// logger.error("Error getting current plan", { orgId, error: e });
// return undefined;
// }
// }
return { ...result, usage };
} catch (e) {
logger.error("Error getting current plan", { orgId, error: e });
return undefined;
}
}
async customerPortalUrl(orgId: string, orgSlug: string) {
if (!this.#billingClient) return undefined;
@@ -105,14 +106,62 @@ export class BillingService {
}
}
async setPlan(orgId: string, plan: SetPlanBody) {
if (!this.#billingClient) return undefined;
async setPlan(
organization: { id: string; slug: string },
request: Request,
callerPath: string,
plan: SetPlanBody
) {
if (!this.#billingClient) {
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
}
try {
const result = await this.#billingClient.setPlan(orgId, plan);
return result;
const result = await this.#billingClient.setPlan(organization.id, plan);
if (!result) {
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
}
if (!result.success) {
throw redirectWithErrorMessage(callerPath, request, result.error);
}
switch (result.action) {
case "free_connect_required": {
return redirect(result.connectUrl);
}
case "free_connected": {
if (result.accepted) {
return redirect(newProjectPath(organization, "You're on the Free plan."));
} else {
return redirectWithErrorMessage(
callerPath,
request,
"Free tier unlock failed, your GitHub account is too new."
);
}
}
case "create_subscription_flow_start": {
return redirect(result.checkoutUrl);
}
case "updated_subscription": {
return redirectWithSuccessMessage(
callerPath,
request,
"Subscription updated successfully."
);
}
case "canceled_subscription": {
return redirectWithSuccessMessage(callerPath, request, "Subscription canceled.");
}
}
} catch (e) {
logger.error("Error setting plan", { orgId, error: e });
return undefined;
logger.error("Error setting plan", { organizationId: organization.id, error: e });
throw redirectWithErrorMessage(
callerPath,
request,
e instanceof Error ? e.message : "Error setting plan"
);
}
}
}
+4 -6
View File
@@ -296,8 +296,10 @@ export function endpointStreamingPath(environment: { id: string }) {
return `/resources/environments/${environment.id}/endpoint/stream`;
}
export function newProjectPath(organization: OrgForPath) {
return `${organizationPath(organization)}/projects/new`;
export function newProjectPath(organization: OrgForPath, message?: string) {
return `${organizationPath(organization)}/projects/new${
message ? `?message=${encodeURIComponent(message)}` : ""
}`;
}
function projectParam(project: ProjectForPath) {
@@ -445,10 +447,6 @@ export function v3PlansPath(organization: OrgForPath) {
return `${organizationPath(organization)}/v3/billing/plans`;
}
export function v3SubscribedPath(organization: OrgForPath) {
return `${organizationPath(organization)}/v3/subscribed`;
}
// Integration
export function integrationClientPath(organization: OrgForPath, client: IntegrationForPath) {
return `${organizationIntegrationsPath(organization)}/${clientParam(client)}`;