170 lines
4.8 KiB
TypeScript
170 lines
4.8 KiB
TypeScript
export const BILLING_STATUS_VALUES = ["paid", "partial", "pending", "overdue"] as const;
|
|
export type BillingStatus = (typeof BILLING_STATUS_VALUES)[number];
|
|
|
|
export const BILLING_STATUS_LABELS: Record<BillingStatus, string> = {
|
|
paid: "已结清",
|
|
partial: "部分收款",
|
|
pending: "待收款",
|
|
overdue: "已逾期",
|
|
};
|
|
|
|
export const PAYMENT_METHOD_VALUES = ["wechat", "alipay", "bank_transfer", "card", "cash"] as const;
|
|
export type PaymentMethod = (typeof PAYMENT_METHOD_VALUES)[number];
|
|
|
|
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
|
|
wechat: "微信支付",
|
|
alipay: "支付宝",
|
|
bank_transfer: "银行转账",
|
|
card: "银行卡",
|
|
cash: "现金",
|
|
};
|
|
|
|
export const INVOICE_STATUS_VALUES = ["not_requested", "pending", "issued"] as const;
|
|
export type InvoiceStatus = (typeof INVOICE_STATUS_VALUES)[number];
|
|
|
|
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
|
not_requested: "未开票",
|
|
pending: "待开票",
|
|
issued: "已开票",
|
|
};
|
|
|
|
export type BillingCharge = {
|
|
id: string;
|
|
category: string;
|
|
description: string;
|
|
quantity: number;
|
|
serviceDate: string;
|
|
unitPriceCents: number;
|
|
};
|
|
|
|
export type BillingPayment = {
|
|
id: string;
|
|
amountCents: number;
|
|
method: PaymentMethod;
|
|
paidAt: string;
|
|
reference: string;
|
|
operator: string;
|
|
note: string;
|
|
};
|
|
|
|
export type BillingStatement = {
|
|
id: string;
|
|
elderId: string;
|
|
elderName: string;
|
|
roomLabel: string;
|
|
contactName: string;
|
|
contactPhone: string;
|
|
period: string;
|
|
dueAt: string;
|
|
charges: BillingCharge[];
|
|
payments: BillingPayment[];
|
|
invoiceStatus: InvoiceStatus;
|
|
invoiceNumber?: string;
|
|
receiptCount: number;
|
|
};
|
|
|
|
export type BillingStatementView = BillingStatement & {
|
|
totalCents: number;
|
|
paidCents: number;
|
|
outstandingCents: number;
|
|
status: BillingStatus;
|
|
};
|
|
|
|
export type BillingSummary = {
|
|
receivableCents: number;
|
|
collectedCents: number;
|
|
outstandingCents: number;
|
|
overdueCount: number;
|
|
};
|
|
|
|
export function getChargeAmountCents(charge: BillingCharge): number {
|
|
return Math.round(charge.quantity * charge.unitPriceCents);
|
|
}
|
|
|
|
export function getStatementTotalCents(statement: BillingStatement): number {
|
|
return statement.charges.reduce((total, charge) => total + getChargeAmountCents(charge), 0);
|
|
}
|
|
|
|
export function getStatementPaidCents(statement: BillingStatement): number {
|
|
return statement.payments.reduce((total, payment) => total + payment.amountCents, 0);
|
|
}
|
|
|
|
export function getStatementOutstandingCents(statement: BillingStatement): number {
|
|
return Math.max(0, getStatementTotalCents(statement) - getStatementPaidCents(statement));
|
|
}
|
|
|
|
export function getBillingStatus(statement: BillingStatement, referenceDate = new Date()): BillingStatus {
|
|
const outstandingCents = getStatementOutstandingCents(statement);
|
|
if (outstandingCents === 0) {
|
|
return "paid";
|
|
}
|
|
|
|
if (new Date(statement.dueAt).getTime() < referenceDate.getTime()) {
|
|
return "overdue";
|
|
}
|
|
|
|
return statement.payments.length > 0 ? "partial" : "pending";
|
|
}
|
|
|
|
export function toBillingStatementView(
|
|
statement: BillingStatement,
|
|
referenceDate = new Date(),
|
|
): BillingStatementView {
|
|
const totalCents = getStatementTotalCents(statement);
|
|
const paidCents = getStatementPaidCents(statement);
|
|
return {
|
|
...statement,
|
|
totalCents,
|
|
paidCents,
|
|
outstandingCents: Math.max(0, totalCents - paidCents),
|
|
status: getBillingStatus(statement, referenceDate),
|
|
};
|
|
}
|
|
|
|
export function calculateBillingSummary(
|
|
statements: readonly BillingStatement[],
|
|
referenceDate = new Date(),
|
|
): BillingSummary {
|
|
return statements.reduce<BillingSummary>(
|
|
(summary, statement) => {
|
|
const view = toBillingStatementView(statement, referenceDate);
|
|
return {
|
|
receivableCents: summary.receivableCents + view.totalCents,
|
|
collectedCents: summary.collectedCents + view.paidCents,
|
|
outstandingCents: summary.outstandingCents + view.outstandingCents,
|
|
overdueCount: summary.overdueCount + (view.status === "overdue" ? 1 : 0),
|
|
};
|
|
},
|
|
{ receivableCents: 0, collectedCents: 0, outstandingCents: 0, overdueCount: 0 },
|
|
);
|
|
}
|
|
|
|
export function addBillingPayment(statement: BillingStatement, payment: BillingPayment): BillingStatement {
|
|
return {
|
|
...statement,
|
|
payments: [payment, ...statement.payments],
|
|
};
|
|
}
|
|
|
|
export function addBillingCharge(statement: BillingStatement, charge: BillingCharge): BillingStatement {
|
|
return {
|
|
...statement,
|
|
charges: [...statement.charges, charge],
|
|
invoiceStatus: "not_requested",
|
|
invoiceNumber: undefined,
|
|
};
|
|
}
|
|
|
|
export function formatCny(cents: number): string {
|
|
return new Intl.NumberFormat("zh-CN", {
|
|
style: "currency",
|
|
currency: "CNY",
|
|
minimumFractionDigits: 2,
|
|
}).format(cents / 100);
|
|
}
|
|
|
|
export function formatBillingPeriod(period: string): string {
|
|
const [year, month] = period.split("-");
|
|
return year && month ? `${year}年${month}月` : period;
|
|
}
|