- {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