Files
NianAIGC/lib/billing.ts

148 lines
6.3 KiB
TypeScript

import type {
BillingConditionValue,
BillingRuleConditions,
BillingParameterSnapshot,
BillingPriceRule,
BillingQuote,
BillingSelectedParameterTier,
BillingUnit,
GenerationCapability,
GenerationProvider
} from "@/lib/types";
export const BILLING_CURRENCY = "CNY" as const;
export const BILLING_UNIT_OPTIONS: Array<{ value: BillingUnit; label: string }> = [
{ value: "request", label: "按次" },
{ value: "image", label: "按张" },
{ value: "video_second", label: "按秒" }
];
export function calculateBillingAmountFen(standardUnitPriceFen: number, quantity: number, markupMultiplier: number): number {
if (!Number.isFinite(standardUnitPriceFen) || standardUnitPriceFen < 0) throw new Error("标准单价必须是非负金额。");
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("计费数量必须大于 0。");
if (!Number.isFinite(markupMultiplier) || markupMultiplier < 1) throw new Error("上浮倍率不能低于 1.00。");
return Math.max(0, Math.ceil(standardUnitPriceFen * quantity * markupMultiplier));
}
export function quoteFromPriceRule(input: {
rule: BillingPriceRule;
provider: GenerationProvider;
capability: GenerationCapability;
reqKey: string;
quantity: number;
parameters?: BillingParameterSnapshot;
conditions?: BillingRuleConditions;
quantitySource?: BillingPriceRule["quantitySource"];
}): BillingQuote {
const { rule } = input;
if (rule.provider !== input.provider || rule.capability !== input.capability) {
throw new Error("计费规则与生成服务不匹配。");
}
const parameterPricing = resolveBillingParameterPricing(rule, input.parameters);
if (!parameterPricing) throw new Error("当前生成参数没有对应的平台标准价格。");
const standardUnitPriceFen = parameterPricing.standardUnitPriceFen;
const markupMultiplier = parameterPricing.markupMultiplier;
return {
priceRuleId: rule.id,
provider: input.provider,
capability: input.capability,
reqKey: input.reqKey,
variantKey: rule.variantKey,
unit: rule.unit,
quantity: input.quantity,
standardUnitPriceFen,
markupMultiplier,
amountFen: calculateBillingAmountFen(standardUnitPriceFen, input.quantity, markupMultiplier),
currency: BILLING_CURRENCY,
conditions: input.conditions ?? rule.conditions,
quantitySource: input.quantitySource ?? rule.quantitySource,
parameters: input.parameters,
baseStandardUnitPriceFen: rule.parameterDimensions?.length ? rule.standardUnitPriceFen : undefined,
parameterTiers: parameterPricing.parameterTiers.length ? parameterPricing.parameterTiers : undefined,
source: rule.source
};
}
export type BillingParameterPricing = {
standardUnitPriceFen: number;
markupMultiplier: number;
parameterTiers: BillingSelectedParameterTier[];
};
export function resolveBillingParameterPricing(rule: BillingPriceRule, parameters: BillingParameterSnapshot = {}): BillingParameterPricing | null {
const dimensions = rule.parameterDimensions?.filter((dimension) => dimension.tiers.length) || [];
if (!dimensions.length) {
return {
standardUnitPriceFen: rule.standardUnitPriceFen,
markupMultiplier: rule.markupMultiplier,
parameterTiers: []
};
}
let standardFactor = 1;
let markupMultiplier = rule.parameterDimensions?.length ? 1 : rule.markupMultiplier;
const parameterTiers: BillingSelectedParameterTier[] = [];
for (const dimension of dimensions) {
const actualValue = parameters[dimension.key] ?? dimension.defaultValue ?? dimension.baselineValue;
const tier = dimension.tiers.find((candidate) => candidate.enabled && billingConditionMatchesValue(candidate.match ?? candidate.value, actualValue));
if (!tier) return null;
const factor = Number(tier.standardFactor);
const tierMarkup = Number(tier.markupMultiplier);
if (!Number.isFinite(factor) || factor <= 0 || !Number.isFinite(tierMarkup) || tierMarkup < 1) return null;
standardFactor *= factor;
// A combination has one customer-facing uplift. The highest selected
// tier multiplier wins so editing one expensive tier never lowers the
// configured uplift of another selected tier.
markupMultiplier = Math.max(markupMultiplier, tierMarkup);
parameterTiers.push({
...tier,
dimensionKey: dimension.key,
dimensionLabel: dimension.label,
actualValue,
standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * factor)
});
}
return {
standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * standardFactor),
markupMultiplier,
parameterTiers
};
}
export function billingConditionMatchesValue(condition: BillingConditionValue, actual: string | number | boolean): boolean {
if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) {
if (Array.isArray(condition.values) && !condition.values.some((value) => billingScalarEquals(value, actual))) return false;
const numericActual = Number(actual);
if (condition.min !== undefined && (!Number.isFinite(numericActual) || numericActual < condition.min)) return false;
if (condition.max !== undefined && (!Number.isFinite(numericActual) || numericActual > condition.max)) return false;
return true;
}
return billingScalarEquals(condition as string | number | boolean, actual);
}
function billingScalarEquals(left: string | number | boolean, right: string | number | boolean): boolean {
if (typeof left === "number" || typeof right === "number") return Number(left) === Number(right);
if (typeof left === "boolean" || typeof right === "boolean") return Boolean(left) === Boolean(right);
return String(left).trim().toLowerCase() === String(right).trim().toLowerCase();
}
export function formatBillingAmount(fen: number, currency = BILLING_CURRENCY): string {
const amount = Math.max(0, Number(fen || 0)) / 100;
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
export function billingUnitLabel(unit: BillingUnit): string {
return BILLING_UNIT_OPTIONS.find((item) => item.value === unit)?.label || unit;
}
export function billingQuantityLabel(quantity: number, unit: BillingUnit): string {
const value = Number.isInteger(quantity) ? String(quantity) : quantity.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
return `${value}${unit === "video_second" ? " 秒" : unit === "image" ? " 张" : " 次"}`;
}