From a127d452bd4ce22ddb6ad455cc112a62f1b734de Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 25 Jun 2023 17:03:41 +0700 Subject: [PATCH] Continue to make progress on usage and billing page revamp --- .../controllers/v1/organizationsController.ts | 142 ++++++++- backend/src/ee/routes/v1/organizations.ts | 151 +++++++++ .../src/components/navigation/NavHeader.tsx | 2 +- frontend/src/hooks/api/organization/index.ts | 19 +- .../src/hooks/api/organization/queries.tsx | 289 +++++++++++++++++- frontend/src/hooks/api/organization/types.ts | 5 + frontend/src/pages/settings/billing/[id].tsx | 80 ++--- .../BillingSettingsPage.tsx | 120 ++++++++ .../BillingCloudTab/BillingCloudTab.tsx | 33 ++ .../BillingCloudTab/CurrentPlanSection.tsx | 94 ++++++ .../BillingCloudTab/ManagePlansModal.tsx | 78 +++++ .../BillingCloudTab/ManagePlansTable.tsx | 187 ++++++++++++ .../BillingCloudTab/PreviewSection.tsx | 75 +++++ .../components/BillingCloudTab/index.tsx | 1 + .../BillingDetailsTab/BillingDetailsTab.tsx | 15 + .../BillingDetailsTab/CompanyNameSection.tsx | 98 ++++++ .../BillingDetailsTab/InvoiceEmailSection.tsx | 86 ++++++ .../BillingDetailsTab/PmtMethodsSection.tsx | 130 ++++++++ .../BillingDetailsTab/TaxIDSection.tsx | 276 +++++++++++++++++ .../components/BillingDetailsTab/index.tsx | 1 + .../BillingReceiptsTab/BillingReceiptsTab.tsx | 100 ++++++ .../components/BillingReceiptsTab/index.tsx | 1 + .../BillingSelfHostedTab.tsx | 0 .../components/BillingSelfHostedTab/index.tsx | 0 .../BillingSettingsPage/components/index.tsx | 3 + .../Settings/BillingSettingsPage/index.tsx | 1 + .../OrgSettingsPage/OrgSettingsPage.tsx | 21 -- 27 files changed, 1940 insertions(+), 68 deletions(-) create mode 100644 frontend/src/views/Settings/BillingSettingsPage/BillingSettingsPage.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/BillingCloudTab.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansModal.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/index.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/BillingDetailsTab.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/index.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/index.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingSelfHostedTab/BillingSelfHostedTab.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/BillingSelfHostedTab/index.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/components/index.tsx create mode 100644 frontend/src/views/Settings/BillingSettingsPage/index.tsx diff --git a/backend/src/ee/controllers/v1/organizationsController.ts b/backend/src/ee/controllers/v1/organizationsController.ts index 7ab2c5d32..d91efa057 100644 --- a/backend/src/ee/controllers/v1/organizationsController.ts +++ b/backend/src/ee/controllers/v1/organizationsController.ts @@ -3,8 +3,37 @@ import { getLicenseServerUrl } from "../../../config"; import { licenseServerKeyRequest } from "../../../config/request"; import { EELicenseService } from "../../services"; +export const createProductCheckoutSession = async (req: Request, res: Response) => { + const { + productId, + success_url + } = req.body; + console.log('createProductCheckoutSession req.body: ', req.body); + + const { data } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/session`, + { + productId, + success_url + } + ); + console.log('createProductCheckoutSession data: ', data); + + return res.status(200).send(data); +} + +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; @@ -17,6 +46,34 @@ export const getOrganizationPlan = async (req: Request, res: Response) => { }); } +/** + * Return the organization's current plan's billing info + * @param req + * @param res + * @returns + */ +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); +} + /** * Update the organization plan to product with id [productId] * @param req @@ -28,16 +85,44 @@ export const updateOrganizationPlan = async (req: Request, res: Response) => { productId, } = req.body; - const { data } = await licenseServerKeyRequest.patch( + console.log('backend update productId: ', productId); + const { data } = await licenseServerKeyRequest.patch( `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan`, { productId, } ); + + console.log(' ttproductId: ', data); 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 +131,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 +164,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 } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/invoices` + ); + + return res.status(200).send(data); } \ No newline at end of file diff --git a/backend/src/ee/routes/v1/organizations.ts b/backend/src/ee/routes/v1/organizations.ts index 5a9c603b1..e9ea8f0e4 100644 --- a/backend/src/ee/routes/v1/organizations.ts +++ b/backend/src/ee/routes/v1/organizations.ts @@ -11,6 +11,37 @@ import { ACCEPTED, ADMIN, MEMBER, OWNER, } from "../../../variables"; +router.post( + "/:organizationId/billing-details/session", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + }), + param("organizationId").exists().trim(), + body("productId").exists().trim(), + body("success_url").exists().trim(), + validateRequest, + organizationsController.createProductCheckoutSession +); + +router.get( + "/:organizationId/plans/table", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + 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({ @@ -26,6 +57,36 @@ router.get( organizationsController.getOrganizationPlan ); +router.get( + "/:organizationId/plan/billing", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + }), + param("organizationId").exists().trim(), + query("workspaceId").optional().isString(), + validateRequest, + organizationsController.getOrganizationPlanBillingInfo +); + +router.get( + "/:organizationId/plan/table", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + }), + param("organizationId").exists().trim(), + query("workspaceId").optional().isString(), + validateRequest, + organizationsController.getOrganizationPlanTable +); + router.patch( "/:organizationId/plan", requireAuth({ @@ -41,6 +102,36 @@ router.patch( organizationsController.updateOrganizationPlan ); +router.get( + "/:organizationId/billing-details", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + }), + param("organizationId").exists().trim(), + validateRequest, + organizationsController.getOrganizationBillingDetails +); + +router.patch( + "/:organizationId/billing-details", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + 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({ @@ -81,8 +172,68 @@ router.delete( acceptedStatuses: [ACCEPTED], }), param("organizationId").exists().trim(), + param("pmtMethodId").exists().trim(), validateRequest, organizationsController.deleteOrganizationPmtMethod ); +router.get( + "/:organizationId/billing-details/tax-ids", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + }), + param("organizationId").exists().trim(), + validateRequest, + organizationsController.getOrganizationTaxIds +); + +router.post( + "/:organizationId/billing-details/tax-ids", + requireAuth({ + acceptedAuthModes: ["jwt", "apiKey"], + }), + 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", "apiKey"], + }), + 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", "apiKey"], + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + }), + param("organizationId").exists().trim(), + validateRequest, + organizationsController.getOrganizationInvoices +); + export default router; \ No newline at end of file diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 6891de792..4bbdce1bc 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -57,7 +57,7 @@ export default function NavHeader({ ); return ( -
+
{currentOrg?.name?.charAt(0)}
diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index 107978949..a856001c7 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -1 +1,18 @@ -export { useGetOrganization, useRenameOrg } from "./queries"; +export { + useGetOrgPlanBillingInfo, + useGetOrgPlanTable, + useGetOrgPlansTable, + useGetOrganization, + useRenameOrg, + useGetOrgBillingDetails, + useUpdateOrgBillingDetails, + useGetOrgPmtMethods, + useAddOrgPmtMethod, + useDeleteOrgPmtMethod, + useGetOrgTaxIds, + useAddOrgTaxId, + useDeleteOrgTaxId, + useGetOrgInvoices, + useUpdateOrgPlan, + useCreateProductCheckoutSession +} from "./queries"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 7e02b21a5..08476ba00 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -1,11 +1,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - import { apiRequest } from "@app/config/request"; - -import { Organization, RenameOrgDTO } from "./types"; +import { Organization, RenameOrgDTO, BillingDetails } 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 () => { @@ -14,6 +19,40 @@ const fetchUserOrganization = async () => { return data.organizations; }; +// TODO: fix the type situation here and move fetches directly into hooks + +const fetchOrgBillingDetails = async (organizationId: string) => { + const { data } = await apiRequest.get( + `/api/v1/organizations/${organizationId}/billing-details` + ); + + return data; +} + +const fetchOrgPmtMethods = async (organizationId: string) => { + const { data } = await apiRequest.get( + `/api/v1/organizations/${organizationId}/billing-details/payment-methods` + ); + + return data; +} + +const fetchOrgTaxIds = async (organizationId: string) => { + const { data } = await apiRequest.get( + `/api/v1/organizations/${organizationId}/billing-details/tax-ids` + ); + + return data; +} + +const fetchOrgInvoices = async (organizationId: string) => { + const { data: { invoices } } = await apiRequest.get( + `/api/v1/organizations/${organizationId}/invoices` + ); + + return invoices; +} + export const useGetOrganization = () => useQuery({ queryKey: organizationKeys.getUserOrganization, queryFn: fetchUserOrganization }); @@ -29,3 +68,245 @@ export const useRenameOrg = () => { } }); }; + +export const useGetOrgPlanBillingInfo = (organizationId: string) => { + return useQuery({ + queryKey: organizationKeys.getOrgPlanBillingInfo(organizationId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/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( + `/api/v1/organizations/${organizationId}/plan/table` + ); + + return data; + }, + enabled: true + }); +} + +export const useGetOrgPlansTable = ({ + organizationId, + billingCycle +}: { + organizationId: string; + billingCycle: "monthly" | "annual" +}) => { + return useQuery({ + queryKey: organizationKeys.getOrgPlansTable(organizationId, billingCycle), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/organizations/${organizationId}/plans/table?billingCycle=${billingCycle}` + ); + + return data; + }, + enabled: true + }); +} + +export const useGetOrgBillingDetails = (organizationId: string) => { + return useQuery({ + queryKey: organizationKeys.getOrgBillingDetails(organizationId), + queryFn: () => fetchOrgBillingDetails(organizationId), + 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: () => fetchOrgPmtMethods(organizationId), + 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: () => fetchOrgTaxIds(organizationId), + 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: () => fetchOrgInvoices(organizationId), + enabled: true + }); +} + +export const useUpdateOrgPlan = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + productId + }: { + organizationId: string; + productId: string; + }) => { + const { data } = await apiRequest.patch(`/api/v1/organizations/${organizationId}/plan`, { + productId + }); + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries([ + organizationKeys.getOrgPlanTable(dto.organizationId), + ]); + } + }); +}; + +export const useCreateProductCheckoutSession = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + productId, + success_url + }: { + organizationId: string; + productId: string; + success_url: string; + }) => { + const { data } = await apiRequest.post(`/api/v1/organizations/${organizationId}/billing-details/session`, { + productId, + success_url + }); + return data; + }, + onSuccess(_, dto) { + console.log('onSuccess'); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 92a5c5f1d..94528cd15 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,3 +9,8 @@ export type RenameOrgDTO = { orgId: string; newOrgName: string; }; + +export type BillingDetails = { + name: string; + email: string; +} \ No newline at end of file diff --git a/frontend/src/pages/settings/billing/[id].tsx b/frontend/src/pages/settings/billing/[id].tsx index e7b396fa3..39c8e71ac 100644 --- a/frontend/src/pages/settings/billing/[id].tsx +++ b/frontend/src/pages/settings/billing/[id].tsx @@ -1,9 +1,7 @@ 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(); @@ -55,45 +53,55 @@ export default function SettingsBilling() { ]; return ( -
+
{t("common.head-title", { title: t("billing.title") })} -
-
- -
-
-

{t("billing.title")}

-

{t("billing.description")}

-
-
-
-

{t("billing.subscription")}

-
- {plans.map((plan) => ( - - ))} -
- {/*

{t("billing.current-usage")}

-
-
-

{numUsers}

-

- Organization members -

-
-
-

1

-

Organization projects

-
-
*/} -
-
-
+
); } SettingsBilling.requireAuth = true; + + + +// return ( +//
+// +// {t("common.head-title", { title: t("billing.title") })} +// +// +//
+// +//
+//
+//

{t("billing.title")}

+//

{t("billing.description")}

+//
+//
+//
+//

{t("billing.subscription")}

+//
+// {plans.map((plan) => ( +// +// ))} +//
+// {/*

{t("billing.current-usage")}

+//
+//
+//

{numUsers}

+//

+// Organization members +//

+//
+//
+//

1

+//

Organization projects

+//
+//
*/} +//
+//
+//
+// ); diff --git a/frontend/src/views/Settings/BillingSettingsPage/BillingSettingsPage.tsx b/frontend/src/views/Settings/BillingSettingsPage/BillingSettingsPage.tsx new file mode 100644 index 000000000..02ee258c9 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/BillingSettingsPage.tsx @@ -0,0 +1,120 @@ +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 { + Input, + Button, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faMagnifyingGlass, faDownload } from "@fortawesome/free-solid-svg-icons"; + +import { Tab } from '@headlessui/react' +import { Fragment } from 'react' + +import { + BillingCloudTab, + BillingReceiptsTab, + BillingDetailsTab +} from "./components"; + +export const BillingSettingsPage = () => { + const { t } = useTranslation(); + return ( +
+ + +
+
+

{t("billing.title")}

+ {/*

+ Manage usage and billing for Infisical Cloud and Self-hosted instances here +

*/} +
+
+
+
+ + + + + {({ selected }) => ( + /* Use the `selected` state to conditionally style the selected tab. */ + + )} + + {/* + {({ selected }) => ( + + )} + */} + + {({ selected }) => ( + /* Use the `selected` state to conditionally style the selected tab. */ + + )} + + + {({ selected }) => ( + /* Use the `selected` state to conditionally style the selected tab. */ + + )} + + + + + + + {/* Content 2 */} + + + + + + + + + + + + {/*
+

{t("billing.subscription")}

+
+ {plans.map((plan) => ( + + ))} +
+

{t("billing.current-usage")}

+
+
+

{numUsers}

+

+ Organization members +

+
+
+

1

+

Organization projects

+
+
+
*/} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/BillingCloudTab.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/BillingCloudTab.tsx new file mode 100644 index 000000000..331b7545c --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/BillingCloudTab.tsx @@ -0,0 +1,33 @@ +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 { + Input, + Button, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faMagnifyingGlass, faDownload } from "@fortawesome/free-solid-svg-icons"; + +import { PreviewSection } from "./PreviewSection"; +import { CurrentPlanSection } from "./CurrentPlanSection"; + +// TODO: optimize + modularize +// TODO: get cloud plan full + +export const BillingCloudTab = () => { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx new file mode 100644 index 000000000..cfdacd869 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/CurrentPlanSection.tsx @@ -0,0 +1,94 @@ +import { + Input, + IconButton, + Button, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + EmptyState +} from "@app/components/v2"; +import { + useGetOrgPlanTable +} from "@app/hooks/api"; +import { useOrganization } from "@app/context"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFileInvoice, faCircleCheck, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; + +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 ( + + ); + + return ( + + ); + } + + return value; + } + + return ( +
+

Current Usage

+ + + + + + + + + + + {!isLoading && data?.rows?.length > 0 && data.rows.map(({ + name, + allowed, + used + }: { + name: string; + allowed: number | boolean; + used: string; + }) => { + return ( + + + + + + ); + })} + {isLoading && } + {!isLoading && data?.length === 0 && ( + + + + )} + +
FeatureAllowedUsed
{name}{displayCell(allowed)}{used}
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansModal.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansModal.tsx new file mode 100644 index 000000000..6a7da542f --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansModal.tsx @@ -0,0 +1,78 @@ +import { useSubscription } from "@app/context"; +import { useOrganization } from "@app/context"; +import { Tab } from '@headlessui/react' +import { Fragment } from 'react' +import { + useGetOrgPlanBillingInfo, + useGetOrgPlanTable, + useGetOrgPlansTable +} from "@app/hooks/api"; +import { + FormControl, + Button, + IconButton, + Input, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + EmptyState, + Modal, + ModalContent +} from "@app/components/v2"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFileInvoice, faCircleCheck, faCircleXmark, faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; + +import { ManagePlansTable } from "./ManagePlansTable"; + +export const ManagePlansModal = ({ + popUp, + handlePopUpToggle +}) => { + const { subscription, isLoading: isSubscriptionLoading } = useSubscription(); + + return ( + { + handlePopUpToggle("managePlan", isOpen); + }} + > + + + + + {({ selected }) => ( + + )} + + + {({ selected }) => ( + + )} + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx new file mode 100644 index 000000000..f4ae1884c --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx @@ -0,0 +1,187 @@ +import { useSubscription } from "@app/context"; +import { useOrganization } from "@app/context"; +import { Tab } from '@headlessui/react' +import { Fragment } from 'react' +import { + useGetOrgPlanBillingInfo, + useGetOrgPlanTable, + useGetOrgPlansTable, + useUpdateOrgPlan, + useCreateProductCheckoutSession +} from "@app/hooks/api"; +import { + FormControl, + Button, + IconButton, + Input, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + EmptyState, + Modal, + ModalContent +} from "@app/components/v2"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFileInvoice, faCircleCheck, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; + +// TODO: upgrade + +type Props = { + billingCycle: 'monthly' | 'yearly' +} + +export const ManagePlansTable = ({ + billingCycle +}: Props) => { + const { currentOrg } = useOrganization(); + const { subscription, isLoading: isSubscriptionLoading } = useSubscription(); + const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({ + organizationId: currentOrg?._id ?? '', + billingCycle + }); + const updateOrgPlan = useUpdateOrgPlan(); + const createProductCheckoutSession = useCreateProductCheckoutSession(); + + console.log('tableData: ', tableData); + + const displayCell = (value: null | number | string | boolean) => { + if (value === null) return '-'; + + if (typeof value === 'boolean') { + if (value) return ( + + ); + + return ( + + ); + } + + return value; + } + + return ( + + + + {!isTableDataLoading && tableData?.head.length > 0 && ( + + + {tableData.head.map(({ + name, + slug, + priceLine + }: { + name: string; + slug: string; + priceLine: string; + }) => { + return ( + + ); + })} + + )} + + + {!isTableDataLoading && tableData?.rows.length > 0 && tableData.rows.map(({ + name, + starter, + team, + pro, + enterprise + }: { + name: string; + starter: null | number | string | boolean; + team: null | number | string | boolean; + pro: null | number | string | boolean; + enterprise: null | number | string | boolean; + }) => { + return ( + + + + + + + + ); + })} + {isTableDataLoading && } + {!isTableDataLoading && tableData?.rows.length === 0 && ( + + + + )} + {subscription && !isTableDataLoading && tableData?.head.length > 0 && ( + + + {tableData.head.map(({ + slug, + productId + }: { + slug: string; + productId: string; + }) => { + const isCurrentPlan = slug === subscription.slug; + + console.log('productId: ', productId); + return isCurrentPlan ? ( + + ) : ( + + ); + })} + + )} + +
Feature / Limit +

{name}

+

{priceLine}

+
{displayCell(name)}{displayCell(starter)}{displayCell(team)}{displayCell(pro)}{displayCell(enterprise)}
+ +
+

Current

+
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx new file mode 100644 index 000000000..e03cb14c9 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx @@ -0,0 +1,75 @@ +import { useSubscription } from "@app/context"; +import { useOrganization } from "@app/context"; +import { useGetOrgPlanBillingInfo } from "@app/hooks/api"; +import { Button } from "@app/components/v2"; +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 { popUp, handlePopUpOpen, handlePopUpClose, 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 ( +
+ {!isSubscriptionLoading && subscription?.slug !== 'enterprise' && subscription?.slug !== 'pro' && subscription?.slug !== 'pro-annual' && ( +
+
+

Become Infisical

+

Unlimited members, projects, RBAC, smart alerts, and so much more

+
+ +
+ )} + {!isLoading && data && ( +
+
+

Current plan

+

Starter

+

Manage plan →

+
+
+

Price

+

{`${formatAmount(data.amount)} / ${data.interval}`}

+
+
+

Subscription renews on

+

{formatDate(data.currentPeriodEnd)}

+
+
+ )} + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/index.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/index.tsx new file mode 100644 index 000000000..07696dc7e --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/index.tsx @@ -0,0 +1 @@ +export { BillingCloudTab } from "./BillingCloudTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/BillingDetailsTab.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/BillingDetailsTab.tsx new file mode 100644 index 000000000..471cdb050 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/BillingDetailsTab.tsx @@ -0,0 +1,15 @@ +import { CompanyNameSection } from "./CompanyNameSection"; +import { InvoiceEmailSection } from "./InvoiceEmailSection"; +import { PmtMethodsSection } from "./PmtMethodsSection"; +import { TaxIDSection } from "./TaxIDSection"; + +export const BillingDetailsTab = () => { + return ( +
+ + + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx new file mode 100644 index 000000000..b8c88ff10 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx @@ -0,0 +1,98 @@ +import { useEffect } from "react"; +import { + Input, +// Button, + FormControl +} from "@app/components/v2"; +import { Controller, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useOrganization } from "@app/context"; +import { + useGetOrgBillingDetails, + useUpdateOrgBillingDetails +} from "@app/hooks/api"; + +import Button from "@app/components/basic/buttons/Button"; + +const schema = yup.object({ + name: yup.string().required('Company name is required') +}).required(); + +export const CompanyNameSection = () => { + const { currentOrg } = useOrganization(); + const { reset, control, register, handleSubmit, watch, formState: { errors } } = 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 + }); + } catch (err) { + console.error(err); + } + } + + return ( +
+
+

+ Business name +

+ +
+
+ ( + + + + )} + control={control} + name="name" + /> +
+
+
+
+ ( + + + + )} + control={control} + name="email" + /> +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx new file mode 100644 index 000000000..f05be12fb --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx @@ -0,0 +1,130 @@ +import { + Button, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + EmptyState +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useGetOrgPmtMethods, + useAddOrgPmtMethod, + useDeleteOrgPmtMethod +} from "@app/hooks/api"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faXmark, faCreditCard } from "@fortawesome/free-solid-svg-icons"; + +// TODO: optimize + modularize + +export const PmtMethodsSection = () => { + const { currentOrg } = useOrganization(); + const { data, isLoading } = useGetOrgPmtMethods(currentOrg?._id ?? ''); + const addOrgPmtMethod = useAddOrgPmtMethod(); + const deleteOrgPmtMethod = useDeleteOrgPmtMethod(); + + 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; + } + + const handleDeletePmtMethodBtnClick = async (pmtMethodId: string) => { + if (!currentOrg?._id) return; + await deleteOrgPmtMethod.mutateAsync({ + organizationId: currentOrg._id, + pmtMethodId + }); + } + + return ( +
+
+

+ Payment Methods +

+ +
+ + + + + + + + + + + + + {!isLoading && data?.length > 0 && data.map(({ + _id, + brand, + exp_month, + exp_year, + funding, + last4 + }: { + _id: string; + brand: string; + exp_month: number; + exp_year: number; + funding: string; + last4: string; + }) => ( + + + + + + + + ))} + {isLoading && } + {!isLoading && data?.length === 0 && ( + + + + )} + +
BrandTypeLast 4 DigitsExpiration
{brand}{funding}{last4}{`${exp_month}/${exp_year}`} + { + console.log('delete pmt method!'); + await handleDeletePmtMethodBtnClick(_id); + console.log('delete pmt method done!'); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx new file mode 100644 index 000000000..4b299a471 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx @@ -0,0 +1,276 @@ +import { + FormControl, + Button, + IconButton, + Input, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + EmptyState, + Modal, + ModalContent +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useGetOrgTaxIds, + useAddOrgTaxId, + useDeleteOrgTaxId +} from "@app/hooks/api"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { Controller, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +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; + +// TODO: optimize + modularize + +export const TaxIDSection = () => { + const { currentOrg } = useOrganization(); + const { data, isLoading } = useGetOrgTaxIds(currentOrg?._id ?? ''); + const addOrgTaxId = useAddOrgTaxId(); + const deleteOrgTaxId = useDeleteOrgTaxId(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addTaxID" + ] as const); + + const onTaxIDModalSubmit = async ({ type, value }: AddTaxIDFormData) => { + try { + if (!currentOrg?._id) return; + await addOrgTaxId.mutateAsync({ + organizationId: currentOrg._id, + type, + value + }); + handlePopUpClose("addTaxID"); + } catch (err) { + console.error(err); + } + } + + const handleDeleteTaxIdBtnClick = async (taxId: string) => { + if (!currentOrg?._id) return; + await deleteOrgTaxId.mutateAsync({ + organizationId: currentOrg._id, + taxId + }); + } + + return ( +
+
+

+ Tax ID +

+ +
+ + + + + + + + + + + {!isLoading && data?.length > 0 && data.map(({ + _id, + country, + type, + value + }: { + _id: string; + country: string; + type: string; + value: string; + }) => ( + + + + + + ))} + {isLoading && } + {!isLoading && data?.length === 0 && ( + + + + )} + +
TypeValue
{type}{value} + { + console.log('del'); + await handleDeleteTaxIdBtnClick(_id); + console.log('del done'); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ { + handlePopUpToggle("addTaxID", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/index.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/index.tsx new file mode 100644 index 000000000..359e74012 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/index.tsx @@ -0,0 +1 @@ +export { BillingDetailsTab } from "./BillingDetailsTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx new file mode 100644 index 000000000..fd7d049c0 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/BillingReceiptsTab.tsx @@ -0,0 +1,100 @@ +import { + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + EmptyState +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useGetOrgInvoices +} from "@app/hooks/api"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faDownload, faFileInvoice } from "@fortawesome/free-solid-svg-icons"; + +// TODO: optimize + modularize + +export const BillingReceiptsTab = () => { + const { currentOrg } = useOrganization(); + const { data, isLoading } = useGetOrgInvoices(currentOrg?._id ?? ''); + return ( +
+

Invoices

+ + + + + + + + + + + + + {!isLoading && data?.length > 0 && data.map(({ + _id, + created, + paid, + number, + total, + invoice_pdf + }: { + _id: string; + created: number; + paid: boolean; + number: string; + total: number; + invoice_pdf: string; + }) => { + 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 ( + + + + + + + + ); + })} + {isLoading && } + {!isLoading && data?.length === 0 && ( + + + + )} + +
Invoice #DateStatusAmount
{number}{formattedDate}{paid ? "Paid" : "Not Paid"}{formattedTotal} + window.open(invoice_pdf)} + size="lg" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/index.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/index.tsx new file mode 100644 index 000000000..4f4b60a8c --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingReceiptsTab/index.tsx @@ -0,0 +1 @@ +export { BillingReceiptsTab } from "./BillingReceiptsTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingSelfHostedTab/BillingSelfHostedTab.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingSelfHostedTab/BillingSelfHostedTab.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingSelfHostedTab/index.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingSelfHostedTab/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/index.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/index.tsx new file mode 100644 index 000000000..a14bd1320 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/components/index.tsx @@ -0,0 +1,3 @@ +export { BillingCloudTab } from "./BillingCloudTab"; +export { BillingReceiptsTab } from "./BillingReceiptsTab"; +export { BillingDetailsTab } from "./BillingDetailsTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/BillingSettingsPage/index.tsx b/frontend/src/views/Settings/BillingSettingsPage/index.tsx new file mode 100644 index 000000000..2c4e77101 --- /dev/null +++ b/frontend/src/views/Settings/BillingSettingsPage/index.tsx @@ -0,0 +1 @@ +export { BillingSettingsPage } from "./BillingSettingsPage"; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx index b9a409e0d..d51d31f39 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -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 (