Files
NianAIGC/tests/billing.test.ts

667 lines
24 KiB
TypeScript

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([]);
});
});