0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(console): retrieve subscription data from tenant response (#4430)

refactor(console): apply tenant subscription data from tenant response
This commit is contained in:
Xiao Yijun 2023-09-06 10:22:10 +08:00 committed by GitHub
parent b3fc33524e
commit 1453e1a2a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 102 additions and 152 deletions

View file

@ -26,7 +26,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.3.1",
"@logto/cloud": "0.2.5-1795c3d",
"@logto/cloud": "0.2.5-71b7fea",
"@logto/connector-kit": "workspace:^1.1.1",
"@logto/core-kit": "workspace:^2.0.1",
"@logto/language-kit": "workspace:^1.0.0",
@ -60,7 +60,7 @@
"@types/react-helmet": "^6.1.6",
"@types/react-modal": "^3.13.1",
"@types/react-syntax-highlighter": "^15.5.1",
"@withtyped/client": "^0.7.21",
"@withtyped/client": "^0.7.22",
"buffer": "^5.7.1",
"classnames": "^2.3.1",
"clean-deep": "^3.4.0",

View file

@ -1,9 +1,7 @@
import { useContext, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import useSWRImmutable from 'swr/immutable';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import PlanUsage from '@/components/PlanUsage';
import { contactEmailLink } from '@/consts';
import { subscriptionPage } from '@/consts/pages';
@ -21,35 +19,22 @@ import PlanName from '../PlanName';
import * as styles from './index.module.scss';
function MauExceededModal() {
const { currentTenantId } = useContext(TenantsContext);
const cloudApi = useCloudApi();
const { currentTenant } = useContext(TenantsContext);
const { usage, subscription } = currentTenant ?? {};
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
const [hasClosed, setHasClosed] = useState(false);
const [hasClosed, setHasClosed] = useState(false);
const handleCloseModal = () => {
setHasClosed(true);
};
const { data: subscriptionPlans } = useSubscriptionPlans();
const { data: currentSubscription } = useSWRImmutable(
`/api/tenants/${currentTenantId}/subscription`,
async () =>
cloudApi.get('/api/tenants/:tenantId/subscription', { params: { tenantId: currentTenantId } })
);
const currentPlan = subscriptionPlans?.find((plan) => plan.id === subscription?.planId);
const { data: currentUsage } = useSWRImmutable(
`/api/tenants/${currentTenantId}/usage`,
async () =>
cloudApi.get('/api/tenants/:tenantId/usage', { params: { tenantId: currentTenantId } })
);
const currentPlan =
currentSubscription &&
subscriptionPlans?.find((plan) => plan.id === currentSubscription.planId);
if (!currentPlan || !currentUsage || hasClosed) {
if (!subscription || !usage || !currentPlan || hasClosed) {
return null;
}
@ -58,7 +43,7 @@ function MauExceededModal() {
name: planName,
} = currentPlan;
const isMauExceeded = mauLimit !== null && currentUsage.activeUsers >= mauLimit;
const isMauExceeded = mauLimit !== null && usage.activeUsers >= mauLimit;
if (!isMauExceeded) {
return null;
@ -102,8 +87,8 @@ function MauExceededModal() {
</InlineNotification>
<FormField title="subscription.plan_usage">
<PlanUsage
subscriptionUsage={currentUsage}
currentSubscription={currentSubscription}
subscriptionUsage={usage}
currentSubscription={subscription}
currentPlan={currentPlan}
/>
</FormField>

View file

@ -1,11 +1,9 @@
import { conditional } from '@silverhand/essentials';
import { useContext, useMemo, useState } from 'react';
import { useContext, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import useSWRImmutable from 'swr/immutable';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { contactEmailLink } from '@/consts';
import { isCloud } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
@ -13,7 +11,6 @@ import InlineNotification from '@/ds-components/InlineNotification';
import ModalLayout from '@/ds-components/ModalLayout';
import useSubscribe from '@/hooks/use-subscribe';
import * as modalStyles from '@/scss/modal.module.scss';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
import BillInfo from '../BillInfo';
@ -22,26 +19,17 @@ import * as styles from './index.module.scss';
function PaymentOverdueModal() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentTenant, currentTenantId } = useContext(TenantsContext);
const cloudApi = useCloudApi();
const { data: invoicesResponse } = useSWRImmutable(
`/api/tenants/${currentTenantId}/invoices`,
async () =>
cloudApi.get('/api/tenants/:tenantId/invoices', { params: { tenantId: currentTenantId } })
);
const { openInvoices = [] } = currentTenant ?? {};
const { visitManagePaymentPage } = useSubscribe();
const [isActionLoading, setIsActionLoading] = useState(false);
const latestUnpaidInvoice = useMemo(
() => conditional(invoicesResponse && getLatestUnpaidInvoice(invoicesResponse.invoices)),
[invoicesResponse]
);
const [hasClosed, setHasClosed] = useState(false);
const handleCloseModal = () => {
setHasClosed(true);
};
if (!invoicesResponse || !latestUnpaidInvoice || hasClosed) {
if (!isCloud || openInvoices.length === 0 || hasClosed) {
return null;
}
@ -82,7 +70,12 @@ function PaymentOverdueModal() {
</InlineNotification>
)}
<FormField title="upsell.payment_overdue_modal.unpaid_bills">
<BillInfo cost={latestUnpaidInvoice.amountDue} />
<BillInfo
cost={openInvoices.reduce(
(total, currentInvoice) => total + currentInvoice.amountDue,
0
)}
/>
</FormField>
</ModalLayout>
</ReactModal>

View file

@ -1,35 +1,16 @@
import { conditional } from '@silverhand/essentials';
import { useMemo } from 'react';
import { type TenantResponse } from '@/cloud/types/router';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
import useInvoices from '@/hooks/use-invoices';
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
import useSubscriptionUsage from '@/hooks/use-subscription-usage';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
import { type SubscriptionPlan } from '@/types/subscriptions';
type Props = {
tenantId: string;
tenantData: TenantResponse;
tenantPlan: SubscriptionPlan;
className?: string;
};
function TenantStatusTag({ tenantId, className }: Props) {
const { data: usage, error: fetchUsageError } = useSubscriptionUsage(tenantId);
const { data: invoices, error: fetchInvoiceError } = useInvoices(tenantId);
const { data: subscriptionPlan, error: fetchSubscriptionError } = useSubscriptionPlan(tenantId);
const isLoadingUsage = !usage && !fetchUsageError;
const isLoadingInvoice = !invoices && !fetchInvoiceError;
const isLoadingSubscription = !subscriptionPlan && !fetchSubscriptionError;
const latestUnpaidInvoice = useMemo(
() => conditional(invoices && getLatestUnpaidInvoice(invoices)),
[invoices]
);
if (isLoadingUsage || isLoadingInvoice || isLoadingSubscription) {
return null;
}
function TenantStatusTag({ tenantData, tenantPlan, className }: Props) {
const { usage, openInvoices } = tenantData;
/**
* Tenant status priority:
@ -38,7 +19,7 @@ function TenantStatusTag({ tenantId, className }: Props) {
* 3. mau exceeded
*/
if (invoices && latestUnpaidInvoice) {
if (openInvoices.length > 0) {
return (
<Tag className={className}>
<DynamicT forKey="tenants.status.overdue" />
@ -46,12 +27,11 @@ function TenantStatusTag({ tenantId, className }: Props) {
);
}
if (subscriptionPlan && usage) {
const { activeUsers } = usage;
const {
quota: { mauLimit },
} = subscriptionPlan;
} = tenantPlan;
const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;
@ -62,7 +42,6 @@ function TenantStatusTag({ tenantId, className }: Props) {
</Tag>
);
}
}
return null;
}

View file

@ -1,10 +1,11 @@
import classNames from 'classnames';
import { useMemo } from 'react';
import Tick from '@/assets/icons/tick.svg';
import { type TenantResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName';
import { DropdownItem } from '@/ds-components/Dropdown';
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
import TenantEnvTag from '../TenantEnvTag';
@ -18,8 +19,18 @@ type Props = {
};
function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
const { id, name, tag } = tenantData;
const { data: tenantPlan } = useSubscriptionPlan(id);
const {
name,
tag,
subscription: { planId },
} = tenantData;
const { data: plans } = useSubscriptionPlans();
const tenantPlan = useMemo(() => plans?.find((plan) => plan.id === planId), [plans, planId]);
if (!tenantPlan) {
return null;
}
return (
<DropdownItem className={styles.item} onClick={onClick}>
@ -27,9 +38,15 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
<div className={styles.meta}>
<div className={styles.name}>{name}</div>
<TenantEnvTag tag={tag} />
<TenantStatusTag tenantId={id} className={styles.statusTag} />
<TenantStatusTag
tenantData={tenantData}
tenantPlan={tenantPlan}
className={styles.statusTag}
/>
</div>
<div className={styles.planName}>
<PlanName name={tenantPlan.name} />
</div>
<div className={styles.planName}>{tenantPlan && <PlanName name={tenantPlan.name} />}</div>
</div>
<Tick className={classNames(styles.checkIcon, isSelected && styles.visible)} />
</DropdownItem>

View file

@ -1,6 +1,7 @@
import { defaultManagementApi, defaultTenantId } from '@logto/schemas';
import { TenantTag } from '@logto/schemas/models';
import { conditionalArray, noop } from '@silverhand/essentials';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { useCallback, useMemo, createContext, useState } from 'react';
import { useMatch, useNavigate } from 'react-router-dom';
@ -65,17 +66,27 @@ const { tenantId, indicator } = defaultManagementApi.resource;
* - For cloud, the initial tenants data is empty, and it will be fetched from the cloud API.
* - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants.
*/
const initialTenants = Object.freeze(
conditionalArray(
!isCloud && {
const defaultTenantResponse: TenantResponse = {
id: tenantId,
name: `tenant_${tenantId}`,
tag: TenantTag.Development,
indicator,
planId: `${ReservedPlanId.free}`, // `planId` is string type.
}
)
);
subscription: {
status: 'active',
planId: ReservedPlanId.free,
currentPeriodStart: dayjs().toDate(),
currentPeriodEnd: dayjs().add(1, 'month').toDate(),
},
usage: {
activeUsers: 0,
cost: 0,
},
openInvoices: [],
isSuspended: false,
planId: ReservedPlanId.free, // Reserved for compatibility with cloud
};
const initialTenants = Object.freeze(conditionalArray(!isCloud && defaultTenantResponse));
export const TenantsContext = createContext<Tenants>({
tenants: initialTenants,

View file

@ -1,32 +1,29 @@
import { conditional } from '@silverhand/essentials';
import { useContext, useMemo, useState } from 'react';
import { useContext, useState } from 'react';
import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT';
import InlineNotification from '@/ds-components/InlineNotification';
import useInvoices from '@/hooks/use-invoices';
import useSubscribe from '@/hooks/use-subscribe';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
type Props = {
className?: string;
};
function PaymentOverdueNotification({ className }: Props) {
const { currentTenantId } = useContext(TenantsContext);
const { currentTenant, currentTenantId } = useContext(TenantsContext);
const { openInvoices = [] } = currentTenant ?? {};
const { visitManagePaymentPage } = useSubscribe();
const [isActionLoading, setIsActionLoading] = useState(false);
const { data: invoices, error } = useInvoices(currentTenantId);
const isLoadingInvoices = !invoices && !error;
const latestUnpaidInvoice = useMemo(
() => conditional(invoices && getLatestUnpaidInvoice(invoices)),
[invoices]
);
if (isLoadingInvoices || !latestUnpaidInvoice) {
if (openInvoices.length === 0) {
return null;
}
const totalAmountDue = openInvoices.reduce(
(total, currentInvoice) => total + currentInvoice.amountDue,
0
);
return (
<InlineNotification
severity="error"
@ -41,7 +38,7 @@ function PaymentOverdueNotification({ className }: Props) {
>
<DynamicT
forKey="subscription.payment_error"
interpolation={{ price: latestUnpaidInvoice.amountDue / 100 }}
interpolation={{ price: totalAmountDue / 100 }}
/>
</InlineNotification>
);

View file

@ -76,6 +76,4 @@ export const localCheckoutSessionGuard = z.object({
export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>;
export type Invoice = InvoicesResponse['invoices'][number];
export type InvoiceStatus = Invoice['status'];
export type InvoiceStatus = InvoicesResponse['invoices'][number]['status'];

View file

@ -5,7 +5,6 @@ import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { reservedPlanIdOrder } from '@/consts/subscriptions';
import { type Invoice } from '@/types/subscriptions';
export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => {
const { id, quota } = subscriptionPlanResponse;
@ -43,15 +42,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri
return `${formattedStart} - ${formattedEnd}`;
};
export const getLatestUnpaidInvoice = (invoices: Invoice[]) =>
invoices
.slice()
.sort(
(invoiceA, invoiceB) =>
new Date(invoiceB.createdAt).getTime() - new Date(invoiceA.createdAt).getTime()
)
.find(({ status }) => status === 'uncollectible');
/**
* Note: this is a temporary solution to handle the case when the user tries to downgrade but the quota limit is exceeded.
* Need a better solution to handle this case by sharing the error type between the console and cloud. - LOG-6608

View file

@ -2834,8 +2834,8 @@ importers:
specifier: workspace:^1.3.1
version: link:../app-insights
'@logto/cloud':
specifier: 0.2.5-1795c3d
version: 0.2.5-1795c3d(zod@3.20.2)
specifier: 0.2.5-71b7fea
version: 0.2.5-71b7fea(zod@3.20.2)
'@logto/connector-kit':
specifier: workspace:^1.1.1
version: link:../toolkit/connector-kit
@ -2936,8 +2936,8 @@ importers:
specifier: ^15.5.1
version: 15.5.1
'@withtyped/client':
specifier: ^0.7.21
version: 0.7.21(zod@3.20.2)
specifier: ^0.7.22
version: 0.7.22(zod@3.20.2)
buffer:
specifier: ^5.7.1
version: 5.7.1
@ -7330,12 +7330,12 @@ packages:
jose: 4.14.4
dev: true
/@logto/cloud@0.2.5-1795c3d(zod@3.20.2):
resolution: {integrity: sha512-zxy9zr5swOxbzYJNYtKXofj2tSIS9565d+1pT6RSbmx3Hn+JG6uzsb75PZXW9vlmmm7p1sGZeTQ+xVzKNFPsMg==}
/@logto/cloud@0.2.5-71b7fea(zod@3.20.2):
resolution: {integrity: sha512-howllmEV6kWAgusP+2OSloG5bZQ146UiKn0PpA7xi9HcpgM6Fd1NPuNjc3BZdInJ5Qn0en6LOZL7c2EwTRx3jw==}
engines: {node: ^18.12.0}
dependencies:
'@silverhand/essentials': 2.8.4
'@withtyped/server': 0.12.8(zod@3.20.2)
'@withtyped/server': 0.12.9(zod@3.20.2)
transitivePeerDependencies:
- zod
dev: true
@ -10048,15 +10048,6 @@ packages:
eslint-visitor-keys: 3.4.1
dev: true
/@withtyped/client@0.7.21(zod@3.20.2):
resolution: {integrity: sha512-N9dvH5nqIwaT7YxaIm83RRQf9AEjxwJ4ugJviZJSxtWy8zLul2/odEMc6epieylFVa6CcLg82yJmRSlqPtJiTw==}
dependencies:
'@withtyped/server': 0.12.8(zod@3.20.2)
'@withtyped/shared': 0.2.2
transitivePeerDependencies:
- zod
dev: true
/@withtyped/client@0.7.22(zod@3.20.2):
resolution: {integrity: sha512-emNtcO0jc0dFWhvL7eUIRYzhTfn+JqgIvCmXb8ZUFOR8wdSSGrr9VDlm+wgQD06DEBBpmqtTHMMHTNXJdUC/Qw==}
dependencies:
@ -10064,17 +10055,6 @@ packages:
'@withtyped/shared': 0.2.2
transitivePeerDependencies:
- zod
dev: false
/@withtyped/server@0.12.8(zod@3.20.2):
resolution: {integrity: sha512-fv9feTOKJhtlaoYM/Kbs2gSTcIXlmu4OMUFwGmK5jqdbVNIOkDBIPxtcC5ZEwevWFgOcd5OqBW+FvbjiaF27Fw==}
peerDependencies:
zod: ^3.19.1
dependencies:
'@silverhand/essentials': 2.8.4
'@withtyped/shared': 0.2.2
zod: 3.20.2
dev: true
/@withtyped/server@0.12.9(zod@3.20.2):
resolution: {integrity: sha512-K5zoV9D+WpawbghtlJKF1KOshKkBjq+gYzNRWuZk13YmFWFLcmZn+QCblNP55z9IGdcHWpTRknqb1APuicdzgA==}