New billing page working with side menu and stripe portal
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
+2
-2
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user