diff --git a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.canceled/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.canceled/route.tsx index 2aa39b96d..02b0338cb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.canceled/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.canceled/route.tsx @@ -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." ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.complete/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.complete/route.tsx index baa00da0d..43d2f0853 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.complete/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.complete/route.tsx @@ -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.") ); }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx index 028740d29..bf723e539 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx @@ -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); }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.free_connect_success/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.free_connect_success/route.tsx new file mode 100644 index 000000000..6eb958882 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.free_connect_success/route.tsx @@ -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.")); +}; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 4ea23d87f..500cc5936 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -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(); + const { organization, message } = useTypedLoaderData(); 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.`} />
- {organization.projectsCount === 0 && ( - - Organizations require at least one project, please create one to continue. + {message && ( + + {message} )}
@@ -184,15 +184,9 @@ export default function Page() { {projectVersion.error} ) : canCreateV3Projects ? ( - <> - This will be a v3 project - - + ) : ( - <> - This will be a v2 project - - + )} (); + const { plans, v3Subscription, organizationSlug } = useTypedLoaderData(); return (
Subscribe for full access
); diff --git a/apps/webapp/app/components/billing/v3/PricingPlans.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx similarity index 64% rename from apps/webapp/app/components/billing/v3/PricingPlans.tsx rename to apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 7f29e747e..769dfb6c0 100644 --- a/apps/webapp/app/components/billing/v3/PricingPlans.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -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 (
- - - + + +
@@ -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 ( - - ${plan.limits.includedUsage} free usage - - -
- -
-
    - - - Unlimited{" "} - - tasks - - - - - - - - -
+ {status === "rejected" ? ( +
+
+
+ + + 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. + + + You can still select a paid plan to continue or if you think this is a mistake, get in + touch. + +
+
+ ) : ( + + + + + ${plan.limits.includedUsage} free usage + +
+ +
+
    + + + Unlimited{" "} + + tasks + + + + + + + + +
+ + )}
); } -export function TierHobby({ plan }: { plan: PaidPlanDefinition }) { +export function TierHobby({ + plan, + organizationSlug, +}: { + plan: PaidPlanDefinition; + organizationSlug: string; +}) { return ( @@ -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 ( diff --git a/apps/webapp/app/services/billing.v3.server.ts b/apps/webapp/app/services/billing.v3.server.ts index bd469d37f..c1165943d 100644 --- a/apps/webapp/app/services/billing.v3.server.ts +++ b/apps/webapp/app/services/billing.v3.server.ts @@ -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" + ); } } } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index c9ed4f4f7..62326805d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -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)}`;