forked from wangziqi/gongxue-base
feat: add usage overage billing
This commit is contained in:
@@ -6,11 +6,13 @@ import {
|
||||
confirmPlatformInvoicePayment,
|
||||
createPlatformInvoiceFromSubscription,
|
||||
createPlatformInvoicesBatchFromSubscriptions,
|
||||
createPlatformInvoicesFromUsageOverage,
|
||||
createPlatformSubscription,
|
||||
loadPlatformInvoiceReminders,
|
||||
loadPlatformInvoices,
|
||||
loadPlatformPlans,
|
||||
loadPlatformSubscriptionInvoiceCandidates,
|
||||
loadPlatformUsageOverageInvoiceCandidates,
|
||||
loadPlatformUsage,
|
||||
processPlatformOverdueInvoices,
|
||||
recordPlatformUsage,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
type PlatformInvoiceItem,
|
||||
type PlatformSaasPlan,
|
||||
type PlatformSubscriptionInvoiceCandidate,
|
||||
type PlatformUsageOverageInvoiceCandidate,
|
||||
type PlatformUsageItem,
|
||||
} from '@/services/platformAdmin';
|
||||
import '../platform.css';
|
||||
@@ -31,6 +34,11 @@ function todayText() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthStartText() {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function centsFromYuan(value: string) {
|
||||
const amount = Number(value || 0);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return 0;
|
||||
@@ -44,6 +52,7 @@ export default function PlatformBillingPage() {
|
||||
const [reminders, setReminders] = useState<PlatformInvoiceReminderItem[]>([]);
|
||||
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
|
||||
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
|
||||
const [overageCandidates, setOverageCandidates] = useState<PlatformUsageOverageInvoiceCandidate[]>([]);
|
||||
const [batchResult, setBatchResult] = useState('');
|
||||
const [subscriptionForm, setSubscriptionForm] = useState({
|
||||
tenantId: '',
|
||||
@@ -74,6 +83,12 @@ export default function PlatformBillingPage() {
|
||||
dueDate: '',
|
||||
note: '',
|
||||
});
|
||||
const [overageForm, setOverageForm] = useState({
|
||||
periodStart: monthStartText(),
|
||||
periodEnd: todayText(),
|
||||
dueDate: '',
|
||||
note: '',
|
||||
});
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -84,7 +99,12 @@ export default function PlatformBillingPage() {
|
||||
loadPlatformInvoiceReminders({ limit: 80 }).catch(() => ({ items: [] })),
|
||||
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
|
||||
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
|
||||
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload]) => {
|
||||
loadPlatformUsageOverageInvoiceCandidates({
|
||||
periodStart: overageForm.periodStart,
|
||||
periodEnd: overageForm.periodEnd,
|
||||
limit: 100,
|
||||
}).catch(() => ({ items: [] })),
|
||||
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload, overagePayload]) => {
|
||||
const nextPlans = planPayload.items || [];
|
||||
setPlans(nextPlans);
|
||||
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
|
||||
@@ -92,6 +112,7 @@ export default function PlatformBillingPage() {
|
||||
setReminders(reminderPayload.items || []);
|
||||
setUsage(usagePayload.items || []);
|
||||
setCandidates(candidatePayload.items || []);
|
||||
setOverageCandidates(overagePayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
|
||||
}
|
||||
|
||||
@@ -127,6 +148,10 @@ export default function PlatformBillingPage() {
|
||||
setBatchInvoiceForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateOverageForm(key: keyof typeof overageForm, value: string) {
|
||||
setOverageForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function confirm(title: string, content: string) {
|
||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||
return result.confirm;
|
||||
@@ -289,6 +314,62 @@ export default function PlatformBillingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsageOverageCandidates() {
|
||||
setError('');
|
||||
if (!overageForm.periodStart || !overageForm.periodEnd) {
|
||||
setError('请填写超额计费账期。');
|
||||
return;
|
||||
}
|
||||
setBusy('overage-candidates');
|
||||
try {
|
||||
const payload = await loadPlatformUsageOverageInvoiceCandidates({
|
||||
periodStart: overageForm.periodStart,
|
||||
periodEnd: overageForm.periodEnd,
|
||||
limit: 100,
|
||||
});
|
||||
setOverageCandidates(payload.items || []);
|
||||
setBatchResult('');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '超额账单候选加载失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUsageOverageInvoices(dryRun: boolean) {
|
||||
setError('');
|
||||
if (!overageForm.periodStart || !overageForm.periodEnd) {
|
||||
setError('请填写超额计费账期。');
|
||||
return;
|
||||
}
|
||||
const ok = dryRun
|
||||
? true
|
||||
: await confirm('生成超额账单', `确认按 ${overageForm.periodStart} 至 ${overageForm.periodEnd} 的后端用量快照生成超额服务费账单?`);
|
||||
if (!ok) return;
|
||||
setBusy(dryRun ? 'overage-dry-run' : 'overage-create');
|
||||
try {
|
||||
const payload = await createPlatformInvoicesFromUsageOverage({
|
||||
periodStart: overageForm.periodStart,
|
||||
periodEnd: overageForm.periodEnd,
|
||||
dueDate: overageForm.dueDate || undefined,
|
||||
note: overageForm.note.trim() || undefined,
|
||||
status: 'issued',
|
||||
dryRun,
|
||||
});
|
||||
const item = payload.item || {};
|
||||
setBatchResult(`${dryRun ? '超额预览' : '超额账单生成'}完成:创建 ${item.createdCount || 0},跳过 ${item.skippedCount || 0},金额 ${money(item.totalCents || 0)}`);
|
||||
setOverageCandidates((item.items || []).map(candidate => ({ ...candidate, wouldCreate: dryRun ? true : candidate.wouldCreate })));
|
||||
if (!dryRun) {
|
||||
Taro.showToast({ title: '已生成', icon: 'success' });
|
||||
reload(status);
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '超额账单处理失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOverdueProcess(dryRun: boolean) {
|
||||
setError('');
|
||||
const ok = dryRun
|
||||
@@ -413,6 +494,34 @@ export default function PlatformBillingPage() {
|
||||
{!candidates.length ? <View className='platform-empty'>暂无即将到期且未开票的订阅。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>用量超额账单</Text>
|
||||
<View className='platform-form compact'>
|
||||
<View className='platform-field'><Text className='platform-field-label'>账期开始</Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={overageForm.periodStart} onInput={event => updateOverageForm('periodStart', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>账期结束</Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={overageForm.periodEnd} onInput={event => updateOverageForm('periodEnd', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>到期日</Text><Input className='platform-input' placeholder='YYYY-MM-DD,可选' value={overageForm.dueDate} onInput={event => updateOverageForm('dueDate', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>备注</Text><Input className='platform-input' placeholder='超额服务费说明,可选' value={overageForm.note} onInput={event => updateOverageForm('note', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button' loading={busy === 'overage-candidates'} onClick={loadUsageOverageCandidates}>刷新超额候选</Button>
|
||||
<Button className='platform-button' loading={busy === 'overage-dry-run'} onClick={() => submitUsageOverageInvoices(true)}>预览超额</Button>
|
||||
<Button className='platform-button primary' loading={busy === 'overage-create'} onClick={() => submitUsageOverageInvoices(false)}>生成超额账单</Button>
|
||||
</View>
|
||||
<View className='platform-list'>
|
||||
{overageCandidates.slice(0, 12).map(item => (
|
||||
<View className='platform-row' key={`${item.tenantId}-${item.periodStart}-${item.periodEnd}`}>
|
||||
<Text className='platform-row-main'>{item.tenantName || item.tenantSlug || item.tenantId}</Text>
|
||||
<Text className='platform-row-meta'>{item.planName || item.planCode || 'plan'} · {item.periodStart || '-'} 至 {item.periodEnd || '-'} · {money(item.totalCents)}</Text>
|
||||
{(item.items || []).map(overage => (
|
||||
<Text className='platform-row-meta' key={`${item.tenantId}-${overage.description}`}>{overage.description || '超额项'} · {String(overage.quantity || 0)} × {money(overage.unitAmountCents)}</Text>
|
||||
))}
|
||||
<Text className='platform-row-meta'>{item.hasExistingInvoice ? `已有超额账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成超额账单' : '可生成超额账单'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!overageCandidates.length ? <View className='platform-empty'>当前账期暂无超过套餐额度的租户。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>逾期与催缴</Text>
|
||||
<View className='platform-actions'>
|
||||
|
||||
@@ -207,6 +207,35 @@ export interface PlatformSubscriptionInvoiceCandidate {
|
||||
wouldCreate?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PlatformUsageOverageItem {
|
||||
itemType?: string | null;
|
||||
description?: string | null;
|
||||
quantity?: number | string | null;
|
||||
unitAmountCents?: number | string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlatformUsageOverageInvoiceCandidate {
|
||||
tenantId: string;
|
||||
tenantSlug?: string | null;
|
||||
tenantName?: string | null;
|
||||
billingStatus?: string | null;
|
||||
subscriptionId?: string | null;
|
||||
planCode?: string | null;
|
||||
planName?: string | null;
|
||||
subscriptionStatus?: string | null;
|
||||
billingCycle?: string | null;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
existingInvoiceId?: string | null;
|
||||
existingInvoiceNo?: string | null;
|
||||
existingInvoiceStatus?: string | null;
|
||||
hasExistingInvoice?: boolean | null;
|
||||
wouldCreate?: boolean | null;
|
||||
totalCents?: number | string | null;
|
||||
items?: PlatformUsageOverageItem[];
|
||||
}
|
||||
|
||||
export interface PlatformQuestionBankItem {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
@@ -459,6 +488,16 @@ export interface CreatePlatformInvoicesBatchFromSubscriptionsInput {
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePlatformInvoicesFromUsageOverageInput {
|
||||
tenantIds?: string[];
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
dueDate?: string;
|
||||
note?: string;
|
||||
status?: 'draft' | 'issued';
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmPlatformInvoicePaymentInput {
|
||||
tenantId: string;
|
||||
invoiceId: string;
|
||||
@@ -695,6 +734,20 @@ export async function loadPlatformSubscriptionInvoiceCandidates(query: { tenantI
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformUsageOverageInvoiceCandidates(query: {
|
||||
tenantIds?: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
includeExisting?: boolean;
|
||||
includeZero?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
return apiRequest<{ items?: PlatformUsageOverageInvoiceCandidate[] }>('/api/platform-admin/invoices/usage-overage-candidates', {
|
||||
query: { ...query, limit: query.limit || 100 },
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformQuestionBanks(query: { q?: string; status?: string; includeTenantBanks?: boolean; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', {
|
||||
query: { status: 'active', ...query, limit: query.limit || 80 },
|
||||
@@ -765,6 +818,23 @@ export async function createPlatformInvoicesBatchFromSubscriptions(input: Create
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatformInvoicesFromUsageOverage(input: CreatePlatformInvoicesFromUsageOverageInput) {
|
||||
return apiRequest<{
|
||||
item?: {
|
||||
dryRun?: boolean;
|
||||
createdCount?: number;
|
||||
skippedCount?: number;
|
||||
totalCents?: number;
|
||||
items?: Array<PlatformUsageOverageInvoiceCandidate & { invoice?: PlatformInvoiceItem }>;
|
||||
skipped?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}>('/api/platform-admin/invoices/from-usage-overage', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user