0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(console, core): refactor console to support new pro plan (#6874)

* refactor(console, core): refactor console to support new pro plan

refactor console to support new pro plan

* fix(console): fix the wrong quota number

fix the wrong quota number

* fix(console): align the util method usage

align the util method usage
This commit is contained in:
simeng-li 2024-12-12 17:23:41 +08:00 committed by GitHub
parent 69986bc179
commit 96fd7ba49f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 167 additions and 96 deletions

View file

@ -10,6 +10,8 @@ import {
freePlanPermissionsLimit, freePlanPermissionsLimit,
freePlanRoleLimit, freePlanRoleLimit,
proPlanAuditLogsRetentionDays, proPlanAuditLogsRetentionDays,
// eslint-disable-next-line unused-imports/no-unused-imports -- for jsdoc usage
featuredPlanIds,
} from '@/consts/subscriptions'; } from '@/consts/subscriptions';
type ContentData = { type ContentData = {
@ -17,6 +19,15 @@ type ContentData = {
readonly isAvailable: boolean; readonly isAvailable: boolean;
}; };
/**
* This hook is used to build the plan content on the SelectTenantPlanModal.
* It is used to display the features of the selected plan.
* Currently, all the feature content is hardcoded.
* For the grandfathered Pro plan and new created Pro202411 plan, the content is the same.
* So we don't need to differentiate them. here.
*
* @param skuId The selected sku id. Can only be one of {@link featuredPlanIds}
*/
const useFeaturedSkuContent = (skuId: string) => { const useFeaturedSkuContent = (skuId: string) => {
const { t } = useTranslation(undefined, { const { t } = useTranslation(undefined, {
keyPrefix: 'admin_console.upsell.featured_plan_content', keyPrefix: 'admin_console.upsell.featured_plan_content',

View file

@ -1,5 +1,5 @@
import { cond } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { useContext, useState } from 'react'; import { useContext, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
@ -33,18 +33,25 @@ function MauExceededModal() {
setHasClosed(true); setHasClosed(true);
}; };
if (hasClosed) { const periodicUsage = useMemo(
return null; () =>
} conditional(
currentTenant && {
mauLimit: currentTenant.usage.activeUsers,
tokenLimit: currentTenant.usage.tokenUsage,
}
),
[currentTenant]
);
const isMauExceeded = cond( const isMauExceeded = conditional(
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
currentTenant && currentTenant &&
currentTenant.quota.mauLimit !== null && currentTenant.quota.mauLimit !== null &&
currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit currentTenant.usage.activeUsers >= currentTenant.quota.mauLimit
); );
if (!isMauExceeded) { if (hasClosed || !isMauExceeded) {
return null; return null;
} }
@ -85,7 +92,7 @@ function MauExceededModal() {
</Trans> </Trans>
</InlineNotification> </InlineNotification>
<FormField title="subscription.plan_usage"> <FormField title="subscription.plan_usage">
<PlanUsage /> <PlanUsage periodicUsage={periodicUsage} />
</FormField> </FormField>
</ModalLayout> </ModalLayout>
</ReactModal> </ReactModal>

View file

@ -1,8 +1,8 @@
import { ReservedPlanId } from '@logto/schemas'; import { ReservedPlanId } from '@logto/schemas';
import { cond, conditional } from '@silverhand/essentials'; import { cond } from '@silverhand/essentials';
import classNames from 'classnames'; import classNames from 'classnames';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useContext, useMemo } from 'react'; import { useContext } from 'react';
import { import {
type NewSubscriptionPeriodicUsage, type NewSubscriptionPeriodicUsage,
@ -10,7 +10,6 @@ import {
type NewSubscriptionQuota, type NewSubscriptionQuota,
} from '@/cloud/types/router'; } from '@/cloud/types/router';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription'; import { formatPeriod, isPaidPlan, isProPlan } from '@/utils/subscription';
@ -26,7 +25,7 @@ import {
} from './utils'; } from './utils';
type Props = { type Props = {
readonly periodicUsage?: NewSubscriptionPeriodicUsage; readonly periodicUsage: NewSubscriptionPeriodicUsage | undefined;
}; };
const getUsageByKey = ( const getUsageByKey = (
@ -58,26 +57,13 @@ const getUsageByKey = (
return countBasedUsage[key]; return countBasedUsage[key];
}; };
function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) { function PlanUsage({ periodicUsage }: Props) {
const { const {
currentSubscriptionQuota, currentSubscriptionQuota,
currentSubscriptionBasicQuota, currentSubscriptionBasicQuota,
currentSubscriptionUsage, currentSubscriptionUsage,
currentSubscription: { currentPeriodStart, currentPeriodEnd, planId, isEnterprisePlan }, currentSubscription: { currentPeriodStart, currentPeriodEnd, planId, isEnterprisePlan },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { currentTenant } = useContext(TenantsContext);
const periodicUsage = useMemo(
() =>
rawPeriodicUsage ??
conditional(
currentTenant && {
mauLimit: currentTenant.usage.activeUsers,
tokenLimit: currentTenant.usage.tokenUsage,
}
),
[currentTenant, rawPeriodicUsage]
);
if (!periodicUsage) { if (!periodicUsage) {
return null; return null;
@ -102,6 +88,7 @@ function PlanUsage({ periodicUsage: rawPeriodicUsage }: Props) {
titleKey: `subscription.usage.${titleKeyMap[key]}`, titleKey: `subscription.usage.${titleKeyMap[key]}`,
unitPrice: usageKeyPriceMap[key], unitPrice: usageKeyPriceMap[key],
...cond( ...cond(
// We only show the usage card for MAU and token for Free plan
(key === 'tokenLimit' || key === 'mauLimit' || isPaidTenant) && { (key === 'tokenLimit' || key === 'mauLimit' || isPaidTenant) && {
quota: currentSubscriptionQuota[key], quota: currentSubscriptionQuota[key],
} }

View file

@ -25,15 +25,25 @@ export const tokenAddOnUnitPrice = 80;
export const hooksAddOnUnitPrice = 2; export const hooksAddOnUnitPrice = 2;
/* === Add-on unit price (in USD) === */ /* === Add-on unit price (in USD) === */
// TODO: Remove this dev feature flag when we have the new Pro202411 plan released.
/** /**
* In console, only featured plans are shown in the plan selection component. * In console, only featured plans are shown in the plan selection component.
* we will this to filter out the public visible featured plans.
*/ */
export const featuredPlanIds: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro]; export const featuredPlanIds: readonly string[] = isDevFeaturesEnabled
? Object.freeze([ReservedPlanId.Free, ReservedPlanId.Pro202411])
: Object.freeze([ReservedPlanId.Free, ReservedPlanId.Pro]);
/** /**
* The order of featured plans in the plan selection content component. * The order of plans in the plan selection content component.
* Unlike the `featuredPlanIds`, include both grandfathered plans and public visible featured plans.
* We need to properly identify the order of the grandfathered plans compared to the new public visible featured plans.
*/ */
export const featuredPlanIdOrder: string[] = [ReservedPlanId.Free, ReservedPlanId.Pro]; export const planIdOrder: Record<string, number> = Object.freeze({
[ReservedPlanId.Free]: 0,
[ReservedPlanId.Pro]: 1,
[ReservedPlanId.Pro202411]: 1,
});
export const checkoutStateQueryKey = 'checkout-state'; export const checkoutStateQueryKey = 'checkout-state';

View file

@ -1,4 +1,3 @@
import { cond } from '@silverhand/essentials';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router'; import { type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
@ -8,7 +7,6 @@ import PlanDescription from '@/components/PlanDescription';
import PlanUsage from '@/components/PlanUsage'; import PlanUsage from '@/components/PlanUsage';
import SkuName from '@/components/SkuName'; import SkuName from '@/components/SkuName';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
import { isPaidPlan } from '@/utils/subscription'; import { isPaidPlan } from '@/utils/subscription';
@ -21,24 +19,11 @@ type Props = {
readonly periodicUsage?: NewSubscriptionPeriodicUsage; readonly periodicUsage?: NewSubscriptionPeriodicUsage;
}; };
function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) { function CurrentPlan({ periodicUsage }: Props) {
const { const {
currentSku: { unitPrice }, currentSku: { unitPrice },
currentSubscription: { upcomingInvoice, isEnterprisePlan, planId }, currentSubscription: { upcomingInvoice, isEnterprisePlan, planId },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { currentTenant } = useContext(TenantsContext);
const periodicUsage = useMemo(
() =>
rawPeriodicUsage ??
cond(
currentTenant && {
mauLimit: currentTenant.usage.activeUsers,
tokenLimit: currentTenant.usage.tokenUsage,
}
),
[currentTenant, rawPeriodicUsage]
);
/** /**
* After the new pricing model goes live, `upcomingInvoice` will always exist. `upcomingInvoice` is updated more frequently than `currentSubscription.upcomingInvoice`. * After the new pricing model goes live, `upcomingInvoice` will always exist. `upcomingInvoice` is updated more frequently than `currentSubscription.upcomingInvoice`.
@ -64,7 +49,7 @@ function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) {
</div> </div>
</div> </div>
<FormField title="subscription.plan_usage"> <FormField title="subscription.plan_usage">
<PlanUsage periodicUsage={rawPeriodicUsage} /> <PlanUsage periodicUsage={periodicUsage} />
</FormField> </FormField>
<FormField title="subscription.next_bill"> <FormField title="subscription.next_bill">
<BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} /> <BillInfo cost={upcomingCost} isManagePaymentVisible={Boolean(upcomingCost)} />
@ -72,10 +57,7 @@ function CurrentPlan({ periodicUsage: rawPeriodicUsage }: Props) {
{isPaidPlan(planId, isEnterprisePlan) && !isEnterprisePlan && ( {isPaidPlan(planId, isEnterprisePlan) && !isEnterprisePlan && (
<AddOnUsageChangesNotification className={styles.notification} /> <AddOnUsageChangesNotification className={styles.notification} />
)} )}
<MauLimitExceedNotification <MauLimitExceedNotification periodicUsage={periodicUsage} className={styles.notification} />
periodicUsage={rawPeriodicUsage}
className={styles.notification}
/>
<PaymentOverdueNotification className={styles.notification} /> <PaymentOverdueNotification className={styles.notification} />
</FormCard> </FormCard>
); );

View file

@ -3,6 +3,7 @@ import { type TFuncKey } from 'i18next';
import { Fragment, useMemo } from 'react'; import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isDevFeaturesEnabled } from '@/consts/env';
import { import {
freePlanAuditLogsRetentionDays, freePlanAuditLogsRetentionDays,
freePlanM2mLimit, freePlanM2mLimit,
@ -63,7 +64,11 @@ function PlanComparisonTable() {
const mauLimitTip = t('mau_tip'); const mauLimitTip = t('mau_tip');
const includedTokens = t('quota.included_tokens'); const includedTokens = t('quota.included_tokens');
const includedTokensTip = t('tokens_tip'); const includedTokensTip = t('tokens_tip');
const proPlanIncludedTokens = t('million', { value: 1 }); const proPlanIncludedTokens = isDevFeaturesEnabled ? '100,000' : t('million', { value: 1 });
const freePlanIncludedTokens = isDevFeaturesEnabled ? '100,000' : '500,000';
const proPlanTokenPrice = isDevFeaturesEnabled
? t('extra_token_price', { value: 0.08, amount: 100 })
: t('extra_token_price', { value: 80, amount: 1_000_000 });
// Applications // Applications
const totalApplications = t('application.total'); const totalApplications = t('application.total');
@ -152,7 +157,11 @@ function PlanComparisonTable() {
}, },
{ {
name: `${includedTokens}|${includedTokensTip}`, name: `${includedTokens}|${includedTokensTip}`,
data: ['500,000', `${proPlanIncludedTokens}`, contact], data: [
`${freePlanIncludedTokens}`,
`${proPlanIncludedTokens}||${proPlanTokenPrice}`,
contact,
],
}, },
], ],
}, },

View file

@ -15,22 +15,75 @@ import Spacer from '@/ds-components/Spacer';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useSubscribe from '@/hooks/use-subscribe'; import useSubscribe from '@/hooks/use-subscribe';
import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent'; import { NotEligibleSwitchSkuModalContent } from '@/pages/TenantSettings/components/NotEligibleSwitchPlanModalContent';
import { isDowngradePlan, parseExceededSkuQuotaLimitError } from '@/utils/subscription'; import {
isDowngradePlan,
isEquivalentPlan,
parseExceededSkuQuotaLimitError,
} from '@/utils/subscription';
import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent'; import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent';
import styles from './index.module.scss'; import styles from './index.module.scss';
type SkuButtonProps = {
readonly targetSkuId: string;
readonly currentSkuId: string;
readonly isCurrentEnterprisePlan: boolean;
readonly loadingSkuId?: string;
readonly onClick: (targetSkuId: string, isDowngrade: boolean) => Promise<void>;
};
function SkuButton({
targetSkuId,
currentSkuId,
isCurrentEnterprisePlan,
loadingSkuId,
onClick,
}: SkuButtonProps) {
const isCurrentSku = currentSkuId === targetSkuId;
const isDowngrade = isDowngradePlan(currentSkuId, targetSkuId);
const isEquivalent = isEquivalentPlan(currentSkuId, targetSkuId);
if (isCurrentEnterprisePlan || isEquivalent) {
return (
<div>
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
<Button title="general.contact_us_action" />
</a>
</div>
);
}
return (
<div>
<Button
title={
isCurrentSku
? 'subscription.current'
: isDowngrade
? 'subscription.downgrade'
: 'subscription.upgrade'
}
type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentSku}
isLoading={!isCurrentSku && loadingSkuId === targetSkuId}
onClick={() => {
void onClick(targetSkuId, isDowngrade);
}}
/>
</div>
);
}
type Props = { type Props = {
readonly currentSkuId: string; readonly currentSkuId: string;
readonly logtoSkus: LogtoSkuResponse[]; readonly logtoSkus: LogtoSkuResponse[];
readonly onSubscriptionUpdated: () => Promise<void>; readonly onSubscriptionUpdated: () => Promise<void>;
}; };
function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: Props) { function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' });
const { currentTenantId } = useContext(TenantsContext); const { currentTenantId } = useContext(TenantsContext);
const { const {
currentSku,
currentSubscription: { isEnterprisePlan }, currentSubscription: { isEnterprisePlan },
} = useContext(SubscriptionDataContext); } = useContext(SubscriptionDataContext);
const { subscribe, cancelSubscription } = useSubscribe(); const { subscribe, cancelSubscription } = useSubscribe();
@ -42,10 +95,9 @@ function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }:
return; return;
} }
const currentSku = logtoSkus.find(({ id }) => id === currentSkuId);
const targetSku = logtoSkus.find(({ id }) => id === targetSkuId); const targetSku = logtoSkus.find(({ id }) => id === targetSkuId);
if (!currentSku || !targetSku) { if (!targetSku) {
return; return;
} }
@ -118,37 +170,20 @@ function SwitchPlanActionBar({ onSubscriptionUpdated, currentSkuId, logtoSkus }:
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Spacer /> <Spacer />
{/** Public reserved plan buttons */}
{logtoSkus.map(({ id: skuId }) => { {logtoSkus.map(({ id: skuId }) => {
const isCurrentSku = currentSkuId === skuId; return (
const isDowngrade = isDowngradePlan(currentSkuId, skuId); <SkuButton
key={skuId}
// Let user contact us when they are currently on Enterprise plan. Do not allow users to self-serve downgrade. targetSkuId={skuId}
return isEnterprisePlan ? ( currentSkuId={currentSkuId}
<div key={skuId}> isCurrentEnterprisePlan={isEnterprisePlan}
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener"> loadingSkuId={currentLoadingSkuId}
<Button title="general.contact_us_action" /> onClick={handleSubscribe}
</a> />
</div>
) : (
<div key={skuId}>
<Button
title={
isCurrentSku
? 'subscription.current'
: isDowngrade
? 'subscription.downgrade'
: 'subscription.upgrade'
}
type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentSku}
isLoading={!isCurrentSku && currentLoadingSkuId === skuId}
onClick={() => {
void handleSubscribe(skuId, isDowngrade);
}}
/>
</div>
); );
})} })}
{/** Enterprise plan button */}
<div> <div>
<a href={contactEmailLink} className={styles.buttonLink} rel="noopener"> <a href={contactEmailLink} className={styles.buttonLink} rel="noopener">
<Button <Button

View file

@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api'; import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
import { type LogtoSkuResponse } from '@/cloud/types/router'; import { type LogtoSkuResponse } from '@/cloud/types/router';
import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas'; import { ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { featuredPlanIdOrder, featuredPlanIds } from '@/consts/subscriptions'; import { featuredPlanIds, planIdOrder } from '@/consts/subscriptions';
import { type LogtoSkuQuota } from '@/types/skus'; import { type LogtoSkuQuota } from '@/types/skus';
const addSupportQuota = (logtoSkuResponse: LogtoSkuResponse) => { const addSupportQuota = (logtoSkuResponse: LogtoSkuResponse) => {
@ -35,25 +35,28 @@ export const formatLogtoSkusResponses = (logtoSkus: LogtoSkuResponse[] | undefin
return []; return [];
} }
return logtoSkus return logtoSkus.map((logtoSku) => addSupportQuota(logtoSku));
.map((logtoSku) => addSupportQuota(logtoSku))
.slice()
.sort(
({ id: previousId }, { id: nextId }) =>
featuredPlanIdOrder.indexOf(previousId) - featuredPlanIdOrder.indexOf(nextId)
);
}; };
const getSubscriptionPlanOrderById = (id: string) => { const getSubscriptionPlanOrderById = (id: string) => {
const index = featuredPlanIdOrder.indexOf(id); const index = planIdOrder[id];
// Note: if the plan id is not in the featuredPlanIdOrder, it will be treated as the highest priority // Note: if the plan id is not in the featuredPlanIdOrder, it will be treated as the highest priority
return index === -1 ? Number.POSITIVE_INFINITY : index; // E.g. enterprise plan.
return index ?? Number.POSITIVE_INFINITY;
}; };
export const isDowngradePlan = (fromPlanId: string, toPlanId: string) => export const isDowngradePlan = (fromPlanId: string, toPlanId: string) =>
getSubscriptionPlanOrderById(fromPlanId) > getSubscriptionPlanOrderById(toPlanId); getSubscriptionPlanOrderById(fromPlanId) > getSubscriptionPlanOrderById(toPlanId);
/**
* Check if the two plan ids are equivalent,
* one is grandfathered and the other is public visible featured plan.
*/
export const isEquivalentPlan = (fromPlanId: string, toPlanId: string) =>
fromPlanId !== toPlanId &&
getSubscriptionPlanOrderById(fromPlanId) === getSubscriptionPlanOrderById(toPlanId);
type FormatPeriodOptions = { type FormatPeriodOptions = {
periodStart: Date; periodStart: Date;
periodEnd: Date; periodEnd: Date;
@ -98,8 +101,18 @@ export const parseExceededSkuQuotaLimitError = async (
return [true, Object.keys(exceededQuota) as Array<keyof LogtoSkuQuota>]; return [true, Object.keys(exceededQuota) as Array<keyof LogtoSkuQuota>];
}; };
/**
* Filter the featured plans (public visible) from the Logto SKUs API response.
* and sorted by the order of {@link planIdOrder}.
*/
export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSkuResponse[] => export const pickupFeaturedLogtoSkus = (logtoSkus: LogtoSkuResponse[]): LogtoSkuResponse[] =>
logtoSkus.filter(({ id }) => featuredPlanIds.includes(id)); logtoSkus
.filter(({ id }) => featuredPlanIds.includes(id))
.slice()
.sort(
({ id: previousId }, { id: nextId }) =>
getSubscriptionPlanOrderById(previousId) - getSubscriptionPlanOrderById(nextId)
);
export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) => export const isPaidPlan = (planId: string, isEnterprisePlan: boolean) =>
isProPlan(planId) || isEnterprisePlan; isProPlan(planId) || isEnterprisePlan;

View file

@ -14,6 +14,7 @@ import { type CloudConnectionLibrary } from './cloud-connection.js';
export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>; export type QuotaLibrary = ReturnType<typeof createQuotaLibrary>;
const paidReservedPlans = new Set<string>([ReservedPlanId.Pro, ReservedPlanId.Pro202411]);
/** /**
* @remarks * @remarks
* Should report usage changes to the Cloud only when the following conditions are met: * Should report usage changes to the Cloud only when the following conditions are met:
@ -25,7 +26,7 @@ const shouldReportSubscriptionUpdates = (
isEnterprisePlan: boolean, isEnterprisePlan: boolean,
key: keyof SubscriptionQuota key: keyof SubscriptionQuota
) => ) =>
(planId === ReservedPlanId.Pro || isEnterprisePlan) && isReportSubscriptionUpdatesUsageKey(key); (paidReservedPlans.has(planId) || isEnterprisePlan) && isReportSubscriptionUpdatesUsageKey(key);
export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => { export const createQuotaLibrary = (cloudConnection: CloudConnectionLibrary) => {
const guardTenantUsageByKey = async (key: keyof SubscriptionUsage) => { const guardTenantUsageByKey = async (key: keyof SubscriptionUsage) => {

View file

@ -101,6 +101,7 @@ const quota_table = {
included: '{{value, number}} مضمن', included: '{{value, number}} مضمن',
included_mao: '{{value, number}} MAO مضمنة', included_mao: '{{value, number}} MAO مضمنة',
extra_quota_price: 'ثم ${{value, number}} شهريًا / لكل واحد بعد ذلك', extra_quota_price: 'ثم ${{value, number}} شهريًا / لكل واحد بعد ذلك',
extra_token_price: 'ثم ${{value, number}} شهريًا / {{amount, number}} بعد ذلك',
per_month_each: '${{value, number}} شهريًا / لكل واحد', per_month_each: '${{value, number}} شهريًا / لكل واحد',
extra_mao_price: 'ثم ${{value, number}} شهريًا لكل MAO', extra_mao_price: 'ثم ${{value, number}} شهريًا لكل MAO',
per_month: '${{value, number}} شهريًا', per_month: '${{value, number}} شهريًا',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} inklusive', included: '{{value, number}} inklusive',
included_mao: '{{value, number}} MAO enthalten', included_mao: '{{value, number}} MAO enthalten',
extra_quota_price: 'Dann ${{value, number}} pro Monat / je danach', extra_quota_price: 'Dann ${{value, number}} pro Monat / je danach',
extra_token_price: 'Dann ${{value, number}} pro Monat / {{amount, number}} danach',
per_month_each: '${{value, number}} pro Monat / je', per_month_each: '${{value, number}} pro Monat / je',
extra_mao_price: 'Dann ${{value, number}} pro MAO', extra_mao_price: 'Dann ${{value, number}} pro MAO',
per_month: '${{value, number}} pro Monat', per_month: '${{value, number}} pro Monat',

View file

@ -104,6 +104,7 @@ const quota_table = {
included: '{{value, number}} included', included: '{{value, number}} included',
included_mao: '{{value, number}} MAO included', included_mao: '{{value, number}} MAO included',
extra_quota_price: 'Then ${{value, number}} per mo / ea after', extra_quota_price: 'Then ${{value, number}} per mo / ea after',
extra_token_price: 'Then ${{value, number}} per mo / {{amount, number}} after',
per_month_each: '${{value, number}} per mo / ea', per_month_each: '${{value, number}} per mo / ea',
extra_mao_price: 'Then ${{value, number}} per MAO', extra_mao_price: 'Then ${{value, number}} per MAO',
per_month: '${{value, number}} per mo', per_month: '${{value, number}} per mo',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'incluido{{value, number}}', included: 'incluido{{value, number}}',
included_mao: '{{value, number}} MAO incluido', included_mao: '{{value, number}} MAO incluido',
extra_quota_price: 'Luego ${{value, number}} por mes / cada uno después', extra_quota_price: 'Luego ${{value, number}} por mes / cada uno después',
extra_token_price: 'Luego ${{value, number}} por mes / {{amount, number}} después',
per_month_each: '${{value, number}} por mes / cada uno', per_month_each: '${{value, number}} por mes / cada uno',
extra_mao_price: 'Luego ${{value, number}} por MAO', extra_mao_price: 'Luego ${{value, number}} por MAO',
per_month: '${{value, number}} por mes', per_month: '${{value, number}} por mes',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} inclus', included: '{{value, number}} inclus',
included_mao: '{{value, number}} MAO inclus', included_mao: '{{value, number}} MAO inclus',
extra_quota_price: 'Ensuite ${{value, number}} par mois / chacun après', extra_quota_price: 'Ensuite ${{value, number}} par mois / chacun après',
extra_token_price: 'Ensuite ${{value, number}} par mois / {{amount, number}} après',
per_month_each: '${{value, number}} par mois / chacun', per_month_each: '${{value, number}} par mois / chacun',
extra_mao_price: 'Ensuite ${{value, number}} par MAO', extra_mao_price: 'Ensuite ${{value, number}} par MAO',
per_month: '${{value, number}} par mois', per_month: '${{value, number}} par mois',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} incluso', included: '{{value, number}} incluso',
included_mao: '{{value, number}} MAO inclusi', included_mao: '{{value, number}} MAO inclusi',
extra_quota_price: 'Quindi ${{value, number}} al mese / ognuno dopo', extra_quota_price: 'Quindi ${{value, number}} al mese / ognuno dopo',
extra_token_price: 'Quindi ${{value, number}} al mese / {{amount, number}} dopo',
per_month_each: '${{value, number}} al mese / ognuno', per_month_each: '${{value, number}} al mese / ognuno',
extra_mao_price: 'Quindi ${{value, number}} per MAO', extra_mao_price: 'Quindi ${{value, number}} per MAO',
per_month: '${{value, number}} al mese', per_month: '${{value, number}} al mese',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} 込み', included: '{{value, number}} 込み',
included_mao: '{{value, number}} MAO込み', included_mao: '{{value, number}} MAO込み',
extra_quota_price: 'その後、各${{value, number}} / 月ごと', extra_quota_price: 'その後、各${{value, number}} / 月ごと',
extra_token_price: 'その後、${{value, number}} / 月ごと {{amount, number}} ごと',
per_month_each: '各${{value, number}} / 月ごと', per_month_each: '各${{value, number}} / 月ごと',
extra_mao_price: 'その後、MAOごとに${{value, number}}', extra_mao_price: 'その後、MAOごとに${{value, number}}',
per_month: '${{value, number}} / 月ごと', per_month: '${{value, number}} / 月ごと',

View file

@ -100,6 +100,7 @@ const quota_table = {
included: '{{value, number}} 포함', included: '{{value, number}} 포함',
included_mao: '{{value, number}} MAO 포함', included_mao: '{{value, number}} MAO 포함',
extra_quota_price: '이후 월당 ${{value, number}} / 각각', extra_quota_price: '이후 월당 ${{value, number}} / 각각',
extra_token_price: '이후 월당 ${{value, number}} / 각각 {{amount, number}} 당',
per_month_each: '월당 ${{value, number}} / 각각', per_month_each: '월당 ${{value, number}} / 각각',
extra_mao_price: '이후 MAO 당 ${{value, number}}', extra_mao_price: '이후 MAO 당 ${{value, number}}',
per_month: '월당 ${{value, number}}', per_month: '월당 ${{value, number}}',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} zawarte', included: '{{value, number}} zawarte',
included_mao: '{{value, number}} MAO wliczone', included_mao: '{{value, number}} MAO wliczone',
extra_quota_price: 'Następnie ${{value, number}} za miesiąc / każdy po', extra_quota_price: 'Następnie ${{value, number}} za miesiąc / każdy po',
extra_token_price: 'Następnie ${{value, number}} za miesiąc / {{amount, number}} po',
per_month_each: '${{value, number}} za miesiąc / każdy', per_month_each: '${{value, number}} za miesiąc / każdy',
extra_mao_price: 'Następnie ${{value, number}} za MAO', extra_mao_price: 'Następnie ${{value, number}} za MAO',
per_month: '${{value, number}} za miesiąc', per_month: '${{value, number}} za miesiąc',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'incluído{{value, number}}', included: 'incluído{{value, number}}',
included_mao: '{{value, number}} MAO incluído', included_mao: '{{value, number}} MAO incluído',
extra_quota_price: 'Então ${{value, number}} por mês / cada depois', extra_quota_price: 'Então ${{value, number}} por mês / cada depois',
extra_token_price: 'Então ${{value, number}} por mês / {{amount, number}} depois',
per_month_each: '${{value, number}} por mês / cada', per_month_each: '${{value, number}} por mês / cada',
extra_mao_price: 'Então ${{value, number}} por MAO', extra_mao_price: 'Então ${{value, number}} por MAO',
per_month: '${{value, number}} por mês', per_month: '${{value, number}} por mês',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'incluído{{value, number}}', included: 'incluído{{value, number}}',
included_mao: '{{value, number}} MAO incluída', included_mao: '{{value, number}} MAO incluída',
extra_quota_price: 'Depois ${{value, number}} por mês / cada um depois', extra_quota_price: 'Depois ${{value, number}} por mês / cada um depois',
extra_token_price: 'Depois ${{value, number}} por mês / {{amount, number}} depois',
per_month_each: '${{value, number}} por mês / cada um', per_month_each: '${{value, number}} por mês / cada um',
extra_mao_price: 'Depois ${{value, number}} por MAO', extra_mao_price: 'Depois ${{value, number}} por MAO',
per_month: '${{value, number}} por mês', per_month: '${{value, number}} por mês',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: 'включено {{value, number}}', included: 'включено {{value, number}}',
included_mao: '{{value, number}} MAO включено', included_mao: '{{value, number}} MAO включено',
extra_quota_price: 'Затем $ {{value, number}} в месяц / за каждый после', extra_quota_price: 'Затем $ {{value, number}} в месяц / за каждый после',
extra_token_price: 'Затем $ {{value, number}} в месяц / {{amount, number}} за каждый после',
per_month_each: '$ {{value, number}} в месяц / за каждый', per_month_each: '$ {{value, number}} в месяц / за каждый',
extra_mao_price: 'Затем $ {{value, number}} за MAO', extra_mao_price: 'Затем $ {{value, number}} за MAO',
per_month: '$ {{value, number}} в месяц', per_month: '$ {{value, number}} в месяц',

View file

@ -102,6 +102,7 @@ const quota_table = {
included: '{{value, number}} dahil', included: '{{value, number}} dahil',
included_mao: '{{value, number}} MAO dahil', included_mao: '{{value, number}} MAO dahil',
extra_quota_price: 'Sonra aylık ${{value, number}} / sonrasında her biri', extra_quota_price: 'Sonra aylık ${{value, number}} / sonrasında her biri',
extra_token_price: 'Sonra aylık ${{value, number}} / {{amount, number}} her biri',
per_month_each: 'Aylık ${{value, number}} / her biri', per_month_each: 'Aylık ${{value, number}} / her biri',
extra_mao_price: 'Sonra MAO başına ${{value, number}}', extra_mao_price: 'Sonra MAO başına ${{value, number}}',
per_month: 'Aylık ${{value, number}}', per_month: 'Aylık ${{value, number}}',

View file

@ -98,6 +98,7 @@ const quota_table = {
included: '已包含{{value, number}}', included: '已包含{{value, number}}',
included_mao: '已包含 {{value, number}} MAO', included_mao: '已包含 {{value, number}} MAO',
extra_quota_price: '然后每月 ${{value, number}} / 每个之后', extra_quota_price: '然后每月 ${{value, number}} / 每个之后',
extra_token_price: '然后每月 ${{value, number}} / 每 {{amount, number}} 之后',
per_month_each: '每月 ${{value, number}} / 每个', per_month_each: '每月 ${{value, number}} / 每个',
extra_mao_price: '然后每 MAO ${{value, number}}', extra_mao_price: '然后每 MAO ${{value, number}}',
per_month: '每月 ${{value, number}}', per_month: '每月 ${{value, number}}',

View file

@ -98,6 +98,7 @@ const quota_table = {
included: '已包含 {{value, number}}', included: '已包含 {{value, number}}',
included_mao: '已包含 {{value, number}} MAO', included_mao: '已包含 {{value, number}} MAO',
extra_quota_price: '然後每月 ${{value, number}} / 每個之後', extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
extra_token_price: '然後每月 ${{value, number}} / 每 {{amount, number}} 之後',
per_month_each: '每月 ${{value, number}} / 每個', per_month_each: '每月 ${{value, number}} / 每個',
extra_mao_price: '然後每 MAO ${{value, number}}', extra_mao_price: '然後每 MAO ${{value, number}}',
per_month: '每月 ${{value, number}}', per_month: '每月 ${{value, number}}',

View file

@ -98,6 +98,7 @@ const quota_table = {
included: '已包含 {{value, number}}', included: '已包含 {{value, number}}',
included_mao: '已包含 {{value, number}} MAO', included_mao: '已包含 {{value, number}} MAO',
extra_quota_price: '然後每月 ${{value, number}} / 每個之後', extra_quota_price: '然後每月 ${{value, number}} / 每個之後',
extra_token_price: '然後每月 ${{value, number}} / 每 {{amount, number}} 之後',
per_month_each: '每月 ${{value, number}} / 每個', per_month_each: '每月 ${{value, number}} / 每個',
extra_mao_price: '然後每 MAO ${{value, number}}', extra_mao_price: '然後每 MAO ${{value, number}}',
per_month: '每月 ${{value, number}}', per_month: '每月 ${{value, number}}',