667 lines
24 KiB
TypeScript
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([]);
|
|
});
|
|
});
|