Skip to content

Commit aaf5958

Browse files
authored
feat: adds admin billing section (calcom#26747)
* feat: adds admin billing section * use loading prop * add log on resend error * add error log in billing portal
1 parent 80941c1 commit aaf5958

10 files changed

Lines changed: 315 additions & 18 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { _generateMetadata, getTranslate } from "app/_utils";
2+
3+
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
4+
5+
import AdminBillingView from "~/settings/admin/billing-view";
6+
7+
export const generateMetadata = async () =>
8+
await _generateMetadata(
9+
(t) => t("admin_billing_title"),
10+
(t) => t("admin_billing_description"),
11+
undefined,
12+
undefined,
13+
"/settings/admin/billing"
14+
);
15+
16+
const Page = async () => {
17+
const t = await getTranslate();
18+
return (
19+
<SettingsHeader title={t("admin_billing_title")} description={t("admin_billing_description")}>
20+
<AdminBillingView />
21+
</SettingsHeader>
22+
);
23+
};
24+
25+
export default Page;

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
220220
href: "/auth/setup?step=1",
221221
trackingMetadata: { section: "admin", page: "license" },
222222
},
223+
{
224+
name: "admin_billing",
225+
href: "/settings/admin/billing",
226+
trackingMetadata: { section: "admin", page: "billing" },
227+
},
223228
{
224229
name: "impersonation",
225230
href: "/settings/admin/impersonation",
@@ -673,12 +678,13 @@ const SettingsSidebarContainer = ({
673678
const searchParams = useCompatSearchParams();
674679
const orgBranding = useOrgBranding();
675680
const { t } = useLocale();
676-
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
677-
{
678-
teamId: number | undefined;
679-
teamMenuOpen: boolean;
680-
}[]
681-
>();
681+
const [otherTeamMenuState, setOtherTeamMenuState] =
682+
useState<
683+
{
684+
teamId: number | undefined;
685+
teamMenuOpen: boolean;
686+
}[]
687+
>();
682688
const session = useSession();
683689

684690
const organizationId = session.data?.user?.org?.id;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"use client";
2+
3+
import LicenseView from "./license-view";
4+
5+
export default function AdminBillingView() {
6+
return <LicenseView />;
7+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import { IS_CALCOM } from "@calcom/lib/constants";
6+
import { useLocale } from "@calcom/lib/hooks/useLocale";
7+
import { trpc } from "@calcom/trpc/react";
8+
import { Button } from "@calcom/ui/components/button";
9+
import { PanelCard } from "@calcom/ui/components/card";
10+
import { TextField } from "@calcom/ui/components/form";
11+
import { showToast } from "@calcom/ui/components/toast";
12+
13+
export default function LicenseView() {
14+
const { t } = useLocale();
15+
const [billingEmail, setBillingEmail] = useState("");
16+
17+
const resendEmailMutation = trpc.viewer.admin.resendPurchaseCompleteEmail.useMutation({
18+
onSuccess: () => {
19+
showToast(t("admin_license_resend_success"), "success");
20+
},
21+
onError: (error) => {
22+
showToast(error.message || t("admin_license_resend_error"), "error");
23+
},
24+
});
25+
26+
const billingPortalMutation = trpc.viewer.admin.billingPortalLink.useMutation({
27+
onSuccess: (data) => {
28+
if (!data?.url) {
29+
showToast(t("admin_license_portal_missing_url"), "error");
30+
return;
31+
}
32+
33+
window.open(data.url, "_blank", "noopener,noreferrer");
34+
},
35+
onError: (error) => {
36+
showToast(error.message || t("admin_license_portal_error"), "error");
37+
},
38+
});
39+
40+
const showResendSection = IS_CALCOM;
41+
42+
return (
43+
<div className="flex flex-col gap-4">
44+
{showResendSection && (
45+
<PanelCard title={t("admin_license_resend_title")} subtitle={t("admin_license_resend_description")}>
46+
<div className="p-4">
47+
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
48+
<TextField
49+
containerClassName="w-full"
50+
label={t("admin_license_billing_email_label")}
51+
name="billingEmail"
52+
type="email"
53+
placeholder={t("admin_license_billing_email_placeholder")}
54+
value={billingEmail}
55+
onChange={(event) => setBillingEmail(event.target.value)}
56+
/>
57+
<Button
58+
type="button"
59+
loading={resendEmailMutation.isPending}
60+
disabled={!billingEmail.trim()}
61+
onClick={() => {
62+
resendEmailMutation.mutate({
63+
billingEmail: billingEmail.trim(),
64+
});
65+
}}>
66+
{t("admin_license_resend_button")}
67+
</Button>
68+
</div>
69+
</div>
70+
</PanelCard>
71+
)}
72+
73+
<PanelCard title={t("admin_license_portal_title")} subtitle={t("admin_license_portal_description")}>
74+
<div className="p-4">
75+
<Button
76+
type="button"
77+
loading={billingPortalMutation.isPending}
78+
onClick={() => billingPortalMutation.mutate({})}>
79+
{t("admin_license_portal_button")}
80+
</Button>
81+
</div>
82+
</PanelCard>
83+
</div>
84+
);
85+
}

apps/web/public/static/locales/en/common.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,6 +2150,23 @@
21502150
"admin_orgs_edit_description": "Here you can edit an organization.",
21512151
"admin_oAuth_description": "Add new OAuth clients",
21522152
"admin_lockedSMS_description": "Lock or unlock SMS sending for users",
2153+
"admin_license_description": "Manage license billing for this deployment",
2154+
"admin_billing": "Billing",
2155+
"admin_billing_title": "Admin billing",
2156+
"admin_billing_description": "Manage billing emails and Stripe portal access for licenses.",
2157+
"admin_license_resend_title": "Resend purchase confirmation",
2158+
"admin_license_resend_description": "Send the purchase confirmation email to a billing address.",
2159+
"admin_license_billing_email_label": "Billing email",
2160+
"admin_license_billing_email_placeholder": "customer@example.com",
2161+
"admin_license_resend_button": "Resend email",
2162+
"admin_license_resend_success": "Purchase confirmation email sent.",
2163+
"admin_license_resend_error": "Failed to resend purchase confirmation email.",
2164+
"admin_license_portal_title": "Billing portal",
2165+
"admin_license_portal_description": "Open the Stripe billing portal for this license.",
2166+
"admin_license_portal_button": "Open billing portal",
2167+
"admin_license_portal_missing_url": "Billing portal link was not returned.",
2168+
"admin_license_portal_error": "Failed to fetch billing portal link.",
2169+
"admin_license_self_hosted_only": "License billing tools are available for self-hosted deployments only.",
21532170
"no_available_apps": "There are no available apps",
21542171
"no_available_apps_description": "Please ensure there are apps in your deployment under 'packages/app-store'",
21552172
"no_apps": "There are no apps enabled in this instance of Cal.com",

packages/trpc/server/routers/viewer/admin/_router.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
22
import { router } from "../../../trpc";
33
import { ZAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema";
4+
import { ZBillingPortalLinkSchema } from "./billingPortalLink.schema";
45
import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema";
56
import { ZAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema";
67
import { ZListMembersSchema } from "./listPaginated.schema";
78
import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema";
89
import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema";
10+
import { ZResendPurchaseCompleteEmailSchema } from "./resendPurchaseCompleteEmail.schema";
911
import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
1012
import { ZSetSMSLockState } from "./setSMSLockState.schema";
1113
import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure";
@@ -56,6 +58,16 @@ export const adminRouter = router({
5658
const { default: handler } = await import("./createSelfHostedLicenseKey.handler");
5759
return handler(opts);
5860
}),
61+
resendPurchaseCompleteEmail: authedAdminProcedure
62+
.input(ZResendPurchaseCompleteEmailSchema)
63+
.mutation(async (opts) => {
64+
const { default: handler } = await import("./resendPurchaseCompleteEmail.handler");
65+
return handler(opts);
66+
}),
67+
billingPortalLink: authedAdminProcedure.input(ZBillingPortalLinkSchema).mutation(async (opts) => {
68+
const { default: handler } = await import("./billingPortalLink.handler");
69+
return handler(opts);
70+
}),
5971
verifyWorkflows: authedAdminProcedure.input(ZAdminVerifyWorkflowsSchema).mutation(async (opts) => {
6072
const { default: handler } = await import("./verifyWorkflows.handler");
6173
return handler(opts);
@@ -64,18 +76,14 @@ export const adminRouter = router({
6476
const { default: handler } = await import("./whitelistUserWorkflows.handler");
6577
return handler(opts);
6678
}),
67-
getTeamsForFeature: authedAdminProcedure
68-
.input(ZAdminGetTeamsForFeatureSchema)
69-
.query(async (opts) => {
70-
const { default: handler } = await import("./getTeamsForFeature.handler");
71-
return handler(opts);
72-
}),
73-
assignFeatureToTeam: authedAdminProcedure
74-
.input(ZAdminAssignFeatureToTeamSchema)
75-
.mutation(async (opts) => {
76-
const { default: handler } = await import("./assignFeatureToTeam.handler");
77-
return handler(opts);
78-
}),
79+
getTeamsForFeature: authedAdminProcedure.input(ZAdminGetTeamsForFeatureSchema).query(async (opts) => {
80+
const { default: handler } = await import("./getTeamsForFeature.handler");
81+
return handler(opts);
82+
}),
83+
assignFeatureToTeam: authedAdminProcedure.input(ZAdminAssignFeatureToTeamSchema).mutation(async (opts) => {
84+
const { default: handler } = await import("./assignFeatureToTeam.handler");
85+
return handler(opts);
86+
}),
7987
unassignFeatureFromTeam: authedAdminProcedure
8088
.input(ZAdminUnassignFeatureFromTeamSchema)
8189
.mutation(async (opts) => {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { z } from "zod";
2+
3+
import { createSignature, generateNonce } from "@calcom/features/ee/common/server/private-api-utils";
4+
import {
5+
getDeploymentKey,
6+
getDeploymentSignatureToken,
7+
} from "@calcom/features/ee/deployment/lib/getDeploymentKey";
8+
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
9+
import { CALCOM_PRIVATE_API_ROUTE } from "@calcom/lib/constants";
10+
import logger from "@calcom/lib/logger";
11+
import { prisma } from "@calcom/prisma";
12+
13+
import type { TrpcSessionUser } from "../../../types";
14+
import type { TBillingPortalLinkSchema } from "./billingPortalLink.schema";
15+
16+
type GetOptions = {
17+
ctx: {
18+
user: NonNullable<TrpcSessionUser>;
19+
};
20+
input: TBillingPortalLinkSchema;
21+
};
22+
23+
const billingPortalLinkHandler = async ({ input }: GetOptions) => {
24+
if (!CALCOM_PRIVATE_API_ROUTE) {
25+
throw new Error("Private API route does not exist in .env");
26+
}
27+
28+
const deploymentRepo = new DeploymentRepository(prisma);
29+
const [licenseKey, signatureToken] = await Promise.all([
30+
getDeploymentKey(deploymentRepo),
31+
getDeploymentSignatureToken(deploymentRepo),
32+
]);
33+
34+
if (!licenseKey) {
35+
throw new Error("License key is missing");
36+
}
37+
38+
if (!signatureToken) {
39+
throw new Error("Signature token is missing");
40+
}
41+
42+
const nonce = generateNonce();
43+
const signature = createSignature(input, nonce, signatureToken);
44+
45+
const response = await fetch(`${CALCOM_PRIVATE_API_ROUTE}/v1/license/billing-portal-link`, {
46+
method: "POST",
47+
headers: {
48+
"Content-Type": "application/json",
49+
nonce,
50+
signature,
51+
"x-cal-license-key": licenseKey,
52+
},
53+
body: JSON.stringify(input),
54+
signal: AbortSignal.timeout(2000),
55+
});
56+
57+
const data = await response.json();
58+
59+
if (!response.ok) {
60+
logger.warn("Failed to fetch billing portal link", {
61+
message: data?.message,
62+
status: response.status,
63+
});
64+
throw new Error(data?.message || "Failed to fetch billing portal link");
65+
}
66+
67+
const schema = z.object({
68+
url: z.string(),
69+
});
70+
71+
return schema.parse(data);
72+
};
73+
74+
export default billingPortalLinkHandler;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { z } from "zod";
2+
3+
export const ZBillingPortalLinkSchema = z.object({});
4+
5+
export type TBillingPortalLinkSchema = z.infer<typeof ZBillingPortalLinkSchema>;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { z } from "zod";
2+
3+
import { createSignature, generateNonce } from "@calcom/features/ee/common/server/private-api-utils";
4+
import { getDeploymentSignatureToken } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
5+
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
6+
import { CALCOM_PRIVATE_API_ROUTE } from "@calcom/lib/constants";
7+
import logger from "@calcom/lib/logger";
8+
import { prisma } from "@calcom/prisma";
9+
10+
import type { TrpcSessionUser } from "../../../types";
11+
import type { TResendPurchaseCompleteEmailSchema } from "./resendPurchaseCompleteEmail.schema";
12+
13+
type GetOptions = {
14+
ctx: {
15+
user: NonNullable<TrpcSessionUser>;
16+
};
17+
input: TResendPurchaseCompleteEmailSchema;
18+
};
19+
20+
const resendPurchaseCompleteEmailHandler = async ({ input }: GetOptions) => {
21+
if (!CALCOM_PRIVATE_API_ROUTE) {
22+
throw new Error("Private API route does not exist in .env");
23+
}
24+
25+
const deploymentRepo = new DeploymentRepository(prisma);
26+
const signatureToken = await getDeploymentSignatureToken(deploymentRepo);
27+
28+
if (!signatureToken) {
29+
throw new Error("Signature token is missing");
30+
}
31+
32+
const nonce = generateNonce();
33+
const signature = createSignature(input, nonce, signatureToken);
34+
35+
const response = await fetch(`${CALCOM_PRIVATE_API_ROUTE}/v1/license/purchase-complete-email/resend`, {
36+
method: "POST",
37+
headers: {
38+
"Content-Type": "application/json",
39+
nonce,
40+
signature,
41+
},
42+
body: JSON.stringify(input),
43+
signal: AbortSignal.timeout(2000),
44+
});
45+
46+
const data = await response.json();
47+
48+
if (!response.ok) {
49+
logger.warn("Failed to resend purchase complete email", {
50+
message: data?.message,
51+
status: response.status,
52+
});
53+
throw new Error(data?.message || "Failed to resend purchase complete email");
54+
}
55+
56+
const schema = z.object({
57+
success: z.boolean(),
58+
});
59+
60+
return schema.parse(data);
61+
};
62+
63+
export default resendPurchaseCompleteEmailHandler;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { z } from "zod";
2+
3+
export const ZResendPurchaseCompleteEmailSchema = z.object({
4+
billingEmail: z.string().email(),
5+
});
6+
7+
export type TResendPurchaseCompleteEmailSchema = z.infer<typeof ZResendPurchaseCompleteEmailSchema>;

0 commit comments

Comments
 (0)