diff --git a/apps/web/__tests__/fixtures/developer-video.ts b/apps/web/__tests__/fixtures/developer-video.ts index eb9a8c898b0..5bfb5f1d075 100644 --- a/apps/web/__tests__/fixtures/developer-video.ts +++ b/apps/web/__tests__/fixtures/developer-video.ts @@ -42,6 +42,7 @@ export function developerVideo( storedBytes: null, errorReason: null, processing: processingState(), + storagePaidUntil: null, deletedAt: null, filesDeletedAt: null, createdAt: new Date("2026-11-01T00:00:00Z"), diff --git a/apps/web/__tests__/integration/developer-transcripts-database.test.ts b/apps/web/__tests__/integration/developer-transcripts-database.test.ts index e988e092da1..6cba759be17 100644 --- a/apps/web/__tests__/integration/developer-transcripts-database.test.ts +++ b/apps/web/__tests__/integration/developer-transcripts-database.test.ts @@ -106,7 +106,7 @@ const { disputedSince, readStandingAccount } = await import( const { getInfraAppStanding, getInfraUploadAdmission } = await import( "@/lib/infra-standing" ); -const { getAppStorageUsage } = await import("@/lib/infra-storage"); +const { getAppStorage } = await import("@/lib/infra-storage"); const { chargeStorageDay } = await import("@/lib/infra-storage-billing"); const { developerVideoCaptions, syncVideoCaptions, videoCaptionsKey } = await import("@/lib/developer-video-captions"); @@ -242,7 +242,7 @@ const chargeStorage = async (appId: string, day: string) => chargeStorageDay({ appId, accountId: await accountOf(appId), - usage: await getAppStorageUsage(appId), + usage: (await getAppStorage(appId)).dailyCharge, dateKey: day, now: new Date(), }); @@ -254,6 +254,7 @@ const videoStarts = () => const STORAGE_DAY_OF_TWO_HOURS = 340; const ADMISSION_FOR_TWO_HOURS = 2_380; +const FIRST_MONTH_FOR_TWO_HOURS = 10_200; const settle = (transcriptId: string, durationSeconds: number) => settleTranscript({ @@ -909,7 +910,7 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { }); it("creates an agent video with a playback id and admits it without charging", async () => { - const appId = await makeApp(ADMISSION_FOR_TWO_HOURS); + const appId = await makeApp(FIRST_MONTH_FOR_TWO_HOURS); const created = await createDeveloperVideoUpload(appId, { fileName: "demo.mp4", contentLength: 5_000_000, @@ -944,12 +945,12 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { expect(stored?.s3Key).toBe(fixture.copies[0]); expect(stored?.processing?.encodeId).toBeTruthy(); expect(videoStarts()).toHaveLength(1); - expect(await balanceOf(appId)).toBe(ADMISSION_FOR_TWO_HOURS); + expect(await balanceOf(appId)).toBe(FIRST_MONTH_FOR_TWO_HOURS); expect(await ledgerOf(appId)).toHaveLength(0); }); it("admits a 4K agent upload at the capped price and refuses one the balance cannot store", async () => { - const appId = await makeApp(ADMISSION_FOR_TWO_HOURS - 1); + const appId = await makeApp(FIRST_MONTH_FOR_TWO_HOURS - 1); const created = await createDeveloperVideoUpload(appId, { fileName: "demo.mp4", contentLength: 20_000_000, @@ -976,13 +977,13 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { }); expect(videoStarts()).toHaveLength(0); - await setBalance(appId, ADMISSION_FOR_TWO_HOURS); + await setBalance(appId, FIRST_MONTH_FOR_TWO_HOURS); expect(await complete()).toMatchObject({ ok: true, video: { status: "processing", durationSeconds: 7200 }, }); expect(videoStarts()).toHaveLength(1); - expect(await balanceOf(appId)).toBe(ADMISSION_FOR_TWO_HOURS); + expect(await balanceOf(appId)).toBe(FIRST_MONTH_FOR_TWO_HOURS); }); it("records an agent upload once and starts it once when completions race", async () => { diff --git a/apps/web/__tests__/integration/infra-first-month-auto-top-up-database.test.ts b/apps/web/__tests__/integration/infra-first-month-auto-top-up-database.test.ts new file mode 100644 index 00000000000..2d4e1a04d0c --- /dev/null +++ b/apps/web/__tests__/integration/infra-first-month-auto-top-up-database.test.ts @@ -0,0 +1,231 @@ +import { randomUUID } from "node:crypto"; +import type { DeveloperVideoProcessingState } from "@cap/database/types"; +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import { createPool, type Pool } from "mysql2/promise"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const fixture = vi.hoisted(() => ({ + database: undefined as unknown, + created: [] as { id: string; metadata: Record }[], +})); + +vi.mock("@cap/database", () => ({ + db: () => { + if (!fixture.database) throw new Error("Test database is not connected."); + return fixture.database; + }, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "", NEXT_PUBLIC_WEB_URL: "https://web" }, + serverEnv: () => ({ WEB_URL: "https://web" }), +})); +vi.mock("@/lib/developer-credits-product", () => ({ + developerCreditsProductId: () => "prod_test", +})); +vi.mock("@/lib/infra-billing-emails", () => ({ + sendInfraTopUpFailedEmail: vi.fn(), + sendInfraOutOfCreditEmail: vi.fn(async () => true), +})); +vi.mock("@cap/utils", () => ({ + stripe: () => ({ + paymentIntents: { + search: async () => ({ data: [] }), + retrieve: async (intentId: string) => { + const intent = fixture.created.find((entry) => entry.id === intentId); + if (!intent) throw new Error(`No such payment_intent: ${intentId}`); + return intent; + }, + create: async (params: { + amount: number; + metadata: Record; + }) => { + const intent = { + id: `pi_${randomUUID().slice(0, 8)}`, + status: "succeeded", + amount: params.amount, + amount_received: params.amount, + currency: "usd", + metadata: params.metadata, + }; + fixture.created.push(intent); + return intent; + }, + }, + }), +})); + +const databaseUrl = process.env.CAP_INFRA_TEST_DATABASE_URL; +const id = () => randomUUID().replaceAll("-", "").slice(0, 15); + +const processing = (): DeveloperVideoProcessingState => + ({ + startedAt: Date.now(), + encodeId: "encode", + hls: { state: "complete", output: null }, + mp4: { state: "complete", output: null }, + attempts: { hls: 1, mp4: 1 }, + unavailableChecks: 0, + poster: null, + bytes: { source: 1, hls: 1, mp4: 1 }, + }) as unknown as DeveloperVideoProcessingState; + +describe.runIf(Boolean(databaseUrl))( + "auto top-up after a first month charge against a real database", + () => { + let pool: Pool; + + const makeAccount = async (balanceMicroCredits: number) => { + const ownerId = id(); + const appId = id(); + const accountId = id(); + await pool.query( + "INSERT INTO users (id, email, activeOrganizationId) VALUES (?, ?, '')", + [ownerId, `${ownerId}@example.com`], + ); + await pool.query( + "INSERT INTO developer_apps (id, ownerId, name, environment) VALUES (?, ?, 'Top-up after first month', 'production')", + [appId, ownerId], + ); + await pool.query( + `INSERT INTO developer_credit_accounts + (id, appId, ownerId, balanceMicroCredits, stripeCustomerId, stripePaymentMethodId, + autoTopUpEnabled, autoTopUpThresholdMicroCredits, autoTopUpAmountCents) + VALUES (?, ?, ?, ?, 'cus_test', 'pm_test', 1, 500000, 500)`, + [accountId, appId, ownerId, balanceMicroCredits], + ); + return { appId, accountId }; + }; + + const makeVideo = async (appId: string, durationSeconds: number) => { + const videoId = id(); + const { developerVideos } = await import("@cap/database/schema"); + await (fixture.database as MySql2Database) + .insert(developerVideos) + .values({ + id: videoId, + appId, + name: "Fixture", + status: "ready", + duration: durationSeconds, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", + processing: processing(), + createdAt: new Date("2026-11-12T00:00:00Z"), + updatedAt: new Date("2026-11-12T00:05:00Z"), + }); + return videoId; + }; + + const balanceOf = async (accountId: string) => { + const [rows] = await pool.query( + "SELECT balanceMicroCredits AS balance FROM developer_credit_accounts WHERE id = ?", + [accountId], + ); + return Number((rows as { balance: number }[])[0]?.balance); + }; + + const topUpsOf = async (accountId: string) => { + const [rows] = await pool.query( + "SELECT id FROM developer_credit_transactions WHERE accountId = ? AND referenceType = 'auto_top_up' AND type = 'topup'", + [accountId], + ); + return (rows as { id: string }[]).length; + }; + + const intentsFor = (accountId: string) => + fixture.created.filter( + (intent) => intent.metadata.accountId === accountId, + ).length; + + beforeAll(async () => { + if (!databaseUrl) throw new Error("Missing isolated test database URL."); + const url = new URL(databaseUrl); + if ( + url.protocol !== "mysql:" || + !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || + !/^\/cap_(infra|speech)_[a-z0-9_]+$/.test(url.pathname) + ) { + throw new Error("These tests require a session-scoped local database."); + } + pool = createPool({ uri: databaseUrl, connectionLimit: 12 }); + fixture.database = drizzle(pool, { mode: "default" }) as MySql2Database; + }); + + afterAll(async () => { + await pool?.end(); + }); + + it("schedules exactly one top-up for a charged month that crosses the threshold, and none for a repeat or a month above it", async () => { + const { chargeDeveloperVideoFirstMonth } = await import( + "@/lib/infra-storage-minimum" + ); + const { appId, accountId } = await makeAccount(510_000); + const videoId = await makeVideo(appId, 14_400); + + const charged = await chargeDeveloperVideoFirstMonth(videoId); + expect(charged).toMatchObject({ + state: "charged", + chargedMicroCredits: 67_200, + }); + expect(intentsFor(accountId)).toBe(1); + expect(await topUpsOf(accountId)).toBe(1); + expect(await balanceOf(accountId)).toBe(942_800); + + expect(await chargeDeveloperVideoFirstMonth(videoId)).toMatchObject({ + state: "skipped", + }); + const above = await makeVideo(appId, 600); + expect(await chargeDeveloperVideoFirstMonth(above)).toMatchObject({ + state: "charged", + chargedMicroCredits: 2_800, + }); + expect(intentsFor(accountId)).toBe(1); + expect(await topUpsOf(accountId)).toBe(1); + }); + + it("does not wait on the top-up inside a caller's transaction that holds the account lock, and tops up once it commits", async () => { + const { chargeDeveloperVideoFirstMonth } = await import( + "@/lib/infra-storage-minimum" + ); + const { lockInfraAccount } = await import("@/lib/infra-balance"); + const { appId, accountId } = await makeAccount(510_000); + const videoId = await makeVideo(appId, 14_400); + const database = fixture.database as MySql2Database; + + const started = Date.now(); + await database.transaction(async (tx) => { + await lockInfraAccount(tx, appId); + expect( + await chargeDeveloperVideoFirstMonth(videoId, { executor: tx }), + ).toMatchObject({ state: "charged", chargedMicroCredits: 67_200 }); + expect(intentsFor(accountId)).toBe(0); + }); + expect(Date.now() - started).toBeLessThan(5_000); + + for (let i = 0; i < 50 && (await topUpsOf(accountId)) === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(intentsFor(accountId)).toBe(1); + expect(await topUpsOf(accountId)).toBe(1); + }); + + it("charges a month with no top-up when the balance pays nothing", async () => { + const { chargeDeveloperVideoFirstMonth } = await import( + "@/lib/infra-storage-minimum" + ); + const { appId, accountId } = await makeAccount(0); + await pool.query( + "UPDATE developer_credit_accounts SET autoTopUpEnabled = 1 WHERE id = ?", + [accountId], + ); + const videoId = await makeVideo(appId, 600); + + expect(await chargeDeveloperVideoFirstMonth(videoId)).toMatchObject({ + state: "charged", + chargedMicroCredits: 0, + }); + expect(intentsFor(accountId)).toBe(0); + }); + }, +); diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts new file mode 100644 index 00000000000..6686f2cd4b2 --- /dev/null +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -0,0 +1,2343 @@ +import { randomUUID } from "node:crypto"; +import { + developerApps, + developerCreditAccounts, + developerCreditTransactions, + developerTranscripts, + developerVideos, +} from "@cap/database/schema"; +import type { DeveloperVideoProcessingState } from "@cap/database/types"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import { Effect } from "effect"; +import { createPool, type Pool } from "mysql2/promise"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const fixture = vi.hoisted(() => ({ database: undefined as unknown })); + +vi.mock("@cap/database", () => ({ + db: () => { + if (!fixture.database) throw new Error("Test database is not connected."); + return fixture.database; + }, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true", NEXT_PUBLIC_WEB_URL: "https://web" }, + serverEnv: () => ({ WEB_URL: "https://web" }), +})); +vi.mock("@cap/web-backend", () => ({ + provideOptionalAuth: (effect: T) => effect, + S3Buckets: {}, +})); +vi.mock("@/lib/server", () => ({ + runPromise: (effect: Effect.Effect) => + Effect.runPromise(effect), +})); +vi.mock("workflow/api", () => ({ start: vi.fn() })); +vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: vi.fn() })); +vi.mock("@cap/utils", () => ({ userIsPro: () => false })); +vi.mock("next/headers", () => ({ + cookies: async () => ({ get: () => null }), + headers: async () => ({ get: () => null }), +})); +vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/infra-access", () => ({ canUseInfra: () => true })); +vi.mock("@/workflows/process-developer-video", () => ({ + processDeveloperVideoWorkflow: "workflow", +})); + +const { addCreditsToAccount } = await import("@/lib/developer-credits"); +const { emptyStorageMinutes, firstMonthStorageMicroCredits } = await import( + "@/lib/developer-pricing" +); +const { getInfraBilling, getInfraUploadAdmission } = await import( + "@/lib/infra-standing" +); +const { getAppStorage, getStorageUsageByApp } = await import( + "@/lib/infra-storage" +); +const { chargeStorageDay } = await import("@/lib/infra-storage-billing"); +const { + chargeDeveloperVideoFirstMonth, + chargeMissedFirstMonths, + settleFirstMonthAtDelete, + settleFirstMonthAtReady, + shortfallsAwaitingNotice, + markShortfallsNotified, + markStorageDayNotified, + openFirstMonthShortfalls, +} = await import("@/lib/infra-storage-minimum"); +const { recordAdmittedInfraUpload } = await import("@/lib/infra-video-upload"); +const { + beginPart, + createDeveloperVideoProcessingState, + markPartDispatched, + releasePart, + settlePart, +} = await import("@/lib/developer-video-state"); +const { getInfraFirstMonthChargesSince, getInfraStorageChargesSince } = + await import("@/app/(infra)/dashboard/infra/infra-data"); +const { getCurrentUser } = await import("@cap/database/auth/session"); +const { deleteDeveloperApp } = await import("@/actions/developers/delete-app"); + +const databaseUrl = process.env.CAP_INFRA_TEST_DATABASE_URL; +const DAY_MS = 24 * 60 * 60 * 1000; +const id = () => randomUUID().replaceAll("-", "").slice(0, 15); +const database = () => fixture.database as MySql2Database; +const day = (key: string, time = "00:41:00") => new Date(`${key}T${time}.000Z`); + +const processing = (): DeveloperVideoProcessingState => ({ + encodeId: "encode-1", + startedAt: Date.parse("2026-11-12T00:00:00Z"), + probe: { duration: 600, width: 1920, height: 1080, fps: 30 }, + hls: { state: "pending" }, + mp4: { state: "complete", output: "out" }, + attempts: { hls: 0, mp4: 1 }, + unavailableChecks: 0, + poster: null, + bytes: { source: 1, hls: 0, mp4: 1, poster: 0 }, +}); + +describe.runIf(Boolean(databaseUrl))( + "first month of storage against a real database", + () => { + let pool: Pool; + + const makeApp = async (balanceMicroCredits: number) => { + const appId = id(); + const accountId = id(); + const ownerId = id() as never; + await database().insert(developerApps).values({ + id: appId, + ownerId, + name: "First month fixture", + environment: "development", + }); + await database().insert(developerCreditAccounts).values({ + id: accountId, + appId, + ownerId, + balanceMicroCredits, + }); + return { appId, accountId }; + }; + + const makeVideo = async ( + appId: string, + overrides: Partial = {}, + ) => { + const videoId = id(); + await database() + .insert(developerVideos) + .values({ + id: videoId, + appId, + name: "Fixture", + status: "ready", + duration: 600, + width: 1920, + height: 1080, + maxResolutionTier: "1080p", + processing: processing(), + createdAt: new Date("2026-11-12T00:00:00Z"), + updatedAt: new Date("2026-11-12T00:05:00Z"), + ...overrides, + }); + return videoId; + }; + + const balanceOf = async (accountId: string) => { + const [row] = await database() + .select({ balance: developerCreditAccounts.balanceMicroCredits }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, accountId)); + return row?.balance; + }; + + const firstMonthRows = (accountId: string) => + database() + .select() + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "video_create"), + ), + ); + + const videoRow = async (videoId: string) => { + const [row] = await database() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)); + return row; + }; + + const billDay = (accountId: string, dateKey: string) => + database() + .insert(developerCreditTransactions) + .values({ + id: id(), + accountId, + type: "storage_daily", + amountMicroCredits: 0, + balanceAfterMicroCredits: 0, + referenceId: dateKey, + referenceType: "storage_day", + metadata: { snapshotDate: dateKey, owedMicroCredits: 0 }, + }); + + const dailyMinutes = async (appId: string, at: Date) => + ( + await getStorageUsageByApp([appId], undefined, undefined, { + dailyChargeAt: at, + }) + ).get(appId)?.totalMinutes ?? 0; + + beforeAll(async () => { + if (!databaseUrl) throw new Error("Missing isolated test database URL."); + const url = new URL(databaseUrl); + if ( + url.protocol !== "mysql:" || + !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || + !/^\/cap_(infra|speech)_[a-z0-9_]+$/.test(url.pathname) + ) { + throw new Error( + "First month tests require a session-scoped local database.", + ); + } + pool = createPool({ uri: databaseUrl, connectionLimit: 12 }); + fixture.database = drizzle(pool); + await database().select().from(developerVideos).limit(1); + }); + + afterAll(async () => { + await pool?.end(); + }); + + it("debits one month at the video's tier and measured length, once, and marks it paid", async () => { + const cases = [ + { seconds: 600, width: 1920, height: 1080, tier: "1080p", owed: 850 }, + { + seconds: 3600, + width: 3840, + height: 2160, + tier: "2160p", + owed: 16_800, + }, + { + seconds: 3600, + width: 3840, + height: 2160, + tier: "1080p", + owed: 5_100, + }, + { seconds: 600, width: 2560, height: 1440, tier: "1440p", owed: 1_450 }, + { seconds: 1, width: 640, height: 360, tier: "1080p", owed: 1 }, + ] as const; + for (const video of cases) { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + duration: video.seconds, + width: video.width, + height: video.height, + maxResolutionTier: video.tier, + }); + const before = await videoRow(videoId); + + const result = await chargeDeveloperVideoFirstMonth(videoId, { + now: new Date("2026-11-12T10:00:00Z"), + }); + + expect(result).toMatchObject({ + state: "charged", + owedMicroCredits: video.owed, + chargedMicroCredits: video.owed, + shortSince: null, + }); + expect(video.owed).toBe( + firstMonthStorageMicroCredits({ + durationSeconds: video.seconds, + tier: result.state === "charged" ? result.tier : "1080p", + }), + ); + expect(await balanceOf(accountId)).toBe(1_000_000 - video.owed); + const rows = await firstMonthRows(accountId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + amountMicroCredits: -video.owed, + balanceAfterMicroCredits: 1_000_000 - video.owed, + referenceId: videoId, + referenceType: "developer_video", + }); + const after = await videoRow(videoId); + expect(after?.storagePaidUntil).toEqual( + new Date("2026-12-12T00:00:00Z"), + ); + expect(after?.updatedAt).toEqual(before?.updatedAt); + expect(after?.status).toBe("ready"); + + expect( + await chargeDeveloperVideoFirstMonth(videoId, { + now: new Date("2026-11-12T11:00:00Z"), + }), + ).toEqual({ state: "skipped", reason: "already_settled" }); + expect(await firstMonthRows(accountId)).toHaveLength(1); + expect(await balanceOf(accountId)).toBe(1_000_000 - video.owed); + } + }); + + it("charges the month on the duration stored at first ready, not on what the client first claimed or the probe state", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + status: "processing", + duration: 4 * 60 * 60, + processing: { + ...processing(), + probe: { duration: 4 * 60 * 60, width: 1920, height: 1080, fps: 30 }, + }, + }); + await database() + .update(developerVideos) + .set({ duration: 60 }) + .where(eq(developerVideos.id, videoId)); + await database() + .update(developerVideos) + .set({ status: "ready" }) + .where(eq(developerVideos.id, videoId)); + + await settleFirstMonthAtReady({ id: videoId, deletedAt: null }); + + const [row] = await firstMonthRows(accountId); + expect(row).toMatchObject({ amountMicroCredits: -85 }); + expect(row?.metadata).toMatchObject({ + owedMicroCredits: 85, + durationSeconds: 60, + }); + expect(await balanceOf(accountId)).toBe(1_000_000 - 85); + }); + + it("charges once when the ready transition and the backstop race for the same video", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId); + + const results = await Promise.all( + Array.from({ length: 8 }, () => + chargeDeveloperVideoFirstMonth(videoId, { + now: new Date("2026-11-12T10:00:00Z"), + }), + ), + ); + + expect(results.filter((r) => r.state === "charged")).toHaveLength(1); + expect( + results.filter( + (r) => r.state === "skipped" && r.reason === "already_settled", + ), + ).toHaveLength(7); + expect(await firstMonthRows(accountId)).toHaveLength(1); + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + }); + + it("leaves the video out of the daily charge for exactly 30 billed days, then charges it daily with no gap", async () => { + const keys = (from: string, count: number) => + Array.from({ length: count }, (_, n) => + new Date(Date.parse(`${from}T00:00:00Z`) + n * DAY_MS) + .toISOString() + .slice(0, 10), + ); + + const afterTodaysCharge = await makeApp(1_000_000); + await billDay(afterTodaysCharge.accountId, "2026-11-12"); + const lateVideo = await makeVideo(afterTodaysCharge.appId); + await chargeDeveloperVideoFirstMonth(lateVideo, { + now: new Date("2026-11-12T10:00:00Z"), + }); + const late = await Promise.all( + keys("2026-11-13", 31).map((key) => + dailyMinutes(afterTodaysCharge.appId, day(key)), + ), + ); + expect(late.slice(0, 30)).toEqual(Array(30).fill(0)); + expect(late[30]).toBe(10); + expect((await videoRow(lateVideo))?.storagePaidUntil).toEqual( + new Date("2026-12-13T00:00:00Z"), + ); + + const beforeTodaysCharge = await makeApp(1_000_000); + const earlyVideo = await makeVideo(beforeTodaysCharge.appId); + await chargeDeveloperVideoFirstMonth(earlyVideo, { + now: new Date("2026-11-12T00:10:00Z"), + }); + const early = await Promise.all( + keys("2026-11-12", 31).map((key) => + dailyMinutes(beforeTodaysCharge.appId, day(key, "06:41:00")), + ), + ); + expect(early.slice(0, 30)).toEqual(Array(30).fill(0)); + expect(early[30]).toBe(10); + + const charge = async (key: string) => + chargeStorageDay({ + appId: beforeTodaysCharge.appId, + accountId: beforeTodaysCharge.accountId, + usage: + ( + await getStorageUsageByApp( + [beforeTodaysCharge.appId], + undefined, + undefined, + { dailyChargeAt: day(key) }, + ) + ).get(beforeTodaysCharge.appId) ?? + (() => { + throw new Error("no usage"); + })(), + dateKey: key, + now: day(key), + }); + expect(await charge("2026-12-11")).toEqual({ state: "skipped" }); + expect(await charge("2026-12-12")).toMatchObject({ + state: "billed", + chargedMicroCredits: Math.floor(850 / 30), + }); + }); + + it("does not charge a day the first month covers when the video turned ready after the day was measured", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId); + const at = new Date(); + const measure = async (executor?: never) => + ( + await getStorageUsageByApp([appId], undefined, executor, { + dailyChargeAt: at, + }) + ).get(appId) ?? + (() => { + throw new Error("no usage"); + })(); + const stale = await measure(); + expect(stale.totalMinutes).toBe(10); + + await chargeDeveloperVideoFirstMonth(videoId, { now: at }); + const result = await chargeStorageDay({ + appId, + accountId, + usage: stale, + measure: (tx) => measure(tx as never), + dateKey: at.toISOString().slice(0, 10), + now: at, + }); + + expect(result).toEqual({ state: "skipped" }); + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + }); + + it("keeps the month paid for a video deleted an hour after it became ready", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId); + await settleFirstMonthAtReady({ id: videoId, deletedAt: null }); + await database() + .update(developerVideos) + .set({ deletedAt: new Date(Date.now() + 60 * 60 * 1000) }) + .where(eq(developerVideos.id, videoId)); + + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + expect(await firstMonthRows(accountId)).toHaveLength(1); + expect(await chargeMissedFirstMonths(new Date())).toMatchObject({ + failed: 0, + }); + expect(await firstMonthRows(accountId)).toHaveLength(1); + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + }); + + it("charges nothing for a video that errored or was deleted before it became ready", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const errored = await makeVideo(appId, { status: "errored" }); + const encoding = await makeVideo(appId, { status: "processing" }); + const deletedFirst = await makeVideo(appId, { + deletedAt: new Date("2026-11-12T09:00:00Z"), + }); + + expect(await chargeDeveloperVideoFirstMonth(errored)).toEqual({ + state: "skipped", + reason: "not_ready", + }); + expect(await chargeDeveloperVideoFirstMonth(encoding)).toEqual({ + state: "skipped", + reason: "not_ready", + }); + await settleFirstMonthAtReady({ + id: deletedFirst, + deletedAt: new Date("2026-11-12T09:00:00Z"), + }); + + const exempt = await videoRow(deletedFirst); + expect(exempt?.storagePaidUntil).not.toBeNull(); + expect(await firstMonthRows(accountId)).toHaveLength(0); + expect(await balanceOf(accountId)).toBe(1_000_000); + + await chargeMissedFirstMonths(new Date()); + expect(await firstMonthRows(accountId)).toHaveLength(0); + expect(await balanceOf(accountId)).toBe(1_000_000); + }); + + it("never charges the minimum for a video that was ready before the feature, or one uploaded before encoding existed", async () => { + await makeVideo((await makeApp(1_000_000)).appId, { + createdAt: new Date("2026-11-12T00:00:00Z"), + storagePaidUntil: new Date("2026-12-12T00:00:00Z"), + }); + const { appId, accountId } = await makeApp(1_000_000); + const old = await makeVideo(appId, { + createdAt: new Date("2026-10-01T00:00:00Z"), + }); + const legacy = await makeVideo(appId, { + status: "uploading", + processing: null, + playbackId: null, + createdAt: new Date("2026-11-20T00:00:00Z"), + }); + + await chargeMissedFirstMonths(new Date("2026-11-21T00:41:00Z")); + await chargeMissedFirstMonths(new Date("2026-11-21T06:41:00Z")); + + expect(await firstMonthRows(accountId)).toHaveLength(0); + expect(await balanceOf(accountId)).toBe(1_000_000); + expect((await videoRow(old))?.storagePaidUntil).toBeNull(); + expect((await videoRow(legacy))?.storagePaidUntil).toBeNull(); + expect(await dailyMinutes(appId, day("2026-11-21"))).toBe(10); + }); + + it("lets the cron charge a live ready video the transition missed exactly once, and exempt a deleted one", async () => { + await makeVideo((await makeApp(1_000_000)).appId, { + storagePaidUntil: new Date("2026-12-12T00:00:00Z"), + }); + const { appId, accountId } = await makeApp(1_000_000); + const missed = await makeVideo(appId, { + createdAt: new Date("2026-11-20T00:00:00Z"), + }); + const missedThenDeleted = await makeVideo(appId, { + createdAt: new Date("2026-11-20T01:00:00Z"), + deletedAt: new Date("2026-11-20T02:00:00Z"), + }); + + const noEncodeState = await makeVideo(appId, { + processing: null, + createdAt: new Date("2026-11-19T00:00:00Z"), + }); + const noAccount = id(); + await database() + .insert(developerApps) + .values({ + id: noAccount, + ownerId: id() as never, + name: "No credit account", + environment: "development", + }); + const unbillable = await makeVideo(noAccount, { + createdAt: new Date("2026-11-19T00:00:00Z"), + }); + + await chargeMissedFirstMonths(new Date("2026-11-20T06:41:00Z"), 2); + await chargeMissedFirstMonths(new Date("2026-11-20T12:41:00Z"), 2); + await chargeMissedFirstMonths(new Date("2026-11-20T18:41:00Z"), 2); + + expect((await videoRow(noEncodeState))?.storagePaidUntil).not.toBeNull(); + expect((await videoRow(unbillable))?.storagePaidUntil).not.toBeNull(); + + const rows = await firstMonthRows(accountId); + expect(rows.map((row) => row.referenceId)).toEqual([missed]); + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + expect((await videoRow(missed))?.storagePaidUntil).not.toBeNull(); + expect( + (await videoRow(missedThenDeleted))?.storagePaidUntil, + ).not.toBeNull(); + }); + + it("takes what the balance holds when it cannot pay the month, leaves the video ready and the account out of credit until topped up", async () => { + const part = await makeApp(300); + const partVideo = await makeVideo(part.appId); + await settleFirstMonthAtReady({ id: partVideo, deletedAt: null }); + + expect(await balanceOf(part.accountId)).toBe(0); + const [partRow] = await firstMonthRows(part.accountId); + expect(partRow).toMatchObject({ + amountMicroCredits: -300, + balanceAfterMicroCredits: 0, + }); + expect(partRow?.metadata).toMatchObject({ + owedMicroCredits: 850, + chargedMicroCredits: 300, + }); + expect( + (partRow?.metadata as { shortSince: string | null }).shortSince, + ).not.toBeNull(); + const partVideoRow = await videoRow(partVideo); + expect(partVideoRow?.status).toBe("ready"); + expect(partVideoRow?.storagePaidUntil).not.toBeNull(); + + const short = await getInfraBilling(part.appId); + expect(short.standing.state).toBe("grace"); + expect(short.admission).toMatchObject({ + allowed: false, + reason: "out_of_credit", + }); + + const unpaid = await makeApp(0); + await settleFirstMonthAtReady({ + id: await makeVideo(unpaid.appId), + deletedAt: null, + }); + const [unpaidRow] = await firstMonthRows(unpaid.accountId); + expect(unpaidRow).toMatchObject({ + amountMicroCredits: 0, + balanceAfterMicroCredits: 0, + }); + expect((await getInfraBilling(unpaid.appId)).standing.state).toBe( + "grace", + ); + + await addCreditsToAccount({ + accountId: part.accountId, + amountCents: 1, + referenceId: id(), + referenceType: "stripe_payment_intent", + }); + expect(await balanceOf(part.accountId)).toBe(1_000); + expect((await getInfraBilling(part.appId)).standing.state).toBe("active"); + }); + + it("latches a shortfall once when a released hold covers it, so it never comes back", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + await settleFirstMonthAtReady({ + id: await makeVideo(appId), + deletedAt: null, + }); + const [month] = await firstMonthRows(accountId); + expect(month).toMatchObject({ amountMicroCredits: 0 }); + expect((await getInfraBilling(appId)).standing.state).toBe("grace"); + + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { duration: 1 }), + deletedAt: null, + }); + const latchedRow = async () => { + const [row] = await database() + .select({ metadata: developerCreditTransactions.metadata }) + .from(developerCreditTransactions) + .where(eq(developerCreditTransactions.id, month?.id ?? "")); + return row?.metadata as { + resolvedAt?: string; + owedMicroCredits?: number; + }; + }; + const first = await latchedRow(); + expect(first.resolvedAt).toEqual(expect.any(String)); + expect(first.owedMicroCredits).toBe(850); + + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { duration: 1 }), + deletedAt: null, + }); + expect((await latchedRow()).resolvedAt).toBe(first.resolvedAt); + expect(await balanceOf(accountId)).toBe(898); + + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 100 }) + .where(eq(developerCreditAccounts.id, accountId)); + const later = await getInfraBilling( + appId, + undefined, + new Date(Date.now() + 10 * DAY_MS), + ); + expect(later.standing).toEqual({ state: "active", pausesAt: null }); + }); + + it("keeps an unpaid month in grace from its first short date when a later, smaller month is paid in full", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { + duration: 3_600, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", + }), + deletedAt: null, + }); + const [unpaid] = await firstMonthRows(accountId); + expect(unpaid).toMatchObject({ amountMicroCredits: 0 }); + expect(unpaid?.metadata).toMatchObject({ owedMicroCredits: 16_800 }); + const movedTo = new Date(Math.floor(Date.now() / 1000) * 1000 - 5_000); + await database() + .update(developerCreditTransactions) + .set({ createdAt: movedTo }) + .where(eq(developerCreditTransactions.id, unpaid?.id ?? "")); + const shortSince = new Date( + (unpaid?.metadata as { shortSince: string }).shortSince, + ); + const pausesAt = new Date(shortSince.getTime() + 7 * DAY_MS); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "grace", + pausesAt, + }); + + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + const paidVideo = await makeVideo(appId); + await settleFirstMonthAtReady({ id: paidVideo, deletedAt: null }); + + const paid = (await firstMonthRows(accountId)).find( + (row) => row.referenceId === paidVideo, + ); + expect(paid).toMatchObject({ + amountMicroCredits: -850, + balanceAfterMicroCredits: 50, + }); + expect(paid?.metadata).toMatchObject({ + owedMicroCredits: 850, + chargedMicroCredits: 850, + outstandingMicroCredits: 16_800, + shortSince: shortSince.toISOString(), + fellShort: false, + }); + expect(paid?.createdAt.getTime()).toBeGreaterThan(movedTo.getTime()); + const billing = await getInfraBilling(appId); + expect(billing.standing).toEqual({ state: "grace", pausesAt }); + expect(billing.admission).toMatchObject({ + allowed: false, + reason: "out_of_credit", + }); + expect(await openFirstMonthShortfalls([accountId])).toEqual( + new Set([accountId]), + ); + expect( + ( + await shortfallsAwaitingNotice( + [accountId], + new Date(Date.now() - DAY_MS), + ) + ).get(accountId), + ).toEqual({ shortSince, ids: [unpaid?.id] }); + + await addCreditsToAccount({ + accountId, + amountCents: 17, + referenceId: id(), + referenceType: "stripe_payment_intent", + }); + expect(await balanceOf(accountId)).toBe(17_050); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + + await database() + .update(developerCreditTransactions) + .set({ createdAt: new Date(movedTo.getTime() + 2_000) }) + .where(eq(developerCreditTransactions.id, paid?.id ?? "")); + const afterVideo = await makeVideo(appId); + await settleFirstMonthAtReady({ id: afterVideo, deletedAt: null }); + const after = (await firstMonthRows(accountId)).find( + (row) => row.referenceId === afterVideo, + ); + expect(after?.metadata).toMatchObject({ shortSince: null }); + expect(after?.metadata).not.toHaveProperty("outstandingMicroCredits"); + expect((await getInfraBilling(appId)).standing.state).toBe("active"); + expect(await openFirstMonthShortfalls([accountId])).toEqual(new Set()); + }); + + it("resolves the daily charge a first month shortfall carried when the shortfall is covered", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + const fourK = { + duration: 3_600, + width: 3840, + height: 2160, + maxResolutionTier: "2160p" as const, + }; + await makeVideo(appId, { + ...fourK, + createdAt: new Date("2026-10-01T00:00:00Z"), + }); + const today = new Date().toISOString().slice(0, 10); + const usage = ( + await getStorageUsageByApp([appId], undefined, undefined, { + dailyChargeAt: new Date(`${today}T00:00:00.000Z`), + }) + ).get(appId); + if (!usage) throw new Error("no usage"); + const day = await chargeStorageDay({ + appId, + accountId, + usage, + dateKey: today, + now: new Date(), + }); + expect(day).toMatchObject({ + state: "billed", + chargedMicroCredits: 0, + owedMicroCredits: 560, + }); + const [dailyRow] = await database() + .select() + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "storage_daily"), + ), + ); + const shortSince = (dailyRow?.metadata as { shortSince: string }) + .shortSince; + expect(shortSince).toEqual(expect.any(String)); + + await settleFirstMonthAtReady({ + id: await makeVideo(appId, fourK), + deletedAt: null, + }); + const [month] = await firstMonthRows(accountId); + expect(month?.metadata).toMatchObject({ + owedMicroCredits: 16_800, + shortSince, + outstandingMicroCredits: 16_800, + }); + + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 16_800 }) + .where(eq(developerCreditAccounts.id, accountId)); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, fourK), + deletedAt: null, + }); + expect(await balanceOf(accountId)).toBe(0); + + const resolvedAt = async (rowId: string) => { + const [row] = await database() + .select({ metadata: developerCreditTransactions.metadata }) + .from(developerCreditTransactions) + .where(eq(developerCreditTransactions.id, rowId)); + return (row?.metadata as { resolvedAt?: string }).resolvedAt; + }; + expect(await resolvedAt(dailyRow?.id ?? "")).toEqual(expect.any(String)); + expect(await resolvedAt(month?.id ?? "")).toBe( + await resolvedAt(dailyRow?.id ?? ""), + ); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + it("resolves the first month a daily latch covers, with the daily rows of the same shortfall", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { duration: 3_600 }), + deletedAt: null, + }); + const [month] = await firstMonthRows(accountId); + const shortSince = (month?.metadata as { shortSince: string }).shortSince; + expect(month?.metadata).toMatchObject({ owedMicroCredits: 5_100 }); + + const dayUsage = (dailyMicroCredits: number) => ({ + videoCount: 1, + totalMinutes: 1, + minutesByTier: emptyStorageMinutes(), + monthlyMicroCredits: dailyMicroCredits * 30, + dailyMicroCredits, + }); + const dateKey = (daysAgo: number) => + new Date(Date.now() - daysAgo * DAY_MS).toISOString().slice(0, 10); + await database() + .update(developerTranscripts) + .set({ reservedMicroCredits: 850 }) + .where(eq(developerTranscripts.id, transcriptId)); + expect( + await chargeStorageDay({ + appId, + accountId, + usage: dayUsage(300), + dateKey: dateKey(1), + now: new Date(), + }), + ).toMatchObject({ state: "billed", chargedMicroCredits: 50 }); + const dailyRows = () => + database() + .select() + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "storage_daily"), + ), + ); + const [carried] = await dailyRows(); + expect(carried?.metadata).toMatchObject({ shortSince }); + + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 5_100 }) + .where(eq(developerCreditAccounts.id, accountId)); + expect( + await chargeStorageDay({ + appId, + accountId, + usage: dayUsage(28), + dateKey: dateKey(0), + now: new Date(), + }), + ).toMatchObject({ + state: "billed", + chargedMicroCredits: 28, + shortSince: null, + }); + + const resolved = [ + ...(await firstMonthRows(accountId)), + ...(await dailyRows()), + ] + .filter( + (row) => + (row.metadata as { shortSince?: string | null }).shortSince === + shortSince, + ) + .map((row) => (row.metadata as { resolvedAt?: string }).resolvedAt); + expect(resolved).toHaveLength(2); + expect(resolved.every((value) => typeof value === "string")).toBe(true); + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 100 }) + .where(eq(developerCreditAccounts.id, accountId)); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + it("keeps standing and resolved shortfalls consistent across 500 seeded charge, hold and top-up sequences", async () => { + let seed = 0x4145; + const random = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const pick = (items: readonly T[]) => + items[Math.floor(random() * items.length)] as T; + const between = (low: number, high: number) => + low + Math.floor(random() * (high - low + 1)); + const kinds = [ + "daily_paid", + "daily_short", + "month_paid", + "month_short", + "month_short_pair", + "hold", + "release", + "hold", + "release", + "top_up", + "daily_part_in_month", + "daily_part_in_month", + ] as const; + type Period = { since: string; monthMax: number }; + const failures: string[] = []; + + for (let sequence = 0; sequence < 500; sequence++) { + const startBalance = pick([0, 500, 5_000, 20_000]); + const { appId, accountId } = await makeApp(startBalance); + let balance = startBalance; + let held = 0; + const holds: { id: string; amount: number }[] = []; + let period: Period | null = null; + let lastDaily: { since: string | null; owed: number } = { + since: null, + owed: 0, + }; + const size = () => + period + ? Math.max( + period.monthMax, + lastDaily.since === period.since ? lastDaily.owed : 0, + ) + : 0; + const close = (latch: boolean) => { + if (!period) return; + covered.add(period.since); + if (latch) latched.add(period.since); + period = null; + lastDaily = { since: null, owed: 0 }; + }; + const covered = new Set(); + const latched = new Set(); + const steps = between(2, 6); + const log: string[] = []; + let dayIndex = 6; + + const latch = () => { + if (period && balance - held >= size()) close(true); + }; + const rowSince = async (referenceId: string) => { + const [row] = await database() + .select({ metadata: developerCreditTransactions.metadata }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.referenceId, referenceId), + ), + ) + .limit(1); + return String( + (row?.metadata as { shortSince?: string | null } | undefined) + ?.shortSince, + ); + }; + + for (let step = 0; step < steps; step++) { + const kind = pick(kinds); + let available = balance - held; + log.push(`${kind} balance=${balance} held=${held}`); + let stepKind: (typeof kinds)[number] = kind; + if (kind === "daily_part_in_month") { + if (dayIndex < 0) continue; + if (!period || period.monthMax === 0) { + if (available <= 0) continue; + const holdId = id(); + await database().insert(developerTranscripts).values({ + id: holdId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: available, + }); + held += available; + const minutes = between(1, 240); + const owed = minutes * 85; + latch(); + const videoId = await makeVideo(appId, { + duration: minutes * 60, + }); + await settleFirstMonthAtReady({ id: videoId, deletedAt: null }); + log[log.length - 1] += ` month owed=${owed}`; + period = period + ? { + since: period.since, + monthMax: Math.max(period.monthMax, owed), + } + : { since: await rowSince(videoId), monthMax: owed }; + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, holdId)); + held -= available; + } + available = balance - held; + if (available <= 0) continue; + stepKind = "daily_short"; + } + if (stepKind === "daily_paid" || stepKind === "daily_short") { + if (dayIndex < 0) continue; + const owed = + stepKind === "daily_paid" + ? between(0, available) + : available + between(1, 20_000); + const dateKey = new Date(Date.now() - dayIndex * DAY_MS) + .toISOString() + .slice(0, 10); + latch(); + const result = await chargeStorageDay({ + appId, + accountId, + usage: { + videoCount: 1, + totalMinutes: 1, + minutesByTier: emptyStorageMinutes(), + monthlyMicroCredits: owed * 30, + dailyMicroCredits: owed, + }, + dateKey, + now: new Date(), + }); + dayIndex--; + log[log.length - 1] += ` owed=${owed} -> ${result.state}`; + if (result.state !== "billed") continue; + const charged = Math.min(balance - held, owed); + balance -= charged; + if (charged < owed) { + const since: string = + (period as Period | null)?.since ?? (await rowSince(dateKey)); + lastDaily = { since, owed }; + period = period ?? { since, monthMax: 0 }; + } else { + lastDaily = { since: null, owed: 0 }; + if (period?.monthMax === 0) period = null; + } + } else if ( + kind === "month_paid" || + kind === "month_short" || + kind === "month_short_pair" + ) { + const minutes = Math.min( + 240, + kind === "month_paid" + ? Math.floor(available / 85) + : Math.floor(available / 85) + between(1, 200), + ); + if (kind !== "month_paid" && minutes * 85 <= available) continue; + if (minutes < 1) continue; + if (kind === "month_short_pair" && minutes >= 240) continue; + const sizes = + kind === "month_short_pair" + ? [minutes, between(minutes + 1, 240)] + : [minutes]; + const videoIds: string[] = []; + for (const size of sizes) { + const owed = size * 85; + latch(); + const videoId = await makeVideo(appId, { duration: size * 60 }); + await settleFirstMonthAtReady({ id: videoId, deletedAt: null }); + videoIds.push(videoId); + log[log.length - 1] += ` owed=${owed}`; + const charged = Math.min(balance - held, owed); + balance -= charged; + if (charged < owed) { + period = period + ? { + since: period.since, + monthMax: Math.max(period.monthMax, owed), + } + : { since: await rowSince(videoId), monthMax: owed }; + } + } + if (videoIds.length > 1) { + const [first] = await database() + .select({ createdAt: developerCreditTransactions.createdAt }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq( + developerCreditTransactions.referenceId, + videoIds[0] ?? "", + ), + ), + ); + await database() + .update(developerCreditTransactions) + .set({ createdAt: first?.createdAt ?? new Date() }) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + inArray(developerCreditTransactions.referenceId, videoIds), + ), + ); + } + } else if (kind === "hold") { + if (available <= 0) continue; + const amount = between(1, available); + const holdId = id(); + await database().insert(developerTranscripts).values({ + id: holdId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: amount, + }); + holds.push({ id: holdId, amount }); + held += amount; + log[log.length - 1] += ` +${amount}`; + } else if (kind === "release") { + const hold = holds.shift(); + if (!hold) continue; + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, hold.id)); + held -= hold.amount; + log[log.length - 1] += ` -${hold.amount}`; + } else { + const cents = between(1, 200); + await addCreditsToAccount({ + accountId, + amountCents: cents, + referenceId: id(), + referenceType: "stripe_payment_intent", + }); + await database() + .update(developerCreditTransactions) + .set({ + createdAt: sql`${developerCreditTransactions.createdAt} - INTERVAL 1 SECOND`, + }) + .where(eq(developerCreditTransactions.accountId, accountId)); + const added = Math.floor((cents / 100) * 100_000); + balance += added; + log[log.length - 1] += ` +${added}`; + if (period && balance >= size()) close(false); + } + + const where = `sequence ${sequence} step ${step}: ${log.join(" | ")}`; + if ((await balanceOf(accountId)) !== balance) { + failures.push( + `${where}: balance ${await balanceOf(accountId)} expected ${balance}`, + ); + break; + } + const short = period !== null && balance - held < size(); + const { standing } = await getInfraBilling(appId); + const expected = short + ? { + state: "grace", + pausesAt: new Date( + Date.parse(period?.since ?? "") + 7 * DAY_MS, + ), + } + : { state: "active", pausesAt: null }; + if (JSON.stringify(standing) !== JSON.stringify(expected)) { + failures.push( + `${where}: standing ${JSON.stringify(standing)} expected ${JSON.stringify(expected)} period ${JSON.stringify(period)} size ${size()} lastDaily ${JSON.stringify(lastDaily)}`, + ); + break; + } + if (latched.size > 0) { + const rows = await database() + .select({ metadata: developerCreditTransactions.metadata }) + .from(developerCreditTransactions) + .where(eq(developerCreditTransactions.accountId, accountId)); + const unresolved = rows.filter((row) => { + const fields = row.metadata as { + shortSince?: string | null; + resolvedAt?: string; + } | null; + return ( + fields?.shortSince && + latched.has(fields.shortSince) && + !fields.resolvedAt + ); + }); + if (unresolved.length > 0) { + failures.push( + `${where}: ${unresolved.length} rows of a covered shortfall unresolved`, + ); + break; + } + } + } + } + + expect(failures).toEqual([]); + }, 600_000); + + const chargeDay = ( + appId: string, + accountId: string, + dailyMicroCredits: number, + daysAgo: number, + ) => + chargeStorageDay({ + appId, + accountId, + usage: { + videoCount: 1, + totalMinutes: 1, + minutesByTier: emptyStorageMinutes(), + monthlyMicroCredits: dailyMicroCredits * 30, + dailyMicroCredits, + }, + dateKey: new Date(Date.now() - daysAgo * DAY_MS) + .toISOString() + .slice(0, 10), + now: new Date(), + }); + + const setBalance = (accountId: string, balanceMicroCredits: number) => + database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits }) + .where(eq(developerCreditAccounts.id, accountId)); + + it("forgives an older unpaid day once the latest day is covered, then a first month latches the whole period", async () => { + const { appId, accountId } = await makeApp(0); + expect(await chargeDay(appId, accountId, 5_000, 3)).toMatchObject({ + state: "billed", + chargedMicroCredits: 0, + }); + await setBalance(accountId, 450); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 400, + }); + expect(await chargeDay(appId, accountId, 300, 2)).toMatchObject({ + state: "billed", + chargedMicroCredits: 50, + owedMicroCredits: 300, + }); + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + expect(await balanceOf(accountId)).toBe(400); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { duration: 106 }), + deletedAt: null, + }); + expect(await balanceOf(accountId)).toBe(250); + const days = await database() + .select({ metadata: developerCreditTransactions.metadata }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "storage_daily"), + ), + ); + expect(days).toHaveLength(2); + for (const day of days) { + expect((day.metadata as { resolvedAt?: string }).resolvedAt).toEqual( + expect.any(String), + ); + } + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + it("sizes a run of short days by the latest day, as before first months", async () => { + const { appId, accountId } = await makeApp(0); + await chargeDay(appId, accountId, 1_000, 4); + const [first] = await database() + .select({ metadata: developerCreditTransactions.metadata }) + .from(developerCreditTransactions) + .where(eq(developerCreditTransactions.accountId, accountId)); + const shortSince = (first?.metadata as { shortSince: string }).shortSince; + const grace = { + state: "grace", + pausesAt: new Date(Date.parse(shortSince) + 7 * DAY_MS), + }; + + await setBalance(accountId, 10); + expect(await chargeDay(appId, accountId, 28, 3)).toMatchObject({ + state: "billed", + chargedMicroCredits: 10, + shortSince: new Date(shortSince), + }); + await setBalance(accountId, 20); + expect((await getInfraBilling(appId)).standing).toEqual(grace); + await setBalance(accountId, 100); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + + await setBalance(accountId, 100); + const third = await chargeDay(appId, accountId, 2_000, 2); + expect(third).toMatchObject({ + state: "billed", + chargedMicroCredits: 100, + }); + const thirdSince = + third.state === "billed" ? (third.shortSince ?? null) : null; + expect(thirdSince?.getTime()).toBeGreaterThan(Date.parse(shortSince)); + await setBalance(accountId, 1_500); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "grace", + pausesAt: new Date((thirdSince?.getTime() ?? 0) + 7 * DAY_MS), + }); + await setBalance(accountId, 500); + expect(await chargeDay(appId, accountId, 400, 1)).toMatchObject({ + state: "billed", + chargedMicroCredits: 400, + shortSince: null, + }); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + it("sizes a first month grace period by a larger daily charge it carries", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + await settleFirstMonthAtReady({ + id: await makeVideo(appId), + deletedAt: null, + }); + const [month] = await firstMonthRows(accountId); + const shortSince = (month?.metadata as { shortSince: string }).shortSince; + expect(month).toMatchObject({ amountMicroCredits: 0 }); + expect(month?.metadata).toMatchObject({ owedMicroCredits: 850 }); + + await database() + .update(developerTranscripts) + .set({ reservedMicroCredits: 400 }) + .where(eq(developerTranscripts.id, transcriptId)); + expect( + await chargeStorageDay({ + appId, + accountId, + usage: { + videoCount: 1, + totalMinutes: 1, + minutesByTier: emptyStorageMinutes(), + monthlyMicroCredits: 1_120 * 30, + dailyMicroCredits: 1_120, + }, + dateKey: new Date().toISOString().slice(0, 10), + now: new Date(), + }), + ).toMatchObject({ + state: "billed", + chargedMicroCredits: 500, + owedMicroCredits: 1_120, + }); + + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 900 }) + .where(eq(developerCreditAccounts.id, accountId)); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "grace", + pausesAt: new Date(Date.parse(shortSince) + 7 * DAY_MS), + }); + + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 1_120 }) + .where(eq(developerCreditAccounts.id, accountId)); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + it("reads the largest unpaid month of a shortfall when two fall short in the same second", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + const smallVideo = await makeVideo(appId); + await settleFirstMonthAtReady({ id: smallVideo, deletedAt: null }); + const largeVideo = await makeVideo(appId, { + duration: 3_600, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", + }); + await settleFirstMonthAtReady({ id: largeVideo, deletedAt: null }); + const rows = await firstMonthRows(accountId); + const small = rows.find((row) => row.referenceId === smallVideo); + const large = rows.find((row) => row.referenceId === largeVideo); + const shortSince = (small?.metadata as { shortSince: string }).shortSince; + expect(small?.metadata).toMatchObject({ owedMicroCredits: 850 }); + expect(large?.metadata).toMatchObject({ + owedMicroCredits: 16_800, + outstandingMicroCredits: 16_800, + shortSince, + }); + const second = new Date(Math.floor(Date.now() / 1000) * 1000 - 5_000); + const smallId = `0${id().slice(1)}`; + const largeId = `z${id().slice(1)}`; + for (const [from, to] of [ + [small?.id ?? "", smallId], + [large?.id ?? "", largeId], + ] as const) { + await database() + .update(developerCreditTransactions) + .set({ id: to, createdAt: second }) + .where(eq(developerCreditTransactions.id, from)); + } + + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + const pausesAt = new Date(Date.parse(shortSince) + 7 * DAY_MS); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "grace", + pausesAt, + }); + + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { duration: 60 }), + deletedAt: null, + }); + expect(await balanceOf(accountId)).toBe(815); + const after = new Map( + (await firstMonthRows(accountId)).map((row) => [ + row.id, + (row.metadata as { resolvedAt?: string }).resolvedAt, + ]), + ); + expect(after.get(smallId)).toBeUndefined(); + expect(after.get(largeId)).toBeUndefined(); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "grace", + pausesAt, + }); + + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 17_000 }) + .where(eq(developerCreditAccounts.id, accountId)); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { duration: 60 }), + deletedAt: null, + }); + const covered = new Map( + (await firstMonthRows(accountId)).map((row) => [ + row.id, + (row.metadata as { resolvedAt?: string }).resolvedAt, + ]), + ); + expect(covered.get(smallId)).toEqual(expect.any(String)); + expect(covered.get(largeId)).toBe(covered.get(smallId)); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + it("resolves every copy of a covered first month shortfall together, and no other shortfall", async () => { + const { appId, accountId } = await makeApp(900); + const transcriptId = id(); + await database().insert(developerTranscripts).values({ + id: transcriptId, + appId, + status: "processing", + sourceType: "upload", + reservedMicroCredits: 900, + }); + const fourK = { + duration: 3_600, + width: 3840, + height: 2160, + maxResolutionTier: "2160p" as const, + }; + await settleFirstMonthAtReady({ + id: await makeVideo(appId, fourK), + deletedAt: null, + }); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, fourK), + deletedAt: null, + }); + const copies = await firstMonthRows(accountId); + expect(copies).toHaveLength(2); + const shortSince = (copies[0]?.metadata as { shortSince: string }) + .shortSince; + for (const copy of copies) { + expect(copy.amountMicroCredits).toBe(0); + expect(copy.metadata).toMatchObject({ + owedMicroCredits: 16_800, + shortSince, + }); + } + const second = new Date(Math.floor(Date.now() / 1000) * 1000 - 5_000); + await database() + .update(developerCreditTransactions) + .set({ createdAt: second }) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "video_create"), + ), + ); + const otherShortSince = new Date( + Date.parse(shortSince) + 60_000, + ).toISOString(); + const otherId = id(); + await database() + .insert(developerCreditTransactions) + .values({ + id: otherId, + accountId, + type: "video_create", + amountMicroCredits: 0, + balanceAfterMicroCredits: 0, + referenceId: id(), + referenceType: "developer_video", + metadata: { + owedMicroCredits: 850, + chargedMicroCredits: 0, + shortSince: otherShortSince, + fellShort: true, + }, + createdAt: new Date(second.getTime() - 60_000), + }); + expect((await getInfraBilling(appId)).standing.state).toBe("grace"); + + await makeVideo(appId, { createdAt: new Date("2026-10-01T00:00:00Z") }); + await database() + .update(developerTranscripts) + .set({ status: "complete", reservedMicroCredits: 0 }) + .where(eq(developerTranscripts.id, transcriptId)); + await database() + .update(developerCreditAccounts) + .set({ balanceMicroCredits: 16_800 }) + .where(eq(developerCreditAccounts.id, accountId)); + const today = new Date().toISOString().slice(0, 10); + const usage = ( + await getStorageUsageByApp([appId], undefined, undefined, { + dailyChargeAt: new Date(`${today}T00:00:00.000Z`), + }) + ).get(appId); + if (!usage) throw new Error("no usage"); + expect( + await chargeStorageDay({ + appId, + accountId, + usage, + dateKey: today, + now: new Date(), + }), + ).toMatchObject({ state: "billed", chargedMicroCredits: 28 }); + expect(await balanceOf(accountId)).toBe(16_772); + + const resolved = new Map( + (await firstMonthRows(accountId)).map((row) => [ + row.id, + (row.metadata as { resolvedAt?: string }).resolvedAt, + ]), + ); + for (const copy of copies) { + expect(resolved.get(copy.id)).toEqual(expect.any(String)); + } + expect(resolved.get(copies[0]?.id ?? "")).toBe( + resolved.get(copies[1]?.id ?? ""), + ); + expect(resolved.get(otherId)).toBeUndefined(); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + + const nextVideo = await makeVideo(appId); + await settleFirstMonthAtReady({ id: nextVideo, deletedAt: null }); + const next = (await firstMonthRows(accountId)).find( + (row) => row.referenceId === nextVideo, + ); + expect(next).toMatchObject({ amountMicroCredits: -850 }); + expect(next?.metadata).toMatchObject({ shortSince: null }); + expect(await balanceOf(accountId)).toBe(15_922); + expect((await getInfraBilling(appId)).standing).toEqual({ + state: "active", + pausesAt: null, + }); + }); + + describe("a video deleted while it encodes", () => { + const encoding = (): DeveloperVideoProcessingState => ({ + ...processing(), + probe: null, + mp4: { + state: "running", + token: "t-mp4", + jobId: "job-1", + startedAt: Date.now(), + }, + attempts: { hls: 0, mp4: 1 }, + }); + const deleteNow = (videoId: string) => + database() + .update(developerVideos) + .set({ deletedAt: new Date() }) + .where(eq(developerVideos.id, videoId)); + + it("is charged its first month once, from its declared length at the tier it was created with, and never again", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + duration: 3600, + width: null, + height: null, + maxResolutionTier: "2160p", + processing: encoding(), + }); + await deleteNow(videoId); + + const charged = await settleFirstMonthAtDelete(videoId); + expect(charged).toMatchObject({ + state: "charged", + tier: "2160p", + owedMicroCredits: 16_800, + chargedMicroCredits: 16_800, + }); + expect(await settleFirstMonthAtDelete(videoId)).toEqual({ + state: "skipped", + reason: "already_settled", + }); + + await database() + .update(developerVideos) + .set({ status: "ready" }) + .where(eq(developerVideos.id, videoId)); + await settleFirstMonthAtReady({ id: videoId, deletedAt: new Date() }); + await chargeMissedFirstMonths(new Date()); + + const rows = await firstMonthRows(accountId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + amountMicroCredits: -16_800, + referenceId: videoId, + referenceType: "developer_video", + }); + expect(rows[0]?.metadata).toMatchObject({ deletedWhileEncoding: true }); + expect(await balanceOf(accountId)).toBe(1_000_000 - 16_800); + expect((await videoRow(videoId))?.storagePaidUntil).not.toBeNull(); + }); + + it("is charged when deleted while a failed attempt waits for its retry", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + duration: 600, + processing: { + ...processing(), + probe: null, + mp4: { state: "pending" }, + hls: { state: "pending" }, + attempts: { hls: 0, mp4: 1 }, + }, + }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toMatchObject({ + state: "charged", + owedMicroCredits: 850, + }); + expect(await firstMonthRows(accountId)).toHaveLength(1); + }); + + it("is charged once when deleted after a dispatched attempt failed and was reset, even if the retry then found the farm busy", async () => { + const started = createDeveloperVideoProcessingState({ + encodeId: "encode-1", + now: Date.now(), + }); + const running = beginPart(started, "mp4", { + token: "t-1", + now: Date.now(), + }); + const dispatched = + running && + markPartDispatched(running, "mp4", { token: "t-1", jobId: "job-1" }); + const failed = + dispatched && + settlePart(dispatched, "mp4", "t-1", { + type: "failed", + reason: "encoder stopped", + }); + const retried = + failed && beginPart(failed, "mp4", { token: "t-2", now: Date.now() }); + const busy = retried && releasePart(retried, "mp4", "t-2"); + if (!failed || !busy) throw new Error("state did not advance"); + expect(failed.mp4).toEqual({ state: "pending" }); + expect(busy.mp4).toEqual({ state: "pending" }); + + for (const state of [failed, busy]) { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + duration: 600, + processing: state, + }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toMatchObject({ + state: "charged", + owedMicroCredits: 850, + }); + expect(await settleFirstMonthAtDelete(videoId)).toEqual({ + state: "skipped", + reason: "already_settled", + }); + expect(await firstMonthRows(accountId)).toHaveLength(1); + } + }); + + it("caps the declared length at the 4 hour limit", async () => { + const { appId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + duration: 6 * 60 * 60, + processing: encoding(), + }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toMatchObject({ + state: "charged", + owedMicroCredits: 240 * 85, + }); + }); + + it("pays nothing when deleted before any encode was handed to the farm, including a slot reserved but not yet sent", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const notClaimed = await makeVideo(appId, { + status: "uploading", + processing: null, + playbackId: null, + }); + const claimedNotSent = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + processing: { + ...processing(), + mp4: { state: "pending" }, + attempts: { hls: 0, mp4: 0 }, + }, + }); + const reservedNotSent = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + processing: { + ...processing(), + mp4: { + state: "running", + token: "t-mp4", + jobId: null, + startedAt: Date.now(), + }, + attempts: { hls: 0, mp4: 1 }, + }, + }); + for (const videoId of [notClaimed, claimedNotSent, reservedNotSent]) { + await deleteNow(videoId); + expect(await settleFirstMonthAtDelete(videoId)).toEqual({ + state: "skipped", + reason: "not_dispatched", + }); + expect((await videoRow(videoId))?.storagePaidUntil).toBeNull(); + } + expect(await firstMonthRows(accountId)).toHaveLength(0); + expect(await balanceOf(accountId)).toBe(1_000_000); + }); + + it("pays nothing for an encode that failed on its own, deleted or not", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const failed = await makeVideo(appId, { + status: "errored", + processing: { + ...processing(), + mp4: { state: "failed", reason: "boom" }, + hls: { state: "failed", reason: "boom" }, + attempts: { hls: 2, mp4: 2 }, + }, + }); + await deleteNow(failed); + + expect(await settleFirstMonthAtDelete(failed)).toEqual({ + state: "skipped", + reason: "not_dispatched", + }); + expect(await firstMonthRows(accountId)).toHaveLength(0); + }); + + it("is charged when the whole app is deleted mid-encode, and leaves videos deleted long before alone", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const [owner] = await database() + .select({ ownerId: developerApps.ownerId }) + .from(developerApps) + .where(eq(developerApps.id, appId)); + vi.mocked(getCurrentUser).mockResolvedValue({ + id: owner?.ownerId, + email: "owner@example.com", + } as never); + const encodingNow = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + duration: 600, + processing: encoding(), + }); + const deletedLongAgo = await makeVideo(appId, { + createdAt: new Date("2026-10-01T00:00:00Z"), + deletedAt: new Date("2026-10-02T00:00:00Z"), + status: "processing", + duration: 600, + processing: encoding(), + }); + const readyLongAgo = await makeVideo(appId, { + createdAt: new Date("2026-10-01T00:00:00Z"), + deletedAt: new Date("2026-10-02T00:00:00Z"), + }); + + await deleteDeveloperApp(appId); + await settleFirstMonthAtDelete(encodingNow); + + const rows = await firstMonthRows(accountId); + expect(rows.map((row) => row.referenceId)).toEqual([encodingNow]); + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + for (const videoId of [deletedLongAgo, readyLongAgo]) { + expect((await videoRow(videoId))?.storagePaidUntil).toBeNull(); + } + }); + + it("charges nothing for a pre-feature ready video and marks it exempt", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + createdAt: new Date("2026-10-01T00:00:00Z"), + }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toMatchObject({ + state: "exempt", + }); + expect((await videoRow(videoId))?.storagePaidUntil).not.toBeNull(); + expect(await firstMonthRows(accountId)).toHaveLength(0); + expect(await balanceOf(accountId)).toBe(1_000_000); + }); + + it("charges nothing for a video mid-encode created before first month charging began", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + createdAt: new Date("2026-10-01T00:00:00Z"), + status: "processing", + duration: 600, + processing: encoding(), + }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toMatchObject({ + state: "exempt", + }); + expect(await firstMonthRows(accountId)).toHaveLength(0); + }); + + it("charges nothing more for a post-feature ready video that already paid its month", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { createdAt: new Date() }); + await settleFirstMonthAtReady({ id: videoId, deletedAt: null }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toEqual({ + state: "skipped", + reason: "already_settled", + }); + expect(await firstMonthRows(accountId)).toHaveLength(1); + expect(await balanceOf(accountId)).toBe(1_000_000 - 850); + }); + + it("records a month the balance cannot pay as owed and leaves the delete in place", async () => { + const { appId, accountId } = await makeApp(100); + const videoId = await makeVideo(appId, { + createdAt: new Date(), + status: "processing", + duration: 600, + processing: encoding(), + }); + await deleteNow(videoId); + + expect(await settleFirstMonthAtDelete(videoId)).toMatchObject({ + state: "charged", + owedMicroCredits: 850, + chargedMicroCredits: 100, + }); + expect(await balanceOf(accountId)).toBe(0); + expect((await videoRow(videoId))?.deletedAt).not.toBeNull(); + expect((await getInfraBilling(appId)).standing.state).toBe("grace"); + }); + }); + + it("finds a new first month shortfall for its email once, and never one that only carried an earlier shortfall", async () => { + const { appId, accountId } = await makeApp(0); + const since = new Date(Date.now() - 60 * 60 * 1000); + await settleFirstMonthAtReady({ + id: await makeVideo(appId), + deletedAt: null, + }); + + const found = await shortfallsAwaitingNotice([accountId], since); + expect([...found.keys()]).toEqual([accountId]); + const [first] = await firstMonthRows(accountId); + expect(first?.metadata).toMatchObject({ fellShort: true }); + + await settleFirstMonthAtReady({ + id: await makeVideo(appId), + deletedAt: null, + }); + const rows = await firstMonthRows(accountId); + expect(rows).toHaveLength(2); + expect( + rows.map((row) => (row.metadata as { fellShort: boolean }).fellShort), + ).toEqual(expect.arrayContaining([true, false])); + expect( + (await shortfallsAwaitingNotice([accountId], since)).get(accountId) + ?.ids, + ).toEqual([first?.id]); + + await markShortfallsNotified(found.get(accountId)?.ids ?? [], new Date()); + await markShortfallsNotified( + found.get(accountId)?.ids ?? [], + new Date(Date.now() + 60_000), + ); + expect(await shortfallsAwaitingNotice([accountId], since)).toEqual( + new Map(), + ); + }); + + it("keeps a daily shortfall whose email was not sent for the next run, and drops it once sent", async () => { + const { appId, accountId } = await makeApp(0); + await makeVideo(appId, { createdAt: new Date("2026-10-01T00:00:00Z") }); + const since = new Date(Date.now() - 60 * 60 * 1000); + const today = new Date().toISOString().slice(0, 10); + const usage = ( + await getStorageUsageByApp([appId], undefined, undefined, { + dailyChargeAt: new Date(`${today}T00:00:00.000Z`), + }) + ).get(appId); + if (!usage) throw new Error("no usage"); + + expect( + await chargeStorageDay({ + appId, + accountId, + usage, + dateKey: today, + now: new Date(), + }), + ).toMatchObject({ state: "billed", fellShort: true }); + + const waiting = await shortfallsAwaitingNotice([accountId], since); + expect(waiting.get(accountId)?.ids).toHaveLength(1); + + await markStorageDayNotified(accountId, today, new Date()); + expect(await shortfallsAwaitingNotice([accountId], since)).toEqual( + new Map(), + ); + }); + + it("never throws from the ready path when the charge fails", async () => { + const broken = { + transaction: async () => { + throw new Error("connection lost"); + }, + } as never; + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + settleFirstMonthAtReady({ id: id(), deletedAt: null }, broken), + ).resolves.toBeUndefined(); + expect(errors).toHaveBeenCalled(); + errors.mockRestore(); + }); + + it("admits one upload on a balance that covers one first month, and refuses the others completing at the same time", async () => { + const { appId } = await makeApp(5_600); + const videos = await Promise.all( + Array.from({ length: 4 }, () => + makeVideo(appId, { + status: "processing", + duration: null, + width: null, + height: null, + maxResolutionTier: "2160p", + }), + ), + ); + + const results = await Promise.all( + videos.map((videoId) => + recordAdmittedInfraUpload( + { + appId, + videoId, + durationSeconds: 1200, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", + }, + { duration: 1200, width: 3840, height: 2160 }, + ), + ), + ); + + const admitted = results.filter( + (result) => "allowed" in result && result.allowed, + ); + const refused = results.filter( + (result) => "allowed" in result && !result.allowed, + ); + expect(admitted).toHaveLength(1); + expect(refused).toHaveLength(3); + for (const refusal of refused) { + expect("message" in refusal && refusal.message).toMatch( + /^Not enough credit /, + ); + expect(refusal).toMatchObject({ + reason: "insufficient_credits", + requiredMicroCredits: 11_200, + shortfallMicroCredits: 5_600, + }); + } + const recorded = await Promise.all(videos.map(videoRow)); + expect(recorded.filter((video) => video?.duration === 1200)).toHaveLength( + 1, + ); + }); + + it("needs the first month of the upload and of every upload in flight", async () => { + const { appId } = await makeApp(0); + const upload = { + durationSeconds: 1200, + width: 3840, + height: 2160, + tier: "2160p" as const, + }; + + expect( + (await getInfraUploadAdmission(appId, upload)).requiredMicroCredits, + ).toBe(5_600); + + for (let n = 0; n < 2; n++) { + await makeVideo(appId, { + status: "processing", + duration: 1200, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", + }); + } + expect( + (await getInfraUploadAdmission(appId, upload)).requiredMicroCredits, + ).toBe(16_800); + + const clips = (await makeApp(0)).appId; + for (let n = 0; n < 3; n++) { + await makeVideo(clips, { status: "processing", duration: 1 }); + } + await makeVideo(clips, { status: "processing", duration: 0.2 }); + const clip = { + durationSeconds: 1, + width: 1920, + height: 1080, + tier: "1080p" as const, + }; + expect((await getAppStorage(clips)).firstMonthMicroCredits).toBe(4); + expect( + (await getInfraUploadAdmission(clips, clip)).requiredMicroCredits, + ).toBe(5); + }); + + describe("a ready video whose first month is not charged yet", () => { + const fourK = { + status: "ready" as const, + duration: 1200, + width: 3840, + height: 2160, + maxResolutionTier: "2160p" as const, + storagePaidUntil: null, + }; + const upload = { + durationSeconds: 1200, + width: 3840, + height: 2160, + tier: "2160p" as const, + }; + let sentinel: string; + + beforeAll(async () => { + const { appId } = await makeApp(0); + sentinel = await makeVideo(appId, { + storagePaidUntil: new Date("2026-10-31T00:00:00Z"), + }); + }); + + afterAll(async () => { + await database() + .delete(developerVideos) + .where(eq(developerVideos.id, sentinel)); + }); + + it("still reserves its month, so a second upload the balance cannot pay for both is refused", async () => { + const { appId } = await makeApp(7_000); + await makeVideo(appId, fourK); + + const storage = await getAppStorage(appId); + expect(storage.firstMonthMicroCredits).toBe(5_600); + expect(storage.dailyCharge.totalMinutes).toBe(0); + expect(storage.stored.totalMinutes).toBe(20); + expect(await getInfraUploadAdmission(appId, upload)).toMatchObject({ + allowed: false, + reason: "insufficient_credits", + requiredMicroCredits: 11_200, + }); + }); + + it("stops reserving it once the month is charged", async () => { + const { appId, accountId } = await makeApp(7_000); + const videoId = await makeVideo(appId, fourK); + await settleFirstMonthAtReady({ id: videoId, deletedAt: null }); + expect(await balanceOf(accountId)).toBe(1_400); + + const storage = await getAppStorage(appId); + expect(storage.firstMonthMicroCredits).toBe(0); + expect(storage.stored.totalMinutes).toBe(20); + expect( + (await getInfraUploadAdmission(appId, upload)).requiredMicroCredits, + ).toBe(5_600); + }); + + it("leaves a video created before first month charging began to the daily charge", async () => { + const { appId } = await makeApp(7_000); + await makeVideo(appId, { + ...fourK, + createdAt: new Date("2026-09-01T00:00:00Z"), + }); + + const storage = await getAppStorage(appId); + expect(storage.firstMonthMicroCredits).toBe(0); + expect(storage.dailyCharge.totalMinutes).toBe(20); + expect( + (await getInfraUploadAdmission(appId, upload)).requiredMicroCredits, + ).toBe(6_907); + }); + }); + + it("counts first months in the month's storage spending on the console", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const monthStart = new Date(Date.now() - 60 * 60 * 1000); + await settleFirstMonthAtReady({ + id: await makeVideo(appId), + deletedAt: null, + }); + await settleFirstMonthAtReady({ + id: await makeVideo(appId, { + duration: 3600, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", + }), + deletedAt: null, + }); + await billDay(accountId, "2026-11-13"); + await database() + .update(developerCreditTransactions) + .set({ amountMicroCredits: -28 }) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "storage_daily"), + ), + ); + + expect(await getInfraStorageChargesSince(accountId, monthStart)).toBe( + 850 + 16_800 + 28, + ); + expect( + await getInfraFirstMonthChargesSince(accountId, monthStart), + ).toEqual({ chargedMicroCredits: 850 + 16_800, videos: 2 }); + }); + + it("reports what is stored apart from what the daily charge covers", async () => { + const { appId } = await makeApp(1_000_000); + await makeVideo(appId, { + storagePaidUntil: new Date(Date.now() + 10 * DAY_MS), + }); + await makeVideo(appId, { + storagePaidUntil: new Date(Date.now() - DAY_MS), + duration: 1200, + }); + await makeVideo(appId, { createdAt: new Date("2026-10-01T00:00:00Z") }); + await makeVideo(appId, { status: "processing", duration: 300 }); + await makeVideo(appId, { status: "uploading", duration: null }); + await makeVideo(appId, { status: "errored" }); + + const storage = await getAppStorage(appId); + + expect(storage.stored).toMatchObject({ + videoCount: 3, + totalMinutes: 40, + dailyMicroCredits: Math.floor((40 * 85) / 30), + }); + expect(storage.dailyCharge).toMatchObject({ + videoCount: 2, + totalMinutes: 30, + monthlyMicroCredits: 30 * 85, + dailyMicroCredits: Math.floor((30 * 85) / 30), + }); + expect(storage.firstMonthMicroCredits).toBe(5 * 85); + expect(storage.uploadingVideos).toBe(1); + + const billing = await getInfraBilling(appId); + expect(billing.stored).toEqual(storage.stored); + expect(billing.admission.requiredMicroCredits).toBe( + Math.ceil(5 * 85 + (30 * 85 * 7) / 30), + ); + }); + }, +); diff --git a/apps/web/__tests__/unit/agent-developer-billing.test.ts b/apps/web/__tests__/unit/agent-developer-billing.test.ts index 8e4eb09b546..925548096dc 100644 --- a/apps/web/__tests__/unit/agent-developer-billing.test.ts +++ b/apps/web/__tests__/unit/agent-developer-billing.test.ts @@ -9,10 +9,13 @@ vi.mock("@/lib/infra-standing", () => ({ infraBillingJson: (billing: unknown) => ({ json: billing }), })); +import { Agent } from "@cap/web-domain"; +import { Schema } from "effect"; import { BILLING_ACCESS_HINT, developerAppBillingSection, } from "@/lib/agent-developer-billing"; +import { developerOpenApiSpec } from "@/lib/developer-openapi"; const credits = { balanceMicroCredits: 500_000, @@ -59,6 +62,37 @@ describe("billing data in the developer app context", () => { ); }); + it("returns the same storage fields as GET /usage", () => { + const tiers = { "1080p": 87.5, "1440p": 0, "2160p": 0 }; + const restStorage = + developerOpenApiSpec("https://cap.so").components.schemas.Usage.properties + .storage; + const storage = Object.fromEntries( + restStorage.required.map((field: string) => [ + field, + field.endsWith("ByTier") ? tiers : 170, + ]), + ); + const billing = Schema.encodeSync( + Agent.AgentDeveloperAppContextResponse.fields.billing, + )({ + standing: { state: "active", pausesAt: null }, + upload: { + allowed: true, + reason: null, + requiredMicroCredits: 1190, + shortfallMicroCredits: 0, + message: null, + }, + storage: storage as never, + }); + + expect(Object.keys(billing?.storage ?? {}).sort()).toEqual( + [...restStorage.required].sort(), + ); + expect(restStorage.required).toContain("dailyChargeMinutesByTier"); + }); + it("is the only way the app context handler returns credits or billing", () => { const route = readFileSync( join(process.cwd(), "app/api/v1/[...route]/route.ts"), diff --git a/apps/web/__tests__/unit/agent-developer-routes.test.ts b/apps/web/__tests__/unit/agent-developer-routes.test.ts index fface584a42..5b1eb97bba2 100644 --- a/apps/web/__tests__/unit/agent-developer-routes.test.ts +++ b/apps/web/__tests__/unit/agent-developer-routes.test.ts @@ -23,6 +23,8 @@ const state = vi.hoisted(() => ({ users: [] as Record[], createCheckoutSession: vi.fn(), cancelEncodes: vi.fn(async () => undefined), + settleAtDelete: vi.fn(async () => null), + settleDeletedApp: vi.fn(async () => undefined), videoUpdates: [] as Record[], appStamps: [] as unknown[], })); @@ -31,6 +33,12 @@ vi.mock("@/lib/developer-video-cancel", () => ({ cancelDeveloperVideoEncodes: state.cancelEncodes, })); +vi.mock("@/lib/infra-storage-minimum", async (original) => ({ + ...(await original()), + settleFirstMonthAtDelete: state.settleAtDelete, + settleFirstMonthsForDeletedApp: state.settleDeletedApp, +})); + vi.mock("@cap/utils", async (original) => ({ ...(await original()), stripe: () => ({ @@ -186,6 +194,8 @@ describe("developer routes of the agent API", () => { state.users = []; state.createCheckoutSession.mockReset(); state.cancelEncodes.mockClear(); + state.settleAtDelete.mockClear(); + state.settleDeletedApp.mockClear(); state.videoUpdates = []; state.appStamps = []; }); @@ -320,6 +330,43 @@ describe("developer routes of the agent API", () => { }); }); + it("settles the first month of the videos an app delete stamped, once, after the delete and before the cancel", async () => { + state.apps = [{ id: APP }]; + + const result = await call("DELETE", `/developer/apps/${APP}`); + + expect(result.status).toBe(200); + const deletedAt = state.videoUpdates[0]?.deletedAt; + expect(state.settleDeletedApp).toHaveBeenCalledExactlyOnceWith( + APP, + deletedAt, + ); + const [settled] = state.settleDeletedApp.mock.invocationCallOrder; + const [cancelled] = state.cancelEncodes.mock.invocationCallOrder; + expect(settled).toBeLessThan(cancelled ?? 0); + }); + + it("settles nothing for an app it could not find", async () => { + const result = await call("DELETE", `/developer/apps/${APP}`); + + expect(result.status).toBe(404); + expect(state.settleDeletedApp).not.toHaveBeenCalled(); + }); + + it("settles a deleted video's first month before cancelling its encode", async () => { + state.apps = [{ id: APP }]; + state.videos = [{ id: "video0000000001" }]; + + await call("DELETE", `/developer/apps/${APP}/videos/video0000000001`); + + expect(state.settleAtDelete).toHaveBeenCalledExactlyOnceWith( + "video0000000001", + ); + const [settled] = state.settleAtDelete.mock.invocationCallOrder; + const [cancelled] = state.cancelEncodes.mock.invocationCallOrder; + expect(settled).toBeLessThan(cancelled ?? 0); + }); + it("opens a credit checkout with the console's receipt, tax and payment tag", async () => { state.apps = [{ id: "account-1" }]; state.users = [{ stripeCustomerId: "cus_agent" }]; diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts index 7c1ca4169b2..f64bf130d3f 100644 --- a/apps/web/__tests__/unit/developer-actions.test.ts +++ b/apps/web/__tests__/unit/developer-actions.test.ts @@ -4,6 +4,16 @@ vi.mock("@/lib/infra-access", () => ({ canUseInfra: () => true, })); +const firstMonth = vi.hoisted(() => ({ + atDelete: vi.fn(), + forDeletedApp: vi.fn(), +})); + +vi.mock("@/lib/infra-storage-minimum", () => ({ + settleFirstMonthAtDelete: firstMonth.atDelete, + settleFirstMonthsForDeletedApp: firstMonth.forDeletedApp, +})); + vi.mock("@cap/database", () => { const mockDb = { select: vi.fn(() => mockDb), @@ -305,6 +315,19 @@ describe("deleteDeveloperApp", () => { expect(result).toEqual({ success: true }); }); + it("settles the first month of the videos this delete stamped, after the delete", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await deleteDeveloperApp("app-456"); + const stamps = mockDb.set.mock.calls + .map(([values]) => (values as { deletedAt?: Date }).deletedAt) + .filter(Boolean); + expect(stamps).toHaveLength(2); + expect(stamps[0]?.getMilliseconds()).toBe(0); + expect(firstMonth.forDeletedApp).toHaveBeenCalledWith("app-456", stamps[0]); + expect(stamps[1]).toBe(stamps[0]); + }); + it("cancels the app's encode jobs once the delete has committed", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); @@ -807,20 +830,38 @@ describe("deleteDeveloperVideo", () => { ); }); - it("soft deletes video", async () => { + it("soft deletes video and settles its first month after the delete", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); + mockDb.where + .mockReturnValueOnce(mockDb) + .mockResolvedValueOnce([{ affectedRows: 1 }] as never); await deleteDeveloperVideo("app-456", "video-001"); expect(mockDb.update).toHaveBeenCalled(); expect(mockDb.set).toHaveBeenCalledWith( expect.objectContaining({ deletedAt: expect.any(Date) }), ); + expect(firstMonth.atDelete).toHaveBeenCalledWith("video-001"); + }); + + it("does not settle anything when the video was already deleted or is another app's", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + mockDb.where + .mockReturnValueOnce(mockDb) + .mockResolvedValueOnce([{ affectedRows: 0 }] as never); + await deleteDeveloperVideo("app-456", "video-001"); + + expect(firstMonth.atDelete).not.toHaveBeenCalled(); }); it("returns success true", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); + mockDb.where + .mockReturnValueOnce(mockDb) + .mockResolvedValueOnce([{ affectedRows: 1 }] as never); const result = await deleteDeveloperVideo("app-456", "video-001"); expect(result).toEqual({ success: true }); }); @@ -828,6 +869,9 @@ describe("deleteDeveloperVideo", () => { it("cancels the video's encode jobs after the delete is written", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); + mockDb.where + .mockReturnValueOnce(mockDb) + .mockResolvedValueOnce([{ affectedRows: 1 }] as never); await deleteDeveloperVideo("app-456", "video-001"); diff --git a/apps/web/__tests__/unit/developer-api-contract.test.ts b/apps/web/__tests__/unit/developer-api-contract.test.ts index f7dc25d5476..6e41031f848 100644 --- a/apps/web/__tests__/unit/developer-api-contract.test.ts +++ b/apps/web/__tests__/unit/developer-api-contract.test.ts @@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({ }[], hashKey: vi.fn(), accounts: [] as { balanceMicroCredits: number }[], - videoStats: [] as Record[], storedVideos: [] as Record[], selected: [] as Record[], createVideo: vi.fn(), @@ -26,23 +25,19 @@ vi.mock("@vercel/firewall", () => ({ checkRateLimit: mocks.firewall })); vi.mock("@cap/database", async () => { const schema = await import("@cap/database/schema"); - const rowsFor = (table: unknown, fields?: Record) => { + const rowsFor = (table: unknown) => { if (table === schema.developerApiKeys) return mocks.keyRows; if (table === schema.developerCreditAccounts) return mocks.accounts; - if (table === schema.developerVideos) { - return fields && "createdVideos" in fields - ? mocks.videoStats - : mocks.storedVideos; - } + if (table === schema.developerVideos) return mocks.storedVideos; return []; }; const select = (fields?: Record) => ({ from: (table: unknown) => { if (fields) mocks.selected.push(fields); const where = () => - Object.assign(Promise.resolve(rowsFor(table, fields)), { - limit: async () => rowsFor(table, fields), - groupBy: async () => rowsFor(table, fields), + Object.assign(Promise.resolve(rowsFor(table)), { + limit: async () => rowsFor(table), + groupBy: async () => rowsFor(table), }); const query = { innerJoin: () => query, @@ -176,11 +171,6 @@ const rateLimit = (response: Response) => ({ retryAfter: response.headers.get("Retry-After"), }); -const billingOf = async (billing: Record) => ({ - ...billing, - stored: (await getAppStorage("app-1")).stored, -}); - beforeEach(() => { vi.clearAllMocks(); mocks.hashKey.mockImplementation(async (key: string) => `hash:${key}`); @@ -192,7 +182,6 @@ beforeEach(() => { }, ]; mocks.accounts = [{ balanceMicroCredits: 4_532_000 }]; - mocks.videoStats = [{ createdVideos: 5, uploadedVideos: 4 }]; mocks.storedVideos = [ { appId: "app-1", @@ -202,6 +191,9 @@ beforeEach(() => { maxResolutionTier: "1080p", seconds: 750, count: 3, + rows: 3, + dailySeconds: 750, + dailyCount: 3, }, { appId: "app-1", @@ -211,12 +203,28 @@ beforeEach(() => { maxResolutionTier: "1080p", seconds: 345.21, count: 1, + rows: 1, + dailySeconds: 345.21, + dailyCount: 1, + }, + { + appId: "app-1", + width: null, + height: null, + status: "uploading", + maxResolutionTier: "1080p", + seconds: 0, + count: 0, + rows: 1, + dailySeconds: 0, + dailyCount: 0, }, ]; mocks.selected = []; - mocks.billing.mockImplementation(() => - billingOf({ standing: { state: "active" } }), - ); + mocks.billing.mockImplementation(async (appId: string) => { + const storage = await getAppStorage(appId); + return { standing: { state: "active" }, storage, stored: storage.stored }; + }); mocks.createVideo.mockResolvedValue({ status: 200, body: { videoId: "video-1" }, @@ -1460,20 +1468,18 @@ describe("usage", () => { }, }); - const stats = mocks.selected.find((fields) => "uploadedVideos" in fields); + const aggregate = mocks.selected.filter((fields) => "rows" in fields); + expect(aggregate).toHaveLength(1); const dialect = new MySqlDialect(); const sqlOf = (name: string) => dialect.sqlToQuery( - stats?.[name] as Parameters[0], + aggregate[0]?.[name] as Parameters[0], ).sql; - expect(sqlOf("uploadedVideos")).toBe( - "count(`developer_videos`.`duration`)", - ); - expect(sqlOf("createdVideos")).toBe("count(*)"); + expect(sqlOf("count")).toBe("COUNT(`developer_videos`.`duration`)"); + expect(sqlOf("rows")).toBe("COUNT(*)"); }); it("reports zero for an app with no videos", async () => { - mocks.videoStats = []; mocks.storedVideos = []; mocks.accounts = []; @@ -1504,9 +1510,13 @@ describe("usage", () => { }); it("returns the fields the OpenAPI description requires", async () => { - mocks.billing.mockImplementation(() => - billingOf({ standing: {}, upload: {}, storage: {} }), - ); + const storage = await getAppStorage("app-1"); + mocks.billing.mockResolvedValue({ + standing: {}, + upload: {}, + storage, + stored: storage.stored, + }); const schema = developerOpenApiSpec(ORIGIN).components.schemas.Usage; const data = await usage(); diff --git a/apps/web/__tests__/unit/developer-billing-docs.test.ts b/apps/web/__tests__/unit/developer-billing-docs.test.ts index 6dfcbfa1ef0..0f33dfc2e58 100644 --- a/apps/web/__tests__/unit/developer-billing-docs.test.ts +++ b/apps/web/__tests__/unit/developer-billing-docs.test.ts @@ -24,9 +24,11 @@ vi.mock("@/lib/developer-credits-product", () => ({ developerCreditsProductId: () => "prod_test", })); +import { developerOpenApiSpec } from "@/lib/developer-openapi"; import { dailyStorageMicroCredits, emptyStorageMinutes, + firstMonthStorageMicroCredits, MICRO_CREDITS_PER_DOLLAR, monthlyStorageMicroCredits, SPEECH_DOLLARS_PER_HOUR, @@ -76,6 +78,7 @@ import { docsConfig } from "../../app/(docs)/docs/docs-config"; const billing = getDocBySlug("api/billing")?.content ?? ""; const rest = getDocBySlug("api/rest-api")?.content ?? ""; +const getStarted = getDocBySlug("api/get-started")?.content ?? ""; const POLICY_SENTENCES = [ "Stripe emails a receipt for every payment, a purchase or an auto top-up, to the email on your Cap account.", @@ -163,7 +166,7 @@ describe("the Billing page", () => { for (const tier of INFRA_RESOLUTION_TIERS) { const price = `$${INFRA_STORAGE_DOLLARS_PER_1000_MINUTES_PER_MONTH[tier].toFixed(2)}`; expect(billing).toContain( - `| ${names[tier]} | ${price} per 1,000 minutes per month | Once a day |`, + `| ${names[tier]} | ${price} per 1,000 minutes per month | First month when the video is ready, then once a day after it |`, ); expect(rest).toContain(`| ${price} |`); } @@ -217,6 +220,90 @@ describe("the Billing page", () => { expect(rest).toContain(`covers ${UPLOAD_ADMISSION_DAYS} days of storage`); }); + it("works every first month example out from the pricing functions", () => { + const tiers: Record = { + "1080p": "1080p", + "1440p": "1440p", + "4K": "2160p", + }; + const rows = tableRows(billing, "### The first month"); + expect(rows).toHaveLength(3); + for (const [video, firstMonth, perDay] of rows) { + const match = /^([\d,]+) minutes at (\S+)$/.exec(video ?? ""); + expect(match, video).not.toBeNull(); + const tier = tiers[match?.[2] ?? ""]; + expect(tier, video).toBeDefined(); + if (!tier) continue; + const minutes = Number(match?.[1]?.replaceAll(",", "")); + expect(firstMonth).toBe( + formatCharge( + firstMonthStorageMicroCredits({ + durationSeconds: minutes * 60, + tier, + }), + ), + ); + expect(perDay).toBe( + formatCharge( + dailyStorageMicroCredits({ + ...emptyStorageMinutes(), + [tier]: minutes, + }), + ), + ); + } + const twentyMinutes4K = firstMonthStorageMicroCredits({ + durationSeconds: 1200, + tier: "2160p", + }); + expect(billing).toContain( + `With ${formatNeed(uploadAdmissionMicroCredits(0, twentyMinutes4K))} of credit and nothing else in flight, one 20 minute video billed at 4K is accepted. Three of them in flight need ${formatNeed(uploadAdmissionMicroCredits(0, 3 * twentyMinutes4K))}.`, + ); + expect(billing).toContain( + `The month is ${STORAGE_BILLING_DAYS_PER_MONTH} UTC days`, + ); + }); + + it("states the first month rule and its example the same way in the billing and get started docs", () => { + const rule = + "Each video is charged one month of storage when it first becomes ready, then daily once that month is over. Uploading and encoding are free."; + const tenMinutes = { durationSeconds: 600, tier: "1080p" as const }; + const example = `A 10 minute 1080p video costs ${formatCharge(firstMonthStorageMicroCredits(tenMinutes))} when it becomes ready, then ${formatCharge(dailyStorageMicroCredits({ ...emptyStorageMinutes(), "1080p": 10 }))} a day after its first month.`; + + for (const doc of [billing, getStarted]) { + expect(doc).toContain(rule); + expect(doc).toContain(example); + } + expect(billing).toContain( + "Videos that were ready before November 13, 2026 are not charged a first month.", + ); + expect(billing).not.toMatch(/\b(before|from) today\b/i); + }); + + it("says in the billing and REST docs that a video deleted while it is processed pays its first month", () => { + const deleted = + "A video deleted while it is being processed is charged its first month too"; + expect(billing).toContain(deleted); + expect(rest).toContain(deleted); + expect(rest).not.toMatch(/\b(before|from) today\b/i); + }); + + it("says grace can follow any storage charge, first month or daily", () => { + const outOfCredit = + "| `402` | `out_of_credit` | The app is in `grace` or `paused` because a storage charge could not be paid. The message says how much to add |"; + const graceStanding = + "grace: a storage charge was more than the balance, uploads are blocked"; + const specText = JSON.stringify(developerOpenApiSpec("https://cap.so")); + + expect(rest).toContain(outOfCredit); + expect(specText).toContain(graceStanding); + for (const text of [rest, billing, specText]) { + expect(text).not.toMatch( + /daily storage charge (could not be paid|was more than)/, + ); + } + }); + it("works every speech example out from the pricing functions", () => { const rows = tableRows(billing, "## Speech to text"); expect(rows).toHaveLength(5); @@ -371,17 +458,17 @@ describe("examples in the REST API reference", () => { balanceMicroCredits: body.balanceMicroCredits, standing: { state: "active", pausesAt: null }, lastOwedMicroCredits: 0, - usage: { - monthlyMicroCredits: - (body.requiredMicroCredits * STORAGE_BILLING_DAYS_PER_MONTH) / - UPLOAD_ADMISSION_DAYS, + storage: { + dailyCharge: { monthlyMicroCredits: 0 }, + firstMonthMicroCredits: body.requiredMicroCredits, }, }), ); expect(body).toEqual(refusal); + expect(body.message).toMatch(/^Not enough credit /); expect(body.message).toContain( - `needs ${formatNeed(body.requiredMicroCredits)} of credit`, + `needs ${formatNeed(body.requiredMicroCredits)}, enough for the first month of storage`, ); expect(body.message).toContain( `The balance is ${formatCredits(body.balanceMicroCredits)}, so add at least ${formatNeed(body.shortfallMicroCredits)}.`, @@ -393,13 +480,17 @@ describe("examples in the REST API reference", () => { it("keeps the usage example consistent with the pricing functions", () => { const { data } = jsonAfter(rest, "### Get Usage"); - const monthly = monthlyStorageMicroCredits(data.storage.minutesByTier); + const daily = data.storage.dailyChargeMinutesByTier; + const monthly = monthlyStorageMicroCredits(daily); expect(data.balanceDollars).toBe(creditDollars(data.balanceMicroCredits)); expect(data.storage.monthlyMicroCredits).toBe(Math.round(monthly)); expect(data.storage.dailyMicroCredits).toBe( - dailyStorageMicroCredits(data.storage.minutesByTier), + dailyStorageMicroCredits(daily), ); + for (const tier of INFRA_RESOLUTION_TIERS) { + expect(daily[tier]).toBeLessThanOrEqual(data.storage.minutesByTier[tier]); + } expect(data.upload.requiredMicroCredits).toBe( uploadAdmissionMicroCredits(monthly), ); diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index 7bf720306e0..2561bee6359 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -14,8 +14,15 @@ const mocks = vi.hoisted(() => ({ yesterdayBilled: null as Record[] | null, markers: [] as unknown[], getStorageUsageByApp: vi.fn(), + chargeMissedFirstMonths: vi.fn(), + shortfallsAwaitingNotice: vi.fn(), + openFirstMonthShortfalls: vi.fn(), + markShortfallsNotified: vi.fn(), + markStorageDayNotified: vi.fn(), chargeStorageDay: vi.fn(), + latestStorageCharge: vi.fn(), latestStorageCharges: vi.fn(), + restoredSinceCharge: vi.fn(), runDueAutoTopUps: vi.fn(), sendInfraOutOfCreditEmail: vi.fn(), })); @@ -82,6 +89,13 @@ vi.mock("drizzle-orm", () => ({ vi.mock("@/lib/infra-storage", () => ({ getStorageUsageByApp: mocks.getStorageUsageByApp, })); +vi.mock("@/lib/infra-storage-minimum", () => ({ + chargeMissedFirstMonths: mocks.chargeMissedFirstMonths, + shortfallsAwaitingNotice: mocks.shortfallsAwaitingNotice, + openFirstMonthShortfalls: mocks.openFirstMonthShortfalls, + markShortfallsNotified: mocks.markShortfallsNotified, + markStorageDayNotified: mocks.markStorageDayNotified, +})); vi.mock("@/lib/infra-storage-billing", () => ({ chargeStorageDay: mocks.chargeStorageDay, recordStorageDayVisits: async ({ appIds }: { appIds: string[] }) => { @@ -91,7 +105,9 @@ vi.mock("@/lib/infra-storage-billing", () => ({ vi.mock("@/lib/infra-standing", () => ({ INFRA_GRACE_DAYS: 7, STORAGE_DAY_REFERENCE_TYPE: "storage_day", + latestStorageCharge: mocks.latestStorageCharge, latestStorageCharges: mocks.latestStorageCharges, + restoredSinceCharge: mocks.restoredSinceCharge, })); vi.mock("@/lib/infra-auto-top-up", () => ({ runDueAutoTopUps: mocks.runDueAutoTopUps, @@ -104,6 +120,7 @@ import { GET } from "@/app/api/cron/developer-storage/route"; const CRON_SECRET = "test-cron-secret"; const NO_TOP_UPS = { attempted: 0, succeeded: 0, failed: 0 }; +const NO_FIRST_MONTHS = { charged: 0, short: 0, failed: 0 }; const usage = (dailyMicroCredits: number) => ({ videoCount: dailyMicroCredits > 0 ? 1 : 0, @@ -149,6 +166,16 @@ beforeEach(() => { ]) { mock.mockReset(); } + mocks.chargeMissedFirstMonths.mockReset(); + mocks.chargeMissedFirstMonths.mockResolvedValue(NO_FIRST_MONTHS); + mocks.shortfallsAwaitingNotice.mockReset(); + mocks.shortfallsAwaitingNotice.mockResolvedValue(new Map()); + mocks.markShortfallsNotified.mockReset(); + mocks.markStorageDayNotified.mockReset(); + mocks.openFirstMonthShortfalls.mockReset(); + mocks.openFirstMonthShortfalls.mockResolvedValue(new Set()); + mocks.restoredSinceCharge.mockReset(); + mocks.restoredSinceCharge.mockResolvedValue(false); mocks.runDueAutoTopUps.mockResolvedValue(NO_TOP_UPS); mocks.latestStorageCharges.mockResolvedValue(new Map()); mocks.chargeStorageDay.mockResolvedValue(billed()); @@ -197,7 +224,10 @@ describe("developer-storage cron job", () => { autoTopUps: NO_TOP_UPS, }); expect(body.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(mocks.getStorageUsageByApp).toHaveBeenCalledWith(["app-1", "app-2"]); + expect(mocks.getStorageUsageByApp.mock.calls[0]?.[0]).toEqual([ + "app-1", + "app-2", + ]); expect(mocks.chargeStorageDay).toHaveBeenCalledTimes(2); expect(mocks.chargeStorageDay).toHaveBeenCalledWith( expect.objectContaining({ @@ -207,6 +237,17 @@ describe("developer-storage cron job", () => { dateKey: body.date, }), ); + const measured = new Map([["app-2", usage(56)]]); + mocks.getStorageUsageByApp.mockResolvedValueOnce(measured); + const tx = { lock: "held" }; + const [{ measure }] = mocks.chargeStorageDay.mock.calls[1] ?? [{}]; + expect(await measure(tx)).toEqual(usage(56)); + expect(mocks.getStorageUsageByApp).toHaveBeenLastCalledWith( + ["app-2"], + undefined, + tx, + { dailyChargeAt: expect.any(Date) }, + ); }); it("skips accounts already billed today", async () => { @@ -224,7 +265,7 @@ describe("developer-storage cron job", () => { const { body } = await authorized(); expect(body.appsProcessed).toBe(1); - expect(mocks.getStorageUsageByApp).toHaveBeenCalledWith(["app-2"]); + expect(mocks.getStorageUsageByApp.mock.calls[0]?.[0]).toEqual(["app-2"]); expect(mocks.chargeStorageDay).toHaveBeenCalledTimes(1); expect(mocks.chargeStorageDay.mock.calls[0]?.[0]).toMatchObject({ accountId: "account-2", @@ -397,6 +438,158 @@ describe("developer-storage cron job", () => { expect(body.autoTopUps).toEqual({ attempted: 3, succeeded: 2, failed: 1 }); }); + it("charges missed first months after the first top-ups and before the day is measured", async () => { + const order: string[] = []; + mocks.selects = [[{ id: "account-1", appId: "app-1" }], []]; + mocks.runDueAutoTopUps.mockImplementation(async () => { + order.push("top-up"); + return NO_TOP_UPS; + }); + mocks.chargeMissedFirstMonths.mockImplementation(async () => { + order.push("first months"); + return { charged: 2, short: 1, failed: 0 }; + }); + mocks.getStorageUsageByApp.mockImplementation(async () => { + order.push("measure"); + return new Map([["app-1", usage(28)]]); + }); + + const { body } = await authorized(); + + expect(order).toEqual(["top-up", "first months", "measure", "top-up"]); + expect(body.firstMonths).toEqual({ charged: 2, short: 1, failed: 0 }); + }); + + it("opens the day's charge for a short first month even when nothing is charged daily, so a recovery can be recorded", async () => { + mocks.selects = [[{ id: "account-1", appId: "app-1" }], []]; + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([["app-1", usage(0)]]), + ); + mocks.openFirstMonthShortfalls.mockResolvedValue(new Set(["account-1"])); + + await authorized(); + + expect(mocks.openFirstMonthShortfalls).toHaveBeenCalledWith(["account-1"]); + expect(mocks.chargeStorageDay).toHaveBeenCalledTimes(1); + expect(mocks.chargeStorageDay.mock.calls[0]?.[0]).toMatchObject({ + accountId: "account-1", + measure: expect.any(Function), + }); + }); + + it("emails a new first month shortfall once, like a daily one, with the pause date seven days after it began", async () => { + const shortSince = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const accounts = () => [ + [ + { id: "account-1", appId: "app-1" }, + { id: "account-2", appId: "app-2" }, + { id: "account-3", appId: "app-3" }, + ], + [], + ]; + mocks.selects = accounts(); + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([ + ["app-1", usage(0)], + ["app-2", usage(0)], + ["app-3", usage(0)], + ]), + ); + mocks.shortfallsAwaitingNotice.mockResolvedValueOnce( + new Map([ + ["account-2", { shortSince, ids: ["row-2"] }], + ["account-3", { shortSince, ids: ["row-3"] }], + ]), + ); + const short = { + createdAt: shortSince, + owedMicroCredits: 850, + shortSince, + firstMonth: true, + }; + mocks.latestStorageCharge.mockImplementation( + async (_db: unknown, accountId: string) => + accountId === "account-1" ? null : short, + ); + mocks.restoredSinceCharge.mockImplementation( + async (_db: unknown, accountId: string) => accountId === "account-3", + ); + mocks.sendInfraOutOfCreditEmail.mockResolvedValue(true); + + const before = Date.now(); + await authorized(); + + const [accountIds, since] = + mocks.shortfallsAwaitingNotice.mock.calls[0] ?? []; + expect(accountIds).toEqual(["account-1", "account-2", "account-3"]); + expect(before - (since as Date).getTime()).toBeGreaterThanOrEqual( + 7 * 24 * 60 * 60 * 1000 - 1000, + ); + expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); + expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledWith({ + accountId: "account-2", + shortSince, + pausesAt: new Date(shortSince.getTime() + 7 * 24 * 60 * 60 * 1000), + }); + expect(mocks.markShortfallsNotified.mock.calls.map(([ids]) => ids)).toEqual( + [["row-2"], ["row-3"]], + ); + + mocks.selects = accounts(); + await authorized(); + expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); + }); + + it("stamps a daily shortfall's row once its email was accepted, and leaves a rejected one for the next run", async () => { + const shortSince = new Date("2026-11-11T06:41:00Z"); + const run = async (sent: boolean) => { + mocks.selects = [[{ id: "account-1", appId: "app-1" }], []]; + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([["app-1", usage(28)]]), + ); + mocks.chargeStorageDay.mockResolvedValueOnce( + billed({ chargedMicroCredits: 5, shortSince, fellShort: true }), + ); + mocks.sendInfraOutOfCreditEmail.mockResolvedValueOnce(sent); + return authorized(); + }; + + const { body } = await run(false); + expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); + expect(mocks.markStorageDayNotified).not.toHaveBeenCalled(); + + await run(true); + expect(mocks.markStorageDayNotified).toHaveBeenCalledWith( + "account-1", + body.date, + expect.any(Date), + ); + }); + + it("keeps a first month shortfall to email again when the email could not be sent", async () => { + const shortSince = new Date(); + mocks.selects = [[{ id: "account-1", appId: "app-1" }], []]; + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([["app-1", usage(0)]]), + ); + mocks.shortfallsAwaitingNotice.mockResolvedValueOnce( + new Map([["account-1", { shortSince, ids: ["row-1"] }]]), + ); + mocks.latestStorageCharge.mockResolvedValue({ + createdAt: shortSince, + owedMicroCredits: 850, + shortSince, + firstMonth: true, + }); + mocks.restoredSinceCharge.mockResolvedValue(false); + mocks.sendInfraOutOfCreditEmail.mockResolvedValue(false); + + await authorized(); + + expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); + expect(mocks.markShortfallsNotified).not.toHaveBeenCalled(); + }); + it("answers with zero apps when there are none", async () => { const { body } = await authorized(); diff --git a/apps/web/__tests__/unit/developer-transcript-fake-db.ts b/apps/web/__tests__/unit/developer-transcript-fake-db.ts index a2d00c6d472..07b3506c14d 100644 --- a/apps/web/__tests__/unit/developer-transcript-fake-db.ts +++ b/apps/web/__tests__/unit/developer-transcript-fake-db.ts @@ -7,6 +7,17 @@ type Table = object; const dialect = new MySqlDialect(); +const metadataOf = (value: unknown): Row => { + if (typeof value === "string") { + try { + return metadataOf(JSON.parse(value)); + } catch { + return {}; + } + } + return typeof value === "object" && value !== null ? (value as Row) : {}; +}; + const comparable = (value: unknown) => value instanceof Date ? value.toISOString().slice(0, -1).replace("T", " ") @@ -82,8 +93,40 @@ function compile(condition: SQL | undefined, outer: Row = {}): Predicate { return (row) => row[column] === value; }; + const jsonField = () => { + expect("json_extract"); + expect("("); + const column = columnName(); + expect(","); + const field = take(); + expect(")"); + return (row: Row) => metadataOf(row[column])[field]; + }; + + const jsonComparison = (): Predicate => { + if (peek() === "json_unquote") { + take(); + expect("("); + const field = jsonField(); + expect(")"); + expect("="); + const value = param(); + return (row) => { + const found = field(row); + return found !== null && found !== undefined && String(found) === value; + }; + } + const field = jsonField(); + expect("is"); + expect("null"); + return (row) => field(row) === null || field(row) === undefined; + }; + const comparison = (): Predicate => { if (peek() === "cast") return binaryComparison(); + if (peek() === "json_unquote" || peek() === "json_extract") { + return jsonComparison(); + } const column = columnName(); const operator = take().toLowerCase(); if (operator === "is") { @@ -163,7 +206,8 @@ const columnName = (value: unknown) => : null; const isAggregate = (value: unknown) => - isSql(value) && /SUM\(|count\(\*\)/i.test(dialect.sqlToQuery(value).sql); + isSql(value) && + /SUM\(|count\(\*\)|MAX\(GREATEST\(/i.test(dialect.sqlToQuery(value).sql); function rowExpression(row: Row, field: SQL) { const text = dialect.sqlToQuery(field).sql; @@ -194,6 +238,17 @@ function project(rows: Row[], fields: Record | undefined) { ); } else if (/count\(\*\)/i.test(text)) { result[alias] = rows.length; + } else if (/MAX\(GREATEST\(/i.test(text)) { + const values = rows.map((row) => { + const metadata = metadataOf(row.metadata); + const owed = Number(metadata.owedMicroCredits) || 0; + const charged = Number(metadata.chargedMicroCredits) || 0; + return Math.max( + charged < owed ? owed : 0, + Number(metadata.outstandingMicroCredits) || 0, + ); + }); + result[alias] = values.length > 0 ? Math.max(...values) : null; } else { throw new Error(`Unsupported aggregate ${text}`); } diff --git a/apps/web/__tests__/unit/developer-usage-counts.test.ts b/apps/web/__tests__/unit/developer-usage-counts.test.ts index 50ebc8eac9a..e174c9b6eee 100644 --- a/apps/web/__tests__/unit/developer-usage-counts.test.ts +++ b/apps/web/__tests__/unit/developer-usage-counts.test.ts @@ -16,43 +16,29 @@ const mocks = vi.hoisted(() => ({ vi.mock("@cap/database", async () => { const schema = await import("@cap/database/schema"); - const rowsFor = (table: unknown, fields?: Record) => { + const rowsFor = (table: unknown) => { if (table === schema.developerApiKeys) return [{ appId: "app-1" }]; if (table === schema.developerCreditAccounts) { return [{ balanceMicroCredits: mocks.balanceMicroCredits }]; } if (table !== schema.developerVideos) return []; - if (fields && "createdVideos" in fields) { - return [ - { - createdVideos: mocks.videos.length, - uploadedVideos: mocks.videos.filter( - (video) => video.duration !== null, - ).length, - totalDurationMinutes: - mocks.videos.reduce( - (sum, video) => sum + (video.duration ?? 0), - 0, - ) / 60, - }, - ]; - } - return mocks.videos - .filter((video) => video.duration !== null) - .map((video) => ({ - appId: "app-1", - width: video.width ?? 1920, - height: video.height ?? 1080, - status: video.status, - maxResolutionTier: video.maxResolutionTier ?? "1080p", - seconds: video.duration, - count: 1, - })); + return mocks.videos.map((video) => ({ + appId: "app-1", + width: video.width ?? 1920, + height: video.height ?? 1080, + status: video.status, + maxResolutionTier: video.maxResolutionTier ?? "1080p", + seconds: video.duration ?? 0, + count: video.duration === null ? 0 : 1, + rows: 1, + dailySeconds: video.duration ?? 0, + dailyCount: video.duration === null ? 0 : 1, + })); }; - const select = (fields?: Record) => ({ + const select = () => ({ from: (table: unknown) => { const where = () => { - const rows = Promise.resolve(rowsFor(table, fields)); + const rows = Promise.resolve(rowsFor(table)); const result = Object.assign(rows, { limit: () => result, groupBy: () => result, @@ -86,7 +72,6 @@ vi.mock("@/lib/infra-standing", async (original) => ({ })); import { app } from "@/app/api/developer/v1/[...route]/usage"; -import { storageUsage } from "@/lib/developer-pricing"; import { getAppStorage } from "@/lib/infra-storage"; const usage = async () => { @@ -100,18 +85,21 @@ const usage = async () => { beforeEach(() => { mocks.balanceMicroCredits = 2_999_901; mocks.videos = []; - mocks.billing.mockImplementation(async (appId: string) => ({ - standing: { state: "active", pausesAt: null }, - usage: storageUsage([]), - stored: (await getAppStorage(appId)).stored, - admission: { - allowed: true, - reason: null, - requiredMicroCredits: 1, - shortfallMicroCredits: 0, - message: null, - }, - })); + mocks.billing.mockImplementation(async (appId: string) => { + const storage = await getAppStorage(appId); + return { + standing: { state: "active", pausesAt: null }, + storage, + stored: storage.stored, + admission: { + allowed: true, + reason: null, + requiredMicroCredits: 1, + shortfallMicroCredits: 0, + message: null, + }, + }; + }); }); describe("GET /usage totals", () => { diff --git a/apps/web/__tests__/unit/developer-usage-route.test.ts b/apps/web/__tests__/unit/developer-usage-route.test.ts index ca34d52280c..4f66f2560af 100644 --- a/apps/web/__tests__/unit/developer-usage-route.test.ts +++ b/apps/web/__tests__/unit/developer-usage-route.test.ts @@ -35,6 +35,7 @@ vi.mock("@/lib/developer-key-hash", () => ({ vi.mock("@/lib/infra-standing", () => ({ getInfraBilling: async () => ({ stored: { videoCount: 2, totalMinutes: 12.5 }, + storage: { uploadingVideos: 1 }, }), infraBillingJson: () => ({ standing: { state: "active", pausesAt: null }, diff --git a/apps/web/__tests__/unit/developer-video-api.test.ts b/apps/web/__tests__/unit/developer-video-api.test.ts index 2a2c1fbe00a..b8342f85724 100644 --- a/apps/web/__tests__/unit/developer-video-api.test.ts +++ b/apps/web/__tests__/unit/developer-video-api.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ appId: "app-1", playbackUrl: undefined as string | undefined, redrive: vi.fn(), + settleAtDelete: vi.fn(), cancelJob: vi.fn(), })); @@ -32,6 +33,10 @@ vi.mock("@cap/env", () => ({ vi.mock("@/lib/developer-video-start", () => ({ redriveDeveloperVideoProcessing: mocks.redrive, })); +vi.mock("@/lib/infra-storage-minimum", () => ({ + settleFirstMonthAtDelete: mocks.settleAtDelete, +})); + vi.mock("@/lib/media-client", () => ({ cancelMediaServerVideoJob: mocks.cancelJob, })); @@ -783,6 +788,8 @@ describe("developer video API", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ success: true }); expect(mocks.store.updates).toEqual([{ deletedAt: expect.any(Date) }]); + expect(mocks.settleAtDelete).toHaveBeenCalledTimes(1); + expect(mocks.settleAtDelete).toHaveBeenCalledWith("video-1"); }); it("cancels the encode jobs of a video deleted mid-encode", async () => { @@ -885,6 +892,7 @@ describe("developer video API", () => { expect(await second.json()).toEqual({ success: true }); expect(mocks.store.updates).toHaveLength(1); expect(mocks.store.video?.deletedAt).toBe(deletedAt); + expect(mocks.settleAtDelete).toHaveBeenCalledTimes(1); }); it("still answers 404 for a video of another app, deleted or not", async () => { diff --git a/apps/web/__tests__/unit/developer-video-processing.test.ts b/apps/web/__tests__/unit/developer-video-processing.test.ts index be7367d043a..02231bd29ce 100644 --- a/apps/web/__tests__/unit/developer-video-processing.test.ts +++ b/apps/web/__tests__/unit/developer-video-processing.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ readJob: vi.fn(), cancelJob: vi.fn(), charge: vi.fn(), + firstMonth: vi.fn(), deleteObjects: vi.fn(), sniff: vi.fn(), })); @@ -41,6 +42,9 @@ vi.mock("@/lib/developer-video-storage", () => ({ developerVideoUploadUrl: async (key: string, contentType: string) => `https://storage/put/${key}?type=${contentType}`, })); +vi.mock("@/lib/infra-storage-minimum", () => ({ + settleFirstMonthAtReady: mocks.firstMonth, +})); vi.mock("@/lib/developer-video-charge", () => ({ DEVELOPER_VIDEO_MAX_DURATION_SECONDS: 4 * 60 * 60, chargeDeveloperVideoUpload: mocks.charge, @@ -201,6 +205,64 @@ describe("developer video webhook", () => { expect(video().storedBytes).toBe(1_000 + 2_000 + 50); }); + it("charges the first month once, after the video turns ready, and not when its second part lands", async () => { + mocks.store.video = developerVideo({ + processing: processingState({ + hls: runningPart("t-hls"), + mp4: runningPart("t-mp4"), + }), + }); + mocks.firstMonth.mockImplementation(async () => { + expect(video().status).toBe("ready"); + }); + + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "complete" }, + "mp4", + "t-mp4", + ); + + expect(mocks.firstMonth).toHaveBeenCalledTimes(1); + expect(mocks.firstMonth.mock.calls[0]?.[0]).toMatchObject({ + id: "video-1", + status: "ready", + deletedAt: null, + }); + + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "complete", ladder }, + "hls", + "t-hls", + ); + + expect(video().processing?.hls.state).toBe("complete"); + expect(mocks.firstMonth).toHaveBeenCalledTimes(1); + }); + + it("does not charge the first month for a part that leaves the video encoding or failed", async () => { + mocks.store.video = developerVideo({ + processing: processingState({ + hls: runningPart("t-hls"), + mp4: runningPart("t-mp4"), + attempts: { hls: 2, mp4: 2 }, + }), + }); + + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "mp4", + "t-mp4", + ); + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "t-hls", + ); + + expect(video().status).toBe("errored"); + expect(mocks.firstMonth).not.toHaveBeenCalled(); + }); + it("marks the video ready with its renditions once both parts are stored", async () => { mocks.store.video = developerVideo({ duration: 999, diff --git a/apps/web/__tests__/unit/developer-video-upload.test.ts b/apps/web/__tests__/unit/developer-video-upload.test.ts index 1465c6f768c..a209737f43e 100644 --- a/apps/web/__tests__/unit/developer-video-upload.test.ts +++ b/apps/web/__tests__/unit/developer-video-upload.test.ts @@ -96,58 +96,50 @@ vi.mock("@/workflows/process-developer-video", () => ({ vi.mock("@/lib/infra-storage", async () => { const schema = await import("@cap/database/schema"); - const { billableVideo, storageUsage } = await import( - "@/lib/developer-pricing" - ); - const storage = { - getAppStorageUsage: async ( + const { billableVideo, firstMonthStorageMicroCredits, storageUsage } = + await import("@/lib/developer-pricing"); + return { + getAppStorage: async ( appId: string, newVideo?: Parameters[0][number]["video"], _executor?: unknown, - options: { includeProcessing?: boolean; excludeVideoId?: string } = {}, + options: { excludeVideoId?: string } = {}, ) => { - const entries = (context.fake?.rows(schema.developerVideos) ?? []) - .filter( - (row) => - row.appId === appId && - !row.deletedAt && - row.duration !== null && - row.id !== options.excludeVideoId, - ) - .map((row) => ({ - video: billableVideo({ - duration: row.duration as number, - width: row.width as number | null, - height: row.height as number | null, - status: row.status as string, - maxResolutionTier: row.maxResolutionTier as string | null, - includeProcessing: options.includeProcessing, - }), - count: 1, - })); - if (newVideo) entries.push({ video: newVideo, count: 1 }); - return storageUsage(entries); + const rows = (context.fake?.rows(schema.developerVideos) ?? []).filter( + (row) => + row.appId === appId && + !row.deletedAt && + row.duration !== null && + row.id !== options.excludeVideoId, + ); + const entriesIn = (status: string) => + rows + .filter((row) => row.status === status) + .map((row) => ({ + video: billableVideo({ + duration: row.duration as number, + width: row.width as number | null, + height: row.height as number | null, + maxResolutionTier: row.maxResolutionTier as string | null, + }), + count: 1, + })); + const ready = entriesIn("ready"); + const due = entriesIn("processing"); + if (newVideo) due.push({ video: newVideo, count: 1 }); + return { + stored: storageUsage(ready), + dailyCharge: storageUsage(ready), + firstMonthMicroCredits: due.reduce( + (sum, entry) => + sum + + (entry.video ? firstMonthStorageMicroCredits(entry.video) : 0), + 0, + ), + uploadingVideos: 0, + }; }, }; - return { - ...storage, - getAppStorage: async ( - ...[appId, newVideo, executor, options]: Parameters< - typeof storage.getAppStorageUsage - > - ) => ({ - stored: await storage.getAppStorageUsage( - appId, - newVideo, - executor, - options, - ), - counted: await storage.getAppStorageUsage(appId, newVideo, executor, { - ...options, - includeProcessing: true, - }), - }), - }; }); vi.mock("@/lib/developer-transcript-storage", () => ({ @@ -223,8 +215,8 @@ const storedCopies = () => [...context.objects.keys()].filter((key) => key.startsWith(STORED_PREFIX)); const START = Date.parse("2026-11-11T10:05:00.000Z"); const FOUR_K = { duration: 7200, width: 3840, height: 2160, fps: 30 }; -const CAPPED_4K_ADMISSION = 2_380; -const UNCAPPED_4K_ADMISSION = 7_840; +const CAPPED_4K_ADMISSION = 10_200; +const UNCAPPED_4K_ADMISSION = 33_600; const create = (overrides: Record = {}) => createDeveloperVideoUpload("app-1", { @@ -386,7 +378,7 @@ describe("developer video upload target", () => { ok: false, status: 402, code: "insufficient_credits", - message: "This app has no credit. Add credits to start uploading.", + message: "Not enough credit to upload. Add credits to start uploading.", shortfallMicroCredits: 1, }); expect(videos()).toHaveLength(0); @@ -407,7 +399,7 @@ describe("developer video upload target", () => { status: 402, code: "insufficient_credits", message: - "Uploading needs $0.02380 of credit, enough for 7 days of storage for everything this app stores. The balance is $0.01, so add at least $0.01380.", + "Not enough credit to upload. Uploading needs $0.02380, enough for 7 days of storage for the videos past their first month. The balance is $0.01, so add at least $0.01380.", shortfallMicroCredits: 1_380, }); }); @@ -723,7 +715,7 @@ describe("admission when the upload is recorded", () => { status: 402, code: "insufficient_credits", message: - "Uploading needs $0.02380 of credit, enough for 7 days of storage for everything this app stores. The balance is $0.01, so add at least $0.01380.", + "Not enough credit to upload. Uploading needs $0.10200, enough for the first month of storage for each video being uploaded or encoded. The balance is $0.01, so add at least $0.09200.", shortfallMicroCredits: CAPPED_4K_ADMISSION - 1_000, }); expect(videos()[0]).toMatchObject({ diff --git a/apps/web/__tests__/unit/infra-billing-fake-db.ts b/apps/web/__tests__/unit/infra-billing-fake-db.ts index 495b7a66990..643b4ae20f3 100644 --- a/apps/web/__tests__/unit/infra-billing-fake-db.ts +++ b/apps/web/__tests__/unit/infra-billing-fake-db.ts @@ -40,6 +40,8 @@ export type VideoGroup = { count: number; status?: string; maxResolutionTier?: string | null; + dailySeconds?: number; + dailyCount?: number; }; const freshAccount = () => ({ @@ -295,6 +297,28 @@ function runSelect(query: Query): Record[] { } if (query.table === developerCreditTransactions) { + if (keys === "createdAt") { + const type = rendered.params[1]; + return latestWritten( + ledger.rows.filter((row) => row.type === type), + ).slice(0, 1); + } + if (keys === "createdAt,id,metadata") { + if ( + rendered.sql.includes( + "`developer_credit_transactions`.`referenceId` <= ?", + ) + ) { + return latestStorageDayRows().slice(0, 1); + } + const [, type, at] = rendered.params; + const ofType = latestWritten( + ledger.rows.filter((row) => row.type === type), + ); + return at === undefined + ? ofType.slice(0, 1) + : ofType.filter((row) => row.createdAt.getTime() === asTime(at)); + } if (keys === "createdAt,metadata") { return rendered.sql.includes( "`developer_credit_transactions`.`referenceId` <= ?", @@ -305,6 +329,25 @@ function runSelect(query: Query): Record[] { if (keys === "accountId,createdAt,id,metadata") { return latestStorageDayRows(); } + if (keys === "owedMicroCredits") { + const shortSince = rendered.params.at(-1); + const owed = ledger.rows + .filter( + (row) => + row.type === "video_create" && + row.metadata?.shortSince === shortSince && + row.metadata?.resolvedAt === undefined, + ) + .map((row) => { + const owedMicroCredits = Number(row.metadata?.owedMicroCredits) || 0; + const charged = Number(row.metadata?.chargedMicroCredits) || 0; + return Math.max( + charged < owedMicroCredits ? owedMicroCredits : 0, + Number(row.metadata?.outstandingMicroCredits) || 0, + ); + }); + return [{ owedMicroCredits: owed.length ? Math.max(...owed) : null }]; + } if (keys === "metadata") { const since = asTime(rendered.params.at(-1)); return storageRows().filter((row) => row.createdAt.getTime() >= since); @@ -446,11 +489,22 @@ function runSelect(query: Query): Record[] { } if (query.table === developerVideos) { - return rendered.sql.includes("<> ?") + const videos = rendered.sql.includes("<> ?") ? ledger.videos.filter( (video) => !video.videoId || !rendered.params.includes(video.videoId), ) : ledger.videos; + const firstMonth = (seconds: number, perMinute: number) => + Math.max(1, Math.floor((seconds / 60) * perMinute)); + return videos.map((video) => ({ + ...video, + status: video.status ?? "ready", + dailySeconds: video.dailySeconds ?? video.seconds, + dailyCount: video.dailyCount ?? video.count, + firstMonth1080p: firstMonth(video.seconds, 85), + firstMonth1440p: firstMonth(video.seconds, 145), + firstMonth2160p: firstMonth(video.seconds, 280), + })); } throw new Error(`Unexpected select: ${keys}`); @@ -491,6 +545,31 @@ function update(table: unknown) { set(values: Record) { return { async where(condition?: SQL) { + if (table === developerCreditTransactions && values.metadata) { + const params = condition + ? dialect.sqlToQuery(condition).params + : []; + const resolvedAt = dialect + .sqlToQuery(values.metadata as SQL) + .params.find((param) => typeof param === "string"); + const [first, ...rest] = params; + const types = rest.slice(0, -1); + const shortSince = rest.at(-1); + const rows = ledger.rows.filter((entry) => + types.includes("video_create") + ? entry.accountId === first && + types.includes(entry.type) && + entry.metadata?.shortSince === shortSince + : entry.id === first, + ); + let affectedRows = 0; + for (const row of rows) { + if (row.metadata?.resolvedAt !== undefined) continue; + row.metadata = { ...row.metadata, resolvedAt }; + affectedRows++; + } + return [{ affectedRows }]; + } if (table === developerCreditTransactions && condition) { const [accountId, referenceId, referenceType, rowId] = dialect.sqlToQuery(condition).params; diff --git a/apps/web/__tests__/unit/infra-billing-math.test.ts b/apps/web/__tests__/unit/infra-billing-math.test.ts index 1e6c04c8371..9871338d4fd 100644 --- a/apps/web/__tests__/unit/infra-billing-math.test.ts +++ b/apps/web/__tests__/unit/infra-billing-math.test.ts @@ -199,6 +199,10 @@ describe("storage rates", () => { describe("upload admission", () => { const active = { state: "active" as const, pausesAt: null }; + const pastFirstMonth = (monthlyMicroCredits: number) => ({ + dailyCharge: { monthlyMicroCredits }, + firstMonthMicroCredits: 0, + }); it("needs seven days of storage, rounded up, and at least one micro-credit", () => { expect(UPLOAD_ADMISSION_DAYS).toBe(7); @@ -207,12 +211,54 @@ describe("upload admission", () => { expect(uploadAdmissionMicroCredits(4335)).toBe(1012); }); + it("needs the whole first month of every video still to be charged, plus seven days of the rest", () => { + expect(uploadAdmissionMicroCredits(0, 5_600)).toBe(5_600); + expect(uploadAdmissionMicroCredits(0, 3 * 5_600)).toBe(16_800); + expect(uploadAdmissionMicroCredits(85_000, 5_600)).toBe(25_434); + expect(uploadAdmissionMicroCredits(0, 0.4)).toBe(1); + }); + + it("says what the credit is for when videos are still to be charged their first month", () => { + const firstMonthOnly = uploadAdmission({ + balanceMicroCredits: 5_599, + standing: active, + lastOwedMicroCredits: 0, + storage: { + dailyCharge: { monthlyMicroCredits: 0 }, + firstMonthMicroCredits: 5_600, + }, + }); + expect(firstMonthOnly).toMatchObject({ + allowed: false, + reason: "insufficient_credits", + requiredMicroCredits: 5_600, + shortfallMicroCredits: 1, + }); + expect(firstMonthOnly.message).toBe( + "Not enough credit to upload. Uploading needs $0.05600, enough for the first month of storage for each video being uploaded or encoded. The balance is $0.05599, so add at least $0.00001.", + ); + + const both = uploadAdmission({ + balanceMicroCredits: 0, + standing: active, + lastOwedMicroCredits: 0, + storage: { + dailyCharge: { monthlyMicroCredits: 85_000 }, + firstMonthMicroCredits: 16_800, + }, + }); + expect(both.requiredMicroCredits).toBe(36_634); + expect(both.message).toBe( + "Not enough credit to upload. Add at least $0.36634, enough for the first month of storage for each video being uploaded or encoded and 7 days of storage for the videos past their first month.", + ); + }); + it("admits when the balance covers seven days of everything stored", () => { const admission = uploadAdmission({ balanceMicroCredits: 19_834, standing: active, lastOwedMicroCredits: 0, - usage: { monthlyMicroCredits: 85_000 }, + storage: pastFirstMonth(85_000), }); expect(admission).toMatchObject({ allowed: true, @@ -228,13 +274,13 @@ describe("upload admission", () => { balanceMicroCredits: 19_833, standing: active, lastOwedMicroCredits: 0, - usage: { monthlyMicroCredits: 85_000 }, + storage: pastFirstMonth(85_000), }); expect(admission.allowed).toBe(false); expect(admission.reason).toBe("insufficient_credits"); expect(admission.shortfallMicroCredits).toBe(1); expect(admission.message).toBe( - "Uploading needs $0.19834 of credit, enough for 7 days of storage for everything this app stores. The balance is $0.19833, so add at least $0.00001.", + "Not enough credit to upload. Uploading needs $0.19834, enough for 7 days of storage for the videos past their first month. The balance is $0.19833, so add at least $0.00001.", ); }); @@ -243,12 +289,12 @@ describe("upload admission", () => { balanceMicroCredits: 967, standing: active, lastOwedMicroCredits: 0, - usage: { monthlyMicroCredits: 4_675 }, + storage: pastFirstMonth(4_675), }); expect(admission.requiredMicroCredits).toBe(1_091); expect(admission.shortfallMicroCredits).toBe(124); expect(admission.message).toBe( - "Uploading needs $0.01091 of credit, enough for 7 days of storage for everything this app stores. The balance is $0.00967, so add at least $0.00124.", + "Not enough credit to upload. Uploading needs $0.01091, enough for 7 days of storage for the videos past their first month. The balance is $0.00967, so add at least $0.00124.", ); }); @@ -257,12 +303,12 @@ describe("upload admission", () => { balanceMicroCredits: 0, standing: active, lastOwedMicroCredits: 0, - usage: { monthlyMicroCredits: 0 }, + storage: pastFirstMonth(0), }); expect(admission.allowed).toBe(false); expect(admission.reason).toBe("insufficient_credits"); expect(admission.message).toBe( - "This app has no credit. Add credits to start uploading.", + "Not enough credit to upload. Add credits to start uploading.", ); }); @@ -272,7 +318,7 @@ describe("upload admission", () => { balanceMicroCredits: 50, standing: { state, pausesAt: new Date("2026-11-18T00:00:00Z") }, lastOwedMicroCredits: 144, - usage: { monthlyMicroCredits: 4335 }, + storage: pastFirstMonth(4335), }); expect(admission.allowed).toBe(false); expect(admission.reason).toBe("out_of_credit"); @@ -289,7 +335,7 @@ describe("upload admission", () => { balanceMicroCredits: 0, standing: { state: "grace", pausesAt: new Date() }, lastOwedMicroCredits: 5000, - usage: { monthlyMicroCredits: 300 }, + storage: pastFirstMonth(300), }); expect(admission.requiredMicroCredits).toBe(5000); }); @@ -301,7 +347,7 @@ describe("upload admission", () => { balanceMicroCredits: 0, standing: { state: "grace", pausesAt }, lastOwedMicroCredits: 144, - usage: { monthlyMicroCredits: 4335 }, + storage: pastFirstMonth(4335), }), ); expect(body).toMatchObject({ @@ -515,4 +561,38 @@ describe("standing", () => { .shortSince, ).toBe(null); }); + + it("keeps a carried first month shortfall in grace until the balance covers what is still unpaid", () => { + const carried = parseStorageCharge({ + createdAt: at("2026-11-12T09:00:00Z"), + metadata: { + owedMicroCredits: 850, + chargedMicroCredits: 850, + outstandingMicroCredits: 16_800, + shortSince: shortSince.toISOString(), + fellShort: false, + }, + }); + + expect(carried).toMatchObject({ owedMicroCredits: 16_800, shortSince }); + expect( + resolveStanding({ + balanceMicroCredits: 50, + lastCharge: carried, + restored: false, + now: at("2026-11-12T09:00:00Z"), + }), + ).toEqual({ + state: "grace", + pausesAt: at("2026-11-18T06:41:00Z"), + }); + expect( + resolveStanding({ + balanceMicroCredits: 16_800, + lastCharge: carried, + restored: false, + now: at("2026-11-12T09:00:00Z"), + }).state, + ).toBe("active"); + }); }); diff --git a/apps/web/__tests__/unit/infra-console-round-trips.test.ts b/apps/web/__tests__/unit/infra-console-round-trips.test.ts index 5e23700765a..ab2a605e2bd 100644 --- a/apps/web/__tests__/unit/infra-console-round-trips.test.ts +++ b/apps/web/__tests__/unit/infra-console-round-trips.test.ts @@ -129,7 +129,7 @@ describe("storage totals are computed once per request", () => { await InfraOverviewPage(params); expect(storageAggregates()).toHaveLength(1); - expect(state.queries).toHaveLength(8); + expect(state.queries).toHaveLength(10); }); it("on the console usage page", async () => { @@ -159,7 +159,7 @@ describe("storage totals are computed once per request", () => { "app-1", undefined, undefined, - { storage: { stored: billing.stored, counted: billing.usage } }, + { storage: billing.storage }, ); expect(storageAggregates()).toHaveLength(0); diff --git a/apps/web/__tests__/unit/infra-landing.test.ts b/apps/web/__tests__/unit/infra-landing.test.ts index 30ca670a6cc..846e6eda723 100644 --- a/apps/web/__tests__/unit/infra-landing.test.ts +++ b/apps/web/__tests__/unit/infra-landing.test.ts @@ -41,6 +41,7 @@ import { getDocBySlug } from "@/utils/docs"; const rest = getDocBySlug("api/rest-api")?.content ?? ""; const billing = getDocBySlug("api/billing")?.content ?? ""; +const getStarted = getDocBySlug("api/get-started")?.content ?? ""; const PAGE_DIR = path.join(process.cwd(), "components/pages/infra"); @@ -142,6 +143,23 @@ describe("the Cap Infra landing page", () => { expect(text).not.toContain(String.fromCodePoint(0x2014)); }); + it("states the first month of storage in the words the docs use", async () => { + const content = await load("true"); + + expect(billing).toContain(content.STORAGE_RULE); + expect(getStarted).toContain(content.STORAGE_RULE); + expect( + content.infraFaqs.find( + (faq) => faq.question === "What does Cap Infra charge for?", + )?.answer, + ).toBe( + `Two things: stored video and speech to text. ${content.STORAGE_RULE}`, + ); + expect( + pageSources().find((source) => source.file === "Pricing.tsx")?.text, + ).toContain("{STORAGE_RULE}"); + }); + it("asks for access while Cap Infra is in preview", async () => { const content = await load("true"); diff --git a/apps/web/__tests__/unit/infra-ledger.test.ts b/apps/web/__tests__/unit/infra-ledger.test.ts index ed00d305a61..6f1571b3f49 100644 --- a/apps/web/__tests__/unit/infra-ledger.test.ts +++ b/apps/web/__tests__/unit/infra-ledger.test.ts @@ -257,6 +257,60 @@ describe("what each row says", () => { }); }); +describe("a video's first month of storage", () => { + const firstMonth = ( + amount: number, + owed: number, + shortSince: string | null, + ) => + row({ + type: "video_create", + amountMicroCredits: amount, + referenceId: "v1d3o9k2m4x7n1p", + referenceType: "developer_video", + metadata: { + owedMicroCredits: owed, + chargedMicroCredits: -amount, + shortSince, + durationSeconds: 600, + tier: "1080p", + paidUntil: "2026-12-12T00:00:00.000Z", + }, + }); + + it("names a paid month and points at the video", () => { + expect(describeLedgerRow(firstMonth(-850, 850, null))).toEqual({ + label: "Storage, first month", + note: "One month of storage for this video, charged when it became ready. It is charged daily after that.", + reference: { kind: "Video", value: "v1d3o9k2m4x7n1p" }, + }); + }); + + it("names a paid month as paid while it carries an earlier unpaid month", () => { + const row = firstMonth(-850, 850, "2026-11-12T14:05:09.000Z"); + expect( + describeLedgerRow({ + ...row, + metadata: { + ...(row.metadata as Record), + outstandingMicroCredits: 16_800, + }, + }).label, + ).toBe("Storage, first month"); + }); + + it("says when the balance paid part of the month or none of it", () => { + const since = "2026-11-12T14:05:09.000Z"; + expect(describeLedgerRow(firstMonth(-300, 850, since)).label).toBe( + "Storage, first month, part paid", + ); + expect(describeLedgerRow(firstMonth(0, 850, since))).toMatchObject({ + label: "Storage, first month, unpaid", + note: "The balance could not pay for this video's first month. It is not charged later.", + }); + }); +}); + describe("the API shape", () => { it("carries exact dollars beside the micro-credits", () => { expect(serializeLedgerRow(row())).toEqual({ diff --git a/apps/web/__tests__/unit/infra-out-of-credit-email.test.ts b/apps/web/__tests__/unit/infra-out-of-credit-email.test.ts new file mode 100644 index 00000000000..80897c5f1c4 --- /dev/null +++ b/apps/web/__tests__/unit/infra-out-of-credit-email.test.ts @@ -0,0 +1,90 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ sendEmail: vi.fn() })); + +vi.mock("@cap/database", () => { + const chain = { + select: () => chain, + from: () => chain, + innerJoin: () => chain, + where: () => chain, + limit: async () => [ + { appId: "app-1", appName: "Acme", email: "owner@example.com" }, + ], + }; + return { db: () => chain }; +}); +vi.mock("@cap/database/emails/config", () => ({ sendEmail: mocks.sendEmail })); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + CAP_INFRA_BILLING_EMAILS: true, + WEB_URL: "https://cap.so", + }), +})); +vi.mock("@/lib/infra-access", () => ({ canUseInfra: () => true })); + +import { InfraOutOfCredit } from "@cap/database/emails/infra-out-of-credit"; +import { sendInfraOutOfCreditEmail } from "@/lib/infra-billing-emails"; + +const shortSince = new Date("2026-11-13T06:41:00Z"); +const send = () => + sendInfraOutOfCreditEmail({ + accountId: "account-1", + shortSince, + pausesAt: new Date(shortSince.getTime() + 7 * 24 * 60 * 60 * 1000), + }); + +beforeEach(() => { + mocks.sendEmail.mockReset(); +}); + +describe("the out-of-credit email", () => { + it("reports a send only when the email service accepted it", async () => { + mocks.sendEmail.mockResolvedValueOnce({ data: { id: "e-1" }, error: null }); + expect(await send()).toBe(true); + + mocks.sendEmail.mockResolvedValueOnce({ + data: null, + error: { name: "validation_error", message: "rejected" }, + }); + expect(await send()).toBe(false); + + mocks.sendEmail.mockResolvedValueOnce(undefined); + expect(await send()).toBe(false); + }); + + it("uses one dedupe key per account and shortfall day, and pauses seven days after the shortfall", async () => { + mocks.sendEmail.mockResolvedValue({ data: { id: "e-1" }, error: null }); + await send(); + + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: "infra-out-of-credit-account-1-2026-11-13", + }), + ); + const html = renderToStaticMarkup(mocks.sendEmail.mock.calls[0]?.[0].react); + expect(html).toContain("November 20"); + }); + + it("words the shortfall for any storage charge", () => { + const html = renderToStaticMarkup( + InfraOutOfCredit({ + email: "owner@example.com", + appName: "Acme", + billingUrl: "https://cap.so/billing", + pausesOn: "November 20", + }), + ); + const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "); + + expect(text).toContain( + "A storage charge for Acme was more than its Cap Infra balance, so the balance is now zero.", + ); + expect(text).toContain( + "Adding credits brings the app straight back. What it could not pay is not charged afterwards.", + ); + expect(text).not.toMatch(/\btoday\b|\bdaily\b|\bdays it could not\b/i); + expect(html).not.toContain(String.fromCodePoint(0x2014)); + }); +}); diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index d77dc3305f8..8ccfbf2f04b 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -20,8 +20,8 @@ import { holder.db = fakeDb; import { + firstMonthStorageMicroCredits, storageUsage, - uploadAdmissionMicroCredits, } from "@/lib/developer-pricing"; import { admitDeveloperVideoUpload } from "@/lib/developer-video-charge"; import { availableBalance } from "@/lib/infra-balance"; @@ -32,7 +32,7 @@ import { latestStorageCharge, latestStorageCharges, } from "@/lib/infra-standing"; -import { getAppStorageUsage } from "@/lib/infra-storage"; +import { getAppStorage } from "@/lib/infra-storage"; import { chargeStorageDay } from "@/lib/infra-storage-billing"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -49,7 +49,7 @@ const charge = async () => chargeStorageDay({ appId: "app-1", accountId: "account-1", - usage: await getAppStorageUsage("app-1"), + usage: (await getAppStorage("app-1")).dailyCharge, dateKey: dateKey(), now: new Date(ledger.now), }); @@ -58,7 +58,7 @@ const finish = async (day: string) => chargeStorageDay({ appId: "app-1", accountId: "account-1", - usage: await getAppStorageUsage("app-1"), + usage: (await getAppStorage("app-1")).dailyCharge, dateKey: day, now: new Date(ledger.now), retryUnpaid: false, @@ -383,6 +383,7 @@ describe("daily storage charge", () => { ); expect(await latestStorageCharge(fakeDb as never, "account-1")).toEqual({ + id: "row-a", createdAt: new Date(ledger.now), owedMicroCredits: 144, shortSince, @@ -392,6 +393,7 @@ describe("daily storage charge", () => { "account-1", ), ).toEqual({ + id: "row-a", createdAt: new Date(ledger.now), owedMicroCredits: 144, shortSince, @@ -506,7 +508,7 @@ describe("admission prices a video the way the storage job will", () => { ledger.videos = []; }); - it("requires exactly 7 days of what the job charges for the same row", async () => { + it("requires exactly the first month the charge at ready takes for the same row", async () => { ledger.account.balanceMicroCredits = 1_000_000; ledger.videos = [ { @@ -519,7 +521,7 @@ describe("admission prices a video the way the storage job will", () => { maxResolutionTier: capped.maxResolutionTier, }, ]; - const jobUsage = await getAppStorageUsage("app-1"); + const jobUsage = (await getAppStorage("app-1")).dailyCharge; ledger.videos = []; const admission = await admitDeveloperVideoUpload(capped); @@ -530,16 +532,20 @@ describe("admission prices a video the way the storage job will", () => { "2160p": 0, }); expect(admission.requiredMicroCredits).toBe( - uploadAdmissionMicroCredits(jobUsage.monthlyMicroCredits), + firstMonthStorageMicroCredits({ + durationSeconds: capped.durationSeconds, + tier: "1080p", + }), ); - expect(admission.requiredMicroCredits).toBe(1190); + expect(admission.requiredMicroCredits).toBe(jobUsage.monthlyMicroCredits); + expect(admission.requiredMicroCredits).toBe(5100); }); it("admits a 4K source capped at 1080p on a balance that covers 1080p storage, and refuses one that does not", async () => { - ledger.account.balanceMicroCredits = 1190; + ledger.account.balanceMicroCredits = 5100; expect((await admitDeveloperVideoUpload(capped)).allowed).toBe(true); - ledger.account.balanceMicroCredits = 1189; + ledger.account.balanceMicroCredits = 5099; expect(await admitDeveloperVideoUpload(capped)).toMatchObject({ allowed: false, reason: "insufficient_credits", @@ -548,14 +554,238 @@ describe("admission prices a video the way the storage job will", () => { }); it("needs 4K credit when the video is allowed to be stored at 4K", async () => { - ledger.account.balanceMicroCredits = 1190; + ledger.account.balanceMicroCredits = 5100; expect( await admitDeveloperVideoUpload({ ...capped, maxResolutionTier: "2160p", }), - ).toMatchObject({ allowed: false, requiredMicroCredits: 3920 }); + ).toMatchObject({ allowed: false, requiredMicroCredits: 16_800 }); + }); +}); + +describe("first months charged in the same second", () => { + const firstMonth = (amount: number, shortSince: Date | null) => ({ + id: `first-${ledger.rows.length}`, + accountId: "account-1", + type: "video_create", + amountMicroCredits: -amount, + balanceAfterMicroCredits: 0, + referenceId: `video-${ledger.rows.length}`, + referenceType: "developer_video", + metadata: { + owedMicroCredits: 850, + chargedMicroCredits: amount, + shortSince: shortSince?.toISOString() ?? null, + }, + createdAt: new Date(ledger.now), + }); + + it("keeps the app out of credit whichever of the two rows comes first", async () => { + for (const order of ["paid first", "short first"]) { + resetLedger(); + ledger.videos = []; + const paid = firstMonth(850, null); + const short = firstMonth(0, new Date(ledger.now)); + ledger.rows = order === "paid first" ? [paid, short] : [short, paid]; + + expect(await standing(), order).toMatchObject({ state: "grace" }); + } + }); +}); + +describe("a first month the balance could not pay", () => { + it("stays in effect through later paid days until credit covers what it owed", async () => { + ledger.videos = [ + { appId: "app-1", width: 1920, height: 1080, seconds: 600, count: 1 }, + ]; + const shortSince = new Date(ledger.now); + ledger.rows.push({ + id: "first-month", + accountId: "account-1", + type: "video_create", + amountMicroCredits: 0, + balanceAfterMicroCredits: 0, + referenceId: "video-1", + referenceType: "developer_video", + metadata: { + owedMicroCredits: 16_800, + chargedMicroCredits: 0, + shortSince: shortSince.toISOString(), + }, + createdAt: new Date(ledger.now), + }); + expect(await standing()).toMatchObject({ state: "grace" }); + + advanceDays(1); + credit(1_000); + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 28, + }); + expect(await standing()).toEqual({ + state: "grace", + pausesAt: new Date(shortSince.getTime() + 7 * DAY_MS), + }); + expect((await admission()).reason).toBe("out_of_credit"); + + credit(16_800); + expect(await standing()).toEqual({ state: "active", pausesAt: null }); + }); +}); + +describe("a shortfall the available balance comes to cover without new credit", () => { + it("is latched as resolved once, stays resolved through later paid days, and a new shortfall starts its own grace", async () => { + ledger.videos = [ + { appId: "app-1", width: 1920, height: 1080, seconds: 600, count: 1 }, + ]; + ledger.account.balanceMicroCredits = 900; + ledger.heldMicroCredits = 900; + const shortSince = new Date(ledger.now); + const month = { + id: "first-month-held", + accountId: "account-1", + type: "video_create", + amountMicroCredits: 0, + balanceAfterMicroCredits: 900, + referenceId: "video-1", + referenceType: "developer_video", + metadata: { + owedMicroCredits: 850, + chargedMicroCredits: 0, + shortSince: shortSince.toISOString(), + } as Record, + createdAt: new Date(ledger.now), + }; + ledger.rows.push(month); + expect(await standing()).toMatchObject({ state: "grace" }); + + ledger.heldMicroCredits = 0; + expect(await standing()).toEqual({ state: "active", pausesAt: null }); + + advanceDays(1); + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 28, + shortSince: null, + }); + const latched = month.metadata.resolvedAt; + expect(latched).toBe(new Date(ledger.now).toISOString()); + + advanceDays(1); + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 28, + }); + expect(ledger.account.balanceMicroCredits).toBe(844); + expect(month.metadata.resolvedAt).toBe(latched); + + advanceDays(8); + expect(await standing()).toEqual({ state: "active", pausesAt: null }); + + ledger.account.balanceMicroCredits = 0; + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 0, + fellShort: true, + }); + expect(await standing()).toEqual({ + state: "grace", + pausesAt: new Date(ledger.now.getTime() + 7 * DAY_MS), + }); + expect(month.metadata.resolvedAt).toBe(latched); + }); +}); + +describe("an unpaid first month is not masked by daily rows", () => { + const unpaidMonth = () => { + const shortSince = new Date(ledger.now); + ledger.rows.push({ + id: "first-month-unpaid", + accountId: "account-1", + type: "video_create", + amountMicroCredits: 0, + balanceAfterMicroCredits: 0, + referenceId: "video-1", + referenceType: "developer_video", + metadata: { + owedMicroCredits: 16_800, + chargedMicroCredits: 0, + shortSince: shortSince.toISOString(), + }, + createdAt: new Date(ledger.now), + }); + return shortSince; + }; + + it("keeps a first month that settled short in effect over a newer fully paid storage_daily row", async () => { + ledger.videos = [ + { appId: "app-1", width: 1920, height: 1080, seconds: 600, count: 1 }, + ]; + const shortSince = unpaidMonth(); + advanceDays(1); + credit(1_000); + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 28, + shortSince: null, + }); + + expect( + await latestStorageCharge(fakeDb as never, "account-1"), + ).toMatchObject({ firstMonth: true, owedMicroCredits: 16_800, shortSince }); + expect((await standing()).state).not.toBe("active"); + }); + + it("gives grace, not active, when a paid storage_day row lands in the same second as a short first month", async () => { + const shortSince = unpaidMonth(); + ledger.account.balanceMicroCredits = 500; + ledger.rows.push({ + id: "storage-day-paid", + accountId: "account-1", + type: "storage_daily", + amountMicroCredits: -28, + balanceAfterMicroCredits: 500, + referenceId: dateKey(), + referenceType: "storage_day", + metadata: { + snapshotDate: dateKey(), + owedMicroCredits: 28, + chargedMicroCredits: 28, + shortSince: null, + }, + createdAt: new Date(ledger.now), + }); + + expect(await standing()).toEqual({ + state: "grace", + pausesAt: new Date(shortSince.getTime() + 7 * DAY_MS), + }); + }); + + it("keeps the shortfall when a partly paid daily row is followed by a day with nothing charged daily", async () => { + ledger.videos = [ + { appId: "app-1", width: 1920, height: 1080, seconds: 600, count: 1 }, + ]; + const shortSince = unpaidMonth(); + advanceDays(1); + credit(10); + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 10, + shortSince, + }); + + advanceDays(1); + ledger.videos = []; + const rowsBefore = ledger.rows.length; + expect(await charge()).toEqual({ state: "skipped" }); + expect(ledger.rows).toHaveLength(rowsBefore); + expect(await standing()).toEqual({ + state: "grace", + pausesAt: new Date(shortSince.getTime() + 7 * DAY_MS), + }); }); }); @@ -720,7 +950,7 @@ describe("which videos are billed after the pipeline measures them", () => { it("charges storage for ready videos only, at the lower of the measured tier and the max tier", async () => { ledger.account.balanceMicroCredits = 1_000_000; - const usage = await getAppStorageUsage("app-1"); + const usage = (await getAppStorage("app-1")).stored; const result = await charge(); expect(usage.minutesByTier).toEqual({ @@ -732,17 +962,17 @@ describe("which videos are billed after the pipeline measures them", () => { expect(result).toMatchObject({ state: "billed", chargedMicroCredits: 31 }); }); - it("counts a still-processing video toward upload admission but not the storage charge", async () => { + it("counts a still-processing video toward upload admission at its first month, but not as stored or charged daily", async () => { ledger.account.balanceMicroCredits = 1_000_000; - const { usage } = await billing(); + const { storage, admission } = await billing(); - expect(usage.minutesByTier).toEqual({ - "1080p": 11, - "1440p": 0, - "2160p": 10, - }); - expect((await getAppStorageUsage("app-1")).minutesByTier["2160p"]).toBe(0); + expect(storage.firstMonthMicroCredits).toBe(10 * 280); + expect(storage.stored.minutesByTier["2160p"]).toBe(0); + expect(storage.dailyCharge.minutesByTier["2160p"]).toBe(0); + expect(admission.requiredMicroCredits).toBe( + Math.ceil(10 * 280 + (11 * 85 * 7) / 30), + ); }); it("leaves a video out of its own check when its measured length is admitted", async () => { @@ -760,10 +990,10 @@ describe("which videos are billed after the pipeline measures them", () => { const doubled = await getInfraUploadAdmission("app-1", measured); expect(own.requiredMicroCredits).toBe( - Math.ceil(((11 * 85 + 11 * 280) * 7) / 30), + Math.ceil(11 * 280 + (11 * 85 * 7) / 30), ); expect(doubled.requiredMicroCredits).toBe( - Math.ceil(((11 * 85 + 21 * 280) * 7) / 30), + Math.ceil(21 * 280 + (11 * 85 * 7) / 30), ); }); }); @@ -783,17 +1013,19 @@ describe("available balance", () => { describe("upload admission against what is stored", () => { it("counts the video being admitted on top of what is stored", async () => { - const stored = await getAppStorageUsage("app-1"); - const withNew = await getAppStorageUsage("app-1", { + const stored = await getAppStorage("app-1"); + const withNew = await getAppStorage("app-1", { durationSeconds: 6000, width: 3840, height: 2160, tier: "2160p", }); - expect(stored.monthlyMicroCredits).toBe(4335); - expect(withNew.monthlyMicroCredits).toBe(4335 + 28_000); - expect(withNew.videoCount).toBe(4); + expect(stored.dailyCharge.monthlyMicroCredits).toBe(4335); + expect(stored.firstMonthMicroCredits).toBe(0); + expect(withNew.dailyCharge.monthlyMicroCredits).toBe(4335); + expect(withNew.firstMonthMicroCredits).toBe(28_000); + expect(withNew.stored.videoCount).toBe(3); ledger.account.balanceMicroCredits = 1012; expect((await getInfraUploadAdmission("app-1")).allowed).toBe(true); @@ -806,13 +1038,13 @@ describe("upload admission against what is stored", () => { expect(refused).toMatchObject({ allowed: false, reason: "insufficient_credits", - requiredMicroCredits: 7545, - shortfallMicroCredits: 6533, + requiredMicroCredits: 29_012, + shortfallMicroCredits: 28_000, }); }); it("matches the pure storage math", async () => { - expect(await getAppStorageUsage("app-1")).toEqual( + expect((await getAppStorage("app-1")).stored).toEqual( storageUsage([ { video: { diff --git a/apps/web/__tests__/unit/infra-storage-created-before.test.ts b/apps/web/__tests__/unit/infra-storage-created-before.test.ts index 65a9f1eb77c..698ec5c3078 100644 --- a/apps/web/__tests__/unit/infra-storage-created-before.test.ts +++ b/apps/web/__tests__/unit/infra-storage-created-before.test.ts @@ -34,16 +34,23 @@ describe("stored video bounded by creation time", () => { expect(sql).toContain("COALESCE(SUM(`duration`), 0)"); expect(sql).toContain("and `developer_videos`.`createdAt` < ?)"); expect(sql).toContain("group by"); - expect(params).toEqual(["app-1", "app-2", "2026-11-13 00:00:00.000"]); + expect(params.slice(-3)).toEqual([ + "app-1", + "app-2", + "2026-11-13 00:00:00.000", + ]); }); it("reads every stored video when no bound is given", async () => { await getStorageUsageByApp(["app-1"]); await getAppStorage("app-1"); - expect(state.queries).toHaveLength(2); - for (const { sql } of state.queries) { - expect(sql).not.toContain("createdAt"); + const aggregates = state.queries.filter(({ sql }) => + sql.includes("group by"), + ); + expect(aggregates).toHaveLength(2); + for (const { sql } of aggregates) { + expect(sql.slice(sql.indexOf(" where "))).not.toContain("createdAt"); } }); }); diff --git a/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts b/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts index d2bfa112f1c..ae4f14f432c 100644 --- a/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts +++ b/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts @@ -218,6 +218,13 @@ vi.mock("@/lib/infra-standing", () => ({ STORAGE_DAY_REFERENCE_TYPE: "storage_day", latestStorageCharges: async () => new Map(), })); +vi.mock("@/lib/infra-storage-minimum", () => ({ + chargeMissedFirstMonths: async () => ({ charged: 0, short: 0, failed: 0 }), + shortfallsAwaitingNotice: async () => new Map(), + openFirstMonthShortfalls: async () => new Set(), + markShortfallsNotified: async () => {}, + markStorageDayNotified: async () => {}, +})); vi.mock("@/lib/infra-auto-top-up", () => ({ runDueAutoTopUps: state.topUps })); vi.mock("@/lib/infra-billing-emails", () => ({ sendInfraOutOfCreditEmail: state.emails, diff --git a/apps/web/__tests__/unit/infra-storage-latest-day.test.ts b/apps/web/__tests__/unit/infra-storage-latest-day.test.ts index 43fb75e8f30..c2ab5ed23d3 100644 --- a/apps/web/__tests__/unit/infra-storage-latest-day.test.ts +++ b/apps/web/__tests__/unit/infra-storage-latest-day.test.ts @@ -36,12 +36,15 @@ describe("latest storage day", () => { it("orders one account's storage day rows by the day they bill, up to tomorrow", async () => { await latestStorageCharge(db() as never, "account-1"); - expect(state.queries).toHaveLength(1); - const [{ sql, params }] = state.queries as [ + expect(state.queries).toHaveLength(2); + const [firstMonth, { sql, params }] = state.queries as [ + { sql: string; params: unknown[] }, { sql: string; params: unknown[] }, ]; + expect(firstMonth.params).toEqual(["account-1", "video_create", 1]); + expect(firstMonth.sql).not.toContain("referenceId"); expect(sql).toBe( - "select `createdAt`, `metadata` from `developer_credit_transactions` where (`developer_credit_transactions`.`accountId` = ? and (`developer_credit_transactions`.`type` = ? and `developer_credit_transactions`.`referenceType` = ?) and `developer_credit_transactions`.`referenceId` <= ?) order by `developer_credit_transactions`.`referenceId` desc, `developer_credit_transactions`.`id` desc limit ?", + "select `id`, `createdAt`, `metadata` from `developer_credit_transactions` where (`developer_credit_transactions`.`accountId` = ? and (`developer_credit_transactions`.`type` = ? and `developer_credit_transactions`.`referenceType` = ?) and `developer_credit_transactions`.`referenceId` <= ?) order by `developer_credit_transactions`.`referenceId` desc, `developer_credit_transactions`.`id` desc limit ?", ); expect(params).toEqual([ "account-1", diff --git a/apps/web/__tests__/unit/infra-storage-once.test.ts b/apps/web/__tests__/unit/infra-storage-once.test.ts index 625a12a4a2d..1587fb62d49 100644 --- a/apps/web/__tests__/unit/infra-storage-once.test.ts +++ b/apps/web/__tests__/unit/infra-storage-once.test.ts @@ -12,7 +12,10 @@ import { fakeDb, ledger, resetLedger } from "./infra-billing-fake-db"; holder.db = fakeDb; -import { billableVideo } from "@/lib/developer-pricing"; +import { + billableVideo, + firstMonthStorageMicroCredits, +} from "@/lib/developer-pricing"; import { getInfraBilling, infraBillingJson } from "@/lib/infra-standing"; import { getAppStorage, getAppStorageUsage } from "@/lib/infra-storage"; @@ -77,7 +80,7 @@ beforeEach(() => { }); describe("one read of stored video serves both totals", () => { - it("gives the same stored and counted totals as two separate reads", async () => { + it("gives the same stored, daily and first month totals as separate reads", async () => { for (const [video, options] of [ [undefined, {}], [newVideo, {}], @@ -87,23 +90,26 @@ describe("one read of stored video serves both totals", () => { const storage = await getAppStorage("app-1", video, undefined, options); expect(storage.stored).toEqual( - await getAppStorageUsage("app-1", video, undefined, options), + await getAppStorageUsage("app-1", undefined, undefined, options), ); - expect(storage.counted).toEqual( - await getAppStorageUsage("app-1", video, undefined, { + expect(storage.dailyCharge).toEqual( + await getAppStorageUsage("app-1", undefined, undefined, { ...options, - includeProcessing: true, + dailyChargeAt: new Date(ledger.now), }), ); + expect(storage.firstMonthMicroCredits).toBe( + (options.excludeVideoId === "v4" ? 0 : 425) + + (video ? firstMonthStorageMicroCredits(video) : 0), + ); } }); it("keeps processing video out of what is shown as stored and in what admission counts", async () => { - const { stored, counted } = await getAppStorage("app-1"); + const { stored, firstMonthMicroCredits } = await getAppStorage("app-1"); expect(stored.videoCount).toBe(6); - expect(counted.videoCount).toBe(10); - expect(counted.totalMinutes - stored.totalMinutes).toBeCloseTo(5, 10); + expect(firstMonthMicroCredits).toBe(5 * 85); }); it("reads video once for billing and returns what the pages show", async () => { @@ -118,13 +124,13 @@ describe("one read of stored video serves both totals", () => { expect(videoReads(select)).toBeGreaterThan(1); select.mockRestore(); expect(billing.stored).toEqual(await getAppStorageUsage("app-1")); - expect(billing.usage).toEqual( + expect(billing.storage.dailyCharge).toEqual( await getAppStorageUsage("app-1", undefined, undefined, { - includeProcessing: true, + dailyChargeAt: now, }), ); expect(infraBillingJson(billing).storage.minutesByTier).toEqual( - billing.usage.minutesByTier, + billing.stored.minutesByTier, ); }); }); diff --git a/apps/web/actions/developers/delete-app.ts b/apps/web/actions/developers/delete-app.ts index 0398c0b8020..c96a1c5cefc 100644 --- a/apps/web/actions/developers/delete-app.ts +++ b/apps/web/actions/developers/delete-app.ts @@ -11,6 +11,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { cancelDeveloperVideoEncodes } from "@/lib/developer-video-cancel"; import { canUseInfra } from "@/lib/infra-access"; +import { settleFirstMonthsForDeletedApp } from "@/lib/infra-storage-minimum"; export async function deleteDeveloperApp(appId: string) { const user = await getCurrentUser(); @@ -30,11 +31,11 @@ export async function deleteDeveloperApp(appId: string) { if (!app) throw new Error("App not found"); - const now = new Date(Math.floor(Date.now() / 1000) * 1000); + const deletedAt = new Date(Math.floor(Date.now() / 1000) * 1000); await db().transaction(async (tx) => { await tx .update(developerApiKeys) - .set({ revokedAt: now }) + .set({ revokedAt: deletedAt }) .where( and( eq(developerApiKeys.appId, appId), @@ -44,12 +45,12 @@ export async function deleteDeveloperApp(appId: string) { await tx .update(developerApps) - .set({ deletedAt: now }) + .set({ deletedAt }) .where(eq(developerApps.id, appId)); await tx .update(developerVideos) - .set({ deletedAt: now }) + .set({ deletedAt }) .where( and( eq(developerVideos.appId, appId), @@ -57,7 +58,8 @@ export async function deleteDeveloperApp(appId: string) { ), ); }); - await cancelDeveloperVideoEncodes({ appId, deletedAt: now }); + await settleFirstMonthsForDeletedApp(appId, deletedAt); + await cancelDeveloperVideoEncodes({ appId, deletedAt }); revalidatePath("/dashboard/infra", "layout"); return { success: true }; diff --git a/apps/web/actions/developers/delete-video.ts b/apps/web/actions/developers/delete-video.ts index 709a24a87de..fd2c4237cdb 100644 --- a/apps/web/actions/developers/delete-video.ts +++ b/apps/web/actions/developers/delete-video.ts @@ -7,6 +7,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { cancelDeveloperVideoEncodes } from "@/lib/developer-video-cancel"; import { canUseInfra } from "@/lib/infra-access"; +import { settleFirstMonthAtDelete } from "@/lib/infra-storage-minimum"; export async function deleteDeveloperVideo(appId: string, videoId: string) { const user = await getCurrentUser(); @@ -26,12 +27,17 @@ export async function deleteDeveloperVideo(appId: string, videoId: string) { if (!app) throw new Error("App not found"); - await db() + const [deleted] = await db() .update(developerVideos) .set({ deletedAt: new Date() }) .where( - and(eq(developerVideos.id, videoId), eq(developerVideos.appId, appId)), + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), ); + if (deleted.affectedRows > 0) await settleFirstMonthAtDelete(videoId); await cancelDeveloperVideoEncodes({ appId, videoId }); revalidatePath("/dashboard/infra", "layout"); diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx index 55d42f61fca..4343c33886b 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx @@ -179,8 +179,9 @@ export function BillingView({

- Storage is charged once a day for each video at its resolution. - Uploading needs a balance that covers seven days of storage.{" "} + Each video is charged one month of storage when it becomes ready, + then once a day at its resolution. Uploading needs a balance that + covers that first month.{" "} { console.error("[infra-usage] Transcription usage unavailable", error); return null; }), + app.creditAccount + ? getInfraFirstMonthChargesSince(app.creditAccount.id, utcMonthStart(now)) + : Promise.resolve({ chargedMicroCredits: 0, videos: 0 }), ]); + const usage = storage.stored; const audioHours = (transcription?.audioSeconds ?? 0) / 3600; const storedTiers = INFRA_RESOLUTION_TIERS.filter( (tier) => usage.minutesByTier[tier] > 0, @@ -92,7 +97,7 @@ export default async function InfraUsagePage({ <> @@ -107,13 +112,22 @@ export default async function InfraUsagePage({ />

0 + ? [ + `${firstMonths.videos} first ${firstMonths.videos === 1 ? "month" : "months"}`, + ] + : []), + ].join(", ")} + value={formatCharge( + month.chargedMicroCredits + firstMonths.chargedMicroCredits, + )} /> {transcription && (
`COALESCE(SUM(-${developerCreditTransactions.amountMicroCredits}), 0)`, + videos: count(), + }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, "video_create"), + gte(developerCreditTransactions.createdAt, since), + ), + ); + + return { + chargedMicroCredits: Number(row?.total ?? 0) || 0, + videos: Number(row?.videos ?? 0) || 0, + }; +} diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index 727fb01d2ed..13fca14f78c 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -13,7 +13,9 @@ import { runDueAutoTopUps } from "@/lib/infra-auto-top-up"; import { sendInfraOutOfCreditEmail } from "@/lib/infra-billing-emails"; import { INFRA_GRACE_DAYS, + latestStorageCharge, latestStorageCharges, + restoredSinceCharge, STORAGE_DAY_REFERENCE_TYPE, type StorageCharge, } from "@/lib/infra-standing"; @@ -22,12 +24,20 @@ import { chargeStorageDay, recordStorageDayVisits, } from "@/lib/infra-storage-billing"; +import { + chargeMissedFirstMonths, + markShortfallsNotified, + markStorageDayNotified, + openFirstMonthShortfalls, + shortfallsAwaitingNotice, +} from "@/lib/infra-storage-minimum"; import { utcDateKey } from "@/lib/infra-usage"; const BATCH_SIZE = 10; const PAGE_SIZE = 200; const DAY_MS = 24 * 60 * 60 * 1000; const GRACE_MS = INFRA_GRACE_DAYS * DAY_MS; +const SHORTFALL_NOTICE_LOOKBACK_MS = GRACE_MS; const TOP_UPS_BEFORE_UNTIL_MS = 60_000; const STORAGE_UNTIL_MS = 200_000; @@ -96,6 +106,32 @@ function continueSweep(request: Request, authorization: string, next: Sweep) { ); } +async function sendShortfallNotices( + accounts: readonly { id: string }[], + now: Date, +) { + const shortfalls = await shortfallsAwaitingNotice( + accounts.map((account) => account.id), + new Date(now.getTime() - SHORTFALL_NOTICE_LOOKBACK_MS), + ); + for (const [accountId, { ids }] of shortfalls) { + const charge = await latestStorageCharge(db(), accountId); + if ( + !charge?.shortSince || + (await restoredSinceCharge(db(), accountId, charge)) + ) { + await markShortfallsNotified(ids, now); + continue; + } + const sent = await sendInfraOutOfCreditEmail({ + accountId, + shortSince: charge.shortSince, + pausesAt: new Date(charge.shortSince.getTime() + GRACE_MS), + }); + if (sent) await markShortfallsNotified(ids, now); + } +} + type DateSweep = { complete: boolean; cursor: string | null; @@ -119,9 +155,12 @@ async function sweepDate({ let processed = 0; let short = 0; let cursor = from; - const dayEnd = finishing - ? new Date(Date.parse(`${date}T00:00:00.000Z`) + DAY_MS) - : null; + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = finishing ? new Date(dayStart.getTime() + DAY_MS) : null; + const dailyCharge = { + dailyChargeAt: dayStart, + ...(dayEnd ? { createdBefore: dayEnd } : {}), + }; while (!outOfTime()) { const page: { id: string; appId: string; createdAt: Date }[] = await db() @@ -218,21 +257,20 @@ async function sweepDate({ const usageByApp = pending.length === 0 ? new Map() - : dayEnd - ? await getStorageUsageByApp(appIds, undefined, undefined, { - createdBefore: dayEnd, - }) - : await getStorageUsageByApp(appIds); + : await getStorageUsageByApp(appIds, undefined, undefined, dailyCharge); const idleAccountIds = pending .filter( (account) => (usageByApp.get(account.appId)?.dailyMicroCredits ?? 1) <= 0, ) .map((account) => account.id); - const lastCharges = + const [lastCharges, openFirstMonths] = idleAccountIds.length > 0 - ? await latestStorageCharges(db(), idleAccountIds) - : new Map(); + ? await Promise.all([ + latestStorageCharges(db(), idleAccountIds), + openFirstMonthShortfalls(idleAccountIds), + ]) + : [new Map(), new Set()]; for (let i = 0; i < pending.length; i += BATCH_SIZE) { if (outOfTime()) { @@ -252,7 +290,8 @@ async function sweepDate({ if (!usage) return null; if ( usage.dailyMicroCredits <= 0 && - !lastCharges.get(account.id)?.shortSince + !lastCharges.get(account.id)?.shortSince && + !openFirstMonths.has(account.id) ) { return null; } @@ -260,6 +299,15 @@ async function sweepDate({ appId: account.appId, accountId: account.id, usage, + measure: async (tx) => + ( + await getStorageUsageByApp( + [account.appId], + undefined, + tx, + dailyCharge, + ) + ).get(account.appId) ?? usage, dateKey: date, now, retryUnpaid: !finishing, @@ -269,11 +317,12 @@ async function sweepDate({ result.fellShort && result.shortSince ) { - await sendInfraOutOfCreditEmail({ + const sent = await sendInfraOutOfCreditEmail({ accountId: account.id, shortSince: result.shortSince, pausesAt: new Date(result.shortSince.getTime() + GRACE_MS), }); + if (sent) await markStorageDayNotified(account.id, date, now); } return result; }), @@ -302,6 +351,7 @@ async function sweepDate({ } await recordVisits(); + if (!finishing) await sendShortfallNotices(accounts, now); cursor = lastAccount.id; if (page.length < PAGE_SIZE) { return { complete: true, cursor, processed, short }; @@ -350,6 +400,8 @@ export async function GET(request: Request) { }) : null; + const firstMonths = hop === 0 ? await chargeMissedFirstMonths(now) : null; + const dates = sweep.date === today ? [today] : [sweep.date, today]; let date = sweep.date; let cursor = sweep.after; @@ -409,6 +461,7 @@ export async function GET(request: Request) { hop, appsProcessed: processed, appsShort: short, + firstMonths, autoTopUps, }); } diff --git a/apps/web/app/api/developer/v1/[...route]/usage.ts b/apps/web/app/api/developer/v1/[...route]/usage.ts index 284b6ee112e..8bf8ccd29e5 100644 --- a/apps/web/app/api/developer/v1/[...route]/usage.ts +++ b/apps/web/app/api/developer/v1/[...route]/usage.ts @@ -1,6 +1,3 @@ -import { db } from "@cap/database"; -import { developerVideos } from "@cap/database/schema"; -import { and, count, eq, isNull } from "drizzle-orm"; import { Hono } from "hono"; import { MICRO_CREDITS_PER_DOLLAR, @@ -27,23 +24,11 @@ export const app = new Hono<{ app.get("/", async (c) => { const appId = c.get("developerAppId"); - const [[videoStats], billing, credit] = await Promise.all([ - db() - .select({ - createdVideos: count(), - uploadedVideos: count(developerVideos.duration), - }) - .from(developerVideos) - .where( - and( - eq(developerVideos.appId, appId), - isNull(developerVideos.deletedAt), - ), - ), + const [billing, credit] = await Promise.all([ getInfraBilling(appId), getSpeechUsage(appId), ]); - const { stored } = billing; + const { stored, storage } = billing; const { balanceMicroCredits, ...speech } = credit; const { standing, upload } = infraBillingJson(billing); @@ -53,14 +38,11 @@ app.get("/", async (c) => { balanceMicroCredits, balanceDollars: creditDollars(balanceMicroCredits), totalVideos: stored.videoCount, - uploadingVideos: Math.max( - 0, - (videoStats?.createdVideos ?? 0) - (videoStats?.uploadedVideos ?? 0), - ), + uploadingVideos: storage.uploadingVideos, totalDurationMinutes: stored.totalMinutes, standing, upload, - storage: infraStorageJson(stored), + storage: infraStorageJson(storage), ...speech, rates: { microCreditsPerDollar: MICRO_CREDITS_PER_DOLLAR, diff --git a/apps/web/app/api/developer/v1/[...route]/videos.ts b/apps/web/app/api/developer/v1/[...route]/videos.ts index 4a04d9da5af..0dba4ae1368 100644 --- a/apps/web/app/api/developer/v1/[...route]/videos.ts +++ b/apps/web/app/api/developer/v1/[...route]/videos.ts @@ -17,6 +17,7 @@ import { import { cancelDeveloperVideoEncodes } from "@/lib/developer-video-cancel"; import { redriveDeveloperVideoProcessing } from "@/lib/developer-video-start"; import { developerVideoRedrive } from "@/lib/developer-video-state"; +import { settleFirstMonthAtDelete } from "@/lib/infra-storage-minimum"; import { withDeveloperSecretAuth } from "../../../utils"; const ownedBy = (userId: string | undefined) => @@ -151,6 +152,7 @@ app.delete("/:id", async (c) => { .update(developerVideos) .set({ deletedAt: new Date() }) .where(eq(developerVideos.id, videoId)); + await settleFirstMonthAtDelete(videoId); await cancelDeveloperVideoEncodes({ appId, videoId }); } diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index ae286f2f1ea..951702233fd 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -169,6 +169,10 @@ import { creditCheckoutSessionParams, } from "@/lib/infra-credit-checkout"; import { normalizeAllowedOrigin } from "@/lib/infra-domain"; +import { + settleFirstMonthAtDelete, + settleFirstMonthsForDeletedApp, +} from "@/lib/infra-storage-minimum"; import { TOP_UP_MAX_CENTS, TOP_UP_MIN_CENTS, @@ -4178,6 +4182,9 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( }); if (deletedAt) { const appDeletedAt = deletedAt; + yield* Effect.promise(() => + settleFirstMonthsForDeletedApp(path.appId, appDeletedAt), + ); yield* Effect.promise(() => cancelDeveloperVideoEncodes({ appId: path.appId, @@ -4688,6 +4695,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( }; }, }); + yield* Effect.promise(() => settleFirstMonthAtDelete(path.videoId)); if (deleted) { yield* Effect.promise(() => cancelDeveloperVideoEncodes({ diff --git a/apps/web/components/pages/infra/Pricing.tsx b/apps/web/components/pages/infra/Pricing.tsx index 2dc89fd9590..4ba694b5525 100644 --- a/apps/web/components/pages/infra/Pricing.tsx +++ b/apps/web/components/pages/infra/Pricing.tsx @@ -1,6 +1,13 @@ import { INFRA_SPEECH_DOLLARS_PER_HOUR } from "@/lib/infra-pricing"; import { Calculator } from "./Calculator"; -import { ANCHORS, PRICE_ROWS, TIER_LABEL, TIERS, usd } from "./content"; +import { + ANCHORS, + PRICE_ROWS, + STORAGE_RULE, + TIER_LABEL, + TIERS, + usd, +} from "./content"; import { Section, SectionHeading } from "./shared"; import { MONO } from "./theme"; @@ -83,9 +90,9 @@ export const Pricing = () => (

- A video is billed by its length and resolution tier once it is ready - to play. Videos are encoded up to 1080p by default, and 1440p and 4K - are set per upload. + {STORAGE_RULE} A video is billed by its length and resolution tier. + Videos are encoded up to 1080p by default, and 1440p and 4K are set + per upload.

diff --git a/apps/web/components/pages/infra/content.ts b/apps/web/components/pages/infra/content.ts index 7bdb1cb3ce4..2f2ef2d10be 100644 --- a/apps/web/components/pages/infra/content.ts +++ b/apps/web/components/pages/infra/content.ts @@ -14,6 +14,9 @@ export const PRICES = { export const TIERS = INFRA_RESOLUTION_TIERS; +export const STORAGE_RULE = + "Each video is charged one month of storage when it first becomes ready, then daily once that month is over. Uploading and encoding are free."; + export const DEFAULT_TIER: InfraResolutionTier = "1080p"; export const TIER_LABEL: Record = { @@ -331,12 +334,11 @@ export const infraFaqs: readonly Faq[] = [ ...PREVIEW_FAQ, { question: "What does Cap Infra charge for?", - answer: - "Two things: stored video and speech to text. Uploading and encoding are free at every resolution.", + answer: `Two things: stored video and speech to text. ${STORAGE_RULE}`, }, { question: "How does resolution change the price?", - answer: `Storage is priced per 1,000 minutes a month by resolution tier: ${perTier(PRICES.storagePer1000Minutes)}. A video is billed at the lower of its source resolution and the tier it was created with. The default tier is 1080p, and 1440p and 4K are set per upload.`, + answer: `Storage is priced per 1,000 minutes a month by resolution tier: ${perTier(PRICES.storagePer1000Minutes)}. One month is charged when a video first becomes ready, then it is charged daily once that month is over. A video is billed at the lower of its source resolution and the tier it was created with. The default tier is 1080p, and 1440p and 4K are set per upload.`, }, { question: "How do I pay?", diff --git a/apps/web/content/docs/api/billing.mdx b/apps/web/content/docs/api/billing.mdx index bede127c9eb..98708360d32 100644 --- a/apps/web/content/docs/api/billing.mdx +++ b/apps/web/content/docs/api/billing.mdx @@ -14,9 +14,9 @@ Two things are charged: stored video and speech to text. Uploading and encoding | | Price | Charged | |---|---|---| -| Storage, up to 1080p | $0.85 per 1,000 minutes per month | Once a day | -| Storage, 1440p | $1.45 per 1,000 minutes per month | Once a day | -| Storage, 4K (2160p) | $2.80 per 1,000 minutes per month | Once a day | +| Storage, up to 1080p | $0.85 per 1,000 minutes per month | First month when the video is ready, then once a day after it | +| Storage, 1440p | $1.45 per 1,000 minutes per month | First month when the video is ready, then once a day after it | +| Storage, 4K (2160p) | $2.80 per 1,000 minutes per month | First month when the video is ready, then once a day after it | | Speech to text | $0.10 per audio hour | When a transcript completes | | Uploading and encoding | Free | | @@ -32,7 +32,9 @@ Credit belongs to one app. It cannot be moved to another app, and deleting an ap ## Storage -A video is charged for storage once it is ready to play. A video that is still uploading or encoding, or that failed, is not charged. +Each video is charged one month of storage when it first becomes ready, then daily once that month is over. Uploading and encoding are free. A video that is still uploading or encoding, that failed, or that was deleted before its encode started is not charged. A video deleted while it is being processed is charged its first month too. + +Videos that were ready before November 13, 2026 are not charged a first month. They keep the daily charge described below. Each video is billed by its length and its resolution tier: @@ -44,17 +46,35 @@ Each video is billed by its length and its resolution tier: A video is billed at the lower of two tiers: the tier its source falls in by pixel count, and the `maxResolutionTier` it was created with. A 3840 by 2160 upload created with the default `1080p` tier is encoded and billed at `1080p`. +### The first month + +The first month costs the video's length in minutes times the monthly price of its tier, rounded down to a whole micro-credit and never less than one. + +| Video | First month | Then per day | +|-------|-------------|--------------| +| 10 minutes at 1080p | $0.00850 | $0.00028 | +| 60 minutes at 1080p | $0.05100 | $0.00170 | +| 60 minutes at 4K | $0.16800 | $0.00560 | + +A 10 minute 1080p video costs $0.00850 when it becomes ready, then $0.00028 a day after its first month. + +The month is 30 UTC days: the day the video becomes ready and the 29 after it, or, when that day's daily charge has already run, the next day and the 29 after that. The daily charge leaves the video out for those 30 days and counts it from the next one, so no day is charged twice and none is skipped. + +Each video's first month is one row in the ledger, labelled "Storage, first month", with the video ID as its reference. It is charged once, however many times the video is processed. Deleting a video during its first month does not give any of that month back. + +If the balance cannot pay the whole month when the video becomes ready, what is there is taken and the rest is not charged later. The row says how much was paid, the video still becomes ready and plays, and the app goes into `grace` the same way it does when a daily charge falls short. See [When credit runs out](#when-credit-runs-out). + ### The daily charge -A month is counted as 30 days and charged one day at a time. One day costs the monthly price of everything stored, divided by 30 and rounded down to a whole micro-credit. +A month is counted as 30 days and charged one day at a time. One day costs the monthly price of every video past its first month, divided by 30 and rounded down to a whole micro-credit. -| Stored | Per month | Per day | Needed to upload | +| Stored past the first month | Per month | Per day | Needed to upload | |--------|-----------|---------|------------------| | 2,000 minutes at 1080p | $1.70 | $0.05666 | $0.39667 | | 100 minutes at 1440p | $0.14500 | $0.00483 | $0.03384 | | 500 minutes at 4K | $1.40 | $0.04666 | $0.32667 | -Days are UTC days. The charge runs at 00:41, 06:41, 12:41 and 18:41 UTC and bills each day once, on the first run that finds video stored. That one charge covers the whole day for what is stored at that moment. A video that becomes ready after the day's charge is first charged the next day, and a video deleted before the day's charge is not charged for that day. +Days are UTC days. The charge runs at 00:41, 06:41, 12:41 and 18:41 UTC and bills each day once, on the first run that finds video stored. That one charge covers the whole day for what is stored at that moment. A video past its first month that is deleted before the day's charge is not charged for that day. Because each day is charged separately, a 31 day month has 31 daily charges. A day that works out to less than one micro-credit is not charged. @@ -62,9 +82,11 @@ The console's Usage page lists every day billed, with the minutes and videos cou ### Credit needed to upload -Creating or completing an upload needs an app in good standing and a balance that covers 7 days of storage for everything the app stores. Videos that are still encoding count, and when an upload completes the new video counts at its measured length. That amount is rounded up to a whole micro-credit and is never less than one, so an app with no credit cannot upload. +Completing an upload needs an app in good standing and a balance that pays for the first month of the video being completed, at its measured length and tier, and the first month of every other video of the app that has been uploaded and is still encoding. On top of that, it needs credit that covers 7 days of storage for the videos past their first month. Creating an upload checks the same without the new video, whose length is not known yet. The amount is rounded up to a whole micro-credit and is never less than one, so an app with no credit cannot upload. + +Counting the videos still encoding means several uploads finishing at once cannot take more than the balance can pay. With $0.05600 of credit and nothing else in flight, one 20 minute video billed at 4K is accepted. Three of them in flight need $0.16800. -Nothing is charged for it. It is a check, and the balance is only drawn down by the daily charge. `GET /api/developer/v1/usage` returns the amount as `upload.requiredMicroCredits` and what is missing as `upload.shortfallMicroCredits`. +Nothing is charged for it. It is a check, and the balance is drawn down by the first month when each video becomes ready and by the daily charge after that. `GET /api/developer/v1/usage` returns the amount as `upload.requiredMicroCredits` and what is missing as `upload.shortfallMicroCredits`. ## Speech to text @@ -78,7 +100,7 @@ Transcription costs $0.10 per audio hour, which is 10,000 micro-credits. Cap mea | 1 hour | 3,600 seconds | $0.10 | | 4 hours | 14,400 seconds | $0.40 | -Credit is taken once, when the transcript completes. Audio with no speech completes with empty text and is billed for its length. A transcript that fails or is deleted before it completes costs nothing. +Credit is taken once, when the transcript completes. Audio with no speech completes with empty text and is billed for its length. A transcript that ends in error, or is deleted before its audio is sent to speech recognition, is not charged. One deleted after that is charged for the length of its audio. ### The hold @@ -131,10 +153,10 @@ Auto top-up adds credit from a saved card when an app's balance runs low. It is | State | What it means | |-------|---------------| | `active` | In good standing. | -| `grace` | A daily storage charge was more than the balance. What was there was taken, uploads and new transcripts are refused, and playback continues until `standing.pausesAt`, 7 days after the app first fell short. | +| `grace` | A storage charge, for a day or for a video's first month, was more than the balance. What was there was taken, uploads and new transcripts are refused, and playback continues until `standing.pausesAt`, 7 days after the app first fell short. | | `paused` | Playback is paused until credit is added. Videos stay stored. | -Adding credit that covers one day of storage returns the app to `active` at once. Days it could not pay for are not charged afterwards. +Once the available balance covers the charge that fell short, one day of storage or the video's first month, the app returns to `active` at once, whether credit was added or a transcript's hold was released. From the next storage charge that shortfall stays settled even if the balance drops again, and only a new charge falling short starts a new grace period. What it could not pay is not charged afterwards. An app that stores nothing owes nothing, so it stays `active` with a zero balance. It cannot upload or start a transcript until it has credit. @@ -148,6 +170,7 @@ Every credit and charge is a row in the app's ledger, with the time in UTC, the | Code redeemed | The checkout the code covered in full | | Cap Pro credit | The month it is for | | Storage | The UTC day it charges | +| Storage, first month | The video ID | | Transcription | The transcript ID | | Adjustment | A credit or correction applied by Cap | | Payment reversed | The payment that was returned to you. The credit it bought is taken back, in proportion to the amount returned | diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 44a8869ff65..97028f21c25 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -836,7 +836,7 @@ A file that is already at a public URL needs no upload. Send it as `url` to `POS ## 9. What it costs -Uploading and encoding are free. Stored video and transcription are paid for from the app's prepaid credit. [Billing](/docs/api/billing) has the prices and the rules. +Each video is charged one month of storage when it first becomes ready, then daily once that month is over. Uploading and encoding are free. A 10 minute 1080p video costs $0.00850 when it becomes ready, then $0.00028 a day after its first month. Stored video and transcription are paid for from the app's prepaid credit. [Billing](/docs/api/billing) has the prices and the rules. - The app's Billing page in the console shows the balance and every charge. - [Get Usage](/docs/api/rest-api#get-usage) returns the balance, whether the app can take an upload, and the current prices. diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 53bc9b2b59a..e65abd670c2 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -116,7 +116,7 @@ Send request bodies as JSON with the header `Content-Type: application/json`. Ma | `200` | Success | | `400` | Bad request. The `code` says what was wrong, for example `invalid_request`, `invalid_json`, `invalid_limit` or `unknown_parameter` | | `401` | Unauthorized. The API key or upload token is missing, invalid, revoked or expired | -| `402` | Payment required. The app is out of credit, its balance does not cover 7 days of storage, or it is below the minimum to start a transcript | +| `402` | Payment required. The app is out of credit, its balance does not cover the storage an upload needs, or it is below the minimum to start a transcript | | `403` | Forbidden. A public key was sent where a secret key or upload token is required (`public_key_not_allowed`). On the SDK API, the `Origin` differs from the upload token's `corsOrigin` (`origin_not_allowed`) or the body names another video (`video_mismatch`). Elsewhere, the account cannot use this feature yet | | `404` | Not found. The video, transcript or multipart upload does not exist (`video_not_found`, `transcript_not_found`, `upload_not_found` for an `uploadId`), or no route matches the path (`not_found`) | | `405` | Method not allowed. The path exists but not with this method (`method_not_allowed`). The `Allow` header lists the methods it takes | @@ -250,18 +250,18 @@ A video whose upload is never completed stays in the `uploading` status and coun `corsOrigin` is a convenience for browser uploads. When it is set, the SDK API refuses a request whose `Origin` header is anything else. A script can send any `Origin` it likes, so the token is what protects the upload, not the origin. Leave `corsOrigin` out to upload from a server or a native app. -Creating an upload is free. It returns `402` if the app is out of credit or its balance does not cover 7 days of storage for what it already stores. The `402` body says how much is needed: +Creating an upload is free. It returns `402` if the app is out of credit, or if its balance does not cover the first month of storage for every upload of the app still encoding plus 7 days for the videos past their first month. Here one 60 minute video billed at 4K is still encoding. The `402` body says how much is needed: ```json { "error": "Insufficient credits", "code": "insufficient_credits", - "message": "Uploading needs $0.19834 of credit, enough for 7 days of storage for everything this app stores. The balance is $0.05, so add at least $0.14834.", + "message": "Not enough credit to upload. Uploading needs $0.16800, enough for the first month of storage for each video being uploaded or encoded. The balance is $0.05, so add at least $0.11800.", "standing": "active", "pausesAt": null, "balanceMicroCredits": 5000, - "requiredMicroCredits": 19834, - "shortfallMicroCredits": 14834 + "requiredMicroCredits": 16800, + "shortfallMicroCredits": 11800 } ``` @@ -582,7 +582,7 @@ On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that ou ### Delete Video -Delete a video. It is excluded from every query straight away, and its playback URLs stop answering new viewers within about a minute. Its files (the upload and every encoded output) are removed within about five minutes, and links that were already handed out stop working within about ten minutes of the delete. That includes signed media URLs, however long they still have before they expire. If the video was still being encoded when it was deleted, anything the encoder writes afterwards is removed within four hours. +Delete a video. It is excluded from every query straight away, and its playback URLs stop answering new viewers within about a minute. Its files (the upload and every encoded output) are removed within about five minutes, and links that were already handed out stop working within about ten minutes of the delete. That includes signed media URLs, however long they still have before they expire. If the video was still being encoded when it was deleted, anything the encoder writes afterwards is removed within four hours. A video deleted while it is being processed is charged its first month too, as [Billing](/docs/api/billing) explains. Deleting a video this app already deleted returns success again, so the call is safe to repeat. An ID that never belonged to this app returns `404`. @@ -647,7 +647,7 @@ A transcript request can be refused. The response carries a `code` and a `messag | `400` | `invalid_idempotency_key` | The `Idempotency-Key` is not 8 to 128 characters of letters, digits, dot, dash and underscore | | `400` | `invalid_status` | The status filter is not one of the transcript statuses | | `400` | `invalid_include` | `include` has a value other than `words` or `segments`. The message names it | -| `402` | `out_of_credit` | The app is in `grace` or `paused` because a daily storage charge could not be paid. The message says how much to add | +| `402` | `out_of_credit` | The app is in `grace` or `paused` because a storage charge could not be paid. The message says how much to add | | `402` | `insufficient_credits` | The available balance is under $0.01. The message says how much to add | | `403` | `infra_not_available` | Cap Infra is not enabled for this account | | `404` | `transcript_not_found` | No transcript with this ID belongs to the app | @@ -963,14 +963,15 @@ curl https://cap.so/api/developer/v1/usage \ "upload": { "allowed": true, "reason": null, - "requiredMicroCredits": 1736, + "requiredMicroCredits": 1190, "shortfallMicroCredits": 0, "message": null }, "storage": { "minutesByTier": { "1080p": 87.5, "1440p": 0, "2160p": 0 }, - "dailyMicroCredits": 247, - "monthlyMicroCredits": 7438 + "dailyChargeMinutesByTier": { "1080p": 60, "1440p": 0, "2160p": 0 }, + "dailyMicroCredits": 170, + "monthlyMicroCredits": 5100 }, "rates": { "microCreditsPerDollar": 100000, @@ -990,7 +991,7 @@ Credits are tracked in micro-credits (1 dollar = 100,000 micro-credits). `balanc **Rates.** `rates` holds the prices and rules the other fields are built from, so a client does not need to hard-code them: dollars to store 1,000 minutes for a 30 day month in each tier, dollars to transcribe one hour of audio, and the number of days of storage the balance must cover before an upload is accepted. -**What is billed.** Uploading and encoding are free. Stored video is charged once a day, for each video by its length and resolution tier, with a month counted as 30 days. [Billing](/docs/api/billing) has the daily arithmetic, worked examples, top-ups, codes, auto top-up and what happens when credit runs out. +**What is billed.** Uploading and encoding are free. From November 13, 2026, each video is charged its first month of storage when it first becomes ready, by its length and resolution tier, and is charged once a day after that month, with a month counted as 30 days. Videos that were ready before November 13, 2026 keep the daily charge. [Billing](/docs/api/billing) has the daily arithmetic, worked examples, top-ups, codes, auto top-up and what happens when credit runs out. | Tier | Pixels (width times height) | Storage per 1,000 minutes per month | |------|-----------------------------|-------------------------------------| @@ -1000,19 +1001,19 @@ Credits are tracked in micro-credits (1 dollar = 100,000 micro-credits). `balanc A video is billed at the lower of two tiers: the tier its source falls in by pixel count, and the `maxResolutionTier` it was created with. A 3840 by 2160 upload created with the default `1080p` tier is encoded and billed at `1080p`. `storage.minutesByTier` groups minutes the same way. -**Storage.** `storage` describes the videos counted in `totalVideos`: their minutes in each tier, and what they cost per day and per 30 day month. +**Storage.** `storage.minutesByTier` is what is stored: the minutes of the videos counted in `totalVideos`, in each tier. `storage.dailyChargeMinutesByTier` is the part of it the daily charge covers, the videos past their first month, and `dailyMicroCredits` and `monthlyMicroCredits` are what those cost per day and per 30 day month. A video in its first month counts in `minutesByTier` and not in the daily figures, because its month was charged when it became ready. -**Uploading.** `upload.allowed` is true when the app is in good standing and its balance covers 7 days of storage for everything it stores, counting videos that are still encoding. `upload.requiredMicroCredits` is that amount and `upload.shortfallMicroCredits` is how much to add. +**Uploading.** `upload.allowed` is true when the app is in good standing and its balance pays for the first month of every video still encoding and covers 7 days of storage for the videos past their first month. `upload.requiredMicroCredits` is that amount and `upload.shortfallMicroCredits` is how much to add. **Standing.** `standing.state` is one of: | State | Meaning | |-------|---------| | `active` | In good standing. | -| `grace` | A daily storage charge was more than the balance. The balance was taken to zero, uploads are blocked, and playback continues until `pausesAt`, 7 days after the app first fell short. | +| `grace` | A storage charge, for a day or for a video's first month, was more than the balance. The balance was taken to zero, uploads are blocked, and playback continues until `pausesAt`, 7 days after the app first fell short. | | `paused` | Playback is paused until credits are added. Videos stay stored. | -Adding credits that cover one day of storage returns the app to `active` at once. Days it could not pay for are not charged afterwards. +Once the available balance covers the charge that fell short, one day of storage or a video's first month, the app returns to `active` at once, whether credit was added or a transcript's hold was released. A later charge falling short starts a new grace period. What it could not pay is not charged afterwards. ### List Transactions @@ -1082,12 +1083,13 @@ curl "https://cap.so/api/developer/v1/transactions?limit=2" \ | `topup_failed` | Auto top-up declined | `auto_top_up_failed` | The payment, or `declined-` and the UTC day when there was none | | `pro_credit` | Cap Pro credit | `pro_monthly_credit` | The month, as `YYYY-MM` | | `storage_daily` | Storage | `storage_day` | The UTC day, as `YYYY-MM-DD` | +| `video_create` | Storage, first month | `developer_video` | The video ID | | `transcription` | Transcription | `developer_transcript` | The transcript ID | | `adjustment` | Adjustment | `manual` | A note, when there is one | | `refund` | Payment reversed | `stripe_refund` | The refund | | `adjustment` | Payment disputed | `stripe_dispute` | The dispute | -A storage row is described as "Storage, part paid" or "Storage, unpaid" when the balance could not cover that day. `note` holds a sentence of explanation for the rows that need one. +A storage row is described as "Storage, part paid" or "Storage, unpaid" when the balance could not cover that day, and a first month row as "Storage, first month, part paid" or "Storage, first month, unpaid" when it could not cover that month. `note` holds a sentence of explanation for the rows that need one. Rows written in the same second have the same `createdAt`. They are ordered so each balance follows from the row beneath it, within a page and across page edges, so walking the list with any `limit` returns the same order. @@ -1288,7 +1290,7 @@ A mistake in the request returns one of these: | `400` | `file_too_large` | The parts add up to more than 36 GB, or more than the token's `maxBytes`. The upload was aborted and its parts were discarded. | Export the video at a lower bitrate or split it, then initiate again on the same video and upload the smaller file. | | `404` | `upload_not_found` | No open upload matches the `uploadId`. It was aborted or the ID is wrong. | Initiate again and upload the parts again. | -The balance must cover 7 days of storage for everything the app stores including this video. If it does not, complete returns `402` with the same body as [Create Upload](/docs/api/rest-api#create-upload), and the upload stays open so the same request succeeds after a top-up. The one exception is when several uploads finish at once and together exceed the balance: the `402` then carries `"fileRemoved": true`, the file that was just assembled has been removed, and it must be uploaded again once credit is added. +The balance must pay for the first month of storage of this video and of every other upload of the app still encoding, and cover 7 days of storage for the videos past their first month. If it does not, complete returns `402` with the same body as [Create Upload](/docs/api/rest-api#create-upload), and the upload stays open so the same request succeeds after a top-up. The one exception is when several uploads finish at once and together exceed the balance: the `402` then carries `"fileRemoved": true`, the file that was just assembled has been removed, and it must be uploaded again once credit is added. Each video accepts one completed upload, and its token is used up with it. Once the upload completes, initiate, presign-part and abort return `409` with the code `upload_already_completed`, and repeating complete returns `{ "success": true }` without doing anything. diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index ccc44a62cd4..0f41ea9ac08 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -922,7 +922,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ operationId: "createUpload", summary: "Create an upload", description: - "Creates a video record and returns an upload token for that one video, with the video's ID and its share and embed URLs. Call this from your server, then give the token to the browser that uploads the file. The token is the only credential the upload operations accept. It works until expiresAt, and stops working once the upload completes, the video is deleted or the secret key that created it is revoked. When corsOrigin is set, the upload operations also refuse a request whose Origin header differs: this is a convenience for browsers, and the token is the security boundary. Creating and uploading are free. The app must be in good standing with a balance that covers 7 days of storage for everything it stores, otherwise this returns 402 with the amount needed. Set maxBytes to refuse a file larger than that many bytes. Send an Idempotency-Key so a retried request returns the same video with a new token instead of creating a second one. A field the request does not know returns 400 with code invalid_request and names the field, so a misspelt userId never creates a video with no user.", + "Creates a video record and returns an upload token for that one video, with the video's ID and its share and embed URLs. Call this from your server, then give the token to the browser that uploads the file. The token is the only credential the upload operations accept. It works until expiresAt, and stops working once the upload completes, the video is deleted or the secret key that created it is revoked. When corsOrigin is set, the upload operations also refuse a request whose Origin header differs: this is a convenience for browsers, and the token is the security boundary. Creating and uploading are free. The app must be in good standing with a balance that pays for the first month of storage of every upload still encoding and covers 7 days of storage for the videos past their first month, otherwise this returns 402 with the amount needed. Set maxBytes to refuse a file larger than that many bytes. Send an Idempotency-Key so a retried request returns the same video with a new token instead of creating a second one. A field the request does not know returns 400 with code invalid_request and names the field, so a misspelt userId never creates a video with no user.", tags: ["Uploads"], security: secretAuth, parameters: [uploadIdempotencyKeyParam], @@ -1135,7 +1135,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ operationId: "completeSdkUpload", summary: "Complete a multipart upload", description: - "Finishes the upload and records duration and dimensions (at most 4 hours). Nothing is charged. The balance must cover 7 days of storage for everything the app stores including this video, otherwise this returns 402 with the amount needed. A 402 whose body has fileRemoved true means the file was already assembled and has been removed, so upload it again after a top-up; any other 402 leaves the upload open. Returns 409 with code completion_in_progress while another request is completing the same upload: wait and send the same request again, and do not abort or delete. Stored video is billed daily by resolution tier. Repeating the call after the upload completed returns success. Mistakes in the request return 400 with code parts_required (parts is empty), parts_mismatch (a listed part is missing, repeated or has a different ETag), part_too_small (a part other than the last is under 5 MB) or file_too_large (the parts add up to more than 36 GB or more than the token's maxBytes: the upload has been aborted and the video can take a new, smaller one), and 404 with code upload_not_found when the uploadId is wrong or was aborted: call initiateSdkUpload again and upload the parts again.", + "Finishes the upload and records duration and dimensions (at most 4 hours). Nothing is charged. The balance must pay for the first month of storage of this video and of every other upload still encoding, and cover 7 days of storage for the videos past their first month, otherwise this returns 402 with the amount needed. A 402 whose body has fileRemoved true means the file was already assembled and has been removed, so upload it again after a top-up; any other 402 leaves the upload open. Returns 409 with code completion_in_progress while another request is completing the same upload: wait and send the same request again, and do not abort or delete. Stored video is billed daily by resolution tier. Repeating the call after the upload completed returns success. Mistakes in the request return 400 with code parts_required (parts is empty), parts_mismatch (a listed part is missing, repeated or has a different ETag), part_too_small (a part other than the last is under 5 MB) or file_too_large (the parts add up to more than 36 GB or more than the token's maxBytes: the upload has been aborted and the video can take a new, smaller one), and 404 with code upload_not_found when the uploadId is wrong or was aborted: call initiateSdkUpload again and upload the parts again.", tags: ["SDK"], security: uploadTokenAuth, requestBody: jsonBody("CompleteUploadRequest"), @@ -1453,7 +1453,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ type: { type: "string", description: - "The kind of row: topup, topup_failed, pro_credit, storage_daily, transcription or adjustment. Treat a value you do not know as a row to show as it is.", + "The kind of row: topup, topup_failed, pro_credit, storage_daily, video_create (a video's first month of storage), transcription or adjustment. Treat a value you do not know as a row to show as it is.", }, description: { type: "string", @@ -1607,7 +1607,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ type: "string", enum: ["active", "grace", "paused"], description: - "active: in good standing. grace: a daily storage charge was more than the balance, uploads are blocked and playback continues until pausesAt. paused: playback is paused until credits are added.", + "active: in good standing. grace: a storage charge was more than the balance, uploads are blocked and playback continues until pausesAt. paused: playback is paused until credits are added.", }, pausesAt: { type: ["string", "null"], @@ -1643,7 +1643,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ requiredMicroCredits: { type: "integer", description: - "Balance needed to upload: 7 days of storage for everything stored.", + "Balance needed to upload: the first month of storage of every upload still encoding, plus 7 days of storage for the videos past their first month.", }, shortfallMicroCredits: { type: "integer", @@ -1657,6 +1657,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ type: "object", required: [ "minutesByTier", + "dailyChargeMinutesByTier", "dailyMicroCredits", "monthlyMicroCredits", ], @@ -1672,10 +1673,21 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ "2160p": { type: "number" }, }, }, + dailyChargeMinutesByTier: { + type: "object", + description: + "The part of minutesByTier the daily charge covers: videos past their first month. A video in its first month is left out, because its month was charged when it became ready.", + required: ["1080p", "1440p", "2160p"], + properties: { + "1080p": { type: "number" }, + "1440p": { type: "number" }, + "2160p": { type: "number" }, + }, + }, dailyMicroCredits: { type: "integer", description: - "What one day of storage costs at what is stored now.", + "What one day of storage costs for the videos past their first month.", }, monthlyMicroCredits: { type: "integer", @@ -1701,7 +1713,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ storageDollarsPer1000MinutesPerMonth: { type: "object", description: - "Dollars to store 1,000 minutes for a 30 day month, by resolution tier. Storage is charged daily.", + "Dollars to store 1,000 minutes for a 30 day month, by resolution tier. Each video is charged its first month when it becomes ready, then daily.", required: [...INFRA_RESOLUTION_TIERS], properties: Object.fromEntries( INFRA_RESOLUTION_TIERS.map((tier) => [ @@ -2181,7 +2193,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ ), UploadPaymentRequired: json( ref("UploadPaymentRequired"), - "The app is out of credit or its balance does not cover 7 days of storage. The body says how much is needed.", + "The app is out of credit or its balance does not cover the storage the upload needs. The body says how much is needed.", ), Forbidden: json( ref("Error"), diff --git a/apps/web/lib/developer-pricing.ts b/apps/web/lib/developer-pricing.ts index 4612b70f2b6..f882df68f5c 100644 --- a/apps/web/lib/developer-pricing.ts +++ b/apps/web/lib/developer-pricing.ts @@ -135,12 +135,39 @@ export function storageUsage( }; } -export function uploadAdmissionMicroCredits(monthlyMicroCredits: number) { +export function firstMonthStorageMicroCredits( + video: Pick, +) { + return Math.max( + 1, + Math.floor( + (positive(video.durationSeconds) / 60) * + storageMicroCreditsPerMinutePerMonth(video.tier), + ), + ); +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +export function firstMonthStoragePaidUntil( + chargedAt: Date, + todayAlreadyBilled: boolean, +) { + const firstPaidDay = + Math.floor(chargedAt.getTime() / DAY_MS) + (todayAlreadyBilled ? 1 : 0); + return new Date((firstPaidDay + STORAGE_BILLING_DAYS_PER_MONTH) * DAY_MS); +} + +export function uploadAdmissionMicroCredits( + dailyChargeMonthlyMicroCredits: number, + firstMonthMicroCredits = 0, +) { return Math.max( 1, Math.ceil( - (positive(monthlyMicroCredits) * UPLOAD_ADMISSION_DAYS) / - STORAGE_BILLING_DAYS_PER_MONTH, + positive(firstMonthMicroCredits) + + (positive(dailyChargeMonthlyMicroCredits) * UPLOAD_ADMISSION_DAYS) / + STORAGE_BILLING_DAYS_PER_MONTH, ), ); } diff --git a/apps/web/lib/developer-transcript-errors.ts b/apps/web/lib/developer-transcript-errors.ts index dabb3dadefb..314480c2fa2 100644 --- a/apps/web/lib/developer-transcript-errors.ts +++ b/apps/web/lib/developer-transcript-errors.ts @@ -80,7 +80,7 @@ export const TRANSCRIPT_REQUEST_ERRORS = [ status: 402, code: "out_of_credit", meaning: - "The app is in grace or paused because a daily storage charge could not be paid. The message says how much to add", + "The app is in grace or paused because a storage charge could not be paid. The message says how much to add", }, { status: 402, diff --git a/apps/web/lib/developer-video-api.ts b/apps/web/lib/developer-video-api.ts index a938008b1cb..c0aeb79e553 100644 --- a/apps/web/lib/developer-video-api.ts +++ b/apps/web/lib/developer-video-api.ts @@ -185,6 +185,7 @@ export function toAgentDeveloperVideo(video: AgentDeveloperVideoRow) { export function serializeDeveloperVideo(video: DeveloperVideoRow) { const { processing: _processing, + storagePaidUntil: _storagePaidUntil, filesDeletedAt: _filesDeletedAt, s3Key: _s3Key, ...fields diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index ecd498de1fd..36e4cd88807 100644 --- a/apps/web/lib/developer-video-processing.ts +++ b/apps/web/lib/developer-video-processing.ts @@ -12,6 +12,7 @@ import { billableVideo } from "@/lib/developer-pricing"; import { lockInfraAccount } from "@/lib/infra-balance"; import { INFRA_RESOLUTION_TIERS } from "@/lib/infra-pricing"; import type { InfraDbExecutor } from "@/lib/infra-standing"; +import { settleFirstMonthAtReady } from "@/lib/infra-storage-minimum"; import { VIDEO_TOO_LONG_MESSAGE } from "@/lib/infra-upload"; import { cancelDeveloperVideoJobs } from "./developer-video-cancel"; import { @@ -113,6 +114,7 @@ async function mutateDeveloperVideo( mutate: (video: DeveloperVideoRow) => DeveloperVideoUpdate | null, executor?: InfraDbExecutor, ): Promise { + let becameReady = false; const run = async (tx: InfraDbExecutor) => { const [video] = await tx .select() @@ -122,6 +124,7 @@ async function mutateDeveloperVideo( if (!video) return null; const updates = mutate(video); if (!updates) return null; + becameReady = video.status !== "ready" && updates.status === "ready"; await tx .update(developerVideos) .set(updates) @@ -129,7 +132,9 @@ async function mutateDeveloperVideo( announceDeveloperVideoStatus(video, updates); return { ...video, ...updates } as DeveloperVideoRow; }; - return executor ? run(executor) : db().transaction(run); + const updated = await (executor ? run(executor) : db().transaction(run)); + if (updated && becameReady) await settleFirstMonthAtReady(updated, executor); + return updated; } async function mutateEncode( diff --git a/apps/web/lib/infra-billing-emails.ts b/apps/web/lib/infra-billing-emails.ts index 162ceb48ddc..984832265f5 100644 --- a/apps/web/lib/infra-billing-emails.ts +++ b/apps/web/lib/infra-billing-emails.ts @@ -58,7 +58,7 @@ export async function sendInfraOutOfCreditEmail({ try { const recipient = await recipientFor(accountId); if (!recipient) return false; - await sendEmail({ + const result = await sendEmail({ email: recipient.email, subject: `${recipient.appName} is out of Cap Infra credit`, react: InfraOutOfCredit({ @@ -67,7 +67,7 @@ export async function sendInfraOutOfCreditEmail({ }), idempotencyKey: `infra-out-of-credit-${accountId}-${shortSince.toISOString().slice(0, 10)}`, }); - return true; + return result?.error === null; } catch (error) { console.error("[infra-billing] Out of credit email failed", error); return false; diff --git a/apps/web/lib/infra-ledger.ts b/apps/web/lib/infra-ledger.ts index 37b03cdf727..be3aa1ae5e2 100644 --- a/apps/web/lib/infra-ledger.ts +++ b/apps/web/lib/infra-ledger.ts @@ -26,7 +26,7 @@ export type LedgerDescription = { const TYPE_LABELS: Record = { topup: "Top-up", - video_create: "Video upload", + video_create: "Storage, first month", storage_daily: "Storage", transcription: "Transcription", refund: "Refund", @@ -72,6 +72,12 @@ const NOTES: Record = { "The balance could not pay for this day. It is not charged later.", "Storage, part paid": "The balance paid for part of this day. The rest is not charged later.", + "Storage, first month": + "One month of storage for this video, charged when it became ready. It is charged daily after that.", + "Storage, first month, unpaid": + "The balance could not pay for this video's first month. It is not charged later.", + "Storage, first month, part paid": + "The balance paid for part of this video's first month. The rest is not charged later.", }; const DECLINED_WITHOUT_PAYMENT = /^declined-(\d{4}-\d{2}-\d{2})$/; @@ -86,9 +92,19 @@ function ledgerLabel(row: LedgerRow) { ? "Storage, unpaid" : "Storage, part paid"; } + if (row.type === "video_create" && firstMonthFellShort(row)) { + return row.amountMicroCredits === 0 + ? "Storage, first month, unpaid" + : "Storage, first month, part paid"; + } return TYPE_LABELS[row.type] ?? row.type; } +function firstMonthFellShort(row: LedgerRow) { + const owed = metadataAmount(row.metadata, "owedMicroCredits") ?? 0; + return owed > Math.abs(row.amountMicroCredits); +} + function ledgerReference(row: LedgerRow) { if (!row.referenceId) return null; const declinedDay = DECLINED_WITHOUT_PAYMENT.exec(row.referenceId)?.[1]; diff --git a/apps/web/lib/infra-standing.ts b/apps/web/lib/infra-standing.ts index 82d283b53c0..a2de6396bc4 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -7,7 +7,6 @@ import { and, desc, eq, gt, gte, inArray, lte, sql } from "drizzle-orm"; import { cache } from "react"; import { type BillableVideo, - type StorageUsage, UPLOAD_ADMISSION_DAYS, uploadAdmissionMicroCredits, } from "@/lib/developer-pricing"; @@ -17,6 +16,8 @@ import { formatCredits, formatNeed, utcDateKey } from "@/lib/infra-usage"; export const INFRA_GRACE_DAYS = 7; export const STORAGE_DAY_REFERENCE_TYPE = "storage_day" as const; +export const FIRST_MONTH_TRANSACTION_TYPE = "video_create" as const; +export const FIRST_MONTH_REFERENCE_TYPE = "developer_video" as const; export const STRIPE_DISPUTE_REFERENCE_TYPE = "stripe_dispute" as const; export const DISPUTE_REVIEW_REFERENCE_TYPE = "manual" as const; export const DISPUTED_UPLOAD_MESSAGE = @@ -33,9 +34,12 @@ export type InfraAppStanding = { }; export type StorageCharge = { + id?: string; createdAt: Date; owedMicroCredits: number; shortSince: Date | null; + resolvedAt?: Date | null; + firstMonth?: boolean; }; export type InfraUploadAdmission = { @@ -74,18 +78,28 @@ export function storageChargeDay(metadata: unknown): string | null { return typeof snapshotDate === "string" ? snapshotDate : null; } +const dateField = (value: unknown) => { + const date = typeof value === "string" ? new Date(value) : null; + return date && !Number.isNaN(date.getTime()) ? date : null; +}; + export function parseStorageCharge(row: { + id?: string; createdAt: Date; metadata: unknown; }): StorageCharge { const fields = metadataFields(row.metadata); - const shortSince = - typeof fields.shortSince === "string" ? new Date(fields.shortSince) : null; + const resolvedAt = dateField(fields.resolvedAt); return { + ...(row.id ? { id: row.id } : {}), createdAt: new Date(row.createdAt), - owedMicroCredits: Math.max(0, Number(fields.owedMicroCredits) || 0), - shortSince: - shortSince && !Number.isNaN(shortSince.getTime()) ? shortSince : null, + owedMicroCredits: Math.max( + 0, + Number(fields.owedMicroCredits) || 0, + Number(fields.outstandingMicroCredits) || 0, + ), + shortSince: dateField(fields.shortSince), + ...(resolvedAt ? { resolvedAt } : {}), }; } @@ -123,12 +137,10 @@ const storageDayRows = () => const latestStorageDay = (accountId: string, ceiling: string) => sql`(select ${developerCreditTransactions.accountId}, ${developerCreditTransactions.referenceId} from ${developerCreditTransactions} where ${developerCreditTransactions.accountId} = ${accountId} and ${developerCreditTransactions.referenceType} = ${STORAGE_DAY_REFERENCE_TYPE} and ${developerCreditTransactions.referenceId} <= ${ceiling} order by ${developerCreditTransactions.referenceId} desc limit 1)`; -export async function latestStorageCharge( - executor: InfraDbExecutor, - accountId: string, -) { +async function latestDailyCharge(executor: InfraDbExecutor, accountId: string) { const [row] = await executor .select({ + id: developerCreditTransactions.id, createdAt: developerCreditTransactions.createdAt, metadata: developerCreditTransactions.metadata, }) @@ -148,6 +160,171 @@ export async function latestStorageCharge( return row ? parseStorageCharge(row) : null; } +async function latestFirstMonthCharge( + executor: InfraDbExecutor, + accountId: string, +) { + const ofAccount = and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, FIRST_MONTH_TRANSACTION_TYPE), + ); + const [latest] = await executor + .select({ createdAt: developerCreditTransactions.createdAt }) + .from(developerCreditTransactions) + .where(ofAccount) + .orderBy(desc(developerCreditTransactions.createdAt)) + .limit(1); + if (!latest) return null; + const sameSecond = ( + await executor + .select({ + id: developerCreditTransactions.id, + createdAt: developerCreditTransactions.createdAt, + metadata: developerCreditTransactions.metadata, + }) + .from(developerCreditTransactions) + .where( + and( + ofAccount, + eq(developerCreditTransactions.createdAt, latest.createdAt), + ), + ) + ).map(parseStorageCharge); + const largestFirst = [...sameSecond].sort( + (a, b) => b.owedMicroCredits - a.owedMicroCredits, + ); + return ( + largestFirst.find((charge) => charge.shortSince && !charge.resolvedAt) ?? + largestFirst.find((charge) => charge.shortSince) ?? + sameSecond[0] ?? + null + ); +} + +const openShortfall = (shortSince: Date) => + and( + sql`JSON_UNQUOTE(JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.shortSince')) = ${shortSince.toISOString()}`, + sql`JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.resolvedAt') IS NULL`, + ); + +const shortfallRows = (accountId: string, shortSince: Date) => + and( + eq(developerCreditTransactions.accountId, accountId), + inArray(developerCreditTransactions.type, [ + FIRST_MONTH_TRANSACTION_TYPE, + "storage_daily", + ]), + openShortfall(shortSince), + ); + +export async function firstMonthShortfallOwed( + executor: InfraDbExecutor, + accountId: string, + shortSince: Date, +) { + const field = (name: string) => + sql`COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(${developerCreditTransactions.metadata}, ${`$.${name}`})) AS SIGNED), 0)`; + const [largest] = await executor + .select({ + owedMicroCredits: sql< + string | number | null + >`MAX(GREATEST(CASE WHEN ${field("chargedMicroCredits")} < ${field("owedMicroCredits")} THEN ${field("owedMicroCredits")} ELSE 0 END, ${field("outstandingMicroCredits")}))`, + }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.type, FIRST_MONTH_TRANSACTION_TYPE), + openShortfall(shortSince), + ), + ); + return Number(largest?.owedMicroCredits) || 0; +} + +export async function shortfallOwed( + executor: InfraDbExecutor, + accountId: string, + shortSince: Date, +) { + const daily = await latestDailyCharge(executor, accountId); + const dailyOwed = + daily?.shortSince?.getTime() === shortSince.getTime() && !daily.resolvedAt + ? daily.owedMicroCredits + : 0; + return Math.max( + dailyOwed, + await firstMonthShortfallOwed(executor, accountId, shortSince), + ); +} + +async function sizedShortfall( + executor: InfraDbExecutor, + accountId: string, + charge: StorageCharge | null, +): Promise { + if (!charge?.shortSince || charge.resolvedAt) return charge; + return { + ...charge, + owedMicroCredits: await shortfallOwed( + executor, + accountId, + charge.shortSince, + ), + }; +} + +export async function latestStorageCharge( + executor: InfraDbExecutor, + accountId: string, +): Promise { + const firstMonth = await sizedShortfall( + executor, + accountId, + await latestFirstMonthCharge(executor, accountId), + ); + if ( + firstMonth?.shortSince && + !(await restoredSinceCharge(executor, accountId, firstMonth)) + ) { + return { ...firstMonth, firstMonth: true }; + } + return sizedShortfall( + executor, + accountId, + await latestDailyCharge(executor, accountId), + ); +} + +export async function latchCoveredShortfall( + tx: InfraDbExecutor, + charge: StorageCharge | null, + availableMicroCredits: number, + now: Date, + accountId: string, +) { + if ( + !charge?.id || + !charge.shortSince || + charge.resolvedAt || + availableMicroCredits < charge.owedMicroCredits + ) { + return false; + } + if ( + availableMicroCredits < + (await shortfallOwed(tx, accountId, charge.shortSince)) + ) { + return false; + } + await tx + .update(developerCreditTransactions) + .set({ + metadata: sql`JSON_SET(COALESCE(${developerCreditTransactions.metadata}, JSON_OBJECT()), '$.resolvedAt', ${now.toISOString()})`, + }) + .where(shortfallRows(accountId, charge.shortSince)); + return true; +} + export async function latestStorageCharges( executor: InfraDbExecutor, accountIds: readonly string[], @@ -185,6 +362,7 @@ export async function restoredSinceCharge( accountId: string, charge: StorageCharge, ) { + if (charge.resolvedAt) return true; const [credit] = await executor .select({ id: developerCreditTransactions.id }) .from(developerCreditTransactions) @@ -362,16 +540,22 @@ export function uploadAdmission({ balanceMicroCredits, standing, lastOwedMicroCredits, - usage, + storage, }: { balanceMicroCredits: number; standing: InfraAppStanding; lastOwedMicroCredits: number; - usage: Pick; + storage: { + dailyCharge: Pick; + firstMonthMicroCredits: number; + }; }): InfraUploadAdmission { const inGoodStanding = standing.state === "active"; const requiredMicroCredits = Math.max( - uploadAdmissionMicroCredits(usage.monthlyMicroCredits), + uploadAdmissionMicroCredits( + storage.dailyCharge.monthlyMicroCredits, + storage.firstMonthMicroCredits, + ), inGoodStanding ? 0 : lastOwedMicroCredits, ); const shortfallMicroCredits = Math.max( @@ -392,6 +576,7 @@ export function uploadAdmission({ message: DISPUTED_UPLOAD_MESSAGE, }; } + const covers = uploadCreditCovers(storage); if (!inGoodStanding) { return { ...base, @@ -405,10 +590,9 @@ export function uploadAdmission({ ...base, allowed: false, reason: "insufficient_credits", - message: - usage.monthlyMicroCredits > 0 - ? `This app has no credit. Add at least ${formatNeed(shortfallMicroCredits)} to upload, enough for ${UPLOAD_ADMISSION_DAYS} days of storage for everything it stores.` - : "This app has no credit. Add credits to start uploading.", + message: covers + ? `Not enough credit to upload. Add at least ${formatNeed(shortfallMicroCredits)}, enough for ${covers}.` + : "Not enough credit to upload. Add credits to start uploading.", }; } if (shortfallMicroCredits > 0) { @@ -416,12 +600,26 @@ export function uploadAdmission({ ...base, allowed: false, reason: "insufficient_credits", - message: `Uploading needs ${formatNeed(requiredMicroCredits)} of credit, enough for ${UPLOAD_ADMISSION_DAYS} days of storage for everything this app stores. The balance is ${formatCredits(balanceMicroCredits)}, so add at least ${formatNeed(shortfallMicroCredits)}.`, + message: `Not enough credit to upload. Uploading needs ${formatNeed(requiredMicroCredits)}, enough for ${covers ?? "one video"}. The balance is ${formatCredits(balanceMicroCredits)}, so add at least ${formatNeed(shortfallMicroCredits)}.`, }; } return { ...base, allowed: true, reason: null, message: null }; } +function uploadCreditCovers(storage: { + dailyCharge: { monthlyMicroCredits: number }; + firstMonthMicroCredits: number; +}) { + const firstMonth = storage.firstMonthMicroCredits > 0; + const daily = storage.dailyCharge.monthlyMicroCredits > 0; + const firstMonthText = + "the first month of storage for each video being uploaded or encoded"; + const dailyText = `${UPLOAD_ADMISSION_DAYS} days of storage for the videos past their first month`; + if (firstMonth && daily) return `${firstMonthText} and ${dailyText}`; + if (firstMonth) return firstMonthText; + return daily ? dailyText : null; +} + export async function getInfraBilling( appId: string, newVideo?: BillableVideo | null, @@ -433,22 +631,22 @@ export async function getInfraBilling( ? [ await appStanding(appId, now, executor), options.storage ?? - (await getAppStorage(appId, newVideo, executor, options)), + (await getAppStorage(appId, newVideo, executor, { ...options, now })), ] : await Promise.all([ appStanding(appId, now), - options.storage ?? getAppStorage(appId, newVideo, undefined, options), + options.storage ?? + getAppStorage(appId, newVideo, undefined, { ...options, now }), ]); - const usage = storage.counted; const admission = uploadAdmission({ balanceMicroCredits: account?.balanceMicroCredits ?? 0, standing, lastOwedMicroCredits: lastCharge?.owedMicroCredits ?? 0, - usage, + storage, }); return { standing, - usage, + storage, stored: storage.stored, admission: account ? admission @@ -471,22 +669,18 @@ export async function getInfraUploadAdmission( .admission; } -export function infraStorageJson( - usage: Pick< - StorageUsage, - "minutesByTier" | "dailyMicroCredits" | "monthlyMicroCredits" - >, -) { +export function infraStorageJson(storage: AppStorage) { return { - minutesByTier: usage.minutesByTier, - dailyMicroCredits: usage.dailyMicroCredits, - monthlyMicroCredits: Math.round(usage.monthlyMicroCredits), + minutesByTier: storage.stored.minutesByTier, + dailyChargeMinutesByTier: storage.dailyCharge.minutesByTier, + dailyMicroCredits: storage.dailyCharge.dailyMicroCredits, + monthlyMicroCredits: Math.round(storage.dailyCharge.monthlyMicroCredits), }; } export function infraBillingJson({ standing, - usage, + storage, admission, }: Awaited>) { return { @@ -501,7 +695,7 @@ export function infraBillingJson({ shortfallMicroCredits: admission.shortfallMicroCredits, message: admission.message, }, - storage: infraStorageJson(usage), + storage: infraStorageJson(storage), }; } diff --git a/apps/web/lib/infra-storage-billing.ts b/apps/web/lib/infra-storage-billing.ts index 113bea85439..834e7e2fe3d 100644 --- a/apps/web/lib/infra-storage-billing.ts +++ b/apps/web/lib/infra-storage-billing.ts @@ -9,6 +9,8 @@ import { and, eq, gte, sql } from "drizzle-orm"; import type { StorageUsage } from "@/lib/developer-pricing"; import { availableBalance } from "@/lib/infra-balance"; import { + type InfraDbExecutor, + latchCoveredShortfall, latestStorageCharge, restoredSinceCharge, STORAGE_DAY_REFERENCE_TYPE, @@ -84,7 +86,8 @@ export async function recordStorageDayVisits({ export async function chargeStorageDay({ appId, accountId, - usage, + usage: measuredBefore, + measure, dateKey, now, retryUnpaid = true, @@ -92,6 +95,7 @@ export async function chargeStorageDay({ appId: string; accountId: string; usage: StorageUsage; + measure?: (tx: InfraDbExecutor) => Promise; dateKey: string; now: Date; retryUnpaid?: boolean; @@ -164,14 +168,27 @@ export async function chargeStorageDay({ return { state: "skipped" as const }; } + const usage = measure ? await measure(tx) : measuredBefore; const lastCharge = await latestStorageCharge(tx, accountId); + const available = (await availableBalance(appId, tx)) ?? 0; + const latched = await latchCoveredShortfall( + tx, + lastCharge, + available, + now, + accountId, + ); const unresolvedShortSince = + !latched && lastCharge?.shortSince && !(await restoredSinceCharge(tx, accountId, lastCharge)) ? lastCharge.shortSince : null; - if (usage.dailyMicroCredits <= 0 && !unresolvedShortSince) { + if ( + usage.dailyMicroCredits <= 0 && + (!unresolvedShortSince || lastCharge?.firstMonth) + ) { return { state: "skipped" as const }; } @@ -198,8 +215,6 @@ export async function chargeStorageDay({ }) : Promise.resolve(); - const available = (await availableBalance(appId, tx)) ?? 0; - if (unresolvedShortSince && usage.dailyMicroCredits > 0 && available <= 0) { await recordSnapshot(0); return { state: "unpaid" as const, shortSince: unresolvedShortSince }; @@ -236,6 +251,7 @@ export async function chargeStorageDay({ owedMicroCredits: settled.owedMicroCredits, chargedMicroCredits: settled.chargedMicroCredits, shortSince: settled.shortSince?.toISOString() ?? null, + fellShort: settled.fellShort, totalDurationMinutes: usage.totalMinutes, minutesByTier: usage.minutesByTier, videoCount: usage.videoCount, diff --git a/apps/web/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts new file mode 100644 index 00000000000..3f69309e505 --- /dev/null +++ b/apps/web/lib/infra-storage-minimum.ts @@ -0,0 +1,587 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +import type { DeveloperVideoProcessingState } from "@cap/database/types"; +import { + and, + asc, + eq, + gte, + inArray, + isNotNull, + isNull, + sql, +} from "drizzle-orm"; +import { + firstMonthStorageMicroCredits, + firstMonthStoragePaidUntil, + STORAGE_BILLING_DAYS_PER_MONTH, + videoStorageTier, +} from "@/lib/developer-pricing"; +import { DEVELOPER_VIDEO_MAX_DURATION_SECONDS } from "@/lib/developer-video-charge"; +import { availableBalance } from "@/lib/infra-balance"; +import { + INFRA_RESOLUTION_TIERS, + type InfraResolutionTier, +} from "@/lib/infra-pricing"; +import { + FIRST_MONTH_REFERENCE_TYPE, + FIRST_MONTH_TRANSACTION_TYPE, + firstMonthShortfallOwed, + type InfraDbExecutor, + latchCoveredShortfall, + latestStorageCharge, + parseStorageCharge, + restoredSinceCharge, + STORAGE_DAY_REFERENCE_TYPE, +} from "@/lib/infra-standing"; +import { settleStorageDay } from "@/lib/infra-storage-billing"; +import { utcDateKey } from "@/lib/infra-usage"; + +export type FirstMonthCharge = + | { + state: "charged"; + accountId: string; + tier: InfraResolutionTier; + owedMicroCredits: number; + chargedMicroCredits: number; + balanceMicroCredits: number; + paidUntil: Date; + shortSince: Date | null; + fellShort: boolean; + } + | { state: "exempt"; paidUntil: Date } + | { + state: "skipped"; + reason: + | "missing" + | "already_settled" + | "not_ready" + | "not_deleted" + | "not_dispatched"; + }; + +export type FirstMonthOptions = { + executor?: InfraDbExecutor; + deletedBeforeReady?: boolean; + exemptIfDeleted?: boolean; + atDelete?: boolean; + now?: Date; +}; + +const encodeDispatched = (processing: DeveloperVideoProcessingState) => + (["mp4", "hls"] as const).some((name) => { + const part = processing[name]; + if (part.state === "running") return part.jobId !== null; + return ( + part.state === "complete" || + processing.attempts[name] > 0 || + (processing.lostJobs?.[name] ?? 0) > 0 + ); + }); + +const DAY_MS = 24 * 60 * 60 * 1000; + +const skipped = ( + reason: Extract["reason"], +): FirstMonthCharge => ({ state: "skipped", reason }); + +const isTier = (value: unknown): value is InfraResolutionTier => + INFRA_RESOLUTION_TIERS.some((tier) => tier === value); + +const positive = (value: number | null) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; + +async function dayAlreadyBilled( + tx: InfraDbExecutor, + accountId: string | null, + now: Date, +) { + if (!accountId) return false; + const [billed] = await tx + .select({ id: developerCreditTransactions.id }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.referenceId, utcDateKey(now)), + eq( + developerCreditTransactions.referenceType, + STORAGE_DAY_REFERENCE_TYPE, + ), + ), + ) + .limit(1); + return !!billed; +} + +async function settleFirstMonth( + tx: InfraDbExecutor, + videoId: string, + appId: string, + options: FirstMonthOptions, +): Promise { + const now = options.now ?? new Date(); + const [account] = await tx + .select({ + id: developerCreditAccounts.id, + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .for("update"); + + const [video] = await tx + .select({ + status: developerVideos.status, + duration: developerVideos.duration, + width: developerVideos.width, + height: developerVideos.height, + maxResolutionTier: developerVideos.maxResolutionTier, + processing: developerVideos.processing, + storagePaidUntil: developerVideos.storagePaidUntil, + deletedAt: developerVideos.deletedAt, + createdAt: developerVideos.createdAt, + updatedAt: developerVideos.updatedAt, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .for("update"); + if (!video) return skipped("missing"); + if (video.storagePaidUntil !== null) return skipped("already_settled"); + let exemptAtDelete = false; + if (options.atDelete) { + if (video.deletedAt === null) return skipped("not_deleted"); + const encoding = + video.status === "processing" && + video.processing !== null && + video.duration !== null && + encodeDispatched(video.processing); + if (video.status !== "ready" && !encoding) return skipped("not_dispatched"); + const began = encoding ? await firstMonthChargesBegan(tx) : null; + exemptAtDelete = + !encoding || + !began || + new Date(video.createdAt).getTime() < began.getTime(); + } else if (video.status !== "ready") { + return skipped("not_ready"); + } + + const paidUntil = firstMonthStoragePaidUntil( + now, + await dayAlreadyBilled(tx, account?.id ?? null, now), + ); + const markSettled = () => + tx + .update(developerVideos) + .set({ + storagePaidUntil: paidUntil, + updatedAt: video.updatedAt, + }) + .where(eq(developerVideos.id, videoId)); + + if ( + options.deletedBeforeReady || + exemptAtDelete || + (options.exemptIfDeleted && video.deletedAt !== null) || + video.processing === null || + !account + ) { + await markSettled(); + return { state: "exempt", paidUntil }; + } + + const [recorded] = await tx + .select({ id: developerCreditTransactions.id }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, account.id), + eq(developerCreditTransactions.referenceId, videoId), + eq( + developerCreditTransactions.referenceType, + FIRST_MONTH_REFERENCE_TYPE, + ), + eq(developerCreditTransactions.type, FIRST_MONTH_TRANSACTION_TYPE), + ), + ) + .limit(1); + if (recorded) { + await markSettled(); + return skipped("already_settled"); + } + + const durationSeconds = Math.min( + positive(video.duration), + DEVELOPER_VIDEO_MAX_DURATION_SECONDS, + ); + const maxTier = isTier(video.maxResolutionTier) + ? video.maxResolutionTier + : null; + const measured = positive(video.width) > 0 && positive(video.height) > 0; + const tier = + !measured && maxTier + ? maxTier + : videoStorageTier( + { width: positive(video.width), height: positive(video.height) }, + maxTier, + ); + const lastCharge = await latestStorageCharge(tx, account.id); + const available = + (await availableBalance(appId, tx)) ?? account.balanceMicroCredits; + const latched = await latchCoveredShortfall( + tx, + lastCharge, + available, + now, + account.id, + ); + const unresolvedShortSince = + !latched && + lastCharge?.shortSince && + !(await restoredSinceCharge(tx, account.id, lastCharge)) + ? lastCharge.shortSince + : null; + const settled = settleStorageDay({ + balanceMicroCredits: account.balanceMicroCredits, + availableMicroCredits: available, + owedMicroCredits: firstMonthStorageMicroCredits({ durationSeconds, tier }), + unresolvedShortSince, + now, + }); + const carried = unresolvedShortSince ? lastCharge : null; + const standingShortSince = + settled.shortSince ?? (carried?.firstMonth ? unresolvedShortSince : null); + const outstandingMicroCredits = + carried && standingShortSince + ? Math.max( + await firstMonthShortfallOwed(tx, account.id, standingShortSince), + settled.shortSince ? settled.owedMicroCredits : 0, + ) + : null; + + if (settled.chargedMicroCredits > 0) { + await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} - ${settled.chargedMicroCredits}`, + }) + .where(eq(developerCreditAccounts.id, account.id)); + } + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId: account.id, + type: FIRST_MONTH_TRANSACTION_TYPE, + amountMicroCredits: + settled.chargedMicroCredits > 0 ? -settled.chargedMicroCredits : 0, + balanceAfterMicroCredits: settled.balanceMicroCredits, + referenceId: videoId, + referenceType: FIRST_MONTH_REFERENCE_TYPE, + metadata: { + owedMicroCredits: settled.owedMicroCredits, + chargedMicroCredits: settled.chargedMicroCredits, + shortSince: standingShortSince?.toISOString() ?? null, + ...(outstandingMicroCredits !== null ? { outstandingMicroCredits } : {}), + fellShort: settled.fellShort, + durationSeconds, + tier, + paidUntil: paidUntil.toISOString(), + ...(options.atDelete && video.status !== "ready" + ? { deletedWhileEncoding: true } + : {}), + }, + }); + await markSettled(); + + return { + state: "charged", + accountId: account.id, + tier, + paidUntil, + ...settled, + }; +} + +export async function chargeDeveloperVideoFirstMonth( + videoId: string, + options: FirstMonthOptions = {}, +): Promise { + const executor = options.executor ?? db(); + const [found] = await executor + .select({ appId: developerVideos.appId }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + if (!found) return skipped("missing"); + const result = await executor.transaction((tx) => + settleFirstMonth(tx, videoId, found.appId, options), + ); + if (result.state === "charged" && result.chargedMicroCredits > 0) { + const scheduled = scheduleTopUpAfterCharge(result.accountId); + if (!options.executor) await scheduled; + } + return result; +} + +async function scheduleTopUpAfterCharge(accountId: string) { + try { + const { scheduleAutoTopUp } = await import("@/lib/infra-auto-top-up"); + await scheduleAutoTopUp({ accountId }); + } catch (error) { + console.error("[infra-storage] Auto top-up after a first month failed", { + accountId, + error, + }); + } +} + +export async function settleFirstMonthAtDelete( + videoId: string, +): Promise { + try { + return await chargeDeveloperVideoFirstMonth(videoId, { atDelete: true }); + } catch (error) { + console.error("[infra-storage] First month charge failed at delete", { + videoId, + error, + }); + return null; + } +} + +export async function settleFirstMonthsForDeletedApp( + appId: string, + deletedAt: Date, +) { + try { + const videos = await db() + .select({ id: developerVideos.id }) + .from(developerVideos) + .where( + and( + eq(developerVideos.appId, appId), + eq(developerVideos.deletedAt, deletedAt), + eq(developerVideos.status, "processing"), + isNull(developerVideos.storagePaidUntil), + ), + ); + for (const video of videos) await settleFirstMonthAtDelete(video.id); + } catch (error) { + console.error("[infra-storage] First month charges failed at app delete", { + appId, + error, + }); + } +} + +export async function settleFirstMonthAtReady( + video: { id: string; deletedAt: Date | null }, + executor?: InfraDbExecutor, +): Promise { + try { + await chargeDeveloperVideoFirstMonth(video.id, { + executor, + deletedBeforeReady: video.deletedAt !== null, + }); + } catch (error) { + console.error("[infra-storage] First month charge failed at ready", { + videoId: video.id, + error, + }); + } +} + +const BACKSTOP_BATCH = 200; + +export async function firstMonthChargesBegan( + executor: Pick = db(), +): Promise { + const [earliest] = await executor + .select({ storagePaidUntil: developerVideos.storagePaidUntil }) + .from(developerVideos) + .where(isNotNull(developerVideos.storagePaidUntil)) + .orderBy(asc(developerVideos.storagePaidUntil)) + .limit(1); + return earliest?.storagePaidUntil + ? new Date( + earliest.storagePaidUntil.getTime() - + (STORAGE_BILLING_DAYS_PER_MONTH - 1) * DAY_MS, + ) + : null; +} + +export function unsettledFirstMonthsQuery( + createdSince: Date, + limit: number, + executor: Pick = db(), +) { + return executor + .select({ id: developerVideos.id }) + .from(developerVideos) + .where( + and( + isNull(developerVideos.storagePaidUntil), + eq(developerVideos.status, "ready"), + gte(developerVideos.createdAt, createdSince), + ), + ) + .orderBy(asc(developerVideos.createdAt)) + .limit(limit); +} + +export async function chargeMissedFirstMonths( + now: Date = new Date(), + limit: number = BACKSTOP_BATCH, +) { + const outcome = { charged: 0, short: 0, failed: 0 }; + const began = await firstMonthChargesBegan(); + if (!began) return outcome; + for (const video of await unsettledFirstMonthsQuery(began, limit)) { + try { + const result = await chargeDeveloperVideoFirstMonth(video.id, { + now, + exemptIfDeleted: true, + }); + if (result.state === "charged") { + outcome.charged++; + if (result.shortSince) outcome.short++; + } + } catch (error) { + outcome.failed++; + console.error("[infra-storage] Missed first month charge failed", { + videoId: video.id, + error, + }); + } + } + return outcome; +} + +export async function shortfallsAwaitingNotice( + accountIds: readonly string[], + since: Date, + executor: Pick = db(), +) { + const shortfalls = new Map(); + if (accountIds.length === 0) return shortfalls; + const rows = await executor + .select({ + id: developerCreditTransactions.id, + accountId: developerCreditTransactions.accountId, + createdAt: developerCreditTransactions.createdAt, + metadata: developerCreditTransactions.metadata, + }) + .from(developerCreditTransactions) + .where( + and( + inArray(developerCreditTransactions.accountId, [...accountIds]), + inArray(developerCreditTransactions.type, [ + "storage_daily", + FIRST_MONTH_TRANSACTION_TYPE, + ]), + gte(developerCreditTransactions.createdAt, since), + ), + ); + for (const row of rows) { + const { shortSince } = parseStorageCharge(row); + const fields = + typeof row.metadata === "object" && row.metadata !== null + ? (row.metadata as Record) + : {}; + if (!shortSince || fields.fellShort !== true || fields.notifiedAt) continue; + const shortfall = shortfalls.get(row.accountId) ?? { + shortSince, + ids: [], + }; + shortfall.ids.push(row.id); + shortfalls.set(row.accountId, shortfall); + } + return shortfalls; +} + +export async function markShortfallsNotified( + ids: readonly string[], + now: Date, + executor: InfraDbExecutor = db(), +) { + if (ids.length === 0) return; + await executor + .update(developerCreditTransactions) + .set({ + metadata: sql`JSON_SET(COALESCE(${developerCreditTransactions.metadata}, JSON_OBJECT()), '$.notifiedAt', ${now.toISOString()})`, + }) + .where( + and( + inArray(developerCreditTransactions.id, [...ids]), + sql`JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.notifiedAt') IS NULL`, + ), + ); +} + +export async function openFirstMonthShortfalls( + accountIds: readonly string[], + executor: InfraDbExecutor = db(), +) { + if (accountIds.length === 0) return new Set(); + const ofAccounts = and( + inArray(developerCreditTransactions.accountId, [...accountIds]), + eq(developerCreditTransactions.type, FIRST_MONTH_TRANSACTION_TYPE), + ); + const latest = executor + .select({ + accountId: developerCreditTransactions.accountId, + createdAt: sql`max(${developerCreditTransactions.createdAt})`.as( + "latestCreatedAt", + ), + }) + .from(developerCreditTransactions) + .where(ofAccounts) + .groupBy(developerCreditTransactions.accountId) + .as("latest"); + const rows = await executor + .selectDistinct({ accountId: developerCreditTransactions.accountId }) + .from(developerCreditTransactions) + .innerJoin( + latest, + and( + eq(latest.accountId, developerCreditTransactions.accountId), + eq(latest.createdAt, developerCreditTransactions.createdAt), + ), + ) + .where( + and( + ofAccounts, + sql`COALESCE(JSON_UNQUOTE(JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.shortSince')), 'null') <> 'null'`, + sql`JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.resolvedAt') IS NULL`, + ), + ); + return new Set(rows.map((row) => row.accountId)); +} + +export async function markStorageDayNotified( + accountId: string, + dateKey: string, + now: Date, + executor: InfraDbExecutor = db(), +) { + await executor + .update(developerCreditTransactions) + .set({ + metadata: sql`JSON_SET(COALESCE(${developerCreditTransactions.metadata}, JSON_OBJECT()), '$.notifiedAt', ${now.toISOString()})`, + }) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.referenceId, dateKey), + eq( + developerCreditTransactions.referenceType, + STORAGE_DAY_REFERENCE_TYPE, + ), + sql`JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.notifiedAt') IS NULL`, + ), + ); +} diff --git a/apps/web/lib/infra-storage.ts b/apps/web/lib/infra-storage.ts index 6cd2210c247..5dd9182c055 100644 --- a/apps/web/lib/infra-storage.ts +++ b/apps/web/lib/infra-storage.ts @@ -1,12 +1,26 @@ import { db } from "@cap/database"; import { developerVideos } from "@cap/database/schema"; -import { and, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm"; +import { + and, + gt, + inArray, + isNotNull, + isNull, + lt, + ne, + type SQL, + sql, +} from "drizzle-orm"; import { type BillableVideo, billableVideo, + firstMonthStorageMicroCredits, + STORAGE_BILLING_DAYS_PER_MONTH, type StorageUsage, + storageMicroCreditsPerMinutePerMonth, storageUsage, } from "@/lib/developer-pricing"; +import type { InfraResolutionTier } from "@/lib/infra-pricing"; import type { InfraDbExecutor } from "@/lib/infra-standing"; type StoredVideoGroup = { @@ -17,59 +31,133 @@ type StoredVideoGroup = { maxResolutionTier: string | null; seconds: number | string | null; count: number | string; + rows: number | string; + dailySeconds: number | string | null; + dailyCount: number | string | null; + firstMonth1080p: number | string | null; + firstMonth1440p: number | string | null; + firstMonth2160p: number | string | null; + owedFirstMonth1080p: number | string | null; + owedFirstMonth1440p: number | string | null; + owedFirstMonth2160p: number | string | null; }; +const firstMonthSum = (tier: InfraResolutionTier) => + sql< + number | string | null + >`COALESCE(SUM(GREATEST(1, FLOOR(${developerVideos.duration} / 60 * ${storageMicroCreditsPerMinutePerMonth(tier)}))), 0)`; + +const owedFirstMonthSum = (tier: InfraResolutionTier, owesMonth: SQL) => + sql< + number | string | null + >`COALESCE(SUM(CASE WHEN ${owesMonth} THEN GREATEST(1, FLOOR(${developerVideos.duration} / 60 * ${storageMicroCreditsPerMinutePerMonth(tier)})) ELSE 0 END), 0)`; + const APP_ID_CHUNK = 500; +const DAY_MS = 24 * 60 * 60 * 1000; + +async function firstMonthsOwedSince(executor: Pick) { + const [earliest] = await executor + .select({ + storagePaidUntil: + sql`MIN(${developerVideos.storagePaidUntil})`.mapWith( + developerVideos.storagePaidUntil, + ), + }) + .from(developerVideos) + .where(isNotNull(developerVideos.storagePaidUntil)); + const paidUntil = earliest?.storagePaidUntil; + return paidUntil instanceof Date && !Number.isNaN(paidUntil.getTime()) + ? new Date( + paidUntil.getTime() - (STORAGE_BILLING_DAYS_PER_MONTH - 1) * DAY_MS, + ) + : null; +} + export type StorageUsageOptions = { includeProcessing?: boolean; excludeVideoId?: string; + dailyChargeAt?: Date; createdBefore?: Date; }; +export function storedVideoGroupsQuery( + appIds: readonly string[], + executor: Pick, + excludeVideoId?: string, + now: Date = new Date(), + createdBefore?: Date, + firstMonthsOwedSince?: Date | null, +) { + const owesMonth = firstMonthsOwedSince + ? sql`(${developerVideos.status} = 'ready' AND ${developerVideos.storagePaidUntil} IS NULL AND ${developerVideos.createdAt} >= ${firstMonthsOwedSince})` + : sql`FALSE`; + const inFirstMonth = sql`(${gt(developerVideos.storagePaidUntil, now)} OR ${owesMonth})`; + return executor + .select({ + appId: developerVideos.appId, + width: developerVideos.width, + height: developerVideos.height, + status: developerVideos.status, + maxResolutionTier: developerVideos.maxResolutionTier, + seconds: sql< + number | string | null + >`COALESCE(SUM(${developerVideos.duration}), 0)`, + count: sql`COUNT(${developerVideos.duration})`, + rows: sql`COUNT(*)`, + dailySeconds: sql< + number | string | null + >`COALESCE(SUM(CASE WHEN ${inFirstMonth} THEN 0 ELSE ${developerVideos.duration} END), 0)`, + dailyCount: sql< + number | string | null + >`COALESCE(SUM(CASE WHEN ${inFirstMonth} THEN 0 WHEN ${developerVideos.duration} IS NULL THEN 0 ELSE 1 END), 0)`, + firstMonth1080p: firstMonthSum("1080p"), + firstMonth1440p: firstMonthSum("1440p"), + firstMonth2160p: firstMonthSum("2160p"), + owedFirstMonth1080p: owedFirstMonthSum("1080p", owesMonth), + owedFirstMonth1440p: owedFirstMonthSum("1440p", owesMonth), + owedFirstMonth2160p: owedFirstMonthSum("2160p", owesMonth), + }) + .from(developerVideos) + .where( + and( + inArray(developerVideos.appId, [...appIds]), + isNull(developerVideos.deletedAt), + excludeVideoId ? ne(developerVideos.id, excludeVideoId) : undefined, + createdBefore + ? lt(developerVideos.createdAt, createdBefore) + : undefined, + ), + ) + .groupBy( + developerVideos.appId, + developerVideos.status, + developerVideos.maxResolutionTier, + developerVideos.width, + developerVideos.height, + ); +} + async function storedVideoGroups( appIds: readonly string[], executor: InfraDbExecutor, excludeVideoId?: string, + now?: Date, createdBefore?: Date, + firstMonthsOwedSince?: Date | null, ) { const groups: StoredVideoGroup[] = []; for (let start = 0; start < appIds.length; start += APP_ID_CHUNK) { - const rows = await executor - .select({ - appId: developerVideos.appId, - width: developerVideos.width, - height: developerVideos.height, - status: developerVideos.status, - maxResolutionTier: developerVideos.maxResolutionTier, - seconds: sql< - number | string | null - >`COALESCE(SUM(${developerVideos.duration}), 0)`, - count: sql`COUNT(*)`, - }) - .from(developerVideos) - .where( - and( - inArray( - developerVideos.appId, - appIds.slice(start, start + APP_ID_CHUNK), - ), - isNull(developerVideos.deletedAt), - isNotNull(developerVideos.duration), - excludeVideoId ? ne(developerVideos.id, excludeVideoId) : undefined, - createdBefore - ? lt(developerVideos.createdAt, createdBefore) - : undefined, - ), - ) - .groupBy( - developerVideos.appId, - developerVideos.width, - developerVideos.height, - developerVideos.status, - developerVideos.maxResolutionTier, - ); - groups.push(...rows); + groups.push( + ...(await storedVideoGroupsQuery( + appIds.slice(start, start + APP_ID_CHUNK), + executor, + excludeVideoId, + now, + createdBefore, + firstMonthsOwedSince, + )), + ); } return groups; } @@ -86,22 +174,24 @@ export async function getStorageUsageByApp( >(); for (const appId of appIds) entriesByApp.set(appId, []); if (appIds.length > 0) { + const daily = options.dailyChargeAt !== undefined; for (const group of await storedVideoGroups( appIds, executor, options.excludeVideoId, + options.dailyChargeAt, options.createdBefore, )) { entriesByApp.get(group.appId)?.push({ video: billableVideo({ - duration: Number(group.seconds), + duration: Number(daily ? group.dailySeconds : group.seconds), width: group.width, height: group.height, status: group.status, maxResolutionTier: group.maxResolutionTier, includeProcessing: options.includeProcessing, }), - count: Number(group.count) || 0, + count: Number(daily ? group.dailyCount : group.count) || 0, }); } } @@ -130,33 +220,63 @@ export async function getAppStorageUsage( return usageByApp.get(appId) ?? storageUsage([]); } -export type AppStorage = { stored: StorageUsage; counted: StorageUsage }; +export type AppStorage = { + stored: StorageUsage; + dailyCharge: StorageUsage; + firstMonthMicroCredits: number; + uploadingVideos: number; +}; export async function getAppStorage( appId: string, newVideo?: BillableVideo | null, executor: InfraDbExecutor = db(), - options: Pick = {}, + options: { excludeVideoId?: string; now?: Date } = {}, ): Promise { const groups = await storedVideoGroups( [appId], executor, options.excludeVideoId, + options.now, + undefined, + await firstMonthsOwedSince(executor), ); - const usage = (includeProcessing: boolean) => - storageUsage([ - ...groups.map((group) => ({ - video: billableVideo({ - duration: Number(group.seconds), - width: group.width, - height: group.height, - status: group.status, - maxResolutionTier: group.maxResolutionTier, - includeProcessing, - }), - count: Number(group.count) || 0, + const videoOf = (group: StoredVideoGroup, daily: boolean) => + billableVideo({ + duration: Number(daily ? group.dailySeconds : group.seconds), + width: group.width, + height: group.height, + maxResolutionTier: group.maxResolutionTier, + }); + const ready = groups.filter((group) => group.status === "ready"); + const usage = (daily: boolean) => + storageUsage( + ready.map((group) => ({ + video: videoOf(group, daily), + count: Number(daily ? group.dailyCount : group.count) || 0, })), - ...(newVideo ? [{ video: newVideo, count: 1 }] : []), - ]); - return { stored: usage(false), counted: usage(true) }; + ); + const firstMonthOf = (group: StoredVideoGroup) => { + const video = videoOf(group, false); + if (!video) return 0; + return group.status === "processing" + ? Number(group[`firstMonth${video.tier}`]) || 0 + : Number(group[`owedFirstMonth${video.tier}`]) || 0; + }; + return { + stored: usage(false), + dailyCharge: usage(true), + firstMonthMicroCredits: + groups + .filter( + (group) => group.status === "processing" || group.status === "ready", + ) + .reduce((sum, group) => sum + firstMonthOf(group), 0) + + (newVideo ? firstMonthStorageMicroCredits(newVideo) : 0), + uploadingVideos: groups.reduce( + (sum, group) => + sum + (Number(group.rows) || 0) - (Number(group.count) || 0), + 0, + ), + }; } diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index f4d3d5124e8..89915539806 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -175,7 +175,7 @@ Environment variables: `CAP_API_KEY` (headless/CI upload auth), `CAP_SERVER_URL` Canonical workflow: `cap doctor --json` → `cap targets --json` → `cap record start --screen --json` (or `--detach`) → `cap record stop --id --json` → `cap project validate --json` → `cap export --json` → `cap upload --json`. -Cap Infra, the video and speech API for your own product, is separate from the CLI: start with the guide at https://cap.so/docs/api/get-started, then see the reference at https://cap.so/docs/api/rest-api and the OpenAPI description at https://cap.so/openapi.json. It is paid for from prepaid credits, with optional auto top-up. Uploading and encoding are free. Stored video is charged daily by length and resolution tier: $0.85 (1080p), $1.45 (1440p) or $2.80 (2160p) per 1,000 minutes per 30 day month. Transcription costs $0.10 per audio hour. `GET /api/developer/v1/usage` returns these rates in `rates`, and `GET /api/developer/v1/transactions` returns every credit and charge. The billing rules (daily storage charge, transcript rounding and holds, top-ups from $5 to $10,000, codes, the Cap Pro monthly credit, auto top-up and what happens when credit runs out) are at https://cap.so/docs/api/billing. Webhooks tell your server when a video is ready or has failed (https://cap.so/docs/api/webhooks). Without one, poll the video status endpoint every 3 seconds, and poll a transcript every 5 seconds. +Cap Infra, the video and speech API for your own product, is separate from the CLI: start with the guide at https://cap.so/docs/api/get-started, then see the reference at https://cap.so/docs/api/rest-api and the OpenAPI description at https://cap.so/openapi.json. It is paid for from prepaid credits, with optional auto top-up. Uploading and encoding are free. Stored video is charged by length and resolution tier: $0.85 (1080p), $1.45 (1440p) or $2.80 (2160p) per 1,000 minutes per 30 day month. Each video is charged one month of storage when it becomes ready, then daily. Transcription costs $0.10 per audio hour. `GET /api/developer/v1/usage` returns these rates in `rates`, and `GET /api/developer/v1/transactions` returns every credit and charge. The billing rules (first month and daily storage charge, transcript rounding and holds, top-ups from $5 to $10,000, codes, the Cap Pro monthly credit, auto top-up and what happens when credit runs out) are at https://cap.so/docs/api/billing. Webhooks tell your server when a video is ready or has failed (https://cap.so/docs/api/webhooks). Without one, poll the video status endpoint every 3 seconds, and poll a transcript every 5 seconds. ## Platforms diff --git a/packages/database/emails/infra-out-of-credit.tsx b/packages/database/emails/infra-out-of-credit.tsx index 54552b2da92..fd0caa8fbbe 100644 --- a/packages/database/emails/infra-out-of-credit.tsx +++ b/packages/database/emails/infra-out-of-credit.tsx @@ -47,7 +47,7 @@ export function InfraOutOfCredit({ {appName} is out of credit - Today's storage charge for {appName} was more than its Cap Infra + A storage charge for {appName} was more than its Cap Infra balance, so the balance is now zero. @@ -64,8 +64,8 @@ export function InfraOutOfCredit({ - Adding credits brings the app straight back. The days it could not - pay for are not charged. + Adding credits brings the app straight back. What it could not pay + is not charged afterwards.