From e70634c84987c1f3b02b794287382fe7310235ff Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 26 Jun 2024 23:07:28 +0100 Subject: [PATCH] New billing page working with side menu and stripe portal --- .../app/components/navigation/SideMenu.tsx | 25 +++++- .../route.tsx | 4 +- .../route.tsx | 4 +- .../route.tsx | 4 +- .../route.tsx | 4 +- .../route.tsx | 11 +-- .../route.tsx | 79 +++++++++++++++++++ .../_app.orgs.$organizationSlug/route.tsx | 2 +- ...organizationSlug.subscription.v3.portal.ts | 47 +++++++++++ ...ces.orgs.$organizationSlug.select-plan.tsx | 1 - apps/webapp/app/utils/pathBuilder.ts | 12 ++- 11 files changed, 170 insertions(+), 23 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx create mode 100644 apps/webapp/app/routes/resources.$organizationSlug.subscription.v3.portal.ts diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 049c2e094..fd35a39bb 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -6,6 +6,7 @@ import { BellAlertIcon, ChartBarIcon, ClockIcon, + CurrencyDollarIcon, CursorArrowRaysIcon, IdentificationIcon, KeyIcon, @@ -52,6 +53,8 @@ import { v3RunsPath, v3SchedulesPath, v3TestPath, + v3BillingPath, + v3UsagePath, } from "~/utils/pathBuilder"; import { Feedback } from "../Feedback"; import { ImpersonationBanner } from "../ImpersonationBanner"; @@ -177,12 +180,28 @@ export function SideMenu({ user, project, organization, organizations }: SideMen data-action="team" /> + + {organization.projects.some((proj) => proj.version === "V2") && ( + + )} { } return redirectWithErrorMessage( - v3PlansPath({ slug: org.slug }), + v3BillingPath({ 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 43d2f0853..88cbc47c3 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 @@ -3,7 +3,7 @@ import { redirect } from "remix-typedjson"; import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; -import { newProjectPath, v3PlansPath } from "~/utils/pathBuilder"; +import { newProjectPath, v3BillingPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ organizationId: z.string(), @@ -34,7 +34,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { if (hasProject) { return redirectWithSuccessMessage( - v3PlansPath({ slug: org.slug }), + v3BillingPath({ slug: org.slug }), request, "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 bf723e539..bdfa0fe75 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 @@ -2,7 +2,7 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; -import { v3PlansPath } from "~/utils/pathBuilder"; +import { v3BillingPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ organizationId: z.string(), @@ -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(v3BillingPath({ 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 index 6eb958882..9470e62cc 100644 --- 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 @@ -3,7 +3,7 @@ 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"; +import { newProjectPath, selectPlanPath, v3BillingPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ organizationId: z.string(), @@ -34,7 +34,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { if (hasProject) { return redirectWithSuccessMessage( - v3PlansPath({ slug: org.slug }), + v3BillingPath({ slug: org.slug }), request, "Free tier unlocked successfully." ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.billing/route.tsx index d7211c6a9..8ffdc5fc0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.billing/route.tsx @@ -86,15 +86,10 @@ export default function Page() {
- {allV3Projects ? ( + {hasV3Project ? ( - This organization only has v3 projects. These are currently free. Usage data and - billing will be available soon. - - ) : hasV3Project ? ( - - This organization has a mix of v2 and v3 projects – v3 projects do not count towards - your usage (below) and are currently free. + This organization has a mix of v2 and v3 projects. They have separate subscriptions, + this is the usage and billing for v2. ) : null} {hasV2Project && ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx new file mode 100644 index 000000000..636efb2a6 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx @@ -0,0 +1,79 @@ +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { prisma } from "~/db.server"; +import { featuresForRequest } from "~/features.server"; +import { BillingService } from "~/services/billing.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { + OrganizationParamsSchema, + organizationPath, + v3StripePortalPath, +} from "~/utils/pathBuilder"; +import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const { isManagedCloud } = featuresForRequest(request); + if (!isManagedCloud) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const billingPresenter = new BillingService(isManagedCloud); + const plans = await billingPresenter.getPlans(); + if (!plans) { + throw new Response(null, { status: 404, statusText: "Plans not found" }); + } + + 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, v3Subscription, organizationSlug } = useTypedLoaderData(); + return ( + + + + + {v3Subscription?.isPaying && ( + <> + + Invoices + + + Manage card details + + + )} + + + + + + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 874506193..1e93ad0be 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -10,7 +10,7 @@ import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; import { useUser } from "~/hooks/useUser"; import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server"; -import { BillingService } from "~/services/billing.v2.server"; +import { BillingService } from "~/services/billing.v3.server"; import { getImpersonationId } from "~/services/impersonation.server"; import { requireUserId } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; diff --git a/apps/webapp/app/routes/resources.$organizationSlug.subscription.v3.portal.ts b/apps/webapp/app/routes/resources.$organizationSlug.subscription.v3.portal.ts new file mode 100644 index 000000000..e34a2603d --- /dev/null +++ b/apps/webapp/app/routes/resources.$organizationSlug.subscription.v3.portal.ts @@ -0,0 +1,47 @@ +import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "remix-typedjson"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { BillingService } from "~/services/billing.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder"; + +export async function loader({ request, params }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const org = await prisma.organization.findUnique({ + select: { + id: true, + }, + where: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, + }); + + if (!org) { + return redirectWithErrorMessage( + usagePath({ slug: organizationSlug }), + request, + "Something went wrong. Please try again later." + ); + } + + const billingPresenter = new BillingService(true); + const result = await billingPresenter.customerPortalUrl(org.id, organizationSlug); + + if (!result || !result.success || !result.customerPortalUrl) { + return redirectWithErrorMessage( + usagePath({ slug: organizationSlug }), + request, + "Something went wrong. Please try again later." + ); + } + + return redirect(result.customerPortalUrl); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 09c324e5a..fdbf33e70 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -139,7 +139,6 @@ type PricingPlansProps = { }; export function PricingPlans({ plans, subscription, organizationSlug }: PricingPlansProps) { - console.log({ plans, subscription }); return (
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 62326805d..d0a51cf05 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -443,8 +443,16 @@ export function v3DeploymentPath( return `${v3DeploymentsPath(organization, project)}/${deployment.shortCode}`; } -export function v3PlansPath(organization: OrgForPath) { - return `${organizationPath(organization)}/v3/billing/plans`; +export function v3BillingPath(organization: OrgForPath) { + return `${organizationPath(organization)}/v3/billing`; +} + +export function v3StripePortalPath(organization: OrgForPath) { + return `/resources/${organization.slug}/subscription/v3/portal`; +} + +export function v3UsagePath(organization: OrgForPath) { + return `${organizationPath(organization)}/v3/usage`; } // Integration