New billing page working with side menu and stripe portal

This commit is contained in:
Matt Aitken
2024-06-26 23:07:28 +01:00
parent 970bd7385c
commit e70634c849
11 changed files with 170 additions and 23 deletions
@@ -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"
/>
<SideMenuItem
name={isManagedCloud ? "Usage & Billing" : "Usage"}
name="Usage"
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
to={v3UsagePath(organization)}
iconColor="text-green-600"
data-action="usage & billing"
data-action="usage"
/>
<SideMenuItem
name="Billing"
icon={CurrencyDollarIcon}
to={v3BillingPath(organization)}
iconColor="text-sun-600"
data-action="billing"
/>
{organization.projects.some((proj) => proj.version === "V2") && (
<SideMenuItem
name="Usage (v2)"
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-green-600"
data-action="usage & billing"
/>
)}
<SideMenuItem
name="Organization settings"
icon="settings"
@@ -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(),
@@ -25,7 +25,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
return redirectWithErrorMessage(
v3PlansPath({ slug: org.slug }),
v3BillingPath({ slug: org.slug }),
request,
"You didn't complete your details on Stripe. Please try again."
);
@@ -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."
);
@@ -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);
};
@@ -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."
);
@@ -86,15 +86,10 @@ export default function Page() {
<PageBody scrollable={false}>
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="px-4 pt-4">
{allV3Projects ? (
{hasV3Project ? (
<Callout variant="warning" className="mb-3">
This organization only has v3 projects. These are currently free. Usage data and
billing will be available soon.
</Callout>
) : hasV3Project ? (
<Callout variant="warning" className="mb-3">
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.
</Callout>
) : null}
{hasV2Project && (
@@ -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<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title="Billing" />
<PageAccessories>
{v3Subscription?.isPaying && (
<>
<LinkButton
to={v3StripePortalPath({ slug: organizationSlug })}
variant="tertiary/small"
>
Invoices
</LinkButton>
<LinkButton
to={v3StripePortalPath({ slug: organizationSlug })}
variant="tertiary/small"
>
Manage card details
</LinkButton>
</>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<PricingPlans
plans={plans}
subscription={v3Subscription}
organizationSlug={organizationSlug}
/>
</PageBody>
</PageContainer>
);
}
@@ -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";
@@ -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);
}
@@ -139,7 +139,6 @@ type PricingPlansProps = {
};
export function PricingPlans({ plans, subscription, organizationSlug }: PricingPlansProps) {
console.log({ plans, subscription });
return (
<div className="flex w-full flex-col">
<div className="flex flex-col lg:flex-row">
+10 -2
View File
@@ -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