0
Fork 0
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:
Xiao Yijun 2023-07-21 14:31:25 +08:00 committed by GitHub
parent 4c9aef827b
commit 0d2f8edcb9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 277 additions and 12 deletions

View file

@ -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']>;

View file

@ -35,4 +35,5 @@ export enum TenantSettingsTabs {
Settings = 'settings',
Domains = 'domains',
Subscription = 'subscription',
BillingHistory = 'billing-history',
}

View file

@ -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>
)}

View 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;

View file

@ -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;

View file

@ -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);

View file

@ -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'),
}}
/>

View file

@ -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 />

View file

@ -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'];

View file

@ -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}`;
};

View file

@ -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: {

View file

@ -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: {

View file

@ -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?',

View file

@ -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,

View file

@ -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: {

View file

@ -36,6 +36,12 @@ const subscription = {
status_column: 'ステータス',
amount_column: '金額',
invoice_created_date_column: '請求書作成日',
invoice_status: {
void: 'キャンセル済み',
paid: '支払済み',
open: '未処理',
uncollectible: '延滞',
},
},
quota_item,
downgrade_modal: {

View file

@ -35,6 +35,12 @@ const subscription = {
status_column: '상태',
amount_column: '금액',
invoice_created_date_column: '송장 생성 날짜',
invoice_status: {
void: '취소됨',
paid: '지불 완료',
open: '미결제',
uncollectible: '연체',
},
},
quota_item,
downgrade_modal: {

View file

@ -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: {

View file

@ -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,

View file

@ -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: {

View file

@ -35,6 +35,12 @@ const subscription = {
status_column: 'Статус',
amount_column: 'Сумма',
invoice_created_date_column: 'Дата создания счета',
invoice_status: {
void: 'Отменено',
paid: 'Оплачено',
open: 'Открыто',
uncollectible: 'Просрочено',
},
},
quota_item,
downgrade_modal: {

View file

@ -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: {

View file

@ -35,6 +35,12 @@ const subscription = {
status_column: '状态',
amount_column: '金额',
invoice_created_date_column: '发票创建日期',
invoice_status: {
void: '已取消',
paid: '已支付',
open: '未完成',
uncollectible: '逾期未付',
},
},
downgrade_modal: {
title: '确认要降级吗?',

View file

@ -33,6 +33,12 @@ const subscription = {
status_column: '狀態',
amount_column: '金額',
invoice_created_date_column: '發票創建日期',
invoice_status: {
void: '已取消',
paid: '已付款',
open: '未結算',
uncollectible: '逾期未付款',
},
},
quota_item,
downgrade_modal: {

View file

@ -33,6 +33,12 @@ const subscription = {
status_column: '狀態',
amount_column: '金額',
invoice_created_date_column: '發票創建日期',
invoice_status: {
void: '已取消',
paid: '已付款',
open: '未完成',
uncollectible: '逾期未付款',
},
},
quota_item,
downgrade_modal: {