Merge pull request #686 from Infisical/org-settings

Revamped organization usage and billing page for Infisical Cloud
This commit is contained in:
BlackMagiq
2023-06-27 16:16:02 +07:00
committed by GitHub
34 changed files with 1948 additions and 160 deletions

View File

@ -3,8 +3,18 @@ import { getLicenseServerUrl } from "../../../config";
import { licenseServerKeyRequest } from "../../../config/request";
import { EELicenseService } from "../../services";
export const getOrganizationPlansTable = async (req: Request, res: Response) => {
const billingCycle = req.query.billingCycle as string;
const { data } = await licenseServerKeyRequest.get(
`${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}`
);
return res.status(200).send(data);
}
/**
* Return the organization's current plan and allowed feature set
* Return the organization current plan's feature set
*/
export const getOrganizationPlan = async (req: Request, res: Response) => {
const { organizationId } = req.params;
@ -18,26 +28,58 @@ export const getOrganizationPlan = async (req: Request, res: Response) => {
}
/**
* Update the organization plan to product with id [productId]
* Return the organization's current plan's billing info
* @param req
* @param res
* @returns
*/
export const updateOrganizationPlan = async (req: Request, res: Response) => {
const {
productId,
} = req.body;
const { data } = await licenseServerKeyRequest.patch(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan`,
{
productId,
}
export const getOrganizationPlanBillingInfo = async (req: Request, res: Response) => {
const { data } = await licenseServerKeyRequest.get(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan/billing`
);
return res.status(200).send(data);
}
/**
* Return the organization's current plan's feature table
* @param req
* @param res
* @returns
*/
export const getOrganizationPlanTable = async (req: Request, res: Response) => {
const { data } = await licenseServerKeyRequest.get(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan/table`
);
return res.status(200).send(data);
}
export const getOrganizationBillingDetails = async (req: Request, res: Response) => {
const { data } = await licenseServerKeyRequest.get(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details`
);
return res.status(200).send(data);
}
export const updateOrganizationBillingDetails = async (req: Request, res: Response) => {
const {
name,
email
} = req.body;
const { data } = await licenseServerKeyRequest.patch(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details`,
{
...(name ? { name } : {}),
...(email ? { email } : {})
}
);
return res.status(200).send(data);
}
/**
* Return the organization's payment methods on file
*/
@ -46,9 +88,7 @@ export const getOrganizationPmtMethods = async (req: Request, res: Response) =>
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`
);
return res.status(200).send({
pmtMethods,
});
return res.status(200).send(pmtMethods);
}
/**
@ -81,4 +121,53 @@ export const deleteOrganizationPmtMethod = async (req: Request, res: Response) =
);
return res.status(200).send(data);
}
/**
* Return the organization's tax ids on file
*/
export const getOrganizationTaxIds = async (req: Request, res: Response) => {
const { data: { tax_ids } } = await licenseServerKeyRequest.get(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids`
);
return res.status(200).send(tax_ids);
}
/**
* Add tax id to organization
*/
export const addOrganizationTaxId = async (req: Request, res: Response) => {
const {
type,
value
} = req.body;
const { data } = await licenseServerKeyRequest.post(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids`,
{
type,
value
}
);
return res.status(200).send(data);
}
export const deleteOrganizationTaxId = async (req: Request, res: Response) => {
const { taxId } = req.params;
const { data } = await licenseServerKeyRequest.delete(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids/${taxId}`,
);
return res.status(200).send(data);
}
export const getOrganizationInvoices = async (req: Request, res: Response) => {
const { data: { invoices } } = await licenseServerKeyRequest.get(
`${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/invoices`
);
return res.status(200).send(invoices);
}

View File

@ -11,10 +11,25 @@ import {
ACCEPTED, ADMIN, MEMBER, OWNER,
} from "../../../variables";
router.get(
"/:organizationId/plans/table",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
query("billingCycle").exists().isString().isIn(["monthly", "yearly"]),
validateRequest,
organizationsController.getOrganizationPlansTable
);
router.get(
"/:organizationId/plan",
requireAuth({
acceptedAuthModes: ["jwt", "apiKey"],
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
@ -26,25 +41,70 @@ router.get(
organizationsController.getOrganizationPlan
);
router.patch(
"/:organizationId/plan",
router.get(
"/:organizationId/plan/billing",
requireAuth({
acceptedAuthModes: ["jwt", "apiKey"],
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
body("productId").exists().isString(),
query("workspaceId").optional().isString(),
validateRequest,
organizationsController.updateOrganizationPlan
organizationsController.getOrganizationPlanBillingInfo
);
router.get(
"/:organizationId/plan/table",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
query("workspaceId").optional().isString(),
validateRequest,
organizationsController.getOrganizationPlanTable
);
router.get(
"/:organizationId/billing-details",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
validateRequest,
organizationsController.getOrganizationBillingDetails
);
router.patch(
"/:organizationId/billing-details",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
body("email").optional().isString().trim(),
body("name").optional().isString().trim(),
validateRequest,
organizationsController.updateOrganizationBillingDetails
);
router.get(
"/:organizationId/billing-details/payment-methods",
requireAuth({
acceptedAuthModes: ["jwt", "apiKey"],
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
@ -58,7 +118,7 @@ router.get(
router.post(
"/:organizationId/billing-details/payment-methods",
requireAuth({
acceptedAuthModes: ["jwt", "apiKey"],
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
@ -74,7 +134,22 @@ router.post(
router.delete(
"/:organizationId/billing-details/payment-methods/:pmtMethodId",
requireAuth({
acceptedAuthModes: ["jwt", "apiKey"],
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
param("pmtMethodId").exists().trim(),
validateRequest,
organizationsController.deleteOrganizationPmtMethod
);
router.get(
"/:organizationId/billing-details/tax-ids",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
@ -82,7 +157,52 @@ router.delete(
}),
param("organizationId").exists().trim(),
validateRequest,
organizationsController.deleteOrganizationPmtMethod
organizationsController.getOrganizationTaxIds
);
router.post(
"/:organizationId/billing-details/tax-ids",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
body("type").exists().isString(),
body("value").exists().isString(),
validateRequest,
organizationsController.addOrganizationTaxId
);
router.delete(
"/:organizationId/billing-details/tax-ids/:taxId",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
param("taxId").exists().trim(),
validateRequest,
organizationsController.deleteOrganizationTaxId
);
router.get(
"/:organizationId/invoices",
requireAuth({
acceptedAuthModes: ["jwt"],
}),
requireOrganizationAuth({
acceptedRoles: [OWNER, ADMIN, MEMBER],
acceptedStatuses: [ACCEPTED],
}),
param("organizationId").exists().trim(),
validateRequest,
organizationsController.getOrganizationInvoices
);
export default router;

View File

@ -97,7 +97,7 @@ export default function InitialLoginStep({
setIsLoading(false);
}
return <div className='flex flex-col mx-auto w-full justify-center items-center'>
<h1 className='text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8' >Login to Infisical</h1>
{/* <div className='lg:w-1/6 w-1/4 min-w-[20rem] rounded-md'>

View File

@ -57,7 +57,7 @@ export default function NavHeader({
);
return (
<div className="ml-6 flex flex-row items-center pt-6">
<div className="ml-4 flex flex-row items-center pt-6">
<div className="mr-2 flex h-6 w-6 items-center justify-center rounded-md bg-primary-900 text-mineshaft-100">
{currentOrg?.name?.charAt(0)}
</div>

View File

@ -1 +1,16 @@
export { useGetOrganization, useRenameOrg } from "./queries";
export {
useAddOrgPmtMethod,
useAddOrgTaxId,
useCreateCustomerPortalSession,
useDeleteOrgPmtMethod,
useDeleteOrgTaxId,
useGetOrganization,
useGetOrgBillingDetails,
useGetOrgInvoices,
useGetOrgPlanBillingInfo,
useGetOrgPlansTable,
useGetOrgPlanTable,
useGetOrgPmtMethods,
useGetOrgTaxIds,
useRenameOrg,
useUpdateOrgBillingDetails} from "./queries";

View File

@ -2,22 +2,39 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { Organization, RenameOrgDTO } from "./types";
import {
BillingDetails,
Invoice,
Organization,
OrgPlanTable,
PlanBillingInfo,
PmtMethod,
ProductsTable,
RenameOrgDTO,
TaxID} from "./types";
const organizationKeys = {
getUserOrganization: ["organization"] as const
getUserOrganization: ["organization"] as const,
getOrgPlanBillingInfo: (orgId: string) => [{ orgId }, "organization-plan-billing"] as const,
getOrgPlanTable: (orgId: string) => [{ orgId }, "organization-plan-table"] as const,
getOrgPlansTable: (orgId: string, billingCycle: "monthly" | "yearly") => [{ orgId, billingCycle }, "organization-plans-table"] as const,
getOrgBillingDetails: (orgId: string) => [{ orgId }, "organization-billing-details"] as const,
getOrgPmtMethods: (orgId: string) => [{ orgId }, "organization-pmt-methods"] as const,
getOrgTaxIds: (orgId: string) => [{ orgId }, "organization-tax-ids"] as const,
getOrgInvoices: (orgId: string) => [{ orgId }, "organization-invoices"] as const
};
const fetchUserOrganization = async () => {
const { data } = await apiRequest.get<{ organizations: Organization[] }>("/api/v1/organization");
export const useGetOrganization = () => {
return useQuery({
queryKey: organizationKeys.getUserOrganization,
queryFn: async () => {
const { data } = await apiRequest.get<{ organizations: Organization[] }>("/api/v1/organization");
return data.organizations;
};
return data.organizations;
}
});
}
export const useGetOrganization = () =>
useQuery({ queryKey: organizationKeys.getUserOrganization, queryFn: fetchUserOrganization });
// mutation
export const useRenameOrg = () => {
const queryClient = useQueryClient();
@ -29,3 +46,250 @@ export const useRenameOrg = () => {
}
});
};
export const useGetOrgPlanBillingInfo = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgPlanBillingInfo(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get<PlanBillingInfo>(
`/api/v1/organizations/${organizationId}/plan/billing`
);
return data;
},
enabled: true
});
}
export const useGetOrgPlanTable = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgPlanTable(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get<OrgPlanTable>(
`/api/v1/organizations/${organizationId}/plan/table`
);
return data;
},
enabled: true
});
}
export const useGetOrgPlansTable = ({
organizationId,
billingCycle
}: {
organizationId: string;
billingCycle: "monthly" | "yearly"
}) => {
return useQuery({
queryKey: organizationKeys.getOrgPlansTable(organizationId, billingCycle),
queryFn: async () => {
const { data } = await apiRequest.get<ProductsTable>(
`/api/v1/organizations/${organizationId}/plans/table?billingCycle=${billingCycle}`
);
return data;
},
enabled: true
});
}
export const useGetOrgBillingDetails = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgBillingDetails(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get<BillingDetails>(
`/api/v1/organizations/${organizationId}/billing-details`
);
return data;
},
enabled: true
});
}
export const useUpdateOrgBillingDetails = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
name,
email
}: {
organizationId: string;
name?: string;
email?: string;
}) => {
const { data } = await apiRequest.patch(
`/api/v1/organizations/${organizationId}/billing-details`,
{
name,
email
}
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(organizationKeys.getOrgBillingDetails(dto.organizationId));
}
});
};
export const useGetOrgPmtMethods = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgPmtMethods(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get<PmtMethod[]>(
`/api/v1/organizations/${organizationId}/billing-details/payment-methods`
);
return data;
},
enabled: true
});
}
export const useAddOrgPmtMethod = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
success_url,
cancel_url
}: {
organizationId: string;
success_url: string;
cancel_url: string;
}) => {
const { data: { url } } = await apiRequest.post(
`/api/v1/organizations/${organizationId}/billing-details/payment-methods`,
{
success_url,
cancel_url
}
);
return url;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(organizationKeys.getOrgPmtMethods(dto.organizationId));
}
});
};
export const useDeleteOrgPmtMethod = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
pmtMethodId,
}: {
organizationId: string;
pmtMethodId: string;
}) => {
const { data } = await apiRequest.delete(
`/api/v1/organizations/${organizationId}/billing-details/payment-methods/${pmtMethodId}`
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(organizationKeys.getOrgPmtMethods(dto.organizationId));
}
});
}
export const useGetOrgTaxIds = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgTaxIds(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get<TaxID[]>(
`/api/v1/organizations/${organizationId}/billing-details/tax-ids`
);
return data;
},
enabled: true
});
}
export const useAddOrgTaxId = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
type,
value
}: {
organizationId: string;
type: string;
value: string;
}) => {
const { data } = await apiRequest.post(
`/api/v1/organizations/${organizationId}/billing-details/tax-ids`,
{
type,
value
}
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(organizationKeys.getOrgTaxIds(dto.organizationId));
}
});
};
export const useDeleteOrgTaxId = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
taxId,
}: {
organizationId: string;
taxId: string;
}) => {
const { data } = await apiRequest.delete(
`/api/v1/organizations/${organizationId}/billing-details/tax-ids/${taxId}`
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(organizationKeys.getOrgTaxIds(dto.organizationId));
}
});
}
export const useGetOrgInvoices = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgInvoices(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get<Invoice[]>(
`/api/v1/organizations/${organizationId}/invoices`
);
return data;
},
enabled: true
});
}
export const useCreateCustomerPortalSession = () => {
return useMutation({
mutationFn: async (organizationId: string) => {
const { data } = await apiRequest.post(
`/api/v1/organization/${organizationId}/customer-portal-session`
);
return data;
}
});
};

View File

@ -9,3 +9,79 @@ export type RenameOrgDTO = {
orgId: string;
newOrgName: string;
};
export type BillingDetails = {
name: string;
email: string;
}
export type PlanBillingInfo = {
amount: number;
currentPeriodEnd: number;
currentPeriodStart: number;
interval: "month" | "year";
intervalCount: number;
quantity: number;
}
export type Invoice = {
_id: string;
created: number;
invoice_pdf: string;
number: string;
paid: boolean;
total: number;
}
export type PmtMethod = {
_id: string;
brand: string;
exp_month: number;
exp_year: number;
funding: string;
last4: string;
}
export type TaxID = {
_id: string;
country: string;
type: string;
value: string;
}
export type OrgPlanTableHead = {
name: string;
}
export type OrgPlanTableRow = {
name: string;
allowed: number | boolean | null;
used: string;
}
export type OrgPlanTable = {
head: OrgPlanTableHead[];
rows: OrgPlanTableRow[];
}
export type ProductsTableHead = {
name: string;
price: number | null;
priceLine: string;
productId: string;
slug: string;
tier: number;
}
export type ProductsTableRow = {
name: string;
starter: number | boolean | null;
team: number | boolean | null;
pro: number | boolean | null;
enterprise: number | boolean | null;
}
export type ProductsTable = {
head: ProductsTableHead[];
rows: ProductsTableRow[];
}

View File

@ -10,14 +10,14 @@ interface UsePopUpProps {
* checks which type of inputProps were given and converts them into key-names
* SIDENOTE: On inputting give it as const and not string with (as const)
*/
type UsePopUpState<T extends Readonly<string[]> | UsePopUpProps[]> = {
export type UsePopUpState<T extends Readonly<string[]> | UsePopUpProps[]> = {
[P in T extends UsePopUpProps[] ? T[number]["name"] : T[number]]: {
isOpen: boolean;
data?: unknown;
};
};
interface UsePopUpReturn<T extends Readonly<string[]> | UsePopUpProps[]> {
export interface UsePopUpReturn<T extends Readonly<string[]> | UsePopUpProps[]> {
popUp: UsePopUpState<T>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<T>, data?: unknown) => void;
handlePopUpClose: (popUpName: keyof UsePopUpState<T>) => void;

View File

@ -20,7 +20,7 @@ import { Menu, Transition } from "@headlessui/react";
import {TFunction} from "i18next";
import guidGenerator from "@app/components/utilities/randomId";
import { useOrganization, useUser } from "@app/context";
import { useOrganization, useSubscription,useUser } from "@app/context";
import { useLogoutUser } from "@app/hooks/api";
const supportOptions = (t: TFunction) => [
@ -63,6 +63,9 @@ export interface IUser {
*/
export const Navbar = () => {
const router = useRouter();
const { subscription } = useSubscription();
console.log("subscription: ", subscription);
const { currentOrg, orgs } = useOrganization();
const { user } = useUser();
@ -220,22 +223,24 @@ export const Navbar = () => {
/>
</div>
</div>
<button
// onClick={buttonAction}
type="button"
className="w-full cursor-pointer"
>
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={() => router.push(`/settings/billing/${router.query.id}`)}
className="relative mt-1 flex cursor-pointer select-none justify-start rounded-md py-2 px-2 text-gray-400 duration-200 hover:bg-white/5 hover:text-gray-200"
{subscription && subscription.slug !== null && (
<button
// onClick={buttonAction}
type="button"
className="w-full cursor-pointer"
>
<FontAwesomeIcon className="pl-1.5 pr-3 text-lg" icon={faCoins} />
<div className="text-sm">{t("nav.user.usage-billing")}</div>
</div>
</button>
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={() => router.push(`/settings/billing/${router.query.id}`)}
className="relative mt-1 flex cursor-pointer select-none justify-start rounded-md py-2 px-2 text-gray-400 duration-200 hover:bg-white/5 hover:text-gray-200"
>
<FontAwesomeIcon className="pl-1.5 pr-3 text-lg" icon={faCoins} />
<div className="text-sm">{t("nav.user.usage-billing")}</div>
</div>
</button>
)}
<button
type="button"
// onClick={buttonAction}

View File

@ -1,99 +1,20 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import Plan from "@app/components/billing/Plan";
import NavHeader from "@app/components/navigation/NavHeader";
import { useSubscription } from "@app/context";
import { BillingSettingsPage } from "@app/views/Settings/BillingSettingsPage";
export default function SettingsBilling() {
const { subscription } = useSubscription();
const { t } = useTranslation();
const plans = [
{
key: 1,
name: t("billing.starter.name")!,
price: t("billing.free")!,
priceExplanation: t("billing.starter.price-explanation")!,
text: t("billing.starter.text")!,
subtext: t("billing.starter.subtext")!,
buttonTextMain: t("billing.downgrade")!,
buttonTextSecondary: t("billing.learn-more")!,
current: subscription?.slug === "starter"
},
{
key: 2,
name: "Team",
price: "$8",
priceExplanation: t("billing.professional.price-explanation")!,
text: "Unlimited members, up to 10 projects. Additional developer experience features.",
buttonTextMain: t("billing.upgrade")!,
buttonTextSecondary: t("billing.learn-more")!,
current: subscription?.slug === "team" || subscription?.slug === "team-annual"
},
{
key: 3,
name: t("billing.professional.name")!,
price: "$18",
priceExplanation: t("billing.professional.price-explanation")!,
text: t("billing.enterprise.text")!,
subtext: t("billing.professional.subtext")!,
buttonTextMain: t("billing.upgrade")!,
buttonTextSecondary: t("billing.learn-more")!,
current: subscription?.slug === "pro" || subscription?.slug === "pro-annual"
},
{
key: 4,
name: t("billing.enterprise.name")!,
price: t("billing.custom-pricing")!,
text: "Boost the security and efficiency of your engineering teams.",
buttonTextMain: t("billing.schedule-demo")!,
buttonTextSecondary: t("billing.learn-more")!,
current: subscription?.slug === "enterprise"
}
];
return (
<div className="flex flex-col justify-between bg-bunker-800 pb-4 text-white">
<div className="h-full bg-bunker-800">
<Head>
<title>{t("common.head-title", { title: t("billing.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<div className="flex flex-row">
<div className="w-full pb-2">
<NavHeader pageName={t("billing.title")} />
<div className="my-8 ml-6 flex max-w-5xl flex-row items-center justify-between text-xl">
<div className="flex flex-col items-start justify-start text-3xl">
<p className="mr-4 font-semibold text-gray-200">{t("billing.title")}</p>
<p className="mr-4 text-base font-normal text-gray-400">{t("billing.description")}</p>
</div>
</div>
<div className="ml-6 flex w-max flex-col text-mineshaft-50">
<p className="text-xl font-semibold">{t("billing.subscription")}</p>
<div className="mt-4 grid grid-cols-2 grid-rows-2 gap-y-6 gap-x-3 overflow-x-auto">
{plans.map((plan) => (
<Plan key={plan.name} plan={plan} />
))}
</div>
{/* <p className="mt-12 text-xl font-bold">{t("billing.current-usage")}</p>
<div className="flex flex-row">
<div className="mr-4 mt-8 flex w-60 flex-col items-center justify-center rounded-md bg-white/5 pt-6 pb-10 text-gray-300">
<p className="text-6xl font-bold">{numUsers}</p>
<p className="text-gray-300">
Organization members
</p>
</div>
<div className="mr-4 mt-8 text-gray-300 w-60 pt-6 pb-10 rounded-md bg-white/5 flex justify-center items-center flex flex-col">
<p className="text-6xl font-bold">1 </p>
<p className="text-gray-300">Organization projects</p>
</div>
</div> */}
</div>
</div>
</div>
<BillingSettingsPage />
</div>
);
}
SettingsBilling.requireAuth = true;
SettingsBilling.requireAuth = true;

View File

@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import NavHeader from "@app/components/navigation/NavHeader";
import {
BillingTabGroup
} from "./components";
export const BillingSettingsPage = () => {
const { t } = useTranslation();
return (
<div className="h-full py-8 px-4">
<NavHeader pageName={t("billing.title")} />
<div className="ml-4 flex text-3xl mt-8 items-start max-w-screen-lg">
<div className="flex-1">
<p className="font-semibold text-gray-200">{t("billing.title")}</p>
</div>
<div />
</div>
<div className="ml-4">
<BillingTabGroup />
</div>
</div>
);
};

View File

@ -0,0 +1,11 @@
import { CurrentPlanSection } from "./CurrentPlanSection";
import { PreviewSection } from "./PreviewSection";
export const BillingCloudTab = () => {
return (
<div>
<PreviewSection />
<CurrentPlanSection />
</div>
);
}

View File

@ -0,0 +1,87 @@
import { faCircleCheck, faCircleXmark,faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useGetOrgPlanTable
} from "@app/hooks/api";
export const CurrentPlanSection = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPlanTable(currentOrg?._id ?? "");
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "-";
if (typeof value === "boolean") {
if (value) return (
<FontAwesomeIcon
icon={faCircleCheck}
color='#2ecc71'
/>
);
return (
<FontAwesomeIcon
icon={faCircleXmark}
color='#e74c3c'
/>
);
}
return value;
}
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">Feature</Th>
<Th className="w-1/3">Allowed</Th>
<Th className="w-1/3">Used</Th>
</Tr>
</THead>
<TBody>
{!isLoading && data && data?.rows?.length > 0 && data.rows.map(({
name,
allowed,
used
}) => {
return (
<Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{name}</Td>
<Td>{displayCell(allowed)}</Td>
<Td>{used}</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={5} key="invoices" />}
{!isLoading && data && data?.rows?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState
title="No plan details found"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { Fragment } from "react"
import { Tab } from "@headlessui/react"
import {
Modal,
ModalContent
} from "@app/components/v2";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { ManagePlansTable } from "./ManagePlansTable";
type Props = {
popUp: UsePopUpState<["managePlan"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["managePlan"]>, state?: boolean) => void;
};
export const ManagePlansModal = ({
popUp,
handlePopUpToggle
}: Props) => {
return (
<Modal
isOpen={popUp?.managePlan?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("managePlan", isOpen);
}}
>
<ModalContent className="max-w-screen-lg" title="Infisical Cloud Plans">
<Tab.Group>
<Tab.List className="border-b-2 border-mineshaft-600 max-w-screen-lg">
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`p-4 ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"} w-30 font-semibold outline-none`}
>
Bill monthly
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`p-4 ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"} w-30 font-semibold outline-none`}
>
Bill yearly
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels className="mt-4">
<Tab.Panel>
<ManagePlansTable billingCycle="monthly" />
</Tab.Panel>
<Tab.Panel>
<ManagePlansTable billingCycle="yearly" />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,176 @@
import { faCircleCheck, faCircleXmark,faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
} from "@app/components/v2";
import { useOrganization,useSubscription } from "@app/context";
import {
useCreateCustomerPortalSession,
useGetOrgPlansTable} from "@app/hooks/api";
type Props = {
billingCycle: "monthly" | "yearly"
}
export const ManagePlansTable = ({
billingCycle
}: Props) => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?._id ?? "",
billingCycle
});
const createCustomerPortalSession = useCreateCustomerPortalSession();
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "-";
if (typeof value === "boolean") {
if (value) return (
<FontAwesomeIcon
icon={faCircleCheck}
color='#2ecc71'
/>
);
return (
<FontAwesomeIcon
icon={faCircleXmark}
color='#e74c3c'
/>
);
}
return value;
}
return (
<TableContainer>
<Table>
<THead>
{subscription && !isTableDataLoading && tableData && (
<Tr>
<Th className="">Feature / Limit</Th>
{tableData.head.map(({
name,
priceLine
}) => {
return (
<Th
key={`plans-feature-head-${billingCycle}-${name}`}
className="text-center flex-1"
>
<p>{name}</p>
<p>{priceLine}</p>
</Th>
);
})}
</Tr>
)}
</THead>
<TBody>
{subscription && !isTableDataLoading && tableData && tableData.rows.map(({
name,
starter,
team,
pro,
enterprise
}) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">
{displayCell(starter)}
</Td>
<Td className="text-center">
{displayCell(team)}
</Td>
<Td className="text-center">
{displayCell(pro)}
</Td>
<Td className="text-center">
{displayCell(enterprise)}
</Td>
</Tr>
);
})}
{isTableDataLoading && <TableSkeleton columns={5} key="cloud-products" />}
{!isTableDataLoading && tableData?.rows.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No cloud product details found"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({
slug,
tier
}) => {
const isCurrentPlan = slug === subscription.slug;
let subscriptionText = "Upgrade";
if (subscription.tier > tier) {
subscriptionText = "Downgrade"
}
if (tier === 3) {
subscriptionText = "Contact sales"
}
return isCurrentPlan ? (
<Td>
<Button
colorSchema="secondary"
className="w-full"
isDisabled
>
Current
</Button>
</Td>
) : (
<Td>
<Button
onClick={async () => {
if (!currentOrg?._id) return;
if (tier !== 3) {
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg._id);
window.location.href = url;
return;
}
window.location.href = "https://infisical.com/scheduledemo";
}}
color="mineshaft"
className="w-full"
>
{subscriptionText}
</Button>
</Td>
);
})}
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}

View File

@ -0,0 +1,92 @@
import { Button } from "@app/components/v2";
import { useOrganization,useSubscription } from "@app/context";
import {
useCreateCustomerPortalSession,
useGetOrgPlanBillingInfo} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ManagePlansModal } from "./ManagePlansModal";
export const PreviewSection = () => {
const { currentOrg } = useOrganization();
const { subscription, isLoading: isSubscriptionLoading } = useSubscription();
const { data, isLoading } = useGetOrgPlanBillingInfo(currentOrg?._id ?? "");
const createCustomerPortalSession = useCreateCustomerPortalSession();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"managePlan"
] as const);
const formatAmount = (amount: number) => {
const formattedTotal = (Math.floor(amount) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
return formattedTotal;
}
const formatDate = (date: number) => {
const createdDate = new Date(date * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return formattedDate;
}
return (
<div>
{!isSubscriptionLoading && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && (
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mt-8 flex items-center bg-mineshaft-600 max-w-screen-lg">
<div className="flex-1">
<h2 className="text-xl font-semibold text-mineshaft-50">Become Infisical</h2>
<p className="text-gray-400 mt-4">Unlimited members, projects, RBAC, smart alerts, and so much more</p>
</div>
<Button
onClick={() => handlePopUpOpen("managePlan")}
color="mineshaft"
>
Upgrade
</Button>
</div>
)}
{!isLoading && data && (
<div className="flex mt-8 max-w-screen-lg">
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 mr-4 border border-mineshaft-600">
<p className="mb-2 text-gray-400">Current plan</p>
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">Starter</p>
<button
type="button"
onClick={async () => {
if (!currentOrg?._id) return;
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg._id);
window.location.href = url;
}}
className="text-primary"
>
Manage plan &rarr;
</button>
</div>
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mr-4">
<p className="mb-2 text-gray-400">Price</p>
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">
{`${formatAmount(data.amount)} / ${data.interval}`}
</p>
</div>
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600">
<p className="mb-2 text-gray-400">Subscription renews on</p>
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">
{formatDate(data.currentPeriodEnd)}
</p>
</div>
</div>
)}
<ManagePlansModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
}

View File

@ -0,0 +1 @@
export { BillingCloudTab } from "./BillingCloudTab";

View File

@ -0,0 +1,15 @@
import { CompanyNameSection } from "./CompanyNameSection";
import { InvoiceEmailSection } from "./InvoiceEmailSection";
import { PmtMethodsSection } from "./PmtMethodsSection";
import { TaxIDSection } from "./TaxIDSection";
export const BillingDetailsTab = () => {
return (
<>
<CompanyNameSection />
<InvoiceEmailSection />
<PmtMethodsSection />
<TaxIDSection />
</>
);
}

View File

@ -0,0 +1,96 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import Button from "@app/components/basic/buttons/Button";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { FormControl,Input } from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useGetOrgBillingDetails,
useUpdateOrgBillingDetails
} from "@app/hooks/api";
const schema = yup.object({
name: yup.string().required("Company name is required")
}).required();
export const CompanyNameSection = () => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
name: ""
},
resolver: yupResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
reset({
name: data?.name ?? ""
});
}
}, [data]);
const onFormSubmit = async ({ name }: { name: string }) => {
try {
if (!currentOrg?._id) return;
if (name === "") return;
await updateOrgBillingDetails.mutateAsync({
name,
organizationId: currentOrg._id
});
createNotification({
text: "Successfully updated business name",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update business name",
type: "error"
});
}
}
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
Business name
</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Acme Corp"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="name"
/>
</div>
<div className="inline-block">
<Button
text="Save"
type="submit"
color="mineshaft"
size="md"
onButtonPressed={() => console.log("Saved company name")}
/>
</div>
</form>
);
}

View File

@ -0,0 +1,99 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import Button from "@app/components/basic/buttons/Button";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
FormControl,
Input} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useGetOrgBillingDetails,
useUpdateOrgBillingDetails
} from "@app/hooks/api";
const schema = yup.object({
email: yup.string().required("Email is required")
}).required();
export const InvoiceEmailSection = () => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
email: ""
},
resolver: yupResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
reset({
email: data?.email ?? ""
});
}
}, [data]);
const onFormSubmit = async ({ email }: { email: string }) => {
try {
if (!currentOrg?._id) return;
if (email === "") return;
await updateOrgBillingDetails.mutateAsync({
email,
organizationId: currentOrg._id
});
createNotification({
text: "Successfully updated invoice email recipient",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update invoice email recipient",
type: "error"
});
}
}
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
Invoice email recipient
</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="jane@acme.com"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="email"
/>
</div>
<div className="inline-block">
<Button
text="Save"
type="submit"
color="mineshaft"
size="md"
onButtonPressed={() => console.log("Saved email address")}
/>
</div>
</form>
);
}

View File

@ -0,0 +1,44 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import Button from "@app/components/basic/buttons/Button";
import { useOrganization } from "@app/context";
import { useAddOrgPmtMethod } from "@app/hooks/api";
import { PmtMethodsTable } from "./PmtMethodsTable";
export const PmtMethodsSection = () => {
const { currentOrg } = useOrganization();
const addOrgPmtMethod = useAddOrgPmtMethod();
const handleAddPmtMethodBtnClick = async () => {
if (!currentOrg?._id) return;
const url = await addOrgPmtMethod.mutateAsync({
organizationId: currentOrg._id,
success_url: window.location.href,
cancel_url: window.location.href
});
window.location.href = url;
}
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex items-center mb-8">
<h2 className="text-xl font-semibold flex-1 text-white">
Payment Methods
</h2>
<div className="inline-block">
<Button
text="Add method"
type="submit"
color="mineshaft"
size="md"
icon={faPlus}
onButtonPressed={handleAddPmtMethodBtnClick}
/>
</div>
</div>
<PmtMethodsTable />
</div>
);
}

View File

@ -0,0 +1,90 @@
import { faCreditCard, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useDeleteOrgPmtMethod,
useGetOrgPmtMethods
} from "@app/hooks/api";
export const PmtMethodsTable = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPmtMethods(currentOrg?._id ?? "");
const deleteOrgPmtMethod = useDeleteOrgPmtMethod();
const handleDeletePmtMethodBtnClick = async (pmtMethodId: string) => {
if (!currentOrg?._id) return;
await deleteOrgPmtMethod.mutateAsync({
organizationId: currentOrg._id,
pmtMethodId
});
}
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">Brand</Th>
<Th className="flex-1">Type</Th>
<Th className="flex-1">Last 4 Digits</Th>
<Th className="flex-1">Expiration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading && data && data?.length > 0 && data.map(({
_id,
brand,
exp_month,
exp_year,
funding,
last4
}) => (
<Tr key={`pmt-method-${_id}`} className="h-10">
<Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td>
<Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td>
<Td>{last4}</Td>
<Td>{`${exp_month}/${exp_year}`}</Td>
<Td>
<IconButton
onClick={async () => {
await handleDeletePmtMethodBtnClick(_id);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
))}
{isLoading && <TableSkeleton columns={5} key="pmt-methods" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No payment methods on file"
icon={faCreditCard}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}

View File

@ -0,0 +1,195 @@
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useAddOrgTaxId } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const taxIDTypes = [
{ label: "Australia ABN", value: "au_abn" },
{ label: "Australia ARN", value: "au_arn" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Brazil CNPJ", value: "br_cnpj" },
{ label: "Brazil CPF", value: "br_cpf" },
{ label: "Canada BN", value: "ca_bn" },
{ label: "Canada GST/HST", value: "ca_gst_hst" },
{ label: "Canada PST BC", value: "ca_pst_bc" },
{ label: "Canada PST MB", value: "ca_pst_mb" },
{ label: "Canada PST SK", value: "ca_pst_sk" },
{ label: "Canada QST", value: "ca_qst" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Chile TIN", value: "cl_tin" },
{ label: "Egypt TIN", value: "eg_tin" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "EU OSS VAT", value: "eu_oss_vat" },
{ label: "EU VAT", value: "eu_vat" },
{ label: "GB VAT", value: "gb_vat" },
{ label: "Georgia VAT", value: "ge_vat" },
{ label: "Hong Kong BR", value: "hk_br" },
{ label: "Hungary TIN", value: "hu_tin" },
{ label: "Indonesia NPWP", value: "id_npwp" },
{ label: "Israel VAT", value: "il_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "Japan CN", value: "jp_cn" },
{ label: "Japan RN", value: "jp_rn" },
{ label: "Japan TRN", value: "jp_trn" },
{ label: "Kenya PIN", value: "ke_pin" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Liechtenstein UID", value: "li_uid" },
{ label: "Mexico RFC", value: "mx_rfc" },
{ label: "Malaysia FRP", value: "my_frp" },
{ label: "Malaysia ITN", value: "my_itn" },
{ label: "Malaysia SST", value: "my_sst" },
{ label: "Norway VAT", value: "no_vat" },
{ label: "New Zealand GST", value: "nz_gst" },
{ label: "Philippines TIN", value: "ph_tin" },
{ label: "Russia INN", value: "ru_inn" },
{ label: "Russia KPP", value: "ru_kpp" },
{ label: "Saudi Arabia VAT", value: "sa_vat" },
{ label: "Singapore GST", value: "sg_gst" },
{ label: "Singapore UEN", value: "sg_uen" },
{ label: "Slovenia TIN", value: "si_tin" },
{ label: "Thailand VAT", value: "th_vat" },
{ label: "Turkey TIN", value: "tr_tin" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Ukraine VAT", value: "ua_vat" },
{ label: "US EIN", value: "us_ein" },
{ label: "South Africa VAT", value: "za_vat" }
];
const schema = yup.object({
type: yup.string().required("Tax ID type is required"),
value: yup.string().required("Tax ID value is required")
}).required();
export type AddTaxIDFormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["addTaxID"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addTaxID"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addTaxID"]>, state?: boolean) => void;
};
export const TaxIDModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const addOrgTaxId = useAddOrgTaxId();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<AddTaxIDFormData>({
resolver: yupResolver(schema)
});
const onTaxIDModalSubmit = async ({ type, value }: AddTaxIDFormData) => {
try {
if (!currentOrg?._id) return;
await addOrgTaxId.mutateAsync({
organizationId: currentOrg._id,
type,
value
});
createNotification({
text: "Successfully added Tax ID",
type: "success"
});
handlePopUpClose("addTaxID");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to add Tax ID",
type: "error"
});
}
}
return (
<Modal
isOpen={popUp?.addTaxID?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addTaxID", isOpen);
reset();
}}
>
<ModalContent title="Add Tax ID">
<form onSubmit={handleSubmit(onTaxIDModalSubmit)}>
<Controller
control={control}
name="type"
defaultValue="eu_vat"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Type"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{taxIDTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="value"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Value"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="DE000000000"
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,39 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import Button from "@app/components/basic/buttons/Button";
import { usePopUp } from "@app/hooks/usePopUp";
import { TaxIDModal } from "./TaxIDModal";
import { TaxIDTable } from "./TaxIDTable";
export const TaxIDSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addTaxID"
] as const);
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex items-center mb-8">
<h2 className="text-xl font-semibold flex-1 text-white">
Tax ID
</h2>
<div className="inline-block">
<Button
text="Add Tax ID"
type="submit"
color="mineshaft"
size="md"
icon={faPlus}
onButtonPressed={() => handlePopUpOpen("addTaxID")}
/>
</div>
</div>
<TaxIDTable />
<TaxIDModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { faFileInvoice,faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useDeleteOrgTaxId,
useGetOrgTaxIds
} from "@app/hooks/api";
const taxIDTypeLabelMap: { [key: string]: string } = {
"au_abn": "Australia ABN",
"au_arn": "Australia ARN",
"bg_uic": "Bulgaria UIC",
"br_cnpj": "Brazil CNPJ",
"br_cpf": "Brazil CPF",
"ca_bn": "Canada BN",
"ca_gst_hst": "Canada GST/HST",
"ca_pst_bc": "Canada PST BC",
"ca_pst_mb": "Canada PST MB",
"ca_pst_sk": "Canada PST SK",
"ca_qst": "Canada QST",
"ch_vat": "Switzerland VAT",
"cl_tin": "Chile TIN",
"eg_tin": "Egypt TIN",
"es_cif": "Spain CIF",
"eu_oss_vat": "EU OSS VAT",
"eu_vat": "EU VAT",
"gb_vat": "GB VAT",
"ge_vat": "Georgia VAT",
"hk_br": "Hong Kong BR",
"hu_tin": "Hungary TIN",
"id_npwp": "Indonesia NPWP",
"il_vat": "Israel VAT",
"in_gst": "India GST",
"is_vat": "Iceland VAT",
"jp_cn": "Japan CN",
"jp_rn": "Japan RN",
"jp_trn": "Japan TRN",
"ke_pin": "Kenya PIN",
"kr_brn": "South Korea BRN",
"li_uid": "Liechtenstein UID",
"mx_rfc": "Mexico RFC",
"my_frp": "Malaysia FRP",
"my_itn": "Malaysia ITN",
"my_sst": "Malaysia SST",
"no_vat": "Norway VAT",
"nz_gst": "New Zealand GST",
"ph_tin": "Philippines TIN",
"ru_inn": "Russia INN",
"ru_kpp": "Russia KPP",
"sa_vat": "Saudi Arabia VAT",
"sg_gst": "Singapore GST",
"sg_uen": "Singapore UEN",
"si_tin": "Slovenia TIN",
"th_vat": "Thailand VAT",
"tr_tin": "Turkey TIN",
"tw_vat": "Taiwan VAT",
"ua_vat": "Ukraine VAT",
"us_ein": "US EIN",
"za_vat": "South Africa VAT"
};
export const TaxIDTable = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgTaxIds(currentOrg?._id ?? "");
const deleteOrgTaxId = useDeleteOrgTaxId();
const handleDeleteTaxIdBtnClick = async (taxId: string) => {
if (!currentOrg?._id) return;
await deleteOrgTaxId.mutateAsync({
organizationId: currentOrg._id,
taxId
});
}
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">Type</Th>
<Th className="flex-1">Value</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading && data && data?.length > 0 && data.map(({
_id,
type,
value
}) => (
<Tr key={`tax-id-${_id}`} className="h-10">
<Td>{taxIDTypeLabelMap[type]}</Td>
<Td>{value}</Td>
<Td>
<IconButton
onClick={async () => {
await handleDeleteTaxIdBtnClick(_id);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
))}
{isLoading && <TableSkeleton columns={3} key="tax-ids" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No Tax IDs on file"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}

View File

@ -0,0 +1 @@
export { BillingDetailsTab } from "./BillingDetailsTab";

View File

@ -0,0 +1,10 @@
import { InvoicesTable } from "./InvoicesTable";
export const BillingReceiptsTab = () => {
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<h2 className="text-xl font-semibold flex-1 text-white">Invoices</h2>
<InvoicesTable />
</div>
);
}

View File

@ -0,0 +1,88 @@
import { faDownload, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useGetOrgInvoices
} from "@app/hooks/api";
export const InvoicesTable = () => {
const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgInvoices(currentOrg?._id ?? "");
return (
<TableContainer className="mt-8">
<Table>
<THead>
<Tr>
<Th className="flex-1">Invoice #</Th>
<Th className="flex-1">Date</Th>
<Th className="flex-1">Status</Th>
<Th className="flex-1">Amount</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading && data && data?.length > 0 && data.map(({
_id,
created,
paid,
number,
total,
invoice_pdf
}) => {
const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
const createdDate = new Date(created * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return (
<Tr key={`invoice-${_id}`} className="h-10">
<Td>{number}</Td>
<Td>{formattedDate}</Td>
<Td>{paid ? "Paid" : "Not Paid"}</Td>
<Td>{formattedTotal}</Td>
<Td>
<IconButton
onClick={async () => window.open(invoice_pdf)}
size="lg"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={5} key="invoices" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No invoices on file"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}

View File

@ -0,0 +1 @@
export { BillingReceiptsTab } from "./BillingReceiptsTab";

View File

@ -0,0 +1,44 @@
import { Fragment } from "react"
import { Tab } from "@headlessui/react"
import { BillingCloudTab } from "../BillingCloudTab";
import { BillingDetailsTab } from "../BillingDetailsTab";
import { BillingReceiptsTab } from "../BillingReceiptsTab";
const tabs = [
{ name: "Infisical Cloud", key: "tab-infisical-cloud" },
{ name: "Receipts", key: "tab-receipts" },
{ name: "Billing details", key: "tab-billing-details" }
];
export const BillingTabGroup = () => {
return (
<Tab.Group>
<Tab.List className="mt-8 border-b-2 border-mineshaft-800 max-w-screen-lg">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 p-4 font-semibold outline-none ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<BillingCloudTab />
</Tab.Panel>
<Tab.Panel>
<BillingReceiptsTab />
</Tab.Panel>
<Tab.Panel>
<BillingDetailsTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
}

View File

@ -0,0 +1 @@
export { BillingTabGroup } from "./BillingTabGroup";

View File

@ -0,0 +1 @@
export { BillingTabGroup } from "./BillingTabGroup";

View File

@ -0,0 +1 @@
export { BillingSettingsPage } from "./BillingSettingsPage";

View File

@ -210,27 +210,6 @@ export const OrgSettingsPage = () => {
}
};
/**
* This function deleted a workspace.
* It first checks if there is more than one workspace available. Otherwise, it doesn't delete
* It then checks if the name of the workspace to be deleted is correct. Otherwise, it doesn't delete.
* It then deletes the workspace and forwards the user to another available workspace.
*/
// const executeDeletingWorkspace = async () => {
// const userWorkspaces = await getWorkspaces();
//
// if (userWorkspaces.length > 1) {
// if (
// userWorkspaces.filter((workspace) => workspace._id === workspaceId)[0].name ===
// workspaceToBeDeletedName
// ) {
// await deleteWorkspace(workspaceId);
// const ws = await getWorkspaces();
// router.push(`/dashboard/${ws[0]._id}`);
// }
// }
// };
//
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<NavHeader pageName={t("settings.org.title")} />