Skip to content

feat(web): charge each Cap Infra video one month of storage when it becomes ready - #4145

Merged
richiemcilroy merged 56 commits into
mainfrom
feat/infra-minimum-storage
Nov 13, 2026
Merged

richiemcilroy merged 56 commits into
mainfrom
feat/infra-minimum-storage

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

What a customer notices

From today, every Cap Infra video is charged a minimum of one month of storage when it first becomes ready to play, then daily after that month. Uploading and encoding stay free.

  • A 10 minute 1080p video costs $0.00850 (850 micro-credits) when it becomes ready, then $0.00028 a day from day 31.
  • A 60 minute video billed at 4K costs $0.16800 (16,800 micro-credits) when it becomes ready, then $0.00560 a day from day 31.

Deleting a video inside its first month gives none of the month back, and a video deleted while it is being processed is charged its first month too. That closes the upload, encode, download, delete loop that cost nothing before. Videos that were already ready before today keep the daily charge they have now. Completing an upload now needs credit for the first month of that video and of every other upload still encoding, so a 20 minute 4K upload needs $0.05600 and three in flight need $0.16800.

The rule

  1. When a video first becomes ready, the account is debited one 30 day month at its billed tier and measured length, rounded down, never less than 1 micro-credit. One video_create ledger row per video (label "Storage, first month", reference type developer_video, reference the video id). A retry, a restart of an errored video or a second ready writes nothing.
  2. The month covers the day the video becomes ready and the 29 after it, or the next 30 days when that day's daily charge has already run. The daily charge leaves the video out for exactly those 30 billed days and counts it from the next one: no day charged twice, none skipped.
  3. A video that never becomes ready pays nothing when it fails on its own or is deleted before its encode was sent to the farm. A video deleted after its encode was sent (a part running with a farm job id, complete, with a job the farm lost, or not running with an attempt already spent, as while a failed attempt waits for its retry; a reserved slot not yet sent does not count), before it became ready, and created on or after first month charging began (the backstop's start; none yet means nothing is charged) is charged its first month at delete, from its duration at that moment (the declared length until the probe overwrites it), capped at 4 hours, at its maxResolutionTier while its dimensions are unmeasured. The charge runs after the delete commits on every path (console video and app delete, REST, agent API) and never fails the delete. storagePaidUntil is set so nothing charges it again. A ready video with no marker at delete is pre-feature by definition and is marked exempt with no ledger row. App delete stamps one deletedAt for the whole operation and, after the commit, settles only the videos carrying that stamp that were still processing, never earlier deletes.
  4. Videos ready before the deploy have no marker and are never charged the minimum.
  5. If the balance cannot pay the month, what is there is taken and the rest is recorded as owed exactly as a short daily charge is (owedMicroCredits, shortSince in the row's metadata). The balance never goes below zero (the column is unsigned). The video still becomes ready and keeps its marker. The account goes into grace: uploads and new transcripts are refused, playback continues for 7 days, then pauses. Once the available balance covers the amount owed (credit added or a transcript hold released) it returns to active, and the next storage charge latches that shortfall as resolved (resolvedAt on the shortfall's ledger row, written once under the account lock) so it never comes back. Later paid days alone do not clear a month that is still uncovered, and a new shortfall starts its own grace.

Pieces and proof

Schema, migration 0059 (0059_developer_video_first_month), after main's 0058 (#4139). Its snapshot's prevId is main's 0058 snapshot and its when is above every migration on main. migrate.ts on an empty scratch database applied all 60, and a second run applied nothing. Not run against any shared database.

ALTER TABLE `developer_videos` ADD `storagePaidUntil` timestamp;--> statement-breakpoint
CREATE INDEX `app_storage_idx` ON `developer_videos` (`appId`,`deletedAt`,`status`,`maxResolutionTier`,`width`,`height`,`storagePaidUntil`,`duration`,`createdAt`);--> statement-breakpoint
CREATE INDEX `first_month_due_idx` ON `developer_videos` (`storagePaidUntil`,`status`,`createdAt`);

app_storage_idx makes app_deleted_idx redundant. Nothing is dropped here.

Storage aggregate (lib/infra-storage.ts). GROUP BY in index order, the uploading count folded in (COUNT(*) and COUNT(duration), no duration IS NOT NULL filter), so GET /usage runs one aggregate instead of two queries. getAppStorage returns stored (what customers see as stored), dailyCharge (videos past their first month) and firstMonthMicroCredits (the sum of each admitted, still encoding video's own first-month charge, floored per video with the 1 micro-credit minimum, computed per group in the same aggregate). EXPLAIN on a seeded scratch database:

Query Plan
Aggregate, one app with 40,000 videos ref app_storage_idx, Using where; Using index, 17 ms median
Same with id <> ? (admission) ref app_storage_idx, Using where; Using index, 19 ms
Aggregate, 200 apps (one cron page) range app_storage_idx, Using where; Using index, 63 ms
Same, day-finishing pass bounded by createdAt range app_storage_idx, Using where; Using index, 66 ms (113 ms with row lookups before createdAt joined the index)
Backstop: ready, no marker, created since start range first_month_due_idx, Using where; Using index
Backstop start (earliest marker) range first_month_due_idx, Using where; Using index

Index only, no temporary table. On the same seeded app, stored totals and the uploading count are identical to main's two queries.

Upload admission (lib/infra-standing.ts, lib/developer-pricing.ts). A ready video whose month is not charged yet (no storagePaidUntil, created on or after first month charging began) is reserved as a first month and left out of the daily figure until it is charged, so the moment between the ready update and the charge cannot let another upload through. The start is one row query passed into the aggregate, which stays index-only (40,000 videos: 19.9 ms to 29.2 ms, plus 2.1 ms for the start). Required = first month of the video being admitted + the actual first-month charge of every admitted video still encoding + 7 days of the daily charge for videos past their first month, minimum 1. Totals are read inside the account lock in recordAdmittedInfraUpload, never passed in. upload.requiredMicroCredits and shortfallMicroCredits keep their names. Credit refusals now start with "Not enough credit ". Proof: four completions for one app run at once on a real database with a balance that covers one 20 minute 4K month: one admitted, three refused with Not enough credit ..., requiredMicroCredits 11,200.

The charge (lib/infra-storage-minimum.ts, chargeDeveloperVideoFirstMonth(videoId, { executor?, deletedBeforeReady?, now? })). Takes the account row lock, then the video row lock, debits, writes the row and sets storagePaidUntil in one transaction. Safe twice and concurrently: eight racing calls on a real database charge once.

The ready hook (one call in lib/developer-video-processing.ts, nothing else in that file). mutateDeveloperVideo notes a not-ready to ready transition and, after the row update commits, calls settleFirstMonthAtReady(updated, executor), which never throws. When the caller passed its own transaction it already holds the account lock (every such caller runs inside underAccountLock), so the lock order stays account then video. Other branches adding a call at the same spot (video.ready webhook, first HLS slice as ready) should keep it beside this one; the per-video idempotency makes repeated passes no-ops.

Merged with #4142. Its storage-day ordering is kept as the daily read; the first-month read stays separate (first-month rows reference a video id, not a day). Its batched idle check also opens the day for accounts with an open first-month shortfall, so a recovery can be latched. The under-lock measure uses its createdBefore with the prepaid exclusion keyed to the start of the date being billed. The backstop runs on the first invocation of a run; shortfall emails are sent per page during today's sweep.

Daily charge. chargeStorageDay re-measures the app's daily usage under the account lock, the same lock the first-month charge takes, so a video that becomes ready between the cron's batch read and its charge is never billed for a day its month covers. Standing treats a short first month as in effect when another month was paid in the same second. A first month charged while a first month shortfall is still open carries it: the row keeps the original shortSince and records outstandingMicroCredits, the larger of what is still unpaid and its own unpaid month, so a later paid month never hides an unpaid one and grace keeps its date. When the available balance covers a shortfall, one UPDATE marks every unresolved daily and first month row of the account with that shortSince resolved (account_type_created_idx), for daily and first month latches alike, so no copy of a covered shortfall can bring grace back. A shortfall has one size everywhere: shortfallOwed reads the largest effective owed amount among its unresolved daily and first month rows (the larger of owedMicroCredits and outstandingMicroCredits) with one indexed query, only while a shortfall is open. latestStorageCharge sizes the charge it returns with it, so standing, the restore check, the cron and the latch use the same number, and when two first months land in the same second the standing read picks the unresolved short row with the largest owed amount.

Auto top-up after a first month. When chargeDeveloperVideoFirstMonth takes a nonzero charge, it calls scheduleAutoTopUp({ accountId }) (#4156) once, through a dynamic import, after its transaction resolves; errors are caught, so a ready or a delete never fails because of it. When the caller passed its own transaction (which holds the account lock), the call is not awaited, so it cannot wait on that lock; the top-up runs after the caller commits. Step bundle: the workflow builder makes one esbuild bundle of all 18 step files. Measured with esbuild's metafile against main, that shipped steps bundle is unchanged at 44 external packages (172 to 174 local modules), because main already ships Stripe and Resend in it since #4156. The encode workflow's own import graph grows from 23 to 28 external packages (stripe, resend, @react-email/components, clsx, tailwind-merge), all already in that bundle.

Deletes after #4177. Every delete path charges the first month, then cancels the encode jobs; the cancel only calls the media server, so the charge reads the same encode state. The agent's app delete now calls settleFirstMonthsForDeletedApp(appId, stamp) with the second-truncated stamp it wrote, after the delete commits and before the cancel.

Cron backstop (app/api/cron/developer-storage/route.ts). Each run first settles up to 200 ready videos with no marker created since the feature started. A live one is charged its month; a deleted one is marked exempt with no ledger row, because the backstop cannot tell whether it was deleted before or after it became ready. The start is the earliest storagePaidUntil minus 29 days, which is always after the deploy, so a video ready before the deploy can never match. Ready rows it cannot charge (no encode state, or no credit account) get the exempt marker so they never block the queue. The out-of-credit email goes out once per new first month shortfall, as for a daily one: the row records fellShort, the cron sends only for rows with it and no notifiedAt (looking back over the 7 day grace period), stamps notifiedAt only after the email service accepted the email. The daily shortfall email works the same way now: its row records fellShort, a sent email stamps it, and the sweep retries any daily or first month shortfall still unsent, and uses the same pause date (shortSince plus 7 days). Its copy now reads "A storage charge for {appName} was more than its Cap Infra balance, so the balance is now zero." and "Adding credits brings the app straight back. What it could not pay is not charged afterwards."

Landing page. components/pages/infra/content.ts carries STORAGE_RULE and DELETED_WHILE_PROCESSING_RULE, used in the FAQ and the pricing note in Pricing.tsx; a test pins them word for word against the billing and get-started docs. The calculator and the Cap Pro credit line stay true.

Agent API. The agent's billing answer now returns storage.dailyChargeMinutesByTier; a test pins that its storage fields equal GET /usage's.

Ledger, docs, console. The Overview's and Usage page's storage spending for the month include first-month charges. Labels "Storage, first month", "..., part paid", "..., unpaid". Billing docs (what is charged, Storage, the first month with worked examples, the daily charge, credit needed to upload, standing, ledger rows), REST reference (402 example, usage paragraphs and example, Complete Upload, transactions table), OpenAPI (new storage.dailyChargeMinutesByTier, descriptions), llms-full.txt, the console Usage and Billing notes and the Infra MCP tool text. The docs test recomputes every new example from the pricing functions.

Tests

  • New __tests__/integration/infra-first-month-database.test.ts, 34 tests on a real scratch database: an unpaid 16,800 month stays in grace from its first date when a later 850 month is paid, and a top-up returns it to active; two copies of a covered shortfall in one second are resolved together and an unrelated shortfall is not; a daily shortfall carried into a first month is resolved with it, and a first month carried by a daily charge is resolved by a daily latch; an 850 and a 16,800 month short in the same second stay in grace with 900 available and are resolved together once 16,800 is covered; 500 seeded sequences of daily charges, first months (including two of different sizes in one second), holds, releases and top-ups and a daily charge part-paid inside an open first month grace period keep standing equal to an independent model after every step, and every row of a latched shortfall resolved (the previous latch fails it); a charged month that crosses the auto top-up threshold creates exactly one top-up, a repeat or a month above the threshold none, and inside a caller's transaction holding the account lock the charge returns at once and the top-up lands after commit; a pre-feature ready delete is exempt and charged nothing; an app delete leaves videos deleted long before untouched; a pre-feature mid-encode delete is charged nothing; a post-feature ready delete charges nothing more; a new first month shortfall is found for its email once and never after notifiedAt, and a carried one is never emailed; a delete mid-encode charges once at the declared length and created tier, then a late ready, the backstop and a repeat delete charge nothing; a delete before the encode was sent is free; a failed encode is free; an app deleted mid-encode is charged once; a delete mid-encode the balance cannot pay is recorded as owed; the month is charged on duration as stored at first ready (a 4 hour claim overwritten to 60 seconds is charged 85 micro-credits, the probe state is never read); a shortfall covered by a released hold is latched once and stays settled; a day measured before a video's month was charged is not billed for it; amounts for 1080p, 1440p, 4K, 4K capped at 1080p and the 1 micro-credit floor; idempotent and eight racing calls; 30 excluded days then a daily charge with no gap, for a video ready before and after the day's charge; deleted an hour after ready keeps the month; errored, encoding and deleted-before-ready pay nothing; pre-feature and pre-encoding videos never charged and the old daily charge unchanged; backstop charges a missed live video once, exempts a missed deleted one, and works through unchargeable rows; part paid and unpaid months leave the video ready and the app in grace, a top-up restores active; the ready path never throws; concurrent completions; admission with one and with several in flight, and with sub-second and one-second clips at their floored charges; console spending includes first months; stored versus daily figures.
  • Unit: an 850 month with a hold released to 900 and two paid 28 days stays active and is never paused, the latch is written once, and a later shortfall gets its own grace; an unpaid first month stays in effect through a later paid day until credit covers it; hook fires once at not-ready to ready and not on the second part or on failure; cron backstop order and shortfall email; ledger labels; admission math and messages; docs examples.
  • Every new test fails on main: the charge module, the second admission argument, the hook call and the documented first month do not exist there.
  • Infra, developer and agent unit suites: 124 files, 2,559 passed, 1 skipped (after merging main). Speech database suites: 46 passed. Speech database suites: 32 passed. Infra MCP package: 129 passed. Typecheck: only the known error in organization-settings-ownership.test.ts. Biome clean.

Not covered

  • Trade-off accepted: a part whose start the farm refused for a bad file keeps its attempt, so a video deleted while that part waits for its retry is charged its first month; it only ever charges a video deleted while it was being retried.
  • __tests__/integration/infra-billing-database.test.ts was not run: its fixture inserts a credit account with no developer_apps row and the migrations' foreign key rejects it, before any billing code runs (same on main).
  • If the ready hook fails for a video deleted after it became ready, the backstop exempts it instead of charging it (undercharge only, a double failure).
  • A video created on the deploy day whose hook fails is never charged its month (the backstop start is a day after the deploy at the earliest). This can only undercharge.
  • The backstop only starts once one first month has been charged or exempted; until then nothing marks the feature's start, by design, so it can never charge a video that was ready before the deploy.
  • completeInfraUpload's cheap pre-check before the lock belongs to the pipeline thread; this PR only changes the admission it calls.
  • restoredSinceCharge treats a top-up in the same second as a later short charge as restoring it, since createdAt has second precision. This existed on main before this PR. It can only delay a grace period, never pause a paying app. Fixed in a follow-up.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new blocking issue was established.

Summary

Adds a first-month storage charge when a Cap Infra video becomes ready, then excludes its prepaid days from daily billing.

  • Cap Infra videos pay their first month of storage when they become ready.
  • Upload checks include first-month costs and usage shows which videos are billed daily.
  • Storage shortfalls keep an app in grace until its unpaid amount is covered.

Reviews (23) · Last reviewed commit: "Merge origin/main into feat/infra-minimu..." · Reviewed by Greptile

…ed from daily charged figures

The storage aggregate groups in index order and folds in the uploading
count, so GET /usage reads one aggregate. Upload admission needs the first
month of the video being admitted and of every admitted video not yet
ready, plus 7 days for videos past their first month, read under the
account lock. Credit refusals start with "Not enough credit ".
…irst becomes ready

chargeDeveloperVideoFirstMonth takes the account lock, debits the month at
the video's tier and length (part paid or unpaid like a short daily charge),
writes one video_create ledger row and sets storagePaidUntil. The ready
transition calls it after the row update; the storage cron charges any ready
video it missed, deleted ones included, and emails a first month that fell
short.
Amounts by tier and capped tier, one charge under racing calls, exactly 30
billed days excluded before daily charging resumes, deletes after and before
ready, old and pre-encoding videos never charged, the cron backstop, part
paid and unpaid months, and concurrent completions admitted under the
account lock. The charge reads under the account lock before any plain
read, so its balance check sees committed debits.

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 4 potential issues (4 medium).

View 1 other finding in Capy.

Open in Capy

Comment thread apps/web/lib/infra-standing.ts Resolved
Comment thread apps/web/lib/infra-storage-minimum.ts Resolved
Comment thread apps/web/lib/infra-storage-minimum.ts Resolved
Comment thread apps/web/lib/infra-standing.ts Resolved
Comment thread apps/web/lib/infra-storage.ts Resolved
Comment thread apps/web/lib/infra-storage-minimum.ts Resolved
@sandchest-com

sandchest-com Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Sandchest

Sandchest reviewed 651f05c: nothing new found in this change.

1 issue found in 6904190 is still open (1 medium).

This repository is public, so the details stay private: only people who can push to it can see them. Reviewed · 10 credits.

@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Skipped Skipped Nov 13, 2026 10:28am UTC

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 02:52 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

Comment thread apps/web/lib/infra-standing.ts Resolved

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

Comment thread apps/web/lib/infra-standing.ts Resolved
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 09:43 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/web/lib/infra-standing.ts Resolved

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 1 potential issue (1 medium).

View 2 other findings in Capy.

Open in Capy

Comment thread apps/web/lib/infra-standing.ts Resolved
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 09:57 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

Open in Capy

Comment thread apps/web/lib/infra-storage.ts Resolved
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 10:28 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@richiemcilroy
richiemcilroy merged commit f0c2806 into main Nov 13, 2026
42 checks passed

This branch was successfully deployed

1 active and 1 inactive deployments
Preview – cap-web — 651f05ca Deployed Nov 13, 2026 by vercel[bot]
Preview – cap-status — 651f05ca Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants