import { quoteFromPriceRule, resolveBillingParameterPricing } from "@/lib/billing"; import { getOrganizationWallet, InsufficientBalanceError, listBillingPriceRules, listBillingLedgerEntries, postWalletEntry } from "@/lib/server/billing-store"; import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog"; import { calculateSeedanceActualAmountFen, estimateSeedanceAmountFen, requestHasInputVideo, requestInputVideoDurationSeconds, seedanceTokenPriceFenPerMillion } from "@/lib/server/seedance-billing"; import { getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; import type { BillingAccountConfig, BillingConditionValue, BillingJobCharge, BillingParameterSnapshot, BillingPriceRule, BillingQuantitySource, BillingRuleConditions, BillingScalar, GenerationCapability, GenerationJob, GenerationProvider, UsageContext } from "@/lib/types"; export class BillingConfigurationError extends Error { status = 503; constructor(message: string) { super(message); this.name = "BillingConfigurationError"; } } export type BillingGenerationInput = { provider: GenerationProvider; capability: GenerationCapability; reqKey: string; requestPayload: Record; usageContext?: UsageContext; externalClientId?: string; allowUnboundOrganization?: boolean; }; export async function quoteGenerationCharge(input: BillingGenerationInput): Promise { if (!billingEnabled() || input.provider === "mock") return undefined; const quotaExempt = isQuotaExemptUsageContext(input.usageContext); if (!input.usageContext?.organizationId && !quotaExempt) { if (input.externalClientId || input.usageContext?.source === "api") return undefined; if (!input.allowUnboundOrganization) { throw new BillingConfigurationError("当前账号未绑定组织,暂时无法提交计费生成任务。"); } } await ensureDefaultBillingPriceRules(); const rule = await findMatchingPriceRule(input); if (!rule) { throw new BillingConfigurationError(`尚未配置 ${input.provider} / ${input.capability} 的计费规则,请联系超级管理员。`); } const parameters = normalizeBillingParameters(input.requestPayload); if (!resolveBillingParameterPricing(rule, parameters)) { throw new BillingConfigurationError(`尚未配置 ${input.provider} / ${input.capability} 当前参数组合的标准价格,请联系超级管理员。`); } const quantity = quantityForRule(rule, parameters); const quote: BillingJobCharge = { ...quoteFromPriceRule({ rule, provider: input.provider, capability: input.capability, reqKey: input.reqKey, quantity, parameters, conditions: effectiveBillingRuleConditions(rule) }), status: "pending", quotaExempt: quotaExempt || undefined }; if (input.provider !== "seedance" || input.reqKey !== "doubao-seedance-2-0-260128") { return quote; } const inputVideo = requestHasInputVideo(input.requestPayload); const estimatedAmountFen = estimateSeedanceAmountFen({ resolution: parameters.resolution, aspectRatio: parameters.aspectRatio, outputDurationSeconds: quantity, inputVideo, inputVideoDurationSeconds: requestInputVideoDurationSeconds(input.requestPayload), markupMultiplier: quote.markupMultiplier }); const reservedAmountFen = Math.max(quote.amountFen, estimatedAmountFen); return { ...quote, amountFen: reservedAmountFen, reservedAmountFen, settlementStatus: "pending" }; } export async function chargeGenerationJob(job: GenerationJob): Promise { const billing = job.billing; if (!billing || billing.status !== "pending") return job; if (isQuotaExemptBilling(job)) { return updateGenerationJob(job.id, { billing: { ...billing, quotaExempt: true, status: "not_charged", settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus } }); } if (!job.usageContext?.organizationId) return job; const reservedAmountFen = billing.reservedAmountFen ?? billing.amountFen; const result = await postWalletEntry({ organizationId: job.usageContext.organizationId, accountId: job.usageContext.accountId, jobId: job.id, kind: "charge", deltaFen: -reservedAmountFen, idempotencyKey: `job-charge:${job.id}`, description: `${capabilityLabel(job.capability)} · ${job.reqKey}`, metadata: { quote: { ...billing, reservedAmountFen }, accountName: job.usageContext.displayName, organizationName: job.usageContext.organizationName } }); return updateGenerationJob(job.id, { billing: { ...billing, reservedAmountFen, status: "charged", settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus, ledgerEntryId: result.entry.id, chargedAt: result.entry.createdAt } }); } export async function refundGenerationCharge(jobOrId: GenerationJob | string, reason: string): Promise { const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; if (!job) return null; if (!["failed", "expired", "cancelled"].includes(job.status)) return job; const billing = job.billing; if (!billing || isQuotaExemptBilling(job) || billing.status !== "charged" || !job.usageContext?.organizationId) return job; const result = await postWalletEntry({ organizationId: job.usageContext.organizationId, accountId: job.usageContext.accountId, jobId: job.id, kind: "refund", deltaFen: billing.amountFen, idempotencyKey: `job-refund:${job.id}`, description: `${capabilityLabel(job.capability)}失败退款 · ${reason}`, metadata: { chargeLedgerEntryId: billing.ledgerEntryId, reason, quote: billing } }); return updateGenerationJob(job.id, { billing: { ...billing, status: "refunded", refundLedgerEntryId: result.entry.id, refundedAt: result.entry.createdAt, refundReason: reason } }); } export async function settleSeedanceGenerationCharge( jobOrId: GenerationJob | string, completionTokens?: number ): Promise { const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; if (!job) return null; const billing = job.billing; const quotaExempt = isQuotaExemptBilling(job); const organizationId = job.usageContext?.organizationId; const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged"; if (job.provider !== "seedance" || !billing || !chargeReady || !quotaExempt && !organizationId) return job; if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job; if (!Number.isFinite(completionTokens) || Number(completionTokens) <= 0) { return updateGenerationJob(job.id, { billing: { ...billing, settlementStatus: "estimated", settlementReason: "provider_usage_unavailable", settledAt: new Date().toISOString() } }); } const inputVideo = requestHasInputVideo(job.requestPayload); const resolution = billing.parameters?.resolution; const actualAmountFen = calculateSeedanceActualAmountFen({ resolution, inputVideo, completionTokens: Number(completionTokens), markupMultiplier: billing.markupMultiplier }); const deltaFen = actualAmountFen - billing.amountFen; let settlementLedgerEntryId: string | undefined; let settledAt = new Date().toISOString(); if (deltaFen !== 0 && !quotaExempt && organizationId) { const settlement = await postWalletEntry({ organizationId, accountId: job.usageContext?.accountId, jobId: job.id, kind: deltaFen > 0 ? "charge" : "refund", deltaFen: -deltaFen, idempotencyKey: `job-settlement:${job.id}`, description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`, metadata: { operation: "seedance_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen, chargedAmountFen: billing.amountFen, actualAmountFen, completionTokens: Math.floor(Number(completionTokens)), inputVideo, resolution: String(resolution || "720p") } }); settlementLedgerEntryId = settlement.entry.id; settledAt = settlement.entry.createdAt; } return updateGenerationJob(job.id, { billing: { ...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt, providerUsage: { completionTokens: Math.floor(Number(completionTokens)), resolution: String(resolution || "720p"), inputVideo, tokenPriceFenPerMillion: seedanceTokenPriceFenPerMillion(resolution, inputVideo) } } }); } export async function getOrganizationBillingSnapshot(organizationId: string) { const wallet = await getOrganizationWallet(organizationId); return { wallet, balanceFen: wallet.balanceFen, balanceYuan: wallet.balanceFen / 100 }; } export function getBillingAccountConfig(): BillingAccountConfig { return { accountName: optionalEnv("ZHINIAN_BILLING_ACCOUNT_NAME"), bankName: optionalEnv("ZHINIAN_BILLING_ACCOUNT_BANK"), accountNumber: optionalEnv("ZHINIAN_BILLING_ACCOUNT_NUMBER"), contact: optionalEnv("ZHINIAN_BILLING_CONTACT") }; } /** * Direct organization top-up entry point for administrator actions and future * payment-success callbacks. The caller must only invoke it after the payment * provider has already confirmed the amount when self-service payments exist. */ export async function postOrganizationTopUp(input: { organizationId: string; amountFen: number; idempotencyKey: string; description?: string; metadata?: Record; }) { if (!Number.isFinite(input.amountFen) || input.amountFen <= 0) { throw Object.assign(new Error("上账金额必须大于 0。"), { status: 400 }); } return postWalletEntry({ organizationId: input.organizationId, kind: "recharge", deltaFen: Math.round(input.amountFen), idempotencyKey: input.idempotencyKey, description: input.description || "组织余额上账", metadata: { operation: "organization_top_up", ...input.metadata } }); } export async function getBillingOverview(input: { organizationId: string; accountId: string }) { const [wallet, ledger, personalLedger] = await Promise.all([ getOrganizationWallet(input.organizationId), listBillingLedgerEntries({ organizationId: input.organizationId, limit: 500 }), listBillingLedgerEntries({ organizationId: input.organizationId, accountId: input.accountId, limit: 500 }) ]); return { wallet, ledger, billingAccount: getBillingAccountConfig(), summary: summarizeLedger(ledger), personal: summarizeLedger(personalLedger) }; } function summarizeLedger(entries: Array<{ kind: string; deltaFen: number }>) { const rechargeFen = entries.filter((entry) => entry.kind === "recharge" || entry.kind === "adjustment" && entry.deltaFen > 0).reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); const chargedFen = entries.filter((entry) => entry.kind === "charge").reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0); const refundedFen = entries.filter((entry) => entry.kind === "refund").reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); return { rechargeFen, chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) }; } function billingEnabled(): boolean { return process.env.ZHINIAN_BILLING_REQUIRED !== "0"; } function isQuotaExemptUsageContext(usageContext?: UsageContext): boolean { return usageContext?.source === "platform" && usageContext.role === "super_admin"; } function isQuotaExemptBilling(job: GenerationJob): boolean { return Boolean(job.billing?.quotaExempt || isQuotaExemptUsageContext(job.usageContext)); } export async function findMatchingPriceRule(input: BillingGenerationInput): Promise { const rules = await listBillingPriceRules({ includeDisabled: false }); return findMatchingBillingPriceRule(rules, input); } export function findMatchingBillingPriceRule(rules: BillingPriceRule[], input: Pick): BillingPriceRule | null { const parameters = normalizeBillingParameters(input.requestPayload); const scoped = rules.filter((rule) => rule.provider === input.provider && rule.capability === input.capability); const matches = scoped.flatMap((rule) => { if (rule.reqKey && rule.reqKey !== input.reqKey) return []; const conditions = effectiveBillingRuleConditions(rule); if (!conditionsMatchParameters(conditions, parameters)) return []; return [{ rule, reqKeySpecificity: rule.reqKey ? 1 : 0, conditionSpecificity: Object.keys(conditions).length, priority: Number.isFinite(rule.priority) ? Number(rule.priority) : 0 }]; }); if (!matches.length) return null; matches.sort((left, right) => right.reqKeySpecificity - left.reqKeySpecificity || right.conditionSpecificity - left.conditionSpecificity || right.priority - left.priority || left.rule.id.localeCompare(right.rule.id)); const winner = matches[0]; const ambiguous = matches.filter((item) => item !== winner && item.reqKeySpecificity === winner.reqKeySpecificity && item.conditionSpecificity === winner.conditionSpecificity && item.priority === winner.priority); if (ambiguous.length) { throw new BillingConfigurationError(`计费规则配置存在歧义:${[winner.rule.id, ...ambiguous.map((item) => item.rule.id)].join("、")}。请让条件更具体或调整优先级。`); } return winner.rule; } export function normalizeBillingParameters(requestPayload: Record): BillingParameterSnapshot { const settings = recordValue(requestPayload.settings); const providerPayload = recordValue(requestPayload.providerPayload); const input = recordValue(requestPayload.input); const providerParameters = recordValue(providerPayload?.parameters); const providerInput = recordValue(providerPayload?.input); const inputSettings = recordValue(input?.settings); const assembled = recordValue(requestPayload.assembled); const parameters: BillingParameterSnapshot = {}; setParameter(parameters, "model", firstValue(providerPayload?.model, input?.model, settings?.model)); setParameter(parameters, "resolution", firstValue( settings?.resolution, providerParameters?.resolution, providerPayload?.resolution, inputSettings?.resolution, input?.resolution ), normalizeTextParameter); setParameter(parameters, "size", firstValue( providerPayload?.size, providerParameters?.size, inputSettings?.size, input?.size ), normalizeSizeParameter); const width = numberParameter(firstValue(providerPayload?.width, input?.width)); const height = numberParameter(firstValue(providerPayload?.height, input?.height)); if (parameters.size === undefined && width !== undefined && height !== undefined) { parameters.size = `${width}*${height}`; } const aspectRatio = firstValue( settings?.ratio, settings?.aspectRatio, providerPayload?.ratio, providerParameters?.ratio, inputSettings?.ratio, input?.ratio, input?.aspectRatio ); const normalizedAspectRatio = normalizeAspectRatioParameter(aspectRatio, width, height); if (normalizedAspectRatio !== undefined) parameters.aspectRatio = normalizedAspectRatio; setParameter(parameters, "quality", firstValue( input?.quality, settings?.quality, providerPayload?.quality, providerParameters?.quality ), normalizeTextParameter); setParameter(parameters, "duration", firstValue( settings?.duration, providerParameters?.duration, providerPayload?.duration, inputSettings?.duration, input?.duration ), numberParameter); const imageCount = numberParameter(firstValue( providerPayload?.n, providerParameters?.n, input?.n, input?.imageCount )); if (imageCount !== undefined && imageCount > 0) parameters.imageCount = Math.ceil(imageCount); const referenceImageCount = countReferenceImages({ requestPayload, input, providerPayload, providerInput, assembled }); if (referenceImageCount > 0) parameters.referenceImageCount = referenceImageCount; const inputVideo = countReferenceVideos({ input, assembled }); if (inputVideo > 0) parameters.inputVideo = true; setParameter(parameters, "scale", firstValue(input?.scale, settings?.scale), numberParameter); setParameter(parameters, "generateAudio", firstValue( settings?.generate_audio, settings?.generateAudio, providerParameters?.generate_audio, providerParameters?.generateAudio, input?.generate_audio, input?.generateAudio ), booleanParameter); return parameters; } export function effectiveBillingRuleConditions(rule: BillingPriceRule): BillingRuleConditions { const legacyConditions = parseLegacyVariantKey(rule.variantKey); return { ...legacyConditions, ...(rule.conditions || {}) }; } export function quantityForRule(rule: BillingPriceRule, parameters: BillingParameterSnapshot): number { const source = rule.quantitySource || defaultQuantitySource(rule.unit); if (source === "request") return 1; if (source === "duration") { const duration = Number(parameters.duration); return Number.isFinite(duration) && duration > 0 ? Math.ceil(duration) : 1; } const imageCount = Number(parameters.imageCount); return Number.isFinite(imageCount) && imageCount > 0 ? Math.ceil(imageCount) : 1; } function defaultQuantitySource(unit: BillingPriceRule["unit"]): BillingQuantitySource { if (unit === "video_second") return "duration"; if (unit === "image") return "image_count"; return "request"; } function conditionsMatchParameters(conditions: BillingRuleConditions, parameters: BillingParameterSnapshot): boolean { return Object.entries(conditions).every(([key, condition]) => conditionMatchesValue(condition, parameters[key])); } function conditionMatchesValue(condition: BillingConditionValue, actual: BillingScalar | undefined): boolean { if (actual === undefined) return false; if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) { if (Array.isArray(condition.values) && !condition.values.some((value) => scalarEquals(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; } if (typeof condition === "string" || typeof condition === "number" || typeof condition === "boolean") { return scalarEquals(condition, actual); } return false; } function scalarEquals(left: BillingScalar, right: BillingScalar): 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 normalizeTextParameter(left) === normalizeTextParameter(right); } function parseLegacyVariantKey(variantKey?: string): BillingRuleConditions { if (!variantKey) return {}; const conditions: BillingRuleConditions = {}; for (const part of variantKey.split(/[;,]/)) { const separator = part.indexOf("="); if (separator <= 0) continue; const key = part.slice(0, separator).trim(); const value = part.slice(separator + 1).trim(); if (!key || !value) continue; if (["model", "resolution", "size", "aspectRatio", "ratio", "quality"].includes(key)) { const normalized = normalizeTextParameter(value); if (normalized !== undefined) conditions[key === "ratio" ? "aspectRatio" : key] = normalized; continue; } if (["duration", "imageCount", "referenceImageCount", "scale"].includes(key)) { const number = Number(value); if (Number.isFinite(number)) conditions[key] = number; } } return conditions; } function countReferenceImages(input: { requestPayload: Record; input?: Record; providerPayload?: Record; providerInput?: Record; assembled?: Record; }): number { const urls = [ ...stringArray(input.input?.imageUrls), ...stringArray(input.providerPayload?.image_urls), ...stringArray(input.providerPayload?.imageUrls) ]; const materials = [ ...(Array.isArray(input.input?.materials) ? input.input.materials : []), ...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : []) ]; const materialImages = materials.filter((item) => { const record = recordValue(item); return record?.type === "image" || typeof record?.url === "string"; }).length; const messages = Array.isArray(input.providerInput?.messages) ? input.providerInput.messages : []; const messageImages = messages.reduce((count, message) => { const content = recordValue(message)?.content; return count + (Array.isArray(content) ? content.filter((item) => Boolean(recordValue(item)?.image)).length : 0); }, 0); return Math.max(urls.length, materialImages, messageImages); } function countReferenceVideos(input: { input?: Record; assembled?: Record }): number { const materials = [ ...(Array.isArray(input.input?.materials) ? input.input.materials : []), ...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : []) ]; return materials.filter((item) => recordValue(item)?.type === "video").length; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : []; } function setParameter( target: BillingParameterSnapshot, key: string, value: unknown, normalize: (value: unknown) => BillingScalar | undefined = normalizeScalarParameter ) { const normalized = normalize(value); if (normalized !== undefined) target[key] = normalized; } function firstValue(...values: unknown[]): unknown { return values.find((value) => value !== undefined && value !== null && value !== ""); } function normalizeScalarParameter(value: unknown): BillingScalar | undefined { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; return undefined; } function normalizeTextParameter(value: unknown): string | undefined { if (typeof value !== "string" && typeof value !== "number") return undefined; const normalized = String(value).trim().toLowerCase().replace(/\s+/g, ""); return normalized || undefined; } function normalizeSizeParameter(value: unknown): string | undefined { const normalized = normalizeTextParameter(value); return normalized?.replace(/[×x]/g, "*"); } function normalizeAspectRatioParameter(value: unknown, width?: number, height?: number): string | undefined { const normalized = normalizeTextParameter(value); if (normalized) return normalized; if (width === undefined || height === undefined || height <= 0) return undefined; const ratio = width / height; const common = [[1, 1], [4, 3], [3, 2], [16, 9], [9, 16], [21, 9], [9, 21], [2, 3], [3, 4]] as const; const match = common.find(([numerator, denominator]) => Math.abs(ratio - numerator / denominator) < 0.02); return match ? `${match[0]}:${match[1]}` : ratio.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); } function numberParameter(value: unknown): number | undefined { const number = Number(value); return Number.isFinite(number) ? number : undefined; } function booleanParameter(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (value === "true" || value === "1") return true; if (value === "false" || value === "0") return false; return undefined; } function recordValue(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; } function capabilityLabel(capability: GenerationCapability): string { return capability === "video.generate" ? "视频生成" : "图片生成"; } function optionalEnv(name: string): string | undefined { const value = process.env[name]?.trim(); return value || undefined; } export { InsufficientBalanceError };