import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { calculateBillingAmountFen } from "@/lib/billing"; import { BillingConfigurationError, chargeGenerationJob, findMatchingBillingPriceRule, normalizeBillingParameters, postOrganizationTopUp, quoteGenerationCharge, refundGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service"; import { createBillingPriceRule, listBillingLedgerEntries, postWalletEntry, InsufficientBalanceError, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store"; import { createGenerationJob, getGenerationJob, listUsageEvents, recordUsageForJob, updateGenerationJob } from "@/lib/server/data-store"; let runtimeDir = ""; describe("organization billing", () => { beforeEach(async () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-billing-")); vi.stubEnv("ZHINIAN_RUNTIME_DIR", runtimeDir); vi.stubEnv("ZHINIAN_BILLING_REQUIRED", "1"); vi.stubEnv("ZHINIAN_DATA_BACKEND", "local"); }); afterEach(async () => { vi.unstubAllEnvs(); await rm(runtimeDir, { recursive: true, force: true }); }); it("calculates provider quantity multiplied by the super-admin markup", () => { expect(calculateBillingAmountFen(35, 2, 1.5)).toBe(105); expect(calculateBillingAmountFen(1, 1, 1.01)).toBe(2); }); it("matches the most specific parameter rule and snapshots normalized parameters", async () => { await createBillingPriceRule({ id: "rule-parameter-generic", provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2-custom", unit: "image", standardUnitPriceFen: 30, markupMultiplier: 1.5, enabled: true }); await createBillingPriceRule({ id: "rule-parameter-specific", provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2-custom", unit: "image", standardUnitPriceFen: 80, markupMultiplier: 1.5, enabled: true, conditions: { resolution: "2K", quality: "high", referenceImageCount: { min: 1 } }, quantitySource: "image_count" }); const requestPayload = { settings: { resolution: " 2K " }, input: { quality: "HIGH", imageUrls: ["https://example.com/reference.png"] }, providerPayload: { model: "gpt-image-2-custom", parameters: { n: 1, size: "2K" } } }; expect(normalizeBillingParameters(requestPayload)).toMatchObject({ resolution: "2k", quality: "high", referenceImageCount: 1, imageCount: 1 }); const quote = await quoteGenerationCharge({ provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2-custom", requestPayload, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote).toMatchObject({ priceRuleId: "rule-parameter-specific", standardUnitPriceFen: 80, amountFen: 120, conditions: { resolution: "2K", quality: "high", referenceImageCount: { min: 1 } }, parameters: { resolution: "2k", quality: "high", referenceImageCount: 1 } }); }); it("uses an explicit duration quantity source for video rules", async () => { await createBillingPriceRule({ id: "rule-duration-specific", provider: "seedance", capability: "video.generate", reqKey: "seedance-custom", unit: "video_second", standardUnitPriceFen: 100, markupMultiplier: 1.2, enabled: true, conditions: { resolution: "1080p", aspectRatio: "16:9" }, quantitySource: "duration" }); const quote = await quoteGenerationCharge({ provider: "seedance", capability: "video.generate", reqKey: "seedance-custom", requestPayload: { settings: { duration: 6, resolution: "1080p", ratio: "16:9" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote).toMatchObject({ quantity: 6, amountFen: 720, quantitySource: "duration" }); }); it("rejects ambiguous rules and returns no rule when conditions do not match", () => { const base = { provider: "evolink" as const, capability: "image.generate" as const, reqKey: "custom", unit: "image" as const, standardUnitPriceFen: 10, markupMultiplier: 1.5, enabled: true, conditions: { quality: "high" } }; const input = { provider: "evolink" as const, capability: "image.generate" as const, reqKey: "custom", requestPayload: { input: { quality: "high" } } }; expect(() => findMatchingBillingPriceRule([ { ...base, id: "ambiguous-a", createdAt: "", updatedAt: "" }, { ...base, id: "ambiguous-b", createdAt: "", updatedAt: "" } ], input)).toThrow(BillingConfigurationError); expect(findMatchingBillingPriceRule([{ ...base, id: "only-1080p", conditions: { resolution: "1080p" }, createdAt: "", updatedAt: "" }], { ...input, requestPayload: { input: { quality: "high", resolution: "720p" } } })).toBeNull(); }); it("seeds the official base catalog and matches video resolution variants", async () => { const quote = await quoteGenerationCharge({ provider: "seedance", capability: "video.generate", reqKey: "doubao-seedance-2-0-260128", requestPayload: { settings: { duration: 5, resolution: "720p" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote).toMatchObject({ variantKey: "resolution=720p", standardUnitPriceFen: 99, quantity: 5, amountFen: 597, markupMultiplier: 1.2, status: "pending" }); expect(quote?.source?.url).toContain("volcengine.com/docs/82379/1544106"); }); it("uses the conservative input-video upper bound for the initial Seedance reserve", async () => { const quote = await quoteGenerationCharge({ provider: "seedance", capability: "video.generate", reqKey: "doubao-seedance-2-0-260128", requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "9:16" }, assembled: { materials: [{ type: "video", url: "https://example.com/reference.mp4" }] } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote).toMatchObject({ amountFen: 1452, reservedAmountFen: 1452, settlementStatus: "pending", parameters: { inputVideo: true } }); }); it("matches EvoLink quality tiers from the platform catalog", async () => { const quote = await quoteGenerationCharge({ provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2", requestPayload: { input: { quality: "high" }, providerPayload: { resolution: "1K", size: "1:1" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote).toMatchObject({ standardUnitPriceFen: 136, amountFen: 164, markupMultiplier: 1.2 }); }); it("settles Seedance from actual completion tokens with an idempotent difference entry", async () => { const quote = await quoteGenerationCharge({ provider: "seedance", capability: "video.generate", reqKey: "doubao-seedance-2-0-260128", requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote?.amountFen).toBe(597); await postOrganizationTopUp({ organizationId: "org-1", amountFen: 5000, idempotencyKey: "seedance-settlement-recharge" }); const job = await createGenerationJob({ ownerId: "user-1", capability: "video.generate", provider: "seedance", reqKey: "doubao-seedance-2-0-260128", status: "running", prompt: "测试", inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" }, billing: quote }); const charged = await chargeGenerationJob(job); const settled = await settleSeedanceGenerationCharge(charged, 100_000); expect(settled?.billing).toMatchObject({ amountFen: 552, reservedAmountFen: 597, settlementStatus: "settled", providerUsage: { completionTokens: 100_000, resolution: "720p", inputVideo: false, tokenPriceFenPerMillion: 4600 } }); const repeated = await settleSeedanceGenerationCharge(settled!, 130_000); expect(repeated?.billing?.amountFen).toBe(552); const ledger = await listBillingLedgerEntries({ organizationId: "org-1", limit: 10 }); expect(ledger.map((entry) => entry.kind)).toEqual(["refund", "charge", "recharge"]); expect(ledger[0].deltaFen).toBe(45); }); it("keeps the frozen Seedance estimate when the provider omits usage", async () => { const quote = await quoteGenerationCharge({ provider: "seedance", capability: "video.generate", reqKey: "doubao-seedance-2-0-260128", requestPayload: { settings: { duration: 5, resolution: "720p" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); await postOrganizationTopUp({ organizationId: "org-1", amountFen: 1000, idempotencyKey: "seedance-no-usage-recharge" }); const job = await createGenerationJob({ ownerId: "user-1", capability: "video.generate", provider: "seedance", reqKey: "doubao-seedance-2-0-260128", status: "running", prompt: "测试", inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { settings: { duration: 5, resolution: "720p" } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" }, billing: quote }); const charged = await chargeGenerationJob(job); const settled = await settleSeedanceGenerationCharge(charged); expect(settled?.billing).toMatchObject({ amountFen: 597, settlementStatus: "estimated" }); expect((await listBillingLedgerEntries({ organizationId: "org-1", limit: 10 })).map((entry) => entry.kind)).toEqual(["charge", "recharge"]); }); it("combines platform parameter tiers and charges high quality at its own standard rate", async () => { await createBillingPriceRule({ id: "rule-parameter-dimensions", provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2-dimensions", unit: "image", standardUnitPriceFen: 34, markupMultiplier: 1.5, enabled: true, parameterDimensions: [ { key: "quality", label: "生成质量", baselineValue: "medium", defaultValue: "medium", tiers: [ { value: "medium", label: "标准", standardFactor: 1, markupMultiplier: 1.5, enabled: true }, { value: "high", label: "精细", standardFactor: 4, markupMultiplier: 1.5, enabled: true } ] }, { key: "resolution", label: "分辨率", baselineValue: "1K", defaultValue: "1K", tiers: [ { value: "1K", label: "1K", standardFactor: 1, markupMultiplier: 1.5, enabled: true }, { value: "2K", label: "2K", standardFactor: 4, markupMultiplier: 1.5, enabled: true } ] } ] }); const baseInput = { provider: "evolink" as const, capability: "image.generate" as const, reqKey: "gpt-image-2-dimensions", usageContext: { source: "platform" as const, accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }; const medium = await quoteGenerationCharge({ ...baseInput, requestPayload: { input: { quality: "medium" }, providerPayload: { resolution: "1K" } } }); const high = await quoteGenerationCharge({ ...baseInput, requestPayload: { input: { quality: "high" }, providerPayload: { resolution: "1K" } } }); expect(medium).toMatchObject({ standardUnitPriceFen: 34, markupMultiplier: 1.5, amountFen: 51 }); expect(high).toMatchObject({ standardUnitPriceFen: 136, markupMultiplier: 1.5, amountFen: 204 }); expect(high?.parameterTiers).toEqual(expect.arrayContaining([ expect.objectContaining({ dimensionKey: "quality", label: "精细", standardUnitPriceFen: 136 }) ])); await updateBillingPriceTierMultiplier({ ruleId: "rule-parameter-dimensions", dimensionKey: "quality", tierValue: "high", markupMultiplier: 2 }); const adjustedHigh = await quoteGenerationCharge({ ...baseInput, requestPayload: { input: { quality: "high" }, providerPayload: { resolution: "1K" } } }); expect(adjustedHigh).toMatchObject({ standardUnitPriceFen: 136, markupMultiplier: 2, amountFen: 272 }); }); it("charges once, keeps the organization wallet shared, and refunds terminal failures", async () => { await createBillingPriceRule({ id: "rule-image", provider: "volcengine-visual", capability: "image.generate", unit: "image", standardUnitPriceFen: 35, markupMultiplier: 1.5, enabled: true }); await postWalletEntry({ organizationId: "org-1", kind: "recharge", deltaFen: 1000, idempotencyKey: "recharge-1", description: "测试充值" }); const quote = await quoteGenerationCharge({ provider: "volcengine-visual", capability: "image.generate", reqKey: "jimeng_seedream46_cvtob", requestPayload: { providerPayload: { n: 2 } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } }); expect(quote).toMatchObject({ amountFen: 105, quantity: 2, status: "pending" }); const job = await createGenerationJob({ ownerId: "user-1", capability: "image.generate", provider: "volcengine-visual", reqKey: "jimeng_seedream46_cvtob", status: "queued", prompt: "测试", inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { providerPayload: { n: 2 } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" }, billing: quote }); const charged = await chargeGenerationJob(job); expect(charged.billing?.status).toBe("charged"); expect((await getGenerationJob(job.id))?.billing?.ledgerEntryId).toBeTruthy(); expect((await postWalletEntry({ organizationId: "org-1", kind: "charge", deltaFen: -105, accountId: "user-1", jobId: job.id, idempotencyKey: `job-charge:${job.id}`, description: "重试时更新的审计文案", metadata: { retry: true } })).entry.id).toBe(charged.billing?.ledgerEntryId); const failed = await updateGenerationJob(charged.id, { status: "failed" }); const refunded = await refundGenerationCharge(failed, "任务失败"); expect(refunded?.billing?.status).toBe("refunded"); const ledger = await listBillingLedgerEntries({ organizationId: "org-1", limit: 20 }); expect(ledger.map((entry) => entry.kind)).toEqual(["refund", "charge", "recharge"]); expect(ledger[0].deltaFen).toBe(105); expect(ledger[0].balanceAfterFen).toBe(1000); }); it("scopes local wallet idempotency by organization and rejects payload drift", async () => { const first = await postWalletEntry({ organizationId: "org-1", kind: "recharge", deltaFen: 100, idempotencyKey: "shared-key", description: "same request" }); const otherOrganization = await postWalletEntry({ organizationId: "org-2", kind: "recharge", deltaFen: 100, idempotencyKey: "shared-key", description: "same request" }); expect(otherOrganization.entry.id).not.toBe(first.entry.id); const retried = await postWalletEntry({ organizationId: "org-1", kind: "recharge", deltaFen: 100, idempotencyKey: "shared-key", description: "updated audit label", metadata: { retry: true } }); expect(retried.entry.id).toBe(first.entry.id); await expect(postWalletEntry({ organizationId: "org-1", kind: "recharge", deltaFen: 200, idempotencyKey: "shared-key", description: "same request" })).rejects.toMatchObject({ status: 409 }); }); it("blocks an ordinary generation before provider dispatch when the organization balance is insufficient", async () => { await createBillingPriceRule({ id: "rule-insufficient-image", provider: "volcengine-visual", capability: "image.generate", unit: "image", standardUnitPriceFen: 35, markupMultiplier: 1.2, enabled: true }); const quote = await quoteGenerationCharge({ provider: "volcengine-visual", capability: "image.generate", reqKey: "jimeng_seedream46_cvtob", requestPayload: { providerPayload: { n: 1 } }, usageContext: { source: "platform", accountId: "user-empty", displayName: "余额不足用户", organizationId: "org-empty" } }); const job = await createGenerationJob({ ownerId: "user-empty", capability: "image.generate", provider: "volcengine-visual", reqKey: "jimeng_seedream46_cvtob", status: "queued", prompt: "测试", inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { providerPayload: { n: 1 } }, usageContext: { source: "platform", accountId: "user-empty", displayName: "余额不足用户", organizationId: "org-empty" }, billing: quote }); await expect(chargeGenerationJob(job)).rejects.toBeInstanceOf(InsufficientBalanceError); expect((await getGenerationJob(job.id))?.billing?.status).toBe("pending"); expect(await listBillingLedgerEntries({ organizationId: "org-empty", limit: 10 })).toEqual([]); }); it("rejects real platform generation without an organization rule or balance", async () => { await expect(quoteGenerationCharge({ provider: "volcengine-visual", capability: "image.generate", reqKey: "jimeng_seedream46_cvtob", requestPayload: { providerPayload: { n: 1 } }, usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户" } })).rejects.toBeInstanceOf(BillingConfigurationError); await expect(postWalletEntry({ organizationId: "org-empty", kind: "charge", deltaFen: -1, idempotencyKey: "charge-empty", description: "余额不足" })).rejects.toBeInstanceOf(InsufficientBalanceError); }); it("lets an unbound super-admin calculate cost without consuming organization quota", async () => { const quote = await quoteGenerationCharge({ provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2", requestPayload: { input: { quality: "high", width: 1440, height: 2560, materials: [{ type: "image", url: "https://example.com/ref.jpg" }] }, providerPayload: { model: "gpt-image-2", resolution: "1K", size: "9:16" } }, usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" } }); expect(quote).toMatchObject({ amountFen: 164, status: "pending", quotaExempt: true }); const job = await createGenerationJob({ ownerId: "super-admin", capability: "image.generate", provider: "evolink", reqKey: "gpt-image-2", status: "queued", prompt: "测试", inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { input: { quality: "high", width: 1440, height: 2560, materials: [{ type: "image", url: "https://example.com/ref.jpg" }] }, providerPayload: { model: "gpt-image-2", resolution: "1K", size: "9:16" } }, usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" }, billing: quote }); const charged = await chargeGenerationJob(job); expect(charged.billing).toMatchObject({ amountFen: 164, status: "not_charged", quotaExempt: true }); expect(await listBillingLedgerEntries({ limit: 10 })).toEqual([]); const succeeded = await updateGenerationJob(charged.id, { status: "succeeded" }); const usage = await recordUsageForJob(succeeded); expect(usage).toMatchObject({ chargedAmountFen: 164, currency: "CNY" }); expect(await listUsageEvents({ source: "platform" })).toHaveLength(1); }); it("settles a super-admin Seedance cost snapshot without touching a wallet", async () => { const quote = await quoteGenerationCharge({ provider: "seedance", capability: "video.generate", reqKey: "doubao-seedance-2-0-260128", requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" } }); const job = await createGenerationJob({ ownerId: "super-admin", capability: "video.generate", provider: "seedance", reqKey: "doubao-seedance-2-0-260128", status: "running", prompt: "测试", inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" }, billing: quote }); const charged = await chargeGenerationJob(job); const settled = await settleSeedanceGenerationCharge(charged, 100_000); expect(settled?.billing).toMatchObject({ amountFen: 552, reservedAmountFen: 597, status: "not_charged", quotaExempt: true, settlementStatus: "settled" }); expect(await listBillingLedgerEntries({ limit: 10 })).toEqual([]); }); it("keeps an unbound ordinary account from submitting a real charge", async () => { await expect(quoteGenerationCharge({ provider: "evolink", capability: "image.generate", reqKey: "gpt-image-2", requestPayload: { providerPayload: { model: "gpt-image-2", resolution: "1K", size: "1:1" } }, usageContext: { source: "platform", accountId: "user-unbound", displayName: "未绑定普通用户" } })).rejects.toBeInstanceOf(BillingConfigurationError); }); it("keeps top-ups and manual balance adjustments on the organization ledger without personal attribution", async () => { const credited = await postOrganizationTopUp({ organizationId: "org-1", amountFen: 5000, idempotencyKey: "manual-adjustment-credit", description: "管理员上账" }); expect(credited.wallet.balanceFen).toBe(5000); expect(credited.entry.accountId).toBeUndefined(); expect(credited.entry.kind).toBe("recharge"); const directCredited = await postWalletEntry({ organizationId: "org-1", accountId: "member-1", kind: "recharge", deltaFen: 100, idempotencyKey: "manual-adjustment-direct-credit", description: "兼容路径上账" }); expect(directCredited.entry.accountId).toBeUndefined(); const debited = await postWalletEntry({ organizationId: "org-1", accountId: "member-1", kind: "adjustment", deltaFen: -1200, idempotencyKey: "manual-adjustment-debit", description: "管理员扣减" }); expect(debited.wallet.balanceFen).toBe(3900); expect(debited.entry.accountId).toBeUndefined(); expect((await listBillingLedgerEntries({ organizationId: "org-1", accountId: "member-1", limit: 10 })).map((entry) => entry.deltaFen)).toEqual([]); }); });