Show usage and billing for self-hosted instances

This commit is contained in:
carlosmonastyrski
2025-03-21 13:57:45 -03:00
parent 351e573fea
commit 9924ef3a71
7 changed files with 171 additions and 60 deletions
@@ -0,0 +1,24 @@
export const BillingPlanRows = {
MemberLimit: { name: "Organization member limit", field: "memberLimit" },
IdentityLimit: { name: "Organization identity limit", field: "identityLimit" },
WorkspaceLimit: { name: "Project limit", field: "workspaceLimit" },
EnvironmentLimit: { name: "Environment limit", field: "environmentLimit" },
SecretVersioning: { name: "Secret versioning", field: "secretVersioning" },
PitRecovery: { name: "Point in time recovery", field: "pitRecovery" },
Rbac: { name: "RBAC", field: "rbac" },
CustomRateLimits: { name: "Custom rate limits", field: "customRateLimits" },
CustomAlerts: { name: "Custom alerts", field: "customAlerts" },
AuditLogs: { name: "Audit logs", field: "auditLogs" },
SamlSSO: { name: "SAML SSO", field: "samlSSO" },
Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" },
OidcSSO: { name: "OIDC SSO", field: "oidcSSO" },
SecretApproval: { name: "Secret approvals", field: "secretApproval" },
SecretRotation: { name: "Secret rotation", field: "secretRotation" },
InstanceUserManagement: { name: "Instance User Management", field: "instanceUserManagement" },
ExternalKms: { name: "External KMS", field: "externalKms" }
} as const;
export const BillingPlanTableHead = {
Allowed: { name: "Allowed" },
Used: { name: "Used" }
} as const;
@@ -12,10 +12,13 @@ import { getConfig } from "@app/lib/config/env";
import { verifyOfflineLicense } from "@app/lib/crypto"; import { verifyOfflineLicense } from "@app/lib/crypto";
import { NotFoundError } from "@app/lib/errors"; import { NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums";
import { TLicenseDALFactory } from "./license-dal"; import { TLicenseDALFactory } from "./license-dal";
import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns"; import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns";
import { import {
@@ -28,6 +31,7 @@ import {
TFeatureSet, TFeatureSet,
TGetOrgBillInfoDTO, TGetOrgBillInfoDTO,
TGetOrgTaxIdDTO, TGetOrgTaxIdDTO,
TOfflineLicense,
TOfflineLicenseContents, TOfflineLicenseContents,
TOrgInvoiceDTO, TOrgInvoiceDTO,
TOrgLicensesDTO, TOrgLicensesDTO,
@@ -39,10 +43,12 @@ import {
} from "./license-types"; } from "./license-types";
type TLicenseServiceFactoryDep = { type TLicenseServiceFactoryDep = {
orgDAL: Pick<TOrgDALFactory, "findOrgById">; orgDAL: Pick<TOrgDALFactory, "findOrgById" | "countAllOrgMembers">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseDAL: TLicenseDALFactory; licenseDAL: TLicenseDALFactory;
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem">; keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem">;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
projectDAL: TProjectDALFactory;
}; };
export type TLicenseServiceFactory = ReturnType<typeof licenseServiceFactory>; export type TLicenseServiceFactory = ReturnType<typeof licenseServiceFactory>;
@@ -57,11 +63,14 @@ export const licenseServiceFactory = ({
orgDAL, orgDAL,
permissionService, permissionService,
licenseDAL, licenseDAL,
keyStore keyStore,
identityOrgMembershipDAL,
projectDAL
}: TLicenseServiceFactoryDep) => { }: TLicenseServiceFactoryDep) => {
let isValidLicense = false; let isValidLicense = false;
let instanceType = InstanceType.OnPrem; let instanceType = InstanceType.OnPrem;
let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures();
let selfHostedLicense: TOfflineLicense | null = null;
const appCfg = getConfig(); const appCfg = getConfig();
const licenseServerCloudApi = setupLicenseRequestWithStore( const licenseServerCloudApi = setupLicenseRequestWithStore(
@@ -125,6 +134,7 @@ export const licenseServiceFactory = ({
instanceType = InstanceType.EnterpriseOnPremOffline; instanceType = InstanceType.EnterpriseOnPremOffline;
logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`);
isValidLicense = true; isValidLicense = true;
selfHostedLicense = contents.license;
return; return;
} }
} }
@@ -348,10 +358,21 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found` message: `Organization with ID '${orgId}' not found`
}); });
} }
const { data } = await licenseServerCloudApi.request.get( if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` const { data } = await licenseServerCloudApi.request.get(
); `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing`
return data; );
return data;
}
return {
currentPeriodStart: selfHostedLicense?.issuedAt ? Date.parse(selfHostedLicense?.issuedAt) / 1000 : undefined,
currentPeriodEnd: selfHostedLicense?.expiresAt ? Date.parse(selfHostedLicense?.expiresAt) / 1000 : undefined,
interval: "month",
intervalCount: 1,
amount: 0,
quantity: 1
};
}; };
// returns org current plan feature table // returns org current plan feature table
@@ -365,10 +386,41 @@ export const licenseServiceFactory = ({
message: `Organization with ID '${orgId}' not found` message: `Organization with ID '${orgId}' not found`
}); });
} }
const { data } = await licenseServerCloudApi.request.get( if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table` const { data } = await licenseServerCloudApi.request.get(
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`
);
return data;
}
const mappedRows = await Promise.all(
Object.values(BillingPlanRows).map(async ({ name, field }: { name: string; field: string }) => {
const allowed = onPremFeatures[field as keyof TFeatureSet];
let used = "-";
if (field === BillingPlanRows.MemberLimit.field) {
const orgMemberships = await orgDAL.countAllOrgMembers(orgId);
used = orgMemberships.toString();
} else if (field === BillingPlanRows.WorkspaceLimit.field) {
const projects = await projectDAL.find({ orgId });
used = projects.length.toString();
} else if (field === BillingPlanRows.IdentityLimit.field) {
const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId });
used = identities.toString();
}
return {
name,
allowed,
used
};
})
); );
return data;
return {
head: Object.values(BillingPlanTableHead),
rows: mappedRows
};
}; };
const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => {
+8 -1
View File
@@ -413,7 +413,14 @@ export const registerRoutes = async (
serviceTokenDAL, serviceTokenDAL,
projectDAL projectDAL
}); });
const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); const licenseService = licenseServiceFactory({
permissionService,
orgDAL,
licenseDAL,
keyStore,
identityOrgMembershipDAL,
projectDAL
});
const hsmService = hsmServiceFactory({ const hsmService = hsmServiceFactory({
hsmModule, hsmModule,
@@ -12,17 +12,13 @@ export const DefaultSideBar = () => (
</MenuItem> </MenuItem>
)} )}
</Link> </Link>
{(window.location.origin.includes("https://app.infisical.com") || <Link to="/organization/billing">
window.location.origin.includes("https://eu.infisical.com") || {({ isActive }) => (
window.location.origin.includes("https://gamma.infisical.com")) && ( <MenuItem isSelected={isActive} icon="spinning-coin">
<Link to="/organization/billing"> Usage & Billing
{({ isActive }) => ( </MenuItem>
<MenuItem isSelected={isActive} icon="spinning-coin"> )}
Usage & Billing </Link>
</MenuItem>
)}
</Link>
)}
</MenuGroup> </MenuGroup>
<MenuGroup title="Other"> <MenuGroup title="Other">
<Link to="/organization/access-management"> <Link to="/organization/access-management">
@@ -370,17 +370,11 @@ export const MinimizedOrgSidebar = () => {
Gateways Gateways
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
{(window.location.origin.includes("https://app.infisical.com") || <Link to="/organization/billing">
window.location.origin.includes("https://eu.infisical.com") || <DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faMoneyBill} />}>
window.location.origin.includes("https://gamma.infisical.com")) && ( Usage & Billing
<Link to="/organization/billing"> </DropdownMenuItem>
<DropdownMenuItem </Link>
icon={<FontAwesomeIcon className="w-3" icon={faMoneyBill} />}
>
Usage & Billing
</DropdownMenuItem>
</Link>
)}
<Link to="/organization/audit-logs"> <Link to="/organization/audit-logs">
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faBook} />}> <DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faBook} />}>
Audit Logs Audit Logs
@@ -50,10 +50,20 @@ export const PreviewSection = () => {
return slug.replace(/(\b[a-z])/g, (match) => match.toUpperCase()).replace(/-/g, " "); return slug.replace(/(\b[a-z])/g, (match) => match.toUpperCase()).replace(/-/g, " ");
} }
const isCloudInstance =
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com");
const handleUpgradeBtnClick = async () => { const handleUpgradeBtnClick = async () => {
try { try {
if (!subscription || !currentOrg) return; if (!subscription || !currentOrg) return;
if (!isCloudInstance) {
window.open("https://infisical.com/pricing", "_blank");
return;
}
if (!subscription.has_used_trial) { if (!subscription.has_used_trial) {
// direct user to start pro trial // direct user to start pro trial
const url = await getOrgTrialUrl.mutateAsync({ const url = await getOrgTrialUrl.mutateAsync({
@@ -71,6 +81,19 @@ export const PreviewSection = () => {
} }
}; };
const getUpgradePlanLabel = () => {
if (!isCloudInstance) {
return (
<div>
Go to Pricing
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="mb-[0.06rem] ml-1 text-xs" />
</div>
);
}
return !subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan";
};
return ( return (
<div> <div>
{subscription && {subscription &&
@@ -97,7 +120,7 @@ export const PreviewSection = () => {
color="mineshaft" color="mineshaft"
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
{!subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"} {getUpgradePlanLabel()}
</Button> </Button>
)} )}
</OrgPermissionCan> </OrgPermissionCan>
@@ -133,22 +156,24 @@ export const PreviewSection = () => {
subscription.status === "trialing" ? "(Trial)" : "" subscription.status === "trialing" ? "(Trial)" : ""
}`} }`}
</p> </p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}> {isCloudInstance && (
{(isAllowed) => ( <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
<button {(isAllowed) => (
type="button" <button
onClick={async () => { type="button"
if (!currentOrg?.id) return; onClick={async () => {
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg.id); if (!currentOrg?.id) return;
window.location.href = url; const { url } = await createCustomerPortalSession.mutateAsync(currentOrg.id);
}} window.location.href = url;
disabled={!isAllowed} }}
className="text-primary" disabled={!isAllowed}
> className="text-primary"
Manage plan &rarr; >
</button> Manage plan &rarr;
)} </button>
</OrgPermissionCan> )}
</OrgPermissionCan>
)}
</div> </div>
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Price</p> <p className="mb-2 text-gray-400">Price</p>
@@ -161,7 +186,7 @@ export const PreviewSection = () => {
<div className="flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Subscription renews on</p> <p className="mb-2 text-gray-400">Subscription renews on</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50"> <p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{formatDate(data.currentPeriodEnd)} {data.currentPeriodEnd ? formatDate(data.currentPeriodEnd) : "-"}
</p> </p>
</div> </div>
</div> </div>
@@ -16,25 +16,38 @@ const tabs = [
export const BillingTabGroup = withPermission( export const BillingTabGroup = withPermission(
() => { () => {
const isCloudInstance =
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://eu.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com");
const tabsFiltered = isCloudInstance
? tabs
: [{ name: "Infisical Self-Hosted", key: "tab-infisical-cloud" }];
return ( return (
<Tabs defaultValue={tabs[0].key}> <Tabs defaultValue={tabs[0].key}>
<TabList> <TabList>
{tabs.map((tab) => ( {tabsFiltered.map((tab) => (
<Tab value={tab.key}>{tab.name}</Tab> <Tab value={tab.key}>{tab.name}</Tab>
))} ))}
</TabList> </TabList>
<TabPanel value={tabs[0].key}> <TabPanel value={tabs[0].key}>
<BillingCloudTab /> <BillingCloudTab />
</TabPanel> </TabPanel>
<TabPanel value={tabs[1].key}> {isCloudInstance && (
<BillingSelfHostedTab /> <>
</TabPanel> <TabPanel value={tabs[1].key}>
<TabPanel value={tabs[2].key}> <BillingSelfHostedTab />
<BillingReceiptsTab /> </TabPanel>
</TabPanel> <TabPanel value={tabs[2].key}>
<TabPanel value={tabs[3].key}> <BillingReceiptsTab />
<BillingDetailsTab /> </TabPanel>
</TabPanel> <TabPanel value={tabs[3].key}>
<BillingDetailsTab />
</TabPanel>
</>
)}
</Tabs> </Tabs>
); );
}, },