mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(console): add billing history page (#4160)
This commit is contained in:
parent
4c9aef827b
commit
0d2f8edcb9
25 changed files with 277 additions and 12 deletions
|
@ -10,3 +10,5 @@ export type SubscriptionPlanResponse = GuardedResponse<
|
|||
export type Subscription = GuardedResponse<GetRoutes['/api/tenants/:tenantId/subscription']>;
|
||||
|
||||
export type SubscriptionUsage = GuardedResponse<GetRoutes['/api/tenants/:tenantId/usage']>;
|
||||
|
||||
export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId/invoices']>;
|
||||
|
|
|
@ -35,4 +35,5 @@ export enum TenantSettingsTabs {
|
|||
Settings = 'settings',
|
||||
Domains = 'domains',
|
||||
Subscription = 'subscription',
|
||||
BillingHistory = 'billing-history',
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ import RoleUsers from '@/pages/RoleDetails/RoleUsers';
|
|||
import Roles from '@/pages/Roles';
|
||||
import SignInExperience from '@/pages/SignInExperience';
|
||||
import TenantSettings from '@/pages/TenantSettings';
|
||||
import BillingHistory from '@/pages/TenantSettings/BillingHistory';
|
||||
import Subscription from '@/pages/TenantSettings/Subscription';
|
||||
import TenantBasicSettings from '@/pages/TenantSettings/TenantBasicSettings';
|
||||
import TenantDomainSettings from '@/pages/TenantSettings/TenantDomainSettings';
|
||||
|
@ -149,7 +150,10 @@ function ConsoleContent() {
|
|||
<Route path={TenantSettingsTabs.Settings} element={<TenantBasicSettings />} />
|
||||
<Route path={TenantSettingsTabs.Domains} element={<TenantDomainSettings />} />
|
||||
{!isProduction && (
|
||||
<Route path={TenantSettingsTabs.Subscription} element={<Subscription />} />
|
||||
<>
|
||||
<Route path={TenantSettingsTabs.Subscription} element={<Subscription />} />
|
||||
<Route path={TenantSettingsTabs.BillingHistory} element={<BillingHistory />} />
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
)}
|
||||
|
|
25
packages/console/src/hooks/use-invoices.ts
Normal file
25
packages/console/src/hooks/use-invoices.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { type InvoicesResponse } from '@/cloud/types/router';
|
||||
import { isCloud, isProduction } from '@/consts/env';
|
||||
|
||||
const useInvoices = (tenantId: string) => {
|
||||
const cloudApi = useCloudApi();
|
||||
const swrResponse = useSWR<InvoicesResponse, Error>(
|
||||
/**
|
||||
* Todo: @xiaoyijun remove this condition on subscription features ready.
|
||||
*/
|
||||
!isProduction && isCloud && `/api/tenants/${tenantId}/invoices`,
|
||||
async () => cloudApi.get('/api/tenants/:tenantId/invoices', { params: { tenantId } })
|
||||
);
|
||||
|
||||
const { data: invoicesResponse } = swrResponse;
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
data: invoicesResponse?.invoices,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInvoices;
|
|
@ -0,0 +1,47 @@
|
|||
import { type Truthy } from '@silverhand/essentials';
|
||||
import { type TFuncKey } from 'i18next';
|
||||
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import type { Props as TagProps } from '@/ds-components/Tag';
|
||||
import { type InvoiceStatus } from '@/types/subscriptions';
|
||||
|
||||
type Props = {
|
||||
status: InvoiceStatus;
|
||||
};
|
||||
|
||||
type TagStatus = TagProps['status'];
|
||||
|
||||
const tagStatusMap: Record<Exclude<Truthy<InvoiceStatus>, 'draft'>, TagStatus> = {
|
||||
open: 'alert',
|
||||
paid: 'success',
|
||||
uncollectible: 'error',
|
||||
void: 'info',
|
||||
};
|
||||
|
||||
const invoiceStatusPhraseMap: Record<
|
||||
Exclude<Truthy<InvoiceStatus>, 'draft'>,
|
||||
TFuncKey<'translation', 'admin_console.subscription.billing_history.invoice_status'>
|
||||
> = {
|
||||
open: 'open',
|
||||
paid: 'paid',
|
||||
uncollectible: 'uncollectible',
|
||||
void: 'void',
|
||||
};
|
||||
|
||||
function InvoiceStatusTag({ status }: Props) {
|
||||
if (status === 'draft' || !status) {
|
||||
// Don't show tag for draft invoices
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag type="state" status={tagStatusMap[status]}>
|
||||
<DynamicT
|
||||
forKey={`subscription.billing_history.invoice_status.${invoiceStatusPhraseMap[status]}`}
|
||||
/>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export default InvoiceStatusTag;
|
|
@ -0,0 +1,77 @@
|
|||
import { withAppInsights } from '@logto/app-insights/react';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import dayjs from 'dayjs';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
import ItemPreview from '@/components/ItemPreview';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import Table from '@/ds-components/Table';
|
||||
import useInvoices from '@/hooks/use-invoices';
|
||||
import { formatPeriod } from '@/utils/subscription';
|
||||
|
||||
import InvoiceStatusTag from './InvoiceStatusTag';
|
||||
|
||||
function BillingHistory() {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { data: invoices, error } = useInvoices(currentTenantId);
|
||||
const isLoadingInvoices = !invoices && !error;
|
||||
const displayInvoices = useMemo(
|
||||
// Don't show draft invoices
|
||||
() => invoices?.filter(({ status }) => status !== 'draft'),
|
||||
[invoices]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageMeta titleKey={['tenants.tabs.billing_history', 'tenants.title']} />
|
||||
<Table
|
||||
rowGroups={[{ key: 'invoices', data: displayInvoices }]}
|
||||
rowIndexKey="id"
|
||||
columns={[
|
||||
{
|
||||
title: <DynamicT forKey="subscription.billing_history.invoice_column" />,
|
||||
dataIndex: 'planName',
|
||||
render: ({ planName, hostedInvoiceUrl, periodStart, periodEnd }) => {
|
||||
return (
|
||||
<ItemPreview
|
||||
title={formatPeriod({ periodStart, periodEnd, displayYear: true })}
|
||||
subtitle={conditional(planName && <PlanName name={planName} />)}
|
||||
to={conditional(hostedInvoiceUrl)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <DynamicT forKey="subscription.billing_history.status_column" />,
|
||||
dataIndex: 'status',
|
||||
render: ({ status }) => {
|
||||
return <InvoiceStatusTag status={status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <DynamicT forKey="subscription.billing_history.amount_column" />,
|
||||
dataIndex: 'amountPaid',
|
||||
render: ({ amountPaid }) => {
|
||||
return `$${(amountPaid / 100).toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <DynamicT forKey="subscription.billing_history.invoice_created_date_column" />,
|
||||
dataIndex: 'created',
|
||||
render: ({ createdAt }) => {
|
||||
return dayjs(createdAt).format('MMMM DD, YYYY');
|
||||
},
|
||||
},
|
||||
]}
|
||||
isLoading={isLoadingInvoices}
|
||||
placeholder={<EmptyDataPlaceholder />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAppInsights(BillingHistory);
|
|
@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
|||
import { type SubscriptionUsage, type Subscription } from '@/cloud/types/router';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
import { formatPeriod } from '@/utils/subscription';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -14,12 +15,6 @@ type Props = {
|
|||
currentPlan: SubscriptionPlan;
|
||||
};
|
||||
|
||||
const formatPeriod = (start: Date, end: Date) => {
|
||||
const formattedStart = dayjs(start).format('MMM D');
|
||||
const formattedEnd = dayjs(end).format('MMM D');
|
||||
return `${formattedStart} - ${formattedEnd}`;
|
||||
};
|
||||
|
||||
function PlanUsage({ subscriptionUsage, currentSubscription, currentPlan }: Props) {
|
||||
const { currentPeriodStart, currentPeriodEnd } = currentSubscription;
|
||||
const { activeUsers } = subscriptionUsage;
|
||||
|
@ -45,7 +40,10 @@ function PlanUsage({ subscriptionUsage, currentSubscription, currentPlan }: Prop
|
|||
<DynamicT
|
||||
forKey="subscription.plan_cycle"
|
||||
interpolation={{
|
||||
period: formatPeriod(currentPeriodStart, currentPeriodEnd),
|
||||
period: formatPeriod({
|
||||
periodStart: currentPeriodStart,
|
||||
periodEnd: currentPeriodEnd,
|
||||
}),
|
||||
renewDate: dayjs(currentPeriodEnd).add(1, 'day').format('MMM D, YYYY'),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -24,9 +24,14 @@ function TenantSettings() {
|
|||
<DynamicT forKey="tenants.tabs.domains" />
|
||||
</TabNavItem>
|
||||
{!isProduction && (
|
||||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Subscription}`}>
|
||||
<DynamicT forKey="tenants.tabs.subscription" />
|
||||
</TabNavItem>
|
||||
<>
|
||||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Subscription}`}>
|
||||
<DynamicT forKey="tenants.tabs.subscription" />
|
||||
</TabNavItem>
|
||||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.BillingHistory}`}>
|
||||
<DynamicT forKey="tenants.tabs.billing_history" />
|
||||
</TabNavItem>
|
||||
</>
|
||||
)}
|
||||
</TabNav>
|
||||
<Outlet />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||
import { type InvoicesResponse, type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||
|
||||
export enum ReservedPlanName {
|
||||
Free = 'Free',
|
||||
|
@ -59,3 +59,5 @@ export const localCheckoutSessionGuard = z.object({
|
|||
});
|
||||
|
||||
export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>;
|
||||
|
||||
export type InvoiceStatus = InvoicesResponse['invoices'][number]['status'];
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import dayjs from 'dayjs';
|
||||
|
||||
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||
import {
|
||||
communitySupportEnabledMap,
|
||||
|
@ -27,3 +29,15 @@ const getSubscriptionPlanOrderById = (id: string) => {
|
|||
|
||||
export const isDowngradePlan = (fromPlanId: string, toPlanId: string) =>
|
||||
getSubscriptionPlanOrderById(fromPlanId) > getSubscriptionPlanOrderById(toPlanId);
|
||||
|
||||
type FormatPeriodOptions = {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
displayYear?: boolean;
|
||||
};
|
||||
export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeriodOptions) => {
|
||||
const format = displayYear ? 'MMM D, YYYY' : 'MMM D';
|
||||
const formattedStart = dayjs(periodStart).format(format);
|
||||
const formattedEnd = dayjs(periodEnd).format(format);
|
||||
return `${formattedStart} - ${formattedEnd}`;
|
||||
};
|
||||
|
|
|
@ -35,6 +35,12 @@ const subscription = {
|
|||
status_column: 'Status',
|
||||
amount_column: 'Betrag',
|
||||
invoice_created_date_column: 'Rechnungsdatum',
|
||||
invoice_status: {
|
||||
void: 'Storniert',
|
||||
paid: 'Bezahlt',
|
||||
open: 'Offen',
|
||||
uncollectible: 'Überfällig',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -35,6 +35,12 @@ const subscription = {
|
|||
status_column: 'Status',
|
||||
amount_column: 'Amount',
|
||||
invoice_created_date_column: 'Invoice created date',
|
||||
invoice_status: {
|
||||
void: 'Canceled',
|
||||
paid: 'Paid',
|
||||
open: 'Open',
|
||||
uncollectible: 'Overdue',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -38,6 +38,12 @@ const subscription = {
|
|||
status_column: 'estado',
|
||||
amount_column: 'monto',
|
||||
invoice_created_date_column: 'Fecha de creación de la factura',
|
||||
invoice_status: {
|
||||
void: 'Cancelada',
|
||||
paid: 'Pagada',
|
||||
open: 'Abierta',
|
||||
uncollectible: 'Vencida',
|
||||
},
|
||||
},
|
||||
downgrade_modal: {
|
||||
title: '¿Está seguro de que desea degradar?',
|
||||
|
|
|
@ -37,6 +37,12 @@ const subscription = {
|
|||
status_column: 'Statut',
|
||||
amount_column: 'Montant',
|
||||
invoice_created_date_column: 'Date de création de la facture',
|
||||
invoice_status: {
|
||||
void: 'Annulée',
|
||||
paid: 'Payée',
|
||||
open: 'Ouverte',
|
||||
uncollectible: 'En souffrance',
|
||||
},
|
||||
},
|
||||
|
||||
quota_item,
|
||||
|
|
|
@ -36,6 +36,12 @@ const subscription = {
|
|||
status_column: 'Stato',
|
||||
amount_column: 'Importo',
|
||||
invoice_created_date_column: 'Data di creazione fattura',
|
||||
invoice_status: {
|
||||
void: 'Annullata',
|
||||
paid: 'Pagata',
|
||||
open: 'Aperta',
|
||||
uncollectible: 'Scaduta',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -36,6 +36,12 @@ const subscription = {
|
|||
status_column: 'ステータス',
|
||||
amount_column: '金額',
|
||||
invoice_created_date_column: '請求書作成日',
|
||||
invoice_status: {
|
||||
void: 'キャンセル済み',
|
||||
paid: '支払済み',
|
||||
open: '未処理',
|
||||
uncollectible: '延滞',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -35,6 +35,12 @@ const subscription = {
|
|||
status_column: '상태',
|
||||
amount_column: '금액',
|
||||
invoice_created_date_column: '송장 생성 날짜',
|
||||
invoice_status: {
|
||||
void: '취소됨',
|
||||
paid: '지불 완료',
|
||||
open: '미결제',
|
||||
uncollectible: '연체',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -36,6 +36,12 @@ const subscription = {
|
|||
status_column: 'Status',
|
||||
amount_column: 'Kwota',
|
||||
invoice_created_date_column: 'Data utworzenia faktury',
|
||||
invoice_status: {
|
||||
void: 'Anulowana',
|
||||
paid: 'Opłacona',
|
||||
open: 'Otwarta',
|
||||
uncollectible: 'Zaległa',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -35,6 +35,12 @@ const subscription = {
|
|||
status_column: 'Status',
|
||||
amount_column: 'Valor',
|
||||
invoice_created_date_column: 'Data de criação da fatura',
|
||||
invoice_status: {
|
||||
void: 'Cancelada',
|
||||
paid: 'Paga',
|
||||
open: 'Em aberto',
|
||||
uncollectible: 'Vencida',
|
||||
},
|
||||
},
|
||||
|
||||
quota_item,
|
||||
|
|
|
@ -36,6 +36,12 @@ const subscription = {
|
|||
status_column: 'Status',
|
||||
amount_column: 'Valor',
|
||||
invoice_created_date_column: 'Data de criação da fatura',
|
||||
invoice_status: {
|
||||
void: 'Cancelada',
|
||||
paid: 'Paga',
|
||||
open: 'Em aberto',
|
||||
uncollectible: 'Vencida',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -35,6 +35,12 @@ const subscription = {
|
|||
status_column: 'Статус',
|
||||
amount_column: 'Сумма',
|
||||
invoice_created_date_column: 'Дата создания счета',
|
||||
invoice_status: {
|
||||
void: 'Отменено',
|
||||
paid: 'Оплачено',
|
||||
open: 'Открыто',
|
||||
uncollectible: 'Просрочено',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -36,6 +36,12 @@ const subscription = {
|
|||
status_column: 'Durum',
|
||||
amount_column: 'Miktar',
|
||||
invoice_created_date_column: 'Fatura oluşturma tarihi',
|
||||
invoice_status: {
|
||||
void: 'İptal Edildi',
|
||||
paid: 'Ödendi',
|
||||
open: 'Açık',
|
||||
uncollectible: 'Gecikmiş',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -35,6 +35,12 @@ const subscription = {
|
|||
status_column: '状态',
|
||||
amount_column: '金额',
|
||||
invoice_created_date_column: '发票创建日期',
|
||||
invoice_status: {
|
||||
void: '已取消',
|
||||
paid: '已支付',
|
||||
open: '未完成',
|
||||
uncollectible: '逾期未付',
|
||||
},
|
||||
},
|
||||
downgrade_modal: {
|
||||
title: '确认要降级吗?',
|
||||
|
|
|
@ -33,6 +33,12 @@ const subscription = {
|
|||
status_column: '狀態',
|
||||
amount_column: '金額',
|
||||
invoice_created_date_column: '發票創建日期',
|
||||
invoice_status: {
|
||||
void: '已取消',
|
||||
paid: '已付款',
|
||||
open: '未結算',
|
||||
uncollectible: '逾期未付款',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
|
@ -33,6 +33,12 @@ const subscription = {
|
|||
status_column: '狀態',
|
||||
amount_column: '金額',
|
||||
invoice_created_date_column: '發票創建日期',
|
||||
invoice_status: {
|
||||
void: '已取消',
|
||||
paid: '已付款',
|
||||
open: '未完成',
|
||||
uncollectible: '逾期未付款',
|
||||
},
|
||||
},
|
||||
quota_item,
|
||||
downgrade_modal: {
|
||||
|
|
Loading…
Add table
Reference in a new issue