Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
4820448
feat(database): add the first month storage marker and the storage ag…
xxxxxxxxxxxxx Nov 13, 2026
e92ac58
feat(web): admit uploads on the first month of storage and split stor…
xxxxxxxxxxxxx Nov 13, 2026
0cd27e7
feat(web): charge each developer video one month of storage when it f…
xxxxxxxxxxxxx Nov 13, 2026
1a8b4a6
test(web): prove the first month of storage on a real database
xxxxxxxxxxxxx Nov 13, 2026
9e3a78e
docs(web): describe the first month of storage, the new upload credit…
xxxxxxxxxxxxx Nov 13, 2026
3d4d7f1
Merge remote-tracking branch 'origin/main' into feat/infra-minimum-st…
xxxxxxxxxxxxx Nov 13, 2026
82ee378
docs(infra-mcp): say stored video is billed one month when ready, the…
xxxxxxxxxxxxx Nov 13, 2026
6904190
fix(web): exempt ready videos the first month cannot charge so the cr…
xxxxxxxxxxxxx Nov 13, 2026
c0bf610
fix(web): measure the day's storage under the account lock so a first…
xxxxxxxxxxxxx Nov 13, 2026
7a78dc1
fix(web): keep a short first month in effect when another month was p…
xxxxxxxxxxxxx Nov 13, 2026
ff9b4d3
fix(web): admit uploads on the sum of each in-flight video's own firs…
xxxxxxxxxxxxx Nov 13, 2026
2c0d4e6
fix(web): count first month charges in the console's monthly storage …
xxxxxxxxxxxxx Nov 13, 2026
be5797f
fix(web): send a first month shortfall email from a day of history an…
xxxxxxxxxxxxx Nov 13, 2026
f062b02
fix(web): keep an unpaid first month in effect until credit covers wh…
xxxxxxxxxxxxx Nov 13, 2026
372ed62
fix(web): exempt deleted videos from the first month backstop instead…
xxxxxxxxxxxxx Nov 13, 2026
ad0d420
fix(web): latch a storage shortfall as resolved once the available ba…
xxxxxxxxxxxxx Nov 13, 2026
417723e
docs(web): say a shortfall is settled once the available balance cove…
xxxxxxxxxxxxx Nov 13, 2026
92688a4
feat(web-domain): return the daily charge's minutes by tier in the ag…
xxxxxxxxxxxxx Nov 13, 2026
82952e3
test(web): pin the first month to the duration stored at first ready
xxxxxxxxxxxxx Nov 13, 2026
406de64
docs(web): state the first month of storage the same way in the billi…
xxxxxxxxxxxxx Nov 13, 2026
9cf0cb6
test(web): pin that daily rows never mask an unpaid first month
xxxxxxxxxxxxx Nov 13, 2026
4e1bafd
test(web): pin grace for a short first month beside a paid storage da…
xxxxxxxxxxxxx Nov 13, 2026
0c6538c
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
a07c4b1
feat(web): say on the Infra landing page that each video is charged o…
richiemcilroy Nov 13, 2026
40232bb
perf(database): cover the storage aggregate's creation bound in its i…
richiemcilroy Nov 13, 2026
446dda5
fix(web): charge the first month of a video deleted after its encode …
richiemcilroy Nov 13, 2026
c92fb04
fix(web): settle the first month after every developer video and app …
richiemcilroy Nov 13, 2026
4e60748
docs(web): say a video deleted while it is being processed is charged…
richiemcilroy Nov 13, 2026
01be96a
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
646e490
fix(web): charge a deleted video's first month only when it was mid-e…
richiemcilroy Nov 13, 2026
a557a43
fix(web): send the out-of-credit email once per new first month short…
richiemcilroy Nov 13, 2026
a0f9485
fix(web): count an encode as sent only once the farm has its job
richiemcilroy Nov 13, 2026
6dff333
fix(web): count an out-of-credit email as sent only when the email se…
richiemcilroy Nov 13, 2026
f3eab77
fix(web): settle an app delete's first months from the videos its own…
richiemcilroy Nov 13, 2026
22eee2c
fix(web): keep counting an encode as sent while a failed attempt wait…
richiemcilroy Nov 13, 2026
4e1d5d3
docs(web): say the daily charge starts once a video's first month is …
richiemcilroy Nov 13, 2026
99d9820
docs(web): match the billing page's transcript rule to the REST refer…
richiemcilroy Nov 13, 2026
3da4e6b
fix(web): retry a daily out-of-credit email that was not sent, like a…
richiemcilroy Nov 13, 2026
2be1255
test(web): charge a delete after a dispatched attempt failed and a re…
richiemcilroy Nov 13, 2026
7cf77cb
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
cdc800b
docs(web): say grace follows any storage charge, not only a daily one
richiemcilroy Nov 13, 2026
dfac799
fix(web): keep an unpaid first month in grace when a later month is paid
richiemcilroy Nov 13, 2026
f5eec59
fix(web): resolve every copy of a covered first month shortfall together
richiemcilroy Nov 13, 2026
169a4ee
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
78c8823
fix(web): resolve every row of a covered shortfall, daily or first month
richiemcilroy Nov 13, 2026
85a8260
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
f7fdde1
fix(web): size a shortfall by its largest unpaid row when reading and…
richiemcilroy Nov 13, 2026
8577d14
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
0b84ce3
fix(web): settle first months when the agent deletes an app
richiemcilroy Nov 13, 2026
9cc13ec
feat(web): schedule an auto top-up after a charged first month
richiemcilroy Nov 13, 2026
95e0ca7
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
dc1e406
fix(web): size a shortfall the same way for standing and the latch
richiemcilroy Nov 13, 2026
7f4896b
fix(web): size a shortfall by its latest day and every unpaid first m…
richiemcilroy Nov 13, 2026
4a8d3e4
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
72a6d22
fix(web): keep reserving a ready video's first month until it is charged
richiemcilroy Nov 13, 2026
651f05c
Merge origin/main into feat/infra-minimum-storage
richiemcilroy Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/__tests__/fixtures/developer-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(),
});
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> }[],
}));

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<string, string>;
}) => {
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);
});
},
);
Loading
Loading