From 4820448cf15a97151923a3a6811f0837fc9e5212 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:44:21 -0500 Subject: [PATCH 01/46] feat(database): add the first month storage marker and the storage aggregate index to developer videos --- .../0059_developer_video_first_month.sql | 3 + .../migrations/meta/0059_snapshot.json | 5860 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/schema.ts | 16 + 4 files changed, 5886 insertions(+) create mode 100644 packages/database/migrations/0059_developer_video_first_month.sql create mode 100644 packages/database/migrations/meta/0059_snapshot.json diff --git a/packages/database/migrations/0059_developer_video_first_month.sql b/packages/database/migrations/0059_developer_video_first_month.sql new file mode 100644 index 00000000000..65fad967b4b --- /dev/null +++ b/packages/database/migrations/0059_developer_video_first_month.sql @@ -0,0 +1,3 @@ +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`);--> statement-breakpoint +CREATE INDEX `first_month_due_idx` ON `developer_videos` (`storagePaidUntil`,`status`,`createdAt`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0059_snapshot.json b/packages/database/migrations/meta/0059_snapshot.json new file mode 100644 index 00000000000..9e9887c6b5c --- /dev/null +++ b/packages/database/migrations/meta/0059_snapshot.json @@ -0,0 +1,5860 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "459962d9-53a1-4131-981d-e20a4b4e33c7", + "prevId": "2773f4a4-e037-4a2a-9537-3e419186570c", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": ["userId", "operation", "keyHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": ["tokenHash"], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": ["state", "updatedAt"], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": ["resourceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaKey": { + "name": "mediaKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaDuration": { + "name": "mediaDuration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaMeta": { + "name": "mediaMeta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + }, + "owner_idx": { + "name": "owner_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_created_idx": { + "name": "account_created_idx", + "columns": ["accountId", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_transcripts": { + "name": "developer_transcripts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resultKey": { + "name": "resultKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceBytes": { + "name": "sourceBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "durationSeconds": { + "name": "durationSeconds", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedLanguage": { + "name": "requestedLanguage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "mediumtext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wordCount": { + "name": "wordCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservedMicroCredits": { + "name": "reservedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billedMicroCredits": { + "name": "billedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queuedAt": { + "name": "queuedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_transcripts_app_list_idx": { + "name": "dev_transcripts_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + }, + "dev_transcripts_app_user_idx": { + "name": "dev_transcripts_app_user_idx", + "columns": ["appId", "externalUserId", "createdAt"], + "isUnique": false + }, + "dev_transcripts_app_status_idx": { + "name": "dev_transcripts_app_status_idx", + "columns": ["appId", "status", "createdAt"], + "isUnique": false + }, + "dev_transcripts_status_updated_idx": { + "name": "dev_transcripts_status_updated_idx", + "columns": ["status", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_transcripts_appId_developer_apps_id_fk": { + "name": "developer_transcripts_appId_developer_apps_id_fk", + "tableFrom": "developer_transcripts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_transcripts_id": { + "name": "developer_transcripts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "playbackId": { + "name": "playbackId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maxResolutionTier": { + "name": "maxResolutionTier", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1080p'" + }, + "renditions": { + "name": "renditions", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storedBytes": { + "name": "storedBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorReason": { + "name": "errorReason", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing": { + "name": "processing", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storagePaidUntil": { + "name": "storagePaidUntil", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filesDeletedAt": { + "name": "filesDeletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + }, + "playback_id_idx": { + "name": "playback_id_idx", + "columns": ["playbackId"], + "isUnique": true + }, + "files_pending_idx": { + "name": "files_pending_idx", + "columns": ["filesDeletedAt", "deletedAt"], + "isUnique": false + }, + "app_storage_idx": { + "name": "app_storage_idx", + "columns": [ + "appId", + "deletedAt", + "status", + "maxResolutionTier", + "width", + "height", + "storagePaidUntil", + "duration" + ], + "isUnique": false + }, + "first_month_due_idx": { + "name": "first_month_due_idx", + "columns": ["storagePaidUntil", "status", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "directory_users": { + "name": "directory_users", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "directoryId": { + "name": "directoryId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "directory_users_organization_id_idx": { + "name": "directory_users_organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "directory_users_user_org_idx": { + "name": "directory_users_user_org_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "directory_users_id": { + "name": "directory_users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": ["public", "parentId"], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": ["public", "spaceId", "parentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_idx": { + "name": "id_idx", + "columns": ["id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": ["provider", "externalId"], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": ["organizationId", "provider", "displayName"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_imports": { + "name": "loom_migration_imports", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_imports_request_idx": { + "name": "loom_migration_imports_request_idx", + "columns": ["requestId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_imports_videoId": { + "name": "loom_migration_imports_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_requests": { + "name": "loom_migration_requests", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedByUserId": { + "name": "requestedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspaceName": { + "name": "workspaceName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerNote": { + "name": "customerNote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerReply": { + "name": "customerReply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "capMessage": { + "name": "capMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expectedVideoCount": { + "name": "expectedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedVideoCount": { + "name": "importedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queuedVideoCount": { + "name": "queuedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportCount": { + "name": "activeImportCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportLeaseToken": { + "name": "activeImportLeaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeImportLeaseUntil": { + "name": "activeImportLeaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorUserId": { + "name": "lastOperatorUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorAt": { + "name": "lastOperatorAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_active_org_idx": { + "name": "loom_migration_active_org_idx", + "columns": ["activeOrganizationId"], + "isUnique": true + }, + "loom_migration_org_created_idx": { + "name": "loom_migration_org_created_idx", + "columns": ["organizationId", "createdAt"], + "isUnique": false + }, + "loom_migration_status_created_idx": { + "name": "loom_migration_status_created_idx", + "columns": ["status", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_requests_id": { + "name": "loom_migration_requests_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loops_sync_jobs": { + "name": "loops_sync_jobs", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "nextAttemptAt": { + "name": "nextAttemptAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaseToken": { + "name": "leaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseUntil": { + "name": "leaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileHash": { + "name": "profileHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teammateJoinedAt": { + "name": "teammateJoinedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedEmail": { + "name": "syncedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "loops_sync_due_idx": { + "name": "loops_sync_due_idx", + "columns": ["nextAttemptAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loops_sync_jobs_userId": { + "name": "loops_sync_jobs_userId", + "columns": ["userId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientName": { + "name": "clientName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activatedAt": { + "name": "activatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "client_id_idx": { + "name": "client_id_idx", + "columns": ["clientId"], + "isUnique": true + }, + "inactive_created_at_idx": { + "name": "inactive_created_at_idx", + "columns": ["activatedAt", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_clients_id": { + "name": "mcp_oauth_clients_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_codes": { + "name": "mcp_oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_codes_id": { + "name": "mcp_oauth_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_registration_quotas": { + "name": "mcp_oauth_registration_quotas", + "columns": { + "windowId": { + "name": "windowId", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registrations": { + "name": "registrations", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_registration_quotas_windowId": { + "name": "mcp_oauth_registration_quotas_windowId", + "columns": ["windowId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessHash": { + "name": "accessHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshHash": { + "name": "refreshHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessExpiresAt": { + "name": "accessExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshExpiresAt": { + "name": "refreshExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "access_hash_idx": { + "name": "access_hash_idx", + "columns": ["accessHash"], + "isUnique": true + }, + "refresh_hash_idx": { + "name": "refresh_hash_idx", + "columns": ["refreshHash"], + "isUnique": true + }, + "family_id_idx": { + "name": "family_id_idx", + "columns": ["familyId"], + "isUnique": false + }, + "client_active_idx": { + "name": "client_active_idx", + "columns": ["clientId", "revokedAt", "refreshExpiresAt"], + "isUnique": false + }, + "refresh_expires_at_idx": { + "name": "refresh_expires_at_idx", + "columns": ["refreshExpiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_tokens_id": { + "name": "mcp_oauth_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media_processing_budgets": { + "name": "media_processing_budgets", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "limit_bytes": { + "name": "limit_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_budget_expiry_idx": { + "name": "media_budget_expiry_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_processing_budgets_id": { + "name": "media_processing_budgets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_claims": { + "name": "mobile_push_claims", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_claims_id": { + "name": "mobile_push_claims_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_tokens": { + "name": "mobile_push_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionHash": { + "name": "sessionHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": ["token"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "session_hash_idx": { + "name": "session_hash_idx", + "columns": ["sessionHash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_tokens_id": { + "name": "mobile_push_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_directory_sync": { + "name": "organization_directory_sync", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedAt": { + "name": "syncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseExpiresAt": { + "name": "leaseExpiresAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastError": { + "name": "lastError", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heldRemovals": { + "name": "heldRemovals", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removalsApprovedAt": { + "name": "removalsApprovedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_directory_sync_organizationId": { + "name": "organization_directory_sync_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_sso": { + "name": "organization_sso", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchasedByUserId": { + "name": "purchasedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paidThrough": { + "name": "paidThrough", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "checkoutAttemptId": { + "name": "checkoutAttemptId", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutCurrency": { + "name": "checkoutCurrency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutPriceId": { + "name": "checkoutPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutSessionId": { + "name": "checkoutSessionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutStartedAt": { + "name": "checkoutStartedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "sso_stripe_subscription_id_idx": { + "name": "sso_stripe_subscription_id_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_sso_organizationId": { + "name": "organization_sso_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultVideoVisibility": { + "name": "defaultVideoVisibility", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + }, + "workos_organization_id_idx": { + "name": "workos_organization_id_idx", + "columns": ["workosOrganizationId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signed_baas": { + "name": "signed_baas", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entityName": { + "name": "entityName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityType": { + "name": "entityType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityAddress": { + "name": "entityAddress", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerName": { + "name": "signerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerTitle": { + "name": "signerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "noticesEmail": { + "name": "noticesEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureData": { + "name": "signatureData", + "type": "longtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signedAt": { + "name": "signedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "signed_baa_organization_id_idx": { + "name": "signed_baa_organization_id_idx", + "columns": ["organizationId"], + "isUnique": true + }, + "signed_baa_stripe_subscription_idx": { + "name": "signed_baa_stripe_subscription_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signed_baas_id": { + "name": "signed_baas_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": ["public", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "integration_object_key_prefix_idx": { + "name": "integration_object_key_prefix_idx", + "columns": ["integrationId", "`objectKey`(191)"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "marketingOrigin": { + "name": "marketingOrigin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_processing_jobs": { + "name": "video_processing_jobs", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committing'" + }, + "attempt_id": { + "name": "attempt_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_job_id": { + "name": "remote_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification": { + "name": "verification", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "processing_state_retry_video_idx": { + "name": "processing_state_retry_video_idx", + "columns": ["state", "next_retry_at", "video_id"], + "isUnique": false + }, + "processing_state_lease_video_idx": { + "name": "processing_state_lease_video_idx", + "columns": ["state", "lease_expires_at", "video_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_processing_jobs_video_id": { + "name": "video_processing_jobs_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": ["phase", "updated_at", "video_id"], + "isUnique": false + }, + "started_at_idx": { + "name": "started_at_idx", + "columns": ["started_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_viewer_grants": { + "name": "video_viewer_grants", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_email_idx": { + "name": "video_email_idx", + "columns": ["videoId", "email"], + "isUnique": true + } + }, + "foreignKeys": { + "video_viewer_grants_videoId_videos_id_fk": { + "name": "video_viewer_grants_videoId_videos_id_fk", + "tableFrom": "video_viewer_grants", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_viewer_grants_id": { + "name": "video_viewer_grants_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "owner_updated_id_idx": { + "name": "owner_updated_id_idx", + "columns": ["ownerId", "updatedAt", "id"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_owner_folder_effective_idx": { + "name": "org_owner_folder_effective_idx", + "columns": ["orgId", "ownerId", "folderId", "effectiveCreatedAt"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + }, + "screenshot_transcription_created_idx": { + "name": "screenshot_transcription_created_idx", + "columns": ["isScreenshot", "transcriptionStatus", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": { + "integration_object_key_prefix_idx": { + "columns": { + "`objectKey`(191)": { + "isExpression": true + } + } + } + } + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 9f14cd20c63..c6bc0ceef48 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1794461929204, "tag": "0055_developer_transcripts", "breakpoints": true + }, + { + "idx": 59, + "version": "5", + "when": 1794534248505, + "tag": "0059_developer_video_first_month", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index a454936392f..d46bb13abb5 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1905,6 +1905,7 @@ export const developerVideos = mysqlTable( storedBytes: bigint("storedBytes", { mode: "number", unsigned: true }), errorReason: varchar("errorReason", { length: 512 }), processing: json("processing").$type(), + storagePaidUntil: timestamp("storagePaidUntil"), deletedAt: timestamp("deletedAt"), filesDeletedAt: timestamp("filesDeletedAt"), createdAt: timestamp("createdAt").notNull().defaultNow(), @@ -1916,6 +1917,21 @@ export const developerVideos = mysqlTable( index("app_deleted_idx").on(table.appId, table.deletedAt), uniqueIndex("playback_id_idx").on(table.playbackId), index("files_pending_idx").on(table.filesDeletedAt, table.deletedAt), + index("app_storage_idx").on( + table.appId, + table.deletedAt, + table.status, + table.maxResolutionTier, + table.width, + table.height, + table.storagePaidUntil, + table.duration, + ), + index("first_month_due_idx").on( + table.storagePaidUntil, + table.status, + table.createdAt, + ), ], ); From e92ac58f6e08556a876a05ccde220f161b7a12d8 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:54:01 -0500 Subject: [PATCH 02/46] feat(web): admit uploads on the first month of storage and split stored 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 ". --- .../web/__tests__/fixtures/developer-video.ts | 1 + .../developer-transcripts-database.test.ts | 4 +- .../unit/developer-api-contract.test.ts | 60 ++++--- .../unit/developer-usage-counts.test.ts | 70 ++++----- .../unit/developer-usage-route.test.ts | 8 +- .../unit/developer-video-upload.test.ts | 63 ++++---- .../__tests__/unit/infra-billing-fake-db.ts | 16 +- .../__tests__/unit/infra-billing-math.test.ts | 66 ++++++-- .../unit/infra-storage-billing.test.ts | 64 ++++---- .../(infra)/dashboard/infra/[appId]/page.tsx | 5 +- .../dashboard/infra/[appId]/usage/page.tsx | 13 +- .../app/api/developer/v1/[...route]/usage.ts | 27 +--- apps/web/lib/developer-pricing.ts | 33 +++- apps/web/lib/developer-video-api.ts | 1 + apps/web/lib/infra-standing.ts | 97 ++++++++---- apps/web/lib/infra-storage-billing.ts | 5 +- apps/web/lib/infra-storage.ts | 146 +++++++++++++----- 17 files changed, 446 insertions(+), 233 deletions(-) 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 e25a3bf5659..5a70eedc59a 100644 --- a/apps/web/__tests__/integration/developer-transcripts-database.test.ts +++ b/apps/web/__tests__/integration/developer-transcripts-database.test.ts @@ -91,7 +91,7 @@ const { availableBalance } = await import("@/lib/infra-balance"); 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 { createTranscriptFromUrl, @@ -222,7 +222,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(), }); diff --git a/apps/web/__tests__/unit/developer-api-contract.test.ts b/apps/web/__tests__/unit/developer-api-contract.test.ts index 4a2c65a0400..226e84a9c39 100644 --- a/apps/web/__tests__/unit/developer-api-contract.test.ts +++ b/apps/web/__tests__/unit/developer-api-contract.test.ts @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ keyRows: [] as { appId: string; environment?: string }[], accounts: [] as { balanceMicroCredits: number }[], - videoStats: [] as Record[], storedVideos: [] as Record[], selected: [] as Record[], createVideo: vi.fn(), @@ -20,23 +19,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, where }; return query; @@ -115,6 +110,7 @@ import { INFRA_RESOLUTION_TIERS, INFRA_STORAGE_DOLLARS_PER_1000_MINUTES_PER_MONTH, } from "@/lib/infra-pricing"; +import { getAppStorage } from "@/lib/infra-storage"; type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; @@ -161,7 +157,6 @@ beforeEach(() => { vi.clearAllMocks(); mocks.keyRows = [{ appId: "app-1", environment: "development" }]; mocks.accounts = [{ balanceMicroCredits: 4_532_000 }]; - mocks.videoStats = [{ createdVideos: 5, uploadedVideos: 4 }]; mocks.storedVideos = [ { appId: "app-1", @@ -171,6 +166,9 @@ beforeEach(() => { maxResolutionTier: "1080p", seconds: 750, count: 3, + rows: 3, + dailySeconds: 750, + dailyCount: 3, }, { appId: "app-1", @@ -180,10 +178,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.mockResolvedValue({ 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" }, @@ -854,20 +870,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 = []; @@ -898,7 +912,13 @@ describe("usage", () => { }); it("returns the fields the OpenAPI description requires", async () => { - mocks.billing.mockResolvedValue({ 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-usage-counts.test.ts b/apps/web/__tests__/unit/developer-usage-counts.test.ts index 1b3373f3953..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,7 @@ 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 () => { const response = await app.request("/", { @@ -99,16 +85,20 @@ const usage = async () => { beforeEach(() => { mocks.balanceMicroCredits = 2_999_901; mocks.videos = []; - mocks.billing.mockResolvedValue({ - standing: { state: "active", pausesAt: null }, - usage: storageUsage([]), - 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, + }, + }; }); }); diff --git a/apps/web/__tests__/unit/developer-usage-route.test.ts b/apps/web/__tests__/unit/developer-usage-route.test.ts index 7054c4816ff..4f66f2560af 100644 --- a/apps/web/__tests__/unit/developer-usage-route.test.ts +++ b/apps/web/__tests__/unit/developer-usage-route.test.ts @@ -33,16 +33,16 @@ vi.mock("@/lib/developer-key-hash", () => ({ hashKey: async (key: string) => `hash:${key}`, })); vi.mock("@/lib/infra-standing", () => ({ - getInfraBilling: async () => ({}), + getInfraBilling: async () => ({ + stored: { videoCount: 2, totalMinutes: 12.5 }, + storage: { uploadingVideos: 1 }, + }), infraBillingJson: () => ({ standing: { state: "active", pausesAt: null }, upload: { allowed: true }, }), infraStorageJson: () => ({ dailyMicroCredits: 0 }), })); -vi.mock("@/lib/infra-storage", () => ({ - getAppStorageUsage: async () => ({ videoCount: 2, totalMinutes: 12.5 }), -})); vi.mock("@/lib/developer-transcript-usage", () => ({ getSpeechUsage: mocks.speech, })); diff --git a/apps/web/__tests__/unit/developer-video-upload.test.ts b/apps/web/__tests__/unit/developer-video-upload.test.ts index 251fbecce06..0ab7cdbfc80 100644 --- a/apps/web/__tests__/unit/developer-video-upload.test.ts +++ b/apps/web/__tests__/unit/developer-video-upload.test.ts @@ -100,33 +100,40 @@ vi.mock("@/lib/infra-storage", async () => { "@/lib/developer-pricing" ); return { - getAppStorageUsage: async ( + 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), + firstMonthDue: storageUsage(due), + uploadingVideos: 0, + }; }, }; }); @@ -204,8 +211,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", { @@ -367,7 +374,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); @@ -388,7 +395,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, }); }); @@ -704,7 +711,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 85239c33378..7f66fb20df8 100644 --- a/apps/web/__tests__/unit/infra-billing-fake-db.ts +++ b/apps/web/__tests__/unit/infra-billing-fake-db.ts @@ -39,6 +39,8 @@ export type VideoGroup = { count: number; status?: string; maxResolutionTier?: string | null; + dailySeconds?: number; + dailyCount?: number; }; const freshAccount = () => ({ @@ -127,7 +129,11 @@ function runSelect(query: Query): Record[] { if (query.table === developerCreditTransactions) { if (keys === "createdAt,metadata") { - return newestFirst(storageRows()).slice(0, 1); + const type = rendered.params.at(-1); + return newestFirst(ledger.rows.filter((row) => row.type === type)).slice( + 0, + 1, + ); } if (keys === "metadata") { const since = asTime(rendered.params.at(-1)); @@ -177,11 +183,17 @@ 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; + return videos.map((video) => ({ + ...video, + status: video.status ?? "ready", + dailySeconds: video.dailySeconds ?? video.seconds, + dailyCount: video.dailyCount ?? video.count, + })); } throw new Error(`Unexpected select: ${keys}`); diff --git a/apps/web/__tests__/unit/infra-billing-math.test.ts b/apps/web/__tests__/unit/infra-billing-math.test.ts index 1e6c04c8371..4b3bc3daf46 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 }, + firstMonthDue: { monthlyMicroCredits: 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 }, + firstMonthDue: { monthlyMicroCredits: 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 }, + firstMonthDue: { monthlyMicroCredits: 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({ diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index f6ad01f874e..6d8d95776b4 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"; @@ -30,7 +30,7 @@ import { getInfraBilling, getInfraUploadAdmission, } 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; @@ -47,7 +47,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), }); @@ -335,7 +335,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 = [ { @@ -348,7 +348,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); @@ -359,16 +359,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", @@ -377,14 +381,14 @@ 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 }); }); }); @@ -549,7 +553,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({ @@ -561,17 +565,21 @@ 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, + expect(storage.firstMonthDue.minutesByTier).toEqual({ + "1080p": 0, "1440p": 0, "2160p": 10, }); - expect((await getAppStorageUsage("app-1")).minutesByTier["2160p"]).toBe(0); + 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 () => { @@ -589,10 +597,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), ); }); }); @@ -612,17 +620,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.firstMonthDue.monthlyMicroCredits).toBe(0); + expect(withNew.dailyCharge.monthlyMicroCredits).toBe(4335); + expect(withNew.firstMonthDue.monthlyMicroCredits).toBe(28_000); + expect(withNew.stored.videoCount).toBe(3); ledger.account.balanceMicroCredits = 1012; expect((await getInfraUploadAdmission("app-1")).allowed).toBe(true); @@ -635,13 +645,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/app/(infra)/dashboard/infra/[appId]/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx index a08cce5fc5a..846b2c49b16 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import Link from "next/link"; import { infraAppPath } from "@/lib/infra-routes"; import { getInfraBilling } from "@/lib/infra-standing"; -import { getAppStorageUsage } from "@/lib/infra-storage"; import { formatCharge, formatCredits, @@ -56,9 +55,8 @@ export default async function InfraOverviewPage({ const { app } = await requireInfraApp(appId); const monthStart = utcMonthStart(new Date()); - const [usage, monthStorageCharges, recentVideos, proCredit, billing] = + const [monthStorageCharges, recentVideos, proCredit, billing] = await Promise.all([ - getAppStorageUsage(app.id), app.creditAccount ? getInfraStorageChargesSince(app.creditAccount.id, monthStart) : Promise.resolve(0), @@ -67,6 +65,7 @@ export default async function InfraOverviewPage({ getInfraBilling(app.id), ]); + const usage = billing.stored; const balance = app.creditAccount?.balanceMicroCredits ?? 0; const secretKey = app.apiKeys.find((k) => k.keyType === "secret"); const empty = recentVideos.length === 0; diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx index d14aabd0604..1d848392f9f 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx @@ -8,7 +8,7 @@ import { INFRA_RESOLUTION_TIERS, INFRA_STORAGE_DOLLARS_PER_1000_MINUTES_PER_MONTH, } from "@/lib/infra-pricing"; -import { getAppStorageUsage } from "@/lib/infra-storage"; +import { getAppStorage } from "@/lib/infra-storage"; import { formatCharge, formatMinutes, @@ -68,14 +68,15 @@ export default async function InfraUsagePage({ const { app } = await requireInfraApp(appId); const now = new Date(); - const [usage, snapshots, transcription] = await Promise.all([ - getAppStorageUsage(app.id), + const [storage, snapshots, transcription] = await Promise.all([ + getAppStorage(app.id), getInfraStorageSnapshots(app.id, utcDaysAgoKey(now, HISTORY_DAYS)), getTranscriptionUsageSince(app.id, utcMonthStart(now)).catch((error) => { console.error("[infra-usage] Transcription usage unavailable", error); return null; }), ]); + const usage = storage.stored; const audioHours = (transcription?.audioSeconds ?? 0) / 3600; const storedTiers = INFRA_RESOLUTION_TIERS.filter( (tier) => usage.minutesByTier[tier] > 0, @@ -92,7 +93,7 @@ export default async function InfraUsagePage({ <> @@ -107,8 +108,8 @@ export default async function InfraUsagePage({ />
{ const appId = c.get("developerAppId"); - const [[videoStats], billing, stored, 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), - getAppStorageUsage(appId), getSpeechUsage(appId), ]); + const { stored, storage } = billing; const { balanceMicroCredits, ...speech } = credit; const { standing, upload } = infraBillingJson(billing); @@ -54,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/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-video-api.ts b/apps/web/lib/developer-video-api.ts index 5ecf2f0c9fb..1ae651a0559 100644 --- a/apps/web/lib/developer-video-api.ts +++ b/apps/web/lib/developer-video-api.ts @@ -104,6 +104,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/infra-standing.ts b/apps/web/lib/infra-standing.ts index 3569c3b0656..f8d9de36286 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -7,16 +7,17 @@ import { and, desc, eq, gt, gte } from "drizzle-orm"; import { cache } from "react"; import { type BillableVideo, - type StorageUsage, UPLOAD_ADMISSION_DAYS, uploadAdmissionMicroCredits, } from "@/lib/developer-pricing"; import { availableBalance } from "@/lib/infra-balance"; -import { getAppStorageUsage } from "@/lib/infra-storage"; +import { type AppStorage, getAppStorage } from "@/lib/infra-storage"; import { formatCredits, formatNeed } 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; const GRACE_MS = INFRA_GRACE_DAYS * 24 * 60 * 60 * 1000; @@ -31,6 +32,7 @@ export type StorageCharge = { createdAt: Date; owedMicroCredits: number; shortSince: Date | null; + firstMonth?: boolean; }; export type InfraUploadAdmission = { @@ -106,9 +108,10 @@ export function resolveStanding({ }; } -export async function latestStorageCharge( +async function latestChargeOfType( executor: InfraDbExecutor, accountId: string, + type: "storage_daily" | typeof FIRST_MONTH_TRANSACTION_TYPE, ) { const [row] = await executor .select({ @@ -119,7 +122,7 @@ export async function latestStorageCharge( .where( and( eq(developerCreditTransactions.accountId, accountId), - eq(developerCreditTransactions.type, "storage_daily"), + eq(developerCreditTransactions.type, type), ), ) .orderBy(desc(developerCreditTransactions.createdAt)) @@ -127,6 +130,25 @@ export async function latestStorageCharge( return row ? parseStorageCharge(row) : null; } +export async function latestStorageCharge( + executor: InfraDbExecutor, + accountId: string, +): Promise { + const daily = await latestChargeOfType(executor, accountId, "storage_daily"); + const firstMonth = await latestChargeOfType( + executor, + accountId, + FIRST_MONTH_TRANSACTION_TYPE, + ); + if ( + firstMonth?.shortSince && + (!daily || firstMonth.createdAt.getTime() >= daily.createdAt.getTime()) + ) { + return { ...firstMonth, firstMonth: true }; + } + return daily; +} + export async function restoredSinceCharge( executor: InfraDbExecutor, accountId: string, @@ -207,16 +229,22 @@ export function uploadAdmission({ balanceMicroCredits, standing, lastOwedMicroCredits, - usage, + storage, }: { balanceMicroCredits: number; standing: InfraAppStanding; lastOwedMicroCredits: number; - usage: Pick; + storage: { + dailyCharge: Pick; + firstMonthDue: Pick; + }; }): InfraUploadAdmission { const inGoodStanding = standing.state === "active"; const requiredMicroCredits = Math.max( - uploadAdmissionMicroCredits(usage.monthlyMicroCredits), + uploadAdmissionMicroCredits( + storage.dailyCharge.monthlyMicroCredits, + storage.firstMonthDue.monthlyMicroCredits, + ), inGoodStanding ? 0 : lastOwedMicroCredits, ); const shortfallMicroCredits = Math.max( @@ -229,6 +257,7 @@ export function uploadAdmission({ requiredMicroCredits, shortfallMicroCredits, }; + const covers = uploadCreditCovers(storage); if (!inGoodStanding) { return { ...base, @@ -242,10 +271,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) { @@ -253,12 +281,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 }; + firstMonthDue: { monthlyMicroCredits: number }; +}) { + const firstMonth = storage.firstMonthDue.monthlyMicroCredits > 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, @@ -266,25 +308,26 @@ export async function getInfraBilling( executor?: InfraDbExecutor, options: { excludeVideoId?: string } = {}, ) { - const counted = { ...options, includeProcessing: true }; - const [{ account, lastCharge, standing }, usage] = executor + const counted = { ...options, now }; + const [{ account, lastCharge, standing }, storage] = executor ? [ await appStanding(appId, now, executor), - await getAppStorageUsage(appId, newVideo, executor, counted), + await getAppStorage(appId, newVideo, executor, counted), ] : await Promise.all([ appStanding(appId, now), - getAppStorageUsage(appId, newVideo, undefined, counted), + getAppStorage(appId, newVideo, undefined, 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 : { @@ -306,22 +349,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 { @@ -336,7 +375,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 32c4bf1db60..6e010f796ba 100644 --- a/apps/web/lib/infra-storage-billing.ts +++ b/apps/web/lib/infra-storage-billing.ts @@ -138,7 +138,10 @@ export async function chargeStorageDay({ ? lastCharge.shortSince : null; - if (usage.dailyMicroCredits <= 0 && !unresolvedShortSince) { + if ( + usage.dailyMicroCredits <= 0 && + (!unresolvedShortSince || lastCharge?.firstMonth) + ) { return { state: "skipped" as const }; } diff --git a/apps/web/lib/infra-storage.ts b/apps/web/lib/infra-storage.ts index 21f380974ef..31b53eda8f6 100644 --- a/apps/web/lib/infra-storage.ts +++ b/apps/web/lib/infra-storage.ts @@ -1,6 +1,6 @@ import { db } from "@cap/database"; import { developerVideos } from "@cap/database/schema"; -import { and, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm"; +import { and, gt, inArray, isNull, ne, sql } from "drizzle-orm"; import { type BillableVideo, billableVideo, @@ -17,6 +17,9 @@ type StoredVideoGroup = { maxResolutionTier: string | null; seconds: number | string | null; count: number | string; + rows: number | string; + dailySeconds: number | string | null; + dailyCount: number | string | null; }; const APP_ID_CHUNK = 500; @@ -24,47 +27,68 @@ const APP_ID_CHUNK = 500; export type StorageUsageOptions = { includeProcessing?: boolean; excludeVideoId?: string; + dailyChargeAt?: Date; }; +export function storedVideoGroupsQuery( + appIds: readonly string[], + executor: Pick, + excludeVideoId?: string, + now: Date = new Date(), +) { + const inFirstMonth = gt(developerVideos.storagePaidUntil, now); + 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)`, + }) + .from(developerVideos) + .where( + and( + inArray(developerVideos.appId, [...appIds]), + isNull(developerVideos.deletedAt), + excludeVideoId ? ne(developerVideos.id, excludeVideoId) : undefined, + ), + ) + .groupBy( + developerVideos.appId, + developerVideos.status, + developerVideos.maxResolutionTier, + developerVideos.width, + developerVideos.height, + ); +} + async function storedVideoGroups( appIds: readonly string[], executor: InfraDbExecutor, excludeVideoId?: string, + now?: Date, ) { 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, - ), - ) - .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, + )), + ); } return groups; } @@ -81,21 +105,23 @@ 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, )) { 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, }); } } @@ -123,3 +149,53 @@ export async function getAppStorageUsage( ); return usageByApp.get(appId) ?? storageUsage([]); } + +export type AppStorage = { + stored: StorageUsage; + dailyCharge: StorageUsage; + firstMonthDue: StorageUsage; + uploadingVideos: number; +}; + +export async function getAppStorage( + appId: string, + newVideo?: BillableVideo | null, + executor: InfraDbExecutor = db(), + options: { excludeVideoId?: string; now?: Date } = {}, +): Promise { + const groups = await storedVideoGroups( + [appId], + executor, + options.excludeVideoId, + options.now, + ); + const usage = ( + status: "ready" | "processing", + daily: boolean, + extra: BillableVideo | null = null, + ) => + storageUsage([ + ...groups + .filter((group) => group.status === status) + .map((group) => ({ + video: billableVideo({ + duration: Number(daily ? group.dailySeconds : group.seconds), + width: group.width, + height: group.height, + maxResolutionTier: group.maxResolutionTier, + }), + count: Number(daily ? group.dailyCount : group.count) || 0, + })), + ...(extra ? [{ video: extra, count: 1 }] : []), + ]); + return { + stored: usage("ready", false), + dailyCharge: usage("ready", true), + firstMonthDue: usage("processing", false, newVideo), + uploadingVideos: groups.reduce( + (sum, group) => + sum + (Number(group.rows) || 0) - (Number(group.count) || 0), + 0, + ), + }; +} From 0cd27e7fbe7a5a8fac1a3859d7d0118b62d21506 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:57:02 -0500 Subject: [PATCH 03/46] feat(web): charge each developer video one month of storage when it first 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. --- .../unit/developer-cron-storage.test.ts | 94 ++++- .../unit/developer-video-processing.test.ts | 62 +++ apps/web/__tests__/unit/infra-ledger.test.ts | 41 ++ .../app/api/cron/developer-storage/route.ts | 24 +- apps/web/lib/developer-video-processing.ts | 7 +- apps/web/lib/infra-ledger.ts | 18 +- apps/web/lib/infra-storage-minimum.ts | 358 ++++++++++++++++++ 7 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 apps/web/lib/infra-storage-minimum.ts diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index ad67787a9fb..c671535cea8 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ selects: [] as Record[][], getStorageUsageByApp: vi.fn(), + chargeMissedFirstMonths: vi.fn(), + firstMonthShortfallsSince: vi.fn(), chargeStorageDay: vi.fn(), latestStorageCharge: vi.fn(), runDueAutoTopUps: vi.fn(), @@ -38,6 +40,10 @@ vi.mock("drizzle-orm", () => ({ vi.mock("@/lib/infra-storage", () => ({ getStorageUsageByApp: mocks.getStorageUsageByApp, })); +vi.mock("@/lib/infra-storage-minimum", () => ({ + chargeMissedFirstMonths: mocks.chargeMissedFirstMonths, + firstMonthShortfallsSince: mocks.firstMonthShortfallsSince, +})); vi.mock("@/lib/infra-storage-billing", () => ({ chargeStorageDay: mocks.chargeStorageDay, })); @@ -57,6 +63,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, @@ -99,6 +106,10 @@ beforeEach(() => { ]) { mock.mockReset(); } + mocks.chargeMissedFirstMonths.mockReset(); + mocks.chargeMissedFirstMonths.mockResolvedValue(NO_FIRST_MONTHS); + mocks.firstMonthShortfallsSince.mockReset(); + mocks.firstMonthShortfallsSince.mockResolvedValue(new Map()); mocks.runDueAutoTopUps.mockResolvedValue(NO_TOP_UPS); mocks.latestStorageCharge.mockResolvedValue(null); mocks.chargeStorageDay.mockResolvedValue(billed()); @@ -147,7 +158,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({ @@ -174,7 +188,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", @@ -309,6 +323,82 @@ 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("does not open a daily charge to settle a short first month when nothing is charged daily", async () => { + const shortSince = new Date(Date.now() - 60 * 60 * 1000); + mocks.selects = [[{ id: "account-1", appId: "app-1" }], []]; + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([["app-1", usage(0)]]), + ); + mocks.latestStorageCharge.mockResolvedValue({ + createdAt: shortSince, + owedMicroCredits: 5_600, + shortSince, + firstMonth: true, + }); + + await authorized(); + + expect(mocks.chargeStorageDay).not.toHaveBeenCalled(); + }); + + it("emails each account whose first month fell short since the last run, once, with the pause date seven days on", async () => { + const shortSince = new Date(Date.now() - 60 * 60 * 1000); + mocks.selects = [ + [ + { id: "account-1", appId: "app-1" }, + { id: "account-2", appId: "app-2" }, + ], + [], + ]; + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([ + ["app-1", usage(0)], + ["app-2", usage(0)], + ]), + ); + mocks.firstMonthShortfallsSince.mockResolvedValue( + new Map([["account-2", shortSince]]), + ); + + const before = Date.now(); + await authorized(); + + const [accountIds, since] = + mocks.firstMonthShortfallsSince.mock.calls[0] ?? []; + expect(accountIds).toEqual(["account-1", "account-2"]); + expect(before - (since as Date).getTime()).toBeGreaterThanOrEqual( + 6 * 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), + }); + }); + it("answers with zero apps when there are none", async () => { const { body } = await authorized(); diff --git a/apps/web/__tests__/unit/developer-video-processing.test.ts b/apps/web/__tests__/unit/developer-video-processing.test.ts index 2eb6962bc22..ea9d4ff1e9e 100644 --- a/apps/web/__tests__/unit/developer-video-processing.test.ts +++ b/apps/web/__tests__/unit/developer-video-processing.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ startJob: vi.fn(), readJob: vi.fn(), charge: vi.fn(), + firstMonth: vi.fn(), deleteObjects: vi.fn(), sniff: vi.fn(), })); @@ -40,6 +41,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, @@ -198,6 +202,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/infra-ledger.test.ts b/apps/web/__tests__/unit/infra-ledger.test.ts index 601d8591d4c..bff1d9e4180 100644 --- a/apps/web/__tests__/unit/infra-ledger.test.ts +++ b/apps/web/__tests__/unit/infra-ledger.test.ts @@ -258,6 +258,47 @@ 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("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/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index 10c38da0f33..d59d33e7ad2 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -16,10 +16,15 @@ import { } from "@/lib/infra-standing"; import { getStorageUsageByApp } from "@/lib/infra-storage"; import { chargeStorageDay } from "@/lib/infra-storage-billing"; +import { + chargeMissedFirstMonths, + firstMonthShortfallsSince, +} from "@/lib/infra-storage-minimum"; import { utcDateKey } from "@/lib/infra-usage"; const BATCH_SIZE = 10; const GRACE_MS = INFRA_GRACE_DAYS * 24 * 60 * 60 * 1000; +const CRON_INTERVAL_MS = 6 * 60 * 60 * 1000; export async function GET(request: Request) { const cronSecret = process.env.CRON_SECRET; @@ -56,6 +61,7 @@ export async function GET(request: Request) { .where(isNull(developerApps.deletedAt)); const topUpsBefore = await runDueAutoTopUps(now); + const firstMonths = await chargeMissedFirstMonths(now); let processed = 0; let short = 0; @@ -83,6 +89,9 @@ export async function GET(request: Request) { ); const usageByApp = await getStorageUsageByApp( pending.map((account) => account.appId), + undefined, + db(), + { dailyChargeAt: now }, ); for (let i = 0; i < pending.length; i += BATCH_SIZE) { @@ -92,7 +101,7 @@ export async function GET(request: Request) { if (!usage) return null; if (usage.dailyMicroCredits <= 0) { const lastCharge = await latestStorageCharge(db(), account.id); - if (!lastCharge?.shortSince) return null; + if (!lastCharge?.shortSince || lastCharge.firstMonth) return null; } const result = await chargeStorageDay({ appId: account.appId, @@ -129,6 +138,18 @@ export async function GET(request: Request) { } } + const shortfalls = await firstMonthShortfallsSince( + accounts.map((account) => account.id), + new Date(now.getTime() - CRON_INTERVAL_MS), + ); + for (const [accountId, shortSince] of shortfalls) { + await sendInfraOutOfCreditEmail({ + accountId, + shortSince, + pausesAt: new Date(shortSince.getTime() + GRACE_MS), + }); + } + const topUpsAfter = await runDueAutoTopUps(now); const autoTopUps = { attempted: topUpsBefore.attempted + topUpsAfter.attempted, @@ -141,6 +162,7 @@ export async function GET(request: Request) { date: today, appsProcessed: processed, appsShort: short, + firstMonths, autoTopUps, }); } diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index d74d6b805ac..71215a3a752 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 { chargeDeveloperVideoUpload, DEVELOPER_VIDEO_MAX_DURATION_SECONDS, @@ -110,6 +111,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() @@ -119,13 +121,16 @@ 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) .where(eq(developerVideos.id, videoId)); 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-ledger.ts b/apps/web/lib/infra-ledger.ts index 76322a1c9bf..65198b50479 100644 --- a/apps/web/lib/infra-ledger.ts +++ b/apps/web/lib/infra-ledger.ts @@ -22,7 +22,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", @@ -55,6 +55,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})$/; @@ -67,9 +73,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 charge = parseStorageCharge(row); + return charge.owedMicroCredits > 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-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts new file mode 100644 index 00000000000..918a32720e0 --- /dev/null +++ b/apps/web/lib/infra-storage-minimum.ts @@ -0,0 +1,358 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +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 { availableBalance } from "@/lib/infra-balance"; +import { + INFRA_RESOLUTION_TIERS, + type InfraResolutionTier, +} from "@/lib/infra-pricing"; +import { + FIRST_MONTH_REFERENCE_TYPE, + FIRST_MONTH_TRANSACTION_TYPE, + type InfraDbExecutor, + 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" + | "never_encoded" + | "no_account"; + }; + +export type FirstMonthOptions = { + executor?: InfraDbExecutor; + deletedBeforeReady?: boolean; + now?: Date; +}; + +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, + options: FirstMonthOptions, +): Promise { + const now = options.now ?? new Date(); + const [found] = await tx + .select({ appId: developerVideos.appId }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + if (!found) return skipped("missing"); + + const [account] = await tx + .select({ + id: developerCreditAccounts.id, + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, found.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, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .for("update"); + if (!video) return skipped("missing"); + if (video.storagePaidUntil !== null) return skipped("already_settled"); + if (video.status !== "ready") return skipped("not_ready"); + if (video.processing === null) return skipped("never_encoded"); + + const paidUntil = firstMonthStoragePaidUntil( + now, + await dayAlreadyBilled(tx, account?.id ?? null, now), + ); + const markSettled = () => + tx + .update(developerVideos) + .set({ + storagePaidUntil: paidUntil, + updatedAt: sql`${developerVideos.updatedAt}`, + }) + .where(eq(developerVideos.id, videoId)); + + if (options.deletedBeforeReady) { + await markSettled(); + return { state: "exempt", paidUntil }; + } + if (!account) return skipped("no_account"); + + 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 = positive(video.duration); + const tier = videoStorageTier( + { width: positive(video.width), height: positive(video.height) }, + isTier(video.maxResolutionTier) ? video.maxResolutionTier : null, + ); + const lastCharge = await latestStorageCharge(tx, account.id); + const unresolvedShortSince = + lastCharge?.shortSince && + !(await restoredSinceCharge(tx, account.id, lastCharge)) + ? lastCharge.shortSince + : null; + const settled = settleStorageDay({ + balanceMicroCredits: account.balanceMicroCredits, + availableMicroCredits: + (await availableBalance(found.appId, tx)) ?? account.balanceMicroCredits, + owedMicroCredits: firstMonthStorageMicroCredits({ durationSeconds, tier }), + unresolvedShortSince, + now, + }); + + 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: settled.shortSince?.toISOString() ?? null, + durationSeconds, + tier, + paidUntil: paidUntil.toISOString(), + }, + }); + await markSettled(); + + return { + state: "charged", + accountId: account.id, + tier, + paidUntil, + ...settled, + }; +} + +export function chargeDeveloperVideoFirstMonth( + videoId: string, + options: FirstMonthOptions = {}, +): Promise { + return (options.executor ?? db()).transaction((tx) => + settleFirstMonth(tx, videoId, options), + ); +} + +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 }); + 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 firstMonthShortfallsSince( + accountIds: readonly string[], + since: Date, + executor: Pick = db(), +) { + const shortfalls = new Map(); + if (accountIds.length === 0) return shortfalls; + const rows = await executor + .select({ + accountId: developerCreditTransactions.accountId, + createdAt: developerCreditTransactions.createdAt, + metadata: developerCreditTransactions.metadata, + }) + .from(developerCreditTransactions) + .where( + and( + inArray(developerCreditTransactions.accountId, [...accountIds]), + eq(developerCreditTransactions.type, FIRST_MONTH_TRANSACTION_TYPE), + gte(developerCreditTransactions.createdAt, since), + ), + ); + for (const row of rows) { + const { shortSince } = parseStorageCharge(row); + if (shortSince && shortSince.getTime() >= since.getTime()) { + shortfalls.set(row.accountId, shortSince); + } + } + return shortfalls; +} From 1a8b4a6ed18c7157673c1ebd1b790706a9ebec88 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:05:05 -0500 Subject: [PATCH 04/46] test(web): prove the first month of storage on a real database 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. --- .../developer-transcripts-database.test.ts | 11 +- .../infra-first-month-database.test.ts | 638 ++++++++++++++++++ apps/web/lib/infra-storage-minimum.ts | 25 +- 3 files changed, 657 insertions(+), 17 deletions(-) create mode 100644 apps/web/__tests__/integration/infra-first-month-database.test.ts diff --git a/apps/web/__tests__/integration/developer-transcripts-database.test.ts b/apps/web/__tests__/integration/developer-transcripts-database.test.ts index 5a70eedc59a..14c51f7a7a9 100644 --- a/apps/web/__tests__/integration/developer-transcripts-database.test.ts +++ b/apps/web/__tests__/integration/developer-transcripts-database.test.ts @@ -234,6 +234,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({ @@ -881,7 +882,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, @@ -916,12 +917,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, @@ -948,13 +949,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-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts new file mode 100644 index 00000000000..e8f9bdcba36 --- /dev/null +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -0,0 +1,638 @@ +import { randomUUID } from "node:crypto"; +import { + developerApps, + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +import type { DeveloperVideoProcessingState } from "@cap/database/types"; +import { and, eq } 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("@/workflows/process-developer-video", () => ({ + processDeveloperVideoWorkflow: "workflow", +})); + +const { addCreditsToAccount } = await import("@/lib/developer-credits"); +const { 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, + settleFirstMonthAtReady, +} = await import("@/lib/infra-storage-minimum"); +const { recordAdmittedInfraUpload } = await import("@/lib/infra-video-upload"); + +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 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("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 ready video the transition missed, deleted or not, exactly once", 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"), + }); + + await chargeMissedFirstMonths(new Date("2026-11-20T06:41:00Z")); + await chargeMissedFirstMonths(new Date("2026-11-20T12:41:00Z")); + + const rows = await firstMonthRows(accountId); + expect(rows.map((row) => row.referenceId).sort()).toEqual( + [missed, missedThenDeleted].sort(), + ); + expect(await balanceOf(accountId)).toBe(1_000_000 - 2 * 850); + }); + + 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("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); + }); + + 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.firstMonthDue).toMatchObject({ + videoCount: 1, + monthlyMicroCredits: 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/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts index 918a32720e0..518fe17a1dc 100644 --- a/apps/web/lib/infra-storage-minimum.ts +++ b/apps/web/lib/infra-storage-minimum.ts @@ -105,23 +105,17 @@ async function dayAlreadyBilled( async function settleFirstMonth( tx: InfraDbExecutor, videoId: string, + appId: string, options: FirstMonthOptions, ): Promise { const now = options.now ?? new Date(); - const [found] = await tx - .select({ appId: developerVideos.appId }) - .from(developerVideos) - .where(eq(developerVideos.id, videoId)) - .limit(1); - if (!found) return skipped("missing"); - const [account] = await tx .select({ id: developerCreditAccounts.id, balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, }) .from(developerCreditAccounts) - .where(eq(developerCreditAccounts.appId, found.appId)) + .where(eq(developerCreditAccounts.appId, appId)) .for("update"); const [video] = await tx @@ -195,7 +189,7 @@ async function settleFirstMonth( const settled = settleStorageDay({ balanceMicroCredits: account.balanceMicroCredits, availableMicroCredits: - (await availableBalance(found.appId, tx)) ?? account.balanceMicroCredits, + (await availableBalance(appId, tx)) ?? account.balanceMicroCredits, owedMicroCredits: firstMonthStorageMicroCredits({ durationSeconds, tier }), unresolvedShortSince, now, @@ -238,12 +232,19 @@ async function settleFirstMonth( }; } -export function chargeDeveloperVideoFirstMonth( +export async function chargeDeveloperVideoFirstMonth( videoId: string, options: FirstMonthOptions = {}, ): Promise { - return (options.executor ?? db()).transaction((tx) => - settleFirstMonth(tx, videoId, options), + 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"); + return executor.transaction((tx) => + settleFirstMonth(tx, videoId, found.appId, options), ); } From 9e3a78e39e5540aa7d51b39f10843faf55051de2 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:15:12 -0500 Subject: [PATCH 05/46] docs(web): describe the first month of storage, the new upload credit rule and the two storage figures --- .../unit/developer-billing-docs.test.ts | 65 ++++++++++++++++--- .../infra/[appId]/billing/billing-view.tsx | 5 +- apps/web/content/docs/api/billing.mdx | 43 ++++++++---- apps/web/content/docs/api/rest-api.mdx | 30 +++++---- apps/web/lib/developer-openapi.ts | 26 ++++++-- apps/web/public/llms-full.txt | 2 +- 6 files changed, 128 insertions(+), 43 deletions(-) diff --git a/apps/web/__tests__/unit/developer-billing-docs.test.ts b/apps/web/__tests__/unit/developer-billing-docs.test.ts index 6967402fd33..5de5e03c845 100644 --- a/apps/web/__tests__/unit/developer-billing-docs.test.ts +++ b/apps/web/__tests__/unit/developer-billing-docs.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/developer-credits-product", () => ({ import { dailyStorageMicroCredits, emptyStorageMinutes, + firstMonthStorageMicroCredits, MICRO_CREDITS_PER_DOLLAR, monthlyStorageMicroCredits, SPEECH_DOLLARS_PER_HOUR, @@ -152,7 +153,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 |`, ); expect(rest).toContain(`| ${price} |`); } @@ -206,6 +207,50 @@ 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("works every speech example out from the pricing functions", () => { const rows = tableRows(billing, "## Speech to text"); expect(rows).toHaveLength(5); @@ -339,17 +384,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 }, + firstMonthDue: { monthlyMicroCredits: 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)}.`, @@ -361,13 +406,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/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx index d72659cb07b..eeba40b7df6 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 @@ -174,8 +174,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.{" "} ({ 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 or the video is deleted. 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. 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 or the video is deleted. 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. 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, requestBody: jsonBody("CreateUploadRequest"), @@ -815,7 +815,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"), @@ -1125,7 +1125,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ type: { type: "string", description: - "The kind of row: topup, topup_failed, topup_pending, 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, topup_pending, 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", @@ -1313,7 +1313,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", @@ -1327,6 +1327,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ type: "object", required: [ "minutesByTier", + "dailyChargeMinutesByTier", "dailyMicroCredits", "monthlyMicroCredits", ], @@ -1342,10 +1343,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", @@ -1371,7 +1383,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) => [ @@ -1690,7 +1702,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/public/llms-full.txt b/apps/web/public/llms-full.txt index f1a479742c3..2d6315e1f72 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -150,7 +150,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`. -A developer REST API is also available for hosting video in your own product: 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 $1,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. There is no public webhook subscription API: poll the status endpoint instead (https://cap.so/docs/api/webhooks). +A developer REST API is also available for hosting video in your own product: 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 $1,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. There is no public webhook subscription API: poll the status endpoint instead (https://cap.so/docs/api/webhooks). ## Platforms From 82ee378ce1144698da0b78d06d6e9a0cef2c4c42 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:20:14 -0500 Subject: [PATCH 06/46] docs(infra-mcp): say stored video is billed one month when ready, then daily --- packages/infra-mcp/README.md | 2 +- packages/infra-mcp/__tests__/tools.test.ts | 2 +- packages/infra-mcp/src/server.ts | 2 +- packages/infra-mcp/src/tools.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/infra-mcp/README.md b/packages/infra-mcp/README.md index 997f6d1dba3..7af9bd0504a 100644 --- a/packages/infra-mcp/README.md +++ b/packages/infra-mcp/README.md @@ -50,7 +50,7 @@ Keep the key in the client's own configuration. A secret key can create and dele | Tool | What it does | Cost | |------|--------------|------| -| `videos_upload` | Uploads a local file and starts encoding. Returns the video id with its share and embed links. Pass `videoId` to retry a failed upload into the same video. | Uploading and encoding are free. Stored video is billed daily until deleted. | +| `videos_upload` | Uploads a local file and starts encoding. Returns the video id with its share and embed links. Pass `videoId` to retry a failed upload into the same video. | Uploading and encoding are free. Stored video is billed one month when it becomes ready, then daily until deleted. | | `videos_wait` | Waits until a video has its playback links or has failed, then returns status, length, playback id and the HLS, MP4, poster, share and embed links. Waits for both HLS and MP4 unless `output` asks for one. | Free | | `videos_get` | Returns one video. | Free | | `videos_list` | Lists videos, newest first, optionally for one `userId`, with `limit` and `offset`. | Free | diff --git a/packages/infra-mcp/__tests__/tools.test.ts b/packages/infra-mcp/__tests__/tools.test.ts index 574c7d7de52..2fd40d71fda 100644 --- a/packages/infra-mcp/__tests__/tools.test.ts +++ b/packages/infra-mcp/__tests__/tools.test.ts @@ -66,7 +66,7 @@ describe("tool list", () => { const description = (name: string) => tools.find((tool) => tool.name === name)?.description ?? ""; expect(description("videos_upload")).toMatch( - /stored video is billed daily/, + /Stored video is billed by its length and resolution tier: one month when it becomes ready, then daily/, ); expect(description("transcripts_create")).toMatch(/billed per audio hour/); expect(description("videos_delete")).toMatch(/cannot be undone/); diff --git a/packages/infra-mcp/src/server.ts b/packages/infra-mcp/src/server.ts index d22267d14c6..70548614df5 100644 --- a/packages/infra-mcp/src/server.ts +++ b/packages/infra-mcp/src/server.ts @@ -40,7 +40,7 @@ export const INSTRUCTIONS = [ "Cap Infra hosts video and turns speech into text, billed from the app's prepaid credit.", "Video: videos_upload a local file, videos_wait until it is ready, then use the hlsUrl, mp4Url, posterUrl, shareUrl or embedUrl it returns.", "Speech: transcripts_create from a url, a local path or a ready videoId, transcripts_wait, then transcripts_text.", - "Stored video is billed daily until deleted and transcripts are billed per audio hour. Check account_usage before large jobs.", + "Stored video is billed one month when it becomes ready, then daily until deleted, and transcripts are billed per audio hour. Check account_usage before large jobs.", "Credit can only be added in the console by a signed-in person. When a tool reports out of credit, give them the addCreditAt link.", ].join(" "); diff --git a/packages/infra-mcp/src/tools.ts b/packages/infra-mcp/src/tools.ts index 8958fc29a23..fe056aa1ebf 100644 --- a/packages/infra-mcp/src/tools.ts +++ b/packages/infra-mcp/src/tools.ts @@ -308,7 +308,7 @@ export function createTools(deps: ToolDeps): Tool[] { name: "videos_upload", title: "Upload a video", description: - "Upload a local video file to Cap Infra and start encoding it. Returns the video id with its share and embed links; call videos_wait for the playback links once it is ready. Uploading and encoding are free, and stored video is billed daily by its length and resolution tier until it is deleted. Pass videoId to retry a failed upload into the same video.", + "Upload a local video file to Cap Infra and start encoding it. Returns the video id with its share and embed links; call videos_wait for the playback links once it is ready. Uploading and encoding are free. Stored video is billed by its length and resolution tier: one month when it becomes ready, then daily until it is deleted. Pass videoId to retry a failed upload into the same video.", kind: "write", input: { path: z From 69041903f4d9b39d78f2a5fc3db4af7d7d01f334 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:22:08 -0500 Subject: [PATCH 07/46] fix(web): exempt ready videos the first month cannot charge so the cron backstop never stalls on them --- .../infra-first-month-database.test.ts | 25 +++++++++++++++++-- apps/web/lib/infra-storage-minimum.ts | 11 ++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index e8f9bdcba36..cdd8fbbd32b 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -436,8 +436,29 @@ describe.runIf(Boolean(databaseUrl))( deletedAt: new Date("2026-11-20T02:00:00Z"), }); - await chargeMissedFirstMonths(new Date("2026-11-20T06:41:00Z")); - await chargeMissedFirstMonths(new Date("2026-11-20T12:41: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).sort()).toEqual( diff --git a/apps/web/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts index 518fe17a1dc..48250155631 100644 --- a/apps/web/lib/infra-storage-minimum.ts +++ b/apps/web/lib/infra-storage-minimum.ts @@ -53,12 +53,7 @@ export type FirstMonthCharge = | { state: "exempt"; paidUntil: Date } | { state: "skipped"; - reason: - | "missing" - | "already_settled" - | "not_ready" - | "never_encoded" - | "no_account"; + reason: "missing" | "already_settled" | "not_ready"; }; export type FirstMonthOptions = { @@ -134,7 +129,6 @@ async function settleFirstMonth( if (!video) return skipped("missing"); if (video.storagePaidUntil !== null) return skipped("already_settled"); if (video.status !== "ready") return skipped("not_ready"); - if (video.processing === null) return skipped("never_encoded"); const paidUntil = firstMonthStoragePaidUntil( now, @@ -149,11 +143,10 @@ async function settleFirstMonth( }) .where(eq(developerVideos.id, videoId)); - if (options.deletedBeforeReady) { + if (options.deletedBeforeReady || video.processing === null || !account) { await markSettled(); return { state: "exempt", paidUntil }; } - if (!account) return skipped("no_account"); const [recorded] = await tx .select({ id: developerCreditTransactions.id }) From c0bf6107f6d6f795ba2c57131a7f5038ebfe5abe Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:32:58 -0500 Subject: [PATCH 08/46] fix(web): measure the day's storage under the account lock so a first month is never charged again that day --- .../infra-first-month-database.test.ts | 30 +++++++++++++++++++ .../unit/developer-cron-storage.test.ts | 11 +++++++ .../app/api/cron/developer-storage/route.ts | 6 ++++ apps/web/lib/infra-storage-billing.ts | 6 +++- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index cdd8fbbd32b..7fceb79c700 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -348,6 +348,36 @@ describe.runIf(Boolean(databaseUrl))( }); }); + 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); diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index c671535cea8..b949602a9f1 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -171,6 +171,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 () => { diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index d59d33e7ad2..85587370957 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -107,6 +107,12 @@ export async function GET(request: Request) { appId: account.appId, accountId: account.id, usage, + measure: async (tx) => + ( + await getStorageUsageByApp([account.appId], undefined, tx, { + dailyChargeAt: now, + }) + ).get(account.appId) ?? usage, dateKey: today, now, }); diff --git a/apps/web/lib/infra-storage-billing.ts b/apps/web/lib/infra-storage-billing.ts index 6e010f796ba..0330497866b 100644 --- a/apps/web/lib/infra-storage-billing.ts +++ b/apps/web/lib/infra-storage-billing.ts @@ -9,6 +9,7 @@ import { and, eq, gte, sql } from "drizzle-orm"; import type { StorageUsage } from "@/lib/developer-pricing"; import { availableBalance } from "@/lib/infra-balance"; import { + type InfraDbExecutor, latestStorageCharge, restoredSinceCharge, STORAGE_DAY_REFERENCE_TYPE, @@ -56,13 +57,15 @@ export function settleStorageDay({ export async function chargeStorageDay({ appId, accountId, - usage, + usage: measuredBefore, + measure, dateKey, now, }: { appId: string; accountId: string; usage: StorageUsage; + measure?: (tx: InfraDbExecutor) => Promise; dateKey: string; now: Date; }): Promise { @@ -131,6 +134,7 @@ export async function chargeStorageDay({ return { state: "skipped" as const }; } + const usage = measure ? await measure(tx) : measuredBefore; const lastCharge = await latestStorageCharge(tx, accountId); const unresolvedShortSince = lastCharge?.shortSince && From 7a78dc1acea3ba98f0a8b90495b1c0d8e9a9b1d5 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:34:41 -0500 Subject: [PATCH 09/46] fix(web): keep a short first month in effect when another month was paid in the same second --- .../__tests__/unit/infra-billing-fake-db.ts | 13 +++++- .../unit/infra-storage-billing.test.ts | 30 +++++++++++++ apps/web/lib/infra-standing.ts | 42 ++++++++++++++++--- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/web/__tests__/unit/infra-billing-fake-db.ts b/apps/web/__tests__/unit/infra-billing-fake-db.ts index 7f66fb20df8..0458345a9ad 100644 --- a/apps/web/__tests__/unit/infra-billing-fake-db.ts +++ b/apps/web/__tests__/unit/infra-billing-fake-db.ts @@ -128,13 +128,22 @@ function runSelect(query: Query): Record[] { } if (query.table === developerCreditTransactions) { - if (keys === "createdAt,metadata") { - const type = rendered.params.at(-1); + if (keys === "createdAt") { + const type = rendered.params[1]; return newestFirst(ledger.rows.filter((row) => row.type === type)).slice( 0, 1, ); } + if (keys === "createdAt,metadata") { + const [, type, at] = rendered.params; + const ofType = newestFirst( + ledger.rows.filter((row) => row.type === type), + ); + return at === undefined + ? ofType.slice(0, 1) + : ofType.filter((row) => row.createdAt.getTime() === asTime(at)); + } if (keys === "metadata") { const since = asTime(rendered.params.at(-1)); return storageRows().filter((row) => row.createdAt.getTime() >= since); diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index 6d8d95776b4..01d3910caf7 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -392,6 +392,36 @@ describe("admission prices a video the way the storage job will", () => { }); }); +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("standing through the ledger", () => { it("goes active, grace, paused and back to active", async () => { ledger.account.balanceMicroCredits = 200; diff --git a/apps/web/lib/infra-standing.ts b/apps/web/lib/infra-standing.ts index f8d9de36286..0fed719e359 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -111,7 +111,7 @@ export function resolveStanding({ async function latestChargeOfType( executor: InfraDbExecutor, accountId: string, - type: "storage_daily" | typeof FIRST_MONTH_TRANSACTION_TYPE, + type: "storage_daily", ) { const [row] = await executor .select({ @@ -130,16 +130,46 @@ async function latestChargeOfType( 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({ + createdAt: developerCreditTransactions.createdAt, + metadata: developerCreditTransactions.metadata, + }) + .from(developerCreditTransactions) + .where( + and( + ofAccount, + eq(developerCreditTransactions.createdAt, latest.createdAt), + ), + ) + ).map(parseStorageCharge); + return ( + sameSecond.find((charge) => charge.shortSince) ?? sameSecond[0] ?? null + ); +} + export async function latestStorageCharge( executor: InfraDbExecutor, accountId: string, ): Promise { const daily = await latestChargeOfType(executor, accountId, "storage_daily"); - const firstMonth = await latestChargeOfType( - executor, - accountId, - FIRST_MONTH_TRANSACTION_TYPE, - ); + const firstMonth = await latestFirstMonthCharge(executor, accountId); if ( firstMonth?.shortSince && (!daily || firstMonth.createdAt.getTime() >= daily.createdAt.getTime()) From ff9b4d393247a4f1a0482c1f3060defaf1e93000 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:37:31 -0500 Subject: [PATCH 10/46] fix(web): admit uploads on the sum of each in-flight video's own first month charge --- .../infra-first-month-database.test.ts | 21 ++++-- .../unit/developer-billing-docs.test.ts | 2 +- .../unit/developer-video-upload.test.ts | 12 ++-- .../__tests__/unit/infra-billing-fake-db.ts | 5 ++ .../__tests__/unit/infra-billing-math.test.ts | 6 +- .../unit/infra-storage-billing.test.ts | 10 +-- apps/web/lib/infra-standing.ts | 8 +-- apps/web/lib/infra-storage.ts | 64 ++++++++++++------- 8 files changed, 82 insertions(+), 46 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index 7fceb79c700..443319866a0 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -644,6 +644,22 @@ describe.runIf(Boolean(databaseUrl))( 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); }); it("reports what is stored apart from what the daily charge covers", async () => { @@ -673,10 +689,7 @@ describe.runIf(Boolean(databaseUrl))( monthlyMicroCredits: 30 * 85, dailyMicroCredits: Math.floor((30 * 85) / 30), }); - expect(storage.firstMonthDue).toMatchObject({ - videoCount: 1, - monthlyMicroCredits: 5 * 85, - }); + expect(storage.firstMonthMicroCredits).toBe(5 * 85); expect(storage.uploadingVideos).toBe(1); const billing = await getInfraBilling(appId); diff --git a/apps/web/__tests__/unit/developer-billing-docs.test.ts b/apps/web/__tests__/unit/developer-billing-docs.test.ts index 5de5e03c845..68873d966d1 100644 --- a/apps/web/__tests__/unit/developer-billing-docs.test.ts +++ b/apps/web/__tests__/unit/developer-billing-docs.test.ts @@ -386,7 +386,7 @@ describe("examples in the REST API reference", () => { lastOwedMicroCredits: 0, storage: { dailyCharge: { monthlyMicroCredits: 0 }, - firstMonthDue: { monthlyMicroCredits: body.requiredMicroCredits }, + firstMonthMicroCredits: body.requiredMicroCredits, }, }), ); diff --git a/apps/web/__tests__/unit/developer-video-upload.test.ts b/apps/web/__tests__/unit/developer-video-upload.test.ts index 0ab7cdbfc80..a209737f43e 100644 --- a/apps/web/__tests__/unit/developer-video-upload.test.ts +++ b/apps/web/__tests__/unit/developer-video-upload.test.ts @@ -96,9 +96,8 @@ 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 { billableVideo, firstMonthStorageMicroCredits, storageUsage } = + await import("@/lib/developer-pricing"); return { getAppStorage: async ( appId: string, @@ -131,7 +130,12 @@ vi.mock("@/lib/infra-storage", async () => { return { stored: storageUsage(ready), dailyCharge: storageUsage(ready), - firstMonthDue: storageUsage(due), + firstMonthMicroCredits: due.reduce( + (sum, entry) => + sum + + (entry.video ? firstMonthStorageMicroCredits(entry.video) : 0), + 0, + ), uploadingVideos: 0, }; }, diff --git a/apps/web/__tests__/unit/infra-billing-fake-db.ts b/apps/web/__tests__/unit/infra-billing-fake-db.ts index 0458345a9ad..09544f9bc94 100644 --- a/apps/web/__tests__/unit/infra-billing-fake-db.ts +++ b/apps/web/__tests__/unit/infra-billing-fake-db.ts @@ -197,11 +197,16 @@ function runSelect(query: Query): Record[] { (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), })); } diff --git a/apps/web/__tests__/unit/infra-billing-math.test.ts b/apps/web/__tests__/unit/infra-billing-math.test.ts index 4b3bc3daf46..f253fabd973 100644 --- a/apps/web/__tests__/unit/infra-billing-math.test.ts +++ b/apps/web/__tests__/unit/infra-billing-math.test.ts @@ -201,7 +201,7 @@ describe("upload admission", () => { const active = { state: "active" as const, pausesAt: null }; const pastFirstMonth = (monthlyMicroCredits: number) => ({ dailyCharge: { monthlyMicroCredits }, - firstMonthDue: { monthlyMicroCredits: 0 }, + firstMonthMicroCredits: 0, }); it("needs seven days of storage, rounded up, and at least one micro-credit", () => { @@ -225,7 +225,7 @@ describe("upload admission", () => { lastOwedMicroCredits: 0, storage: { dailyCharge: { monthlyMicroCredits: 0 }, - firstMonthDue: { monthlyMicroCredits: 5_600 }, + firstMonthMicroCredits: 5_600, }, }); expect(firstMonthOnly).toMatchObject({ @@ -244,7 +244,7 @@ describe("upload admission", () => { lastOwedMicroCredits: 0, storage: { dailyCharge: { monthlyMicroCredits: 85_000 }, - firstMonthDue: { monthlyMicroCredits: 16_800 }, + firstMonthMicroCredits: 16_800, }, }); expect(both.requiredMicroCredits).toBe(36_634); diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index 01d3910caf7..c7e75d0d698 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -600,11 +600,7 @@ describe("which videos are billed after the pipeline measures them", () => { const { storage, admission } = await billing(); - expect(storage.firstMonthDue.minutesByTier).toEqual({ - "1080p": 0, - "1440p": 0, - "2160p": 10, - }); + expect(storage.firstMonthMicroCredits).toBe(10 * 280); expect(storage.stored.minutesByTier["2160p"]).toBe(0); expect(storage.dailyCharge.minutesByTier["2160p"]).toBe(0); expect(admission.requiredMicroCredits).toBe( @@ -659,9 +655,9 @@ describe("upload admission against what is stored", () => { }); expect(stored.dailyCharge.monthlyMicroCredits).toBe(4335); - expect(stored.firstMonthDue.monthlyMicroCredits).toBe(0); + expect(stored.firstMonthMicroCredits).toBe(0); expect(withNew.dailyCharge.monthlyMicroCredits).toBe(4335); - expect(withNew.firstMonthDue.monthlyMicroCredits).toBe(28_000); + expect(withNew.firstMonthMicroCredits).toBe(28_000); expect(withNew.stored.videoCount).toBe(3); ledger.account.balanceMicroCredits = 1012; diff --git a/apps/web/lib/infra-standing.ts b/apps/web/lib/infra-standing.ts index 0fed719e359..fb70ac60f34 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -266,14 +266,14 @@ export function uploadAdmission({ lastOwedMicroCredits: number; storage: { dailyCharge: Pick; - firstMonthDue: Pick; + firstMonthMicroCredits: number; }; }): InfraUploadAdmission { const inGoodStanding = standing.state === "active"; const requiredMicroCredits = Math.max( uploadAdmissionMicroCredits( storage.dailyCharge.monthlyMicroCredits, - storage.firstMonthDue.monthlyMicroCredits, + storage.firstMonthMicroCredits, ), inGoodStanding ? 0 : lastOwedMicroCredits, ); @@ -319,9 +319,9 @@ export function uploadAdmission({ function uploadCreditCovers(storage: { dailyCharge: { monthlyMicroCredits: number }; - firstMonthDue: { monthlyMicroCredits: number }; + firstMonthMicroCredits: number; }) { - const firstMonth = storage.firstMonthDue.monthlyMicroCredits > 0; + 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"; diff --git a/apps/web/lib/infra-storage.ts b/apps/web/lib/infra-storage.ts index 31b53eda8f6..c04eddaacfb 100644 --- a/apps/web/lib/infra-storage.ts +++ b/apps/web/lib/infra-storage.ts @@ -4,9 +4,12 @@ import { and, gt, inArray, isNull, ne, sql } from "drizzle-orm"; import { type BillableVideo, billableVideo, + firstMonthStorageMicroCredits, type StorageUsage, + storageMicroCreditsPerMinutePerMonth, storageUsage, } from "@/lib/developer-pricing"; +import type { InfraResolutionTier } from "@/lib/infra-pricing"; import type { InfraDbExecutor } from "@/lib/infra-standing"; type StoredVideoGroup = { @@ -20,8 +23,16 @@ type StoredVideoGroup = { rows: number | string; dailySeconds: number | string | null; dailyCount: number | string | null; + firstMonth1080p: number | string | null; + firstMonth1440p: number | string | null; + firstMonth2160p: number | string | null; }; +const firstMonthSum = (tier: InfraResolutionTier) => + sql< + number | string | null + >`COALESCE(SUM(GREATEST(1, FLOOR(${developerVideos.duration} / 60 * ${storageMicroCreditsPerMinutePerMonth(tier)}))), 0)`; + const APP_ID_CHUNK = 500; export type StorageUsageOptions = { @@ -55,6 +66,9 @@ export function storedVideoGroupsQuery( 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"), }) .from(developerVideos) .where( @@ -153,7 +167,7 @@ export async function getAppStorageUsage( export type AppStorage = { stored: StorageUsage; dailyCharge: StorageUsage; - firstMonthDue: StorageUsage; + firstMonthMicroCredits: number; uploadingVideos: number; }; @@ -169,29 +183,33 @@ export async function getAppStorage( options.excludeVideoId, options.now, ); - const usage = ( - status: "ready" | "processing", - daily: boolean, - extra: BillableVideo | null = null, - ) => - storageUsage([ - ...groups - .filter((group) => group.status === status) - .map((group) => ({ - video: billableVideo({ - duration: Number(daily ? group.dailySeconds : group.seconds), - width: group.width, - height: group.height, - maxResolutionTier: group.maxResolutionTier, - }), - count: Number(daily ? group.dailyCount : group.count) || 0, - })), - ...(extra ? [{ video: extra, count: 1 }] : []), - ]); + 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, + })), + ); + const firstMonthOf = (group: StoredVideoGroup) => { + const video = videoOf(group, false); + return video ? Number(group[`firstMonth${video.tier}`]) || 0 : 0; + }; return { - stored: usage("ready", false), - dailyCharge: usage("ready", true), - firstMonthDue: usage("processing", false, newVideo), + stored: usage(false), + dailyCharge: usage(true), + firstMonthMicroCredits: + groups + .filter((group) => group.status === "processing") + .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), From 2c0d4e6c1f82b689e01187c9b3057e56bed00482 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:40:43 -0500 Subject: [PATCH 11/46] fix(web): count first month charges in the console's monthly storage spending --- .../infra-first-month-database.test.ts | 44 +++++++++++++++++++ .../dashboard/infra/[appId]/usage/page.tsx | 19 ++++++-- .../app/(infra)/dashboard/infra/infra-data.ts | 31 ++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index 443319866a0..f39b3de0123 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -33,6 +33,13 @@ vi.mock("@/lib/server", () => ({ 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("@/workflows/process-developer-video", () => ({ processDeveloperVideoWorkflow: "workflow", })); @@ -54,6 +61,8 @@ const { settleFirstMonthAtReady, } = await import("@/lib/infra-storage-minimum"); const { recordAdmittedInfraUpload } = await import("@/lib/infra-video-upload"); +const { getInfraFirstMonthChargesSince, getInfraStorageChargesSince } = + await import("@/app/(infra)/dashboard/infra/infra-data"); const databaseUrl = process.env.CAP_INFRA_TEST_DATABASE_URL; const DAY_MS = 24 * 60 * 60 * 1000; @@ -662,6 +671,41 @@ describe.runIf(Boolean(databaseUrl))( ).toBe(5); }); + 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, { diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx index 1d848392f9f..cafbc9b4819 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/usage/page.tsx @@ -30,6 +30,7 @@ import { TR, } from "../../_components/ui"; import { + getInfraFirstMonthChargesSince, getInfraStorageSnapshots, infraPageMetadata, requireInfraApp, @@ -68,13 +69,16 @@ export default async function InfraUsagePage({ const { app } = await requireInfraApp(appId); const now = new Date(); - const [storage, snapshots, transcription] = await Promise.all([ + const [storage, snapshots, transcription, firstMonths] = await Promise.all([ getAppStorage(app.id), getInfraStorageSnapshots(app.id, utcDaysAgoKey(now, HISTORY_DAYS)), getTranscriptionUsageSince(app.id, utcMonthStart(now)).catch((error) => { 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; @@ -113,8 +117,17 @@ 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, + }; +} From be5797fc6338b7c562a1403aba6e9bd52ed99d70 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:43:19 -0500 Subject: [PATCH 12/46] fix(web): send a first month shortfall email from a day of history and only while the app is still short --- .../unit/developer-cron-storage.test.ts | 32 ++++++++++++++++--- .../app/api/cron/developer-storage/route.ts | 18 ++++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index b949602a9f1..3dd15dd1a27 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ firstMonthShortfallsSince: vi.fn(), chargeStorageDay: vi.fn(), latestStorageCharge: vi.fn(), + restoredSinceCharge: vi.fn(), runDueAutoTopUps: vi.fn(), sendInfraOutOfCreditEmail: vi.fn(), })); @@ -51,6 +52,7 @@ vi.mock("@/lib/infra-standing", () => ({ INFRA_GRACE_DAYS: 7, STORAGE_DAY_REFERENCE_TYPE: "storage_day", latestStorageCharge: mocks.latestStorageCharge, + restoredSinceCharge: mocks.restoredSinceCharge, })); vi.mock("@/lib/infra-auto-top-up", () => ({ runDueAutoTopUps: mocks.runDueAutoTopUps, @@ -110,6 +112,8 @@ beforeEach(() => { mocks.chargeMissedFirstMonths.mockResolvedValue(NO_FIRST_MONTHS); mocks.firstMonthShortfallsSince.mockReset(); mocks.firstMonthShortfallsSince.mockResolvedValue(new Map()); + mocks.restoredSinceCharge.mockReset(); + mocks.restoredSinceCharge.mockResolvedValue(false); mocks.runDueAutoTopUps.mockResolvedValue(NO_TOP_UPS); mocks.latestStorageCharge.mockResolvedValue(null); mocks.chargeStorageDay.mockResolvedValue(billed()); @@ -374,12 +378,13 @@ describe("developer-storage cron job", () => { expect(mocks.chargeStorageDay).not.toHaveBeenCalled(); }); - it("emails each account whose first month fell short since the last run, once, with the pause date seven days on", async () => { - const shortSince = new Date(Date.now() - 60 * 60 * 1000); + it("emails an account whose first month fell short in the last day and is still short, with the pause date seven days on", async () => { + const shortSince = new Date(Date.now() - 20 * 60 * 60 * 1000); mocks.selects = [ [ { id: "account-1", appId: "app-1" }, { id: "account-2", appId: "app-2" }, + { id: "account-3", appId: "app-3" }, ], [], ]; @@ -387,10 +392,27 @@ describe("developer-storage cron job", () => { new Map([ ["app-1", usage(0)], ["app-2", usage(0)], + ["app-3", usage(0)], ]), ); mocks.firstMonthShortfallsSince.mockResolvedValue( - new Map([["account-2", shortSince]]), + new Map([ + ["account-2", shortSince], + ["account-3", shortSince], + ]), + ); + 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", ); const before = Date.now(); @@ -398,9 +420,9 @@ describe("developer-storage cron job", () => { const [accountIds, since] = mocks.firstMonthShortfallsSince.mock.calls[0] ?? []; - expect(accountIds).toEqual(["account-1", "account-2"]); + expect(accountIds).toEqual(["account-1", "account-2", "account-3"]); expect(before - (since as Date).getTime()).toBeGreaterThanOrEqual( - 6 * 60 * 60 * 1000 - 1000, + 24 * 60 * 60 * 1000 - 1000, ); expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledWith({ diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index 85587370957..a22996b5fcb 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -12,6 +12,7 @@ import { sendInfraOutOfCreditEmail } from "@/lib/infra-billing-emails"; import { INFRA_GRACE_DAYS, latestStorageCharge, + restoredSinceCharge, STORAGE_DAY_REFERENCE_TYPE, } from "@/lib/infra-standing"; import { getStorageUsageByApp } from "@/lib/infra-storage"; @@ -24,7 +25,7 @@ import { utcDateKey } from "@/lib/infra-usage"; const BATCH_SIZE = 10; const GRACE_MS = INFRA_GRACE_DAYS * 24 * 60 * 60 * 1000; -const CRON_INTERVAL_MS = 6 * 60 * 60 * 1000; +const FIRST_MONTH_NOTICE_LOOKBACK_MS = 24 * 60 * 60 * 1000; export async function GET(request: Request) { const cronSecret = process.env.CRON_SECRET; @@ -146,13 +147,20 @@ export async function GET(request: Request) { const shortfalls = await firstMonthShortfallsSince( accounts.map((account) => account.id), - new Date(now.getTime() - CRON_INTERVAL_MS), + new Date(now.getTime() - FIRST_MONTH_NOTICE_LOOKBACK_MS), ); - for (const [accountId, shortSince] of shortfalls) { + for (const accountId of shortfalls.keys()) { + const charge = await latestStorageCharge(db(), accountId); + if ( + !charge?.shortSince || + (await restoredSinceCharge(db(), accountId, charge)) + ) { + continue; + } await sendInfraOutOfCreditEmail({ accountId, - shortSince, - pausesAt: new Date(shortSince.getTime() + GRACE_MS), + shortSince: charge.shortSince, + pausesAt: new Date(charge.shortSince.getTime() + GRACE_MS), }); } From f062b023ae4163e846d1cbe6f6d82c8e712b1d67 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:51:59 -0500 Subject: [PATCH 13/46] fix(web): keep an unpaid first month in effect until credit covers what it owed --- .../unit/infra-storage-billing.test.ts | 40 +++++++++++++++++++ apps/web/lib/infra-standing.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index c7e75d0d698..df6bfbf1362 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -422,6 +422,46 @@ describe("first months charged in the same second", () => { }); }); +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("standing through the ledger", () => { it("goes active, grace, paused and back to active", async () => { ledger.account.balanceMicroCredits = 200; diff --git a/apps/web/lib/infra-standing.ts b/apps/web/lib/infra-standing.ts index fb70ac60f34..53b975e92d8 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -172,7 +172,7 @@ export async function latestStorageCharge( const firstMonth = await latestFirstMonthCharge(executor, accountId); if ( firstMonth?.shortSince && - (!daily || firstMonth.createdAt.getTime() >= daily.createdAt.getTime()) + !(await restoredSinceCharge(executor, accountId, firstMonth)) ) { return { ...firstMonth, firstMonth: true }; } From 372ed62dc48f4e0af045d4db1f1d3003db3f4490 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:57:09 -0500 Subject: [PATCH 14/46] fix(web): exempt deleted videos from the first month backstop instead of charging them --- .../integration/infra-first-month-database.test.ts | 12 +++++++----- apps/web/lib/infra-storage-minimum.ts | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index f39b3de0123..a432dc3ae58 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -462,7 +462,7 @@ describe.runIf(Boolean(databaseUrl))( expect(await dailyMinutes(appId, day("2026-11-21"))).toBe(10); }); - it("lets the cron charge a ready video the transition missed, deleted or not, exactly once", async () => { + 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"), }); @@ -500,10 +500,12 @@ describe.runIf(Boolean(databaseUrl))( expect((await videoRow(unbillable))?.storagePaidUntil).not.toBeNull(); const rows = await firstMonthRows(accountId); - expect(rows.map((row) => row.referenceId).sort()).toEqual( - [missed, missedThenDeleted].sort(), - ); - expect(await balanceOf(accountId)).toBe(1_000_000 - 2 * 850); + 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 () => { diff --git a/apps/web/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts index 48250155631..81363db1d1a 100644 --- a/apps/web/lib/infra-storage-minimum.ts +++ b/apps/web/lib/infra-storage-minimum.ts @@ -59,6 +59,7 @@ export type FirstMonthCharge = export type FirstMonthOptions = { executor?: InfraDbExecutor; deletedBeforeReady?: boolean; + exemptIfDeleted?: boolean; now?: Date; }; @@ -122,6 +123,7 @@ async function settleFirstMonth( maxResolutionTier: developerVideos.maxResolutionTier, processing: developerVideos.processing, storagePaidUntil: developerVideos.storagePaidUntil, + deletedAt: developerVideos.deletedAt, }) .from(developerVideos) .where(eq(developerVideos.id, videoId)) @@ -143,7 +145,12 @@ async function settleFirstMonth( }) .where(eq(developerVideos.id, videoId)); - if (options.deletedBeforeReady || video.processing === null || !account) { + if ( + options.deletedBeforeReady || + (options.exemptIfDeleted && video.deletedAt !== null) || + video.processing === null || + !account + ) { await markSettled(); return { state: "exempt", paidUntil }; } @@ -305,7 +312,10 @@ export async function chargeMissedFirstMonths( if (!began) return outcome; for (const video of await unsettledFirstMonthsQuery(began, limit)) { try { - const result = await chargeDeveloperVideoFirstMonth(video.id, { now }); + const result = await chargeDeveloperVideoFirstMonth(video.id, { + now, + exemptIfDeleted: true, + }); if (result.state === "charged") { outcome.charged++; if (result.shortSince) outcome.short++; From ad0d4204e0fb5e7636e866bb14bf6bf080b62c86 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 22:03:04 -0500 Subject: [PATCH 15/46] fix(web): latch a storage shortfall as resolved once the available balance covers it --- .../infra-first-month-database.test.ts | 60 ++++++++++++++++++ .../unit/developer-cron-storage.test.ts | 4 +- .../__tests__/unit/infra-billing-fake-db.ts | 16 ++++- .../unit/infra-storage-billing.test.ts | 63 +++++++++++++++++++ .../app/api/cron/developer-storage/route.ts | 2 +- apps/web/lib/infra-standing.ts | 54 ++++++++++++++-- apps/web/lib/infra-storage-billing.ts | 6 +- apps/web/lib/infra-storage-minimum.ts | 8 ++- 8 files changed, 198 insertions(+), 15 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index a432dc3ae58..95e82c7e025 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -3,6 +3,7 @@ import { developerApps, developerCreditAccounts, developerCreditTransactions, + developerTranscripts, developerVideos, } from "@cap/database/schema"; import type { DeveloperVideoProcessingState } from "@cap/database/types"; @@ -561,6 +562,65 @@ describe.runIf(Boolean(databaseUrl))( 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("never throws from the ready path when the charge fails", async () => { const broken = { transaction: async () => { diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index 3dd15dd1a27..d0c99460bb2 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -360,7 +360,7 @@ describe("developer-storage cron job", () => { expect(body.firstMonths).toEqual({ charged: 2, short: 1, failed: 0 }); }); - it("does not open a daily charge to settle a short first month when nothing is charged daily", async () => { + it("opens the day's charge for a short first month even when nothing is charged daily, so a recovery can be recorded", async () => { const shortSince = new Date(Date.now() - 60 * 60 * 1000); mocks.selects = [[{ id: "account-1", appId: "app-1" }], []]; mocks.getStorageUsageByApp.mockResolvedValue( @@ -375,7 +375,7 @@ describe("developer-storage cron job", () => { await authorized(); - expect(mocks.chargeStorageDay).not.toHaveBeenCalled(); + expect(mocks.chargeStorageDay).toHaveBeenCalledTimes(1); }); it("emails an account whose first month fell short in the last day and is still short, with the pause date seven days on", async () => { diff --git a/apps/web/__tests__/unit/infra-billing-fake-db.ts b/apps/web/__tests__/unit/infra-billing-fake-db.ts index 09544f9bc94..0720f48a163 100644 --- a/apps/web/__tests__/unit/infra-billing-fake-db.ts +++ b/apps/web/__tests__/unit/infra-billing-fake-db.ts @@ -135,7 +135,7 @@ function runSelect(query: Query): Record[] { 1, ); } - if (keys === "createdAt,metadata") { + if (keys === "createdAt,id,metadata") { const [, type, at] = rendered.params; const ofType = newestFirst( ledger.rows.filter((row) => row.type === type), @@ -240,7 +240,19 @@ function update(table: unknown) { return { set(values: Record) { return { - async where() { + async where(condition?: SQL) { + if (table === developerCreditTransactions && values.metadata) { + const [id] = condition ? dialect.sqlToQuery(condition).params : []; + const resolvedAt = dialect + .sqlToQuery(values.metadata as SQL) + .params.find((param) => typeof param === "string"); + const row = ledger.rows.find((entry) => entry.id === id); + if (!row || row.metadata?.resolvedAt !== undefined) { + return [{ affectedRows: 0 }]; + } + row.metadata = { ...row.metadata, resolvedAt }; + return [{ affectedRows: 1 }]; + } if (table !== developerCreditAccounts) return [{ affectedRows: 0 }]; const { balanceMicroCredits, ...rest } = values; if (balanceMicroCredits !== undefined) { diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index df6bfbf1362..21c19f9b495 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -462,6 +462,69 @@ describe("a first month the balance could not pay", () => { }); }); +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("standing through the ledger", () => { it("goes active, grace, paused and back to active", async () => { ledger.account.balanceMicroCredits = 200; diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index a22996b5fcb..89a1f41bbad 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -102,7 +102,7 @@ export async function GET(request: Request) { if (!usage) return null; if (usage.dailyMicroCredits <= 0) { const lastCharge = await latestStorageCharge(db(), account.id); - if (!lastCharge?.shortSince || lastCharge.firstMonth) return null; + if (!lastCharge?.shortSince) return null; } const result = await chargeStorageDay({ appId: account.appId, diff --git a/apps/web/lib/infra-standing.ts b/apps/web/lib/infra-standing.ts index 53b975e92d8..fef179bada2 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -3,7 +3,7 @@ import { developerCreditAccounts, developerCreditTransactions, } from "@cap/database/schema"; -import { and, desc, eq, gt, gte } from "drizzle-orm"; +import { and, desc, eq, gt, gte, sql } from "drizzle-orm"; import { cache } from "react"; import { type BillableVideo, @@ -29,9 +29,11 @@ export type InfraAppStanding = { }; export type StorageCharge = { + id?: string; createdAt: Date; owedMicroCredits: number; shortSince: Date | null; + resolvedAt?: Date | null; firstMonth?: boolean; }; @@ -71,18 +73,24 @@ 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, + shortSince: dateField(fields.shortSince), + ...(resolvedAt ? { resolvedAt } : {}), }; } @@ -115,6 +123,7 @@ async function latestChargeOfType( ) { const [row] = await executor .select({ + id: developerCreditTransactions.id, createdAt: developerCreditTransactions.createdAt, metadata: developerCreditTransactions.metadata, }) @@ -148,6 +157,7 @@ async function latestFirstMonthCharge( const sameSecond = ( await executor .select({ + id: developerCreditTransactions.id, createdAt: developerCreditTransactions.createdAt, metadata: developerCreditTransactions.metadata, }) @@ -160,7 +170,10 @@ async function latestFirstMonthCharge( ) ).map(parseStorageCharge); return ( - sameSecond.find((charge) => charge.shortSince) ?? sameSecond[0] ?? null + sameSecond.find((charge) => charge.shortSince && !charge.resolvedAt) ?? + sameSecond.find((charge) => charge.shortSince) ?? + sameSecond[0] ?? + null ); } @@ -179,11 +192,40 @@ export async function latestStorageCharge( return daily; } +export async function latchCoveredShortfall( + tx: InfraDbExecutor, + charge: StorageCharge | null, + availableMicroCredits: number, + now: Date, +) { + if ( + !charge?.id || + !charge.shortSince || + charge.resolvedAt || + availableMicroCredits < charge.owedMicroCredits + ) { + return false; + } + await tx + .update(developerCreditTransactions) + .set({ + metadata: sql`JSON_SET(COALESCE(${developerCreditTransactions.metadata}, JSON_OBJECT()), '$.resolvedAt', ${now.toISOString()})`, + }) + .where( + and( + eq(developerCreditTransactions.id, charge.id), + sql`JSON_EXTRACT(${developerCreditTransactions.metadata}, '$.resolvedAt') IS NULL`, + ), + ); + return true; +} + export async function restoredSinceCharge( executor: InfraDbExecutor, accountId: string, charge: StorageCharge, ) { + if (charge.resolvedAt) return true; const [credit] = await executor .select({ id: developerCreditTransactions.id }) .from(developerCreditTransactions) diff --git a/apps/web/lib/infra-storage-billing.ts b/apps/web/lib/infra-storage-billing.ts index 0330497866b..b1f390759a2 100644 --- a/apps/web/lib/infra-storage-billing.ts +++ b/apps/web/lib/infra-storage-billing.ts @@ -10,6 +10,7 @@ import type { StorageUsage } from "@/lib/developer-pricing"; import { availableBalance } from "@/lib/infra-balance"; import { type InfraDbExecutor, + latchCoveredShortfall, latestStorageCharge, restoredSinceCharge, STORAGE_DAY_REFERENCE_TYPE, @@ -136,7 +137,10 @@ export async function chargeStorageDay({ 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); const unresolvedShortSince = + !latched && lastCharge?.shortSince && !(await restoredSinceCharge(tx, accountId, lastCharge)) ? lastCharge.shortSince @@ -172,8 +176,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 }; diff --git a/apps/web/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts index 81363db1d1a..d60483f7b70 100644 --- a/apps/web/lib/infra-storage-minimum.ts +++ b/apps/web/lib/infra-storage-minimum.ts @@ -30,6 +30,7 @@ import { FIRST_MONTH_REFERENCE_TYPE, FIRST_MONTH_TRANSACTION_TYPE, type InfraDbExecutor, + latchCoveredShortfall, latestStorageCharge, parseStorageCharge, restoredSinceCharge, @@ -181,15 +182,18 @@ async function settleFirstMonth( isTier(video.maxResolutionTier) ? video.maxResolutionTier : null, ); const lastCharge = await latestStorageCharge(tx, account.id); + const available = + (await availableBalance(appId, tx)) ?? account.balanceMicroCredits; + const latched = await latchCoveredShortfall(tx, lastCharge, available, now); const unresolvedShortSince = + !latched && lastCharge?.shortSince && !(await restoredSinceCharge(tx, account.id, lastCharge)) ? lastCharge.shortSince : null; const settled = settleStorageDay({ balanceMicroCredits: account.balanceMicroCredits, - availableMicroCredits: - (await availableBalance(appId, tx)) ?? account.balanceMicroCredits, + availableMicroCredits: available, owedMicroCredits: firstMonthStorageMicroCredits({ durationSeconds, tier }), unresolvedShortSince, now, From 417723e9defa97fd3b8f13bee27689ca24ed0c01 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 22:03:23 -0500 Subject: [PATCH 16/46] docs(web): say a shortfall is settled once the available balance covers it --- apps/web/content/docs/api/billing.mdx | 2 +- apps/web/content/docs/api/rest-api.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/content/docs/api/billing.mdx b/apps/web/content/docs/api/billing.mdx index bf5050c0d8c..20063e7a9bd 100644 --- a/apps/web/content/docs/api/billing.mdx +++ b/apps/web/content/docs/api/billing.mdx @@ -148,7 +148,7 @@ Auto top-up adds credit from a saved card when an app's balance runs low. It is | `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 the charge that fell short, one day of storage or the video's first month, returns the app to `active` at once. What it could not pay is 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. diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index ef0874977c5..8cefe345f64 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -957,7 +957,7 @@ A video is billed at the lower of two tiers: the tier its source falls in by pix | `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 From 92688a432fa5ca5678e7d77b65a39941b47b8efe Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 22:05:52 -0500 Subject: [PATCH 17/46] feat(web-domain): return the daily charge's minutes by tier in the agent billing answer --- .../unit/agent-developer-billing.test.ts | 34 +++++++++++++++++++ packages/web-domain/src/Agent.ts | 5 +++ 2 files changed, 39 insertions(+) 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/packages/web-domain/src/Agent.ts b/packages/web-domain/src/Agent.ts index aba02367275..38ff39f00c6 100644 --- a/packages/web-domain/src/Agent.ts +++ b/packages/web-domain/src/Agent.ts @@ -1068,6 +1068,11 @@ export const AgentDeveloperAppContextResponse = Schema.Struct({ "1440p": Schema.Number, "2160p": Schema.Number, }), + dailyChargeMinutesByTier: Schema.Struct({ + "1080p": Schema.Number, + "1440p": Schema.Number, + "2160p": Schema.Number, + }), dailyMicroCredits: Schema.Number, monthlyMicroCredits: Schema.Number, }), From 82952e3585024ce40d1deed03241f23262f99fb7 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 22:06:12 -0500 Subject: [PATCH 18/46] test(web): pin the first month to the duration stored at first ready --- .../infra-first-month-database.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index 95e82c7e025..85a428b6363 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -272,6 +272,36 @@ describe.runIf(Boolean(databaseUrl))( } }); + 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); From 406de64817d04224362c3453427233b7e460aad4 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 22:26:54 -0500 Subject: [PATCH 19/46] docs(web): state the first month of storage the same way in the billing and get started docs --- .../__tests__/unit/developer-billing-docs.test.ts | 13 +++++++++++++ apps/web/content/docs/api/billing.mdx | 4 +++- apps/web/content/docs/api/get-started.mdx | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/developer-billing-docs.test.ts b/apps/web/__tests__/unit/developer-billing-docs.test.ts index 68873d966d1..be2fe129a7e 100644 --- a/apps/web/__tests__/unit/developer-billing-docs.test.ts +++ b/apps/web/__tests__/unit/developer-billing-docs.test.ts @@ -75,6 +75,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 wholeDollars = (cents: number) => `$${(cents / 100).toLocaleString("en-US")}`; @@ -251,6 +252,18 @@ describe("the Billing page", () => { ); }); + 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 after that. Uploading and encoding stay 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); + } + }); + it("works every speech example out from the pricing functions", () => { const rows = tableRows(billing, "## Speech to text"); expect(rows).toHaveLength(5); diff --git a/apps/web/content/docs/api/billing.mdx b/apps/web/content/docs/api/billing.mdx index 20063e7a9bd..6ef1d153c69 100644 --- a/apps/web/content/docs/api/billing.mdx +++ b/apps/web/content/docs/api/billing.mdx @@ -32,7 +32,7 @@ Credit belongs to one app. It cannot be moved to another app, and deleting an ap ## Storage -From today, every video has a minimum storage charge of one month. When a video first becomes ready to play, its first month is charged at once. After that month it is charged one day at a time. A video that is still uploading or encoding, that failed, or that was deleted before it became ready is not charged. +Each video is charged one month of storage when it first becomes ready, then daily after that. Uploading and encoding stay free. A video that is still uploading or encoding, that failed, or that was deleted before it became ready is not charged. Videos that were already ready before today are not charged a first month. They keep the daily charge described below. @@ -56,6 +56,8 @@ The first month costs the video's length in minutes times the monthly price of i | 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. diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index 134be925b88..f34075978ff 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -815,7 +815,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 after that. Uploading and encoding stay 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. From 9cf0cb6bfb10329ab8c6a97c43816ccf82d2f2ba Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 22:29:30 -0500 Subject: [PATCH 20/46] test(web): pin that daily rows never mask an unpaid first month --- .../unit/infra-storage-billing.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index 21c19f9b495..dfd2f314149 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -29,6 +29,7 @@ import { getInfraAppStanding, getInfraBilling, getInfraUploadAdmission, + latestStorageCharge, } from "@/lib/infra-standing"; import { getAppStorage } from "@/lib/infra-storage"; import { chargeStorageDay } from "@/lib/infra-storage-billing"; @@ -525,6 +526,71 @@ describe("a shortfall the available balance comes to cover without new credit", }); }); +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("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), + }); + }); +}); + describe("standing through the ledger", () => { it("goes active, grace, paused and back to active", async () => { ledger.account.balanceMicroCredits = 200; From 4e1bafd3e044ef11a3d92f73aa843ecc6a79e134 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 23:13:32 -0500 Subject: [PATCH 21/46] test(web): pin grace for a short first month beside a paid storage day in the same second --- .../unit/infra-storage-billing.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index dfd2f314149..232ad6da306 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -566,6 +566,32 @@ describe("an unpaid first month is not masked by daily rows", () => { 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 }, From a07c4b1fe159965d0658b386ce39840bf505df7e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:19:06 -0500 Subject: [PATCH 22/46] feat(web): say on the Infra landing page that each video is charged one month of storage when it becomes ready --- apps/web/__tests__/unit/infra-landing.test.ts | 16 ++++++++++++++++ apps/web/components/pages/infra/Pricing.tsx | 15 +++++++++++---- apps/web/components/pages/infra/content.ts | 8 +++++--- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/web/__tests__/unit/infra-landing.test.ts b/apps/web/__tests__/unit/infra-landing.test.ts index d39f2893795..93b73b07685 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"); @@ -146,6 +147,21 @@ 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, + ).toContain(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/components/pages/infra/Pricing.tsx b/apps/web/components/pages/infra/Pricing.tsx index 362bf374325..5b3deab1daa 100644 --- a/apps/web/components/pages/infra/Pricing.tsx +++ b/apps/web/components/pages/infra/Pricing.tsx @@ -1,5 +1,12 @@ 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"; @@ -82,9 +89,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 0577807cb60..c51fd17ab6c 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 after that. Uploading and encoding stay free."; + export const DEFAULT_TIER: InfraResolutionTier = "1080p"; export const TIER_LABEL: Record = { @@ -333,12 +336,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. 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?", From 40232bb5ec2ee7cad9dd13bd5fcb721e66bee712 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:26:12 -0500 Subject: [PATCH 23/46] perf(database): cover the storage aggregate's creation bound in its index --- .../database/migrations/0058_developer_video_first_month.sql | 2 +- packages/database/migrations/meta/0058_snapshot.json | 5 +++-- packages/database/migrations/meta/_journal.json | 2 +- packages/database/schema.ts | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/database/migrations/0058_developer_video_first_month.sql b/packages/database/migrations/0058_developer_video_first_month.sql index 65fad967b4b..27ef9143411 100644 --- a/packages/database/migrations/0058_developer_video_first_month.sql +++ b/packages/database/migrations/0058_developer_video_first_month.sql @@ -1,3 +1,3 @@ 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`);--> 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`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0058_snapshot.json b/packages/database/migrations/meta/0058_snapshot.json index e01862dd623..902264f6091 100644 --- a/packages/database/migrations/meta/0058_snapshot.json +++ b/packages/database/migrations/meta/0058_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "mysql", - "id": "80a971f7-0015-4026-ac3c-854abc283bb0", + "id": "3f8dd862-bcf1-4494-9b5b-eb59ef8d77b5", "prevId": "7568082e-2748-491f-897f-16005b895884", "tables": { "accounts": { @@ -1765,7 +1765,8 @@ "width", "height", "storagePaidUntil", - "duration" + "duration", + "createdAt" ], "isUnique": false }, diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index f11637ef4cb..aab5d8627b5 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -404,7 +404,7 @@ { "idx": 58, "version": "5", - "when": 1794546167827, + "when": 1794547418121, "tag": "0058_developer_video_first_month", "breakpoints": true } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index a7b49fff055..4ac32297bcf 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1926,6 +1926,7 @@ export const developerVideos = mysqlTable( table.height, table.storagePaidUntil, table.duration, + table.createdAt, ), index("first_month_due_idx").on( table.storagePaidUntil, From 446dda5045da904481433046539143643b1b00f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:37:17 -0500 Subject: [PATCH 24/46] fix(web): charge the first month of a video deleted after its encode was sent --- .../infra-first-month-database.test.ts | 161 ++++++++++++++++++ apps/web/lib/infra-storage-minimum.ts | 83 ++++++++- 2 files changed, 238 insertions(+), 6 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index 85a428b6363..6c630be5d76 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -59,7 +59,9 @@ const { chargeStorageDay } = await import("@/lib/infra-storage-billing"); const { chargeDeveloperVideoFirstMonth, chargeMissedFirstMonths, + settleFirstMonthAtDelete, settleFirstMonthAtReady, + settleFirstMonthsForDeletedApp, } = await import("@/lib/infra-storage-minimum"); const { recordAdmittedInfraUpload } = await import("@/lib/infra-video-upload"); const { getInfraFirstMonthChargesSince, getInfraStorageChargesSince } = @@ -651,6 +653,165 @@ describe.runIf(Boolean(databaseUrl))( expect(later.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, { + 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("caps the declared length at the 4 hour limit", async () => { + const { appId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + 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", 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, { + status: "processing", + processing: { + ...processing(), + mp4: { state: "pending" }, + attempts: { hls: 0, mp4: 0 }, + }, + }); + for (const videoId of [notClaimed, claimedNotSent]) { + 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", async () => { + const { appId, accountId } = await makeApp(1_000_000); + const videoId = await makeVideo(appId, { + status: "processing", + duration: 600, + processing: encoding(), + }); + await deleteNow(videoId); + + await settleFirstMonthsForDeletedApp(appId); + await settleFirstMonthsForDeletedApp(appId); + + 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, { + 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("never throws from the ready path when the charge fails", async () => { const broken = { transaction: async () => { diff --git a/apps/web/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts index d7be56d45d5..3858c771032 100644 --- a/apps/web/lib/infra-storage-minimum.ts +++ b/apps/web/lib/infra-storage-minimum.ts @@ -5,6 +5,7 @@ import { developerCreditTransactions, developerVideos, } from "@cap/database/schema"; +import type { DeveloperVideoProcessingState } from "@cap/database/types"; import { and, asc, @@ -21,6 +22,7 @@ import { 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, @@ -54,16 +56,26 @@ export type FirstMonthCharge = | { state: "exempt"; paidUntil: Date } | { state: "skipped"; - reason: "missing" | "already_settled" | "not_ready"; + 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) => + processing.attempts.mp4 + processing.attempts.hls > 0 || + [processing.mp4, processing.hls].some((part) => part.state !== "pending"); + const DAY_MS = 24 * 60 * 60 * 1000; const skipped = ( @@ -131,7 +143,17 @@ async function settleFirstMonth( .for("update"); if (!video) return skipped("missing"); if (video.storagePaidUntil !== null) return skipped("already_settled"); - if (video.status !== "ready") return skipped("not_ready"); + 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"); + } else if (video.status !== "ready") { + return skipped("not_ready"); + } const paidUntil = firstMonthStoragePaidUntil( now, @@ -176,11 +198,21 @@ async function settleFirstMonth( return skipped("already_settled"); } - const durationSeconds = positive(video.duration); - const tier = videoStorageTier( - { width: positive(video.width), height: positive(video.height) }, - isTier(video.maxResolutionTier) ? video.maxResolutionTier : null, + 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; @@ -223,6 +255,9 @@ async function settleFirstMonth( durationSeconds, tier, paidUntil: paidUntil.toISOString(), + ...(options.atDelete && video.status !== "ready" + ? { deletedWhileEncoding: true } + : {}), }, }); await markSettled(); @@ -252,6 +287,42 @@ export async function chargeDeveloperVideoFirstMonth( ); } +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) { + try { + const videos = await db() + .select({ id: developerVideos.id }) + .from(developerVideos) + .where( + and( + eq(developerVideos.appId, appId), + isNotNull(developerVideos.deletedAt), + inArray(developerVideos.status, ["processing", "ready"]), + 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, From c92fb04b40d84cddfcef24af3bb3b17d26e7fda5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:37:17 -0500 Subject: [PATCH 25/46] fix(web): settle the first month after every developer video and app delete --- .../__tests__/unit/developer-actions.test.ts | 37 ++++++++++++++++++- .../unit/developer-video-api.test.ts | 7 ++++ apps/web/actions/developers/delete-app.ts | 2 + apps/web/actions/developers/delete-video.ts | 10 ++++- .../app/api/developer/v1/[...route]/videos.ts | 2 + apps/web/app/api/v1/[...route]/route.ts | 5 ++- 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts index 479898bad71..6de83e0e7b0 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), @@ -296,6 +306,13 @@ describe("deleteDeveloperApp", () => { const result = await deleteDeveloperApp("app-456"); expect(result).toEqual({ success: true }); }); + + it("settles the first month of its videos that were mid-encode after the delete", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + await deleteDeveloperApp("app-456"); + expect(firstMonth.forDeletedApp).toHaveBeenCalledWith("app-456"); + }); }); describe("updateDeveloperApp", () => { @@ -765,20 +782,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 }); }); diff --git a/apps/web/__tests__/unit/developer-video-api.test.ts b/apps/web/__tests__/unit/developer-video-api.test.ts index ed081dba51a..9051c78a9ba 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(), })); vi.mock("@cap/database", async () => { @@ -31,6 +32,9 @@ vi.mock("@cap/env", () => ({ vi.mock("@/lib/developer-video-start", () => ({ redriveDeveloperVideoProcessing: mocks.redrive, })); +vi.mock("@/lib/infra-storage-minimum", () => ({ + settleFirstMonthAtDelete: mocks.settleAtDelete, +})); vi.mock("@/app/api/utils", async () => { const { createMiddleware } = await import("hono/factory"); return { @@ -627,6 +631,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("answers a repeated delete with success and leaves the row alone", async () => { @@ -639,6 +645,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/actions/developers/delete-app.ts b/apps/web/actions/developers/delete-app.ts index 83b011db127..c894cb733b8 100644 --- a/apps/web/actions/developers/delete-app.ts +++ b/apps/web/actions/developers/delete-app.ts @@ -10,6 +10,7 @@ import { import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { canUseInfra } from "@/lib/infra-access"; +import { settleFirstMonthsForDeletedApp } from "@/lib/infra-storage-minimum"; export async function deleteDeveloperApp(appId: string) { const user = await getCurrentUser(); @@ -55,6 +56,7 @@ export async function deleteDeveloperApp(appId: string) { ), ); }); + await settleFirstMonthsForDeletedApp(appId); 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 83304bedb48..e9f6be10833 100644 --- a/apps/web/actions/developers/delete-video.ts +++ b/apps/web/actions/developers/delete-video.ts @@ -6,6 +6,7 @@ import { developerApps, developerVideos } from "@cap/database/schema"; import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; 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(); @@ -25,12 +26,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); revalidatePath("/dashboard/infra", "layout"); return { success: true }; diff --git a/apps/web/app/api/developer/v1/[...route]/videos.ts b/apps/web/app/api/developer/v1/[...route]/videos.ts index 6e4c289031d..6112b01dce9 100644 --- a/apps/web/app/api/developer/v1/[...route]/videos.ts +++ b/apps/web/app/api/developer/v1/[...route]/videos.ts @@ -16,6 +16,7 @@ import { } from "@/lib/developer-video-api"; 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) => @@ -150,6 +151,7 @@ app.delete("/:id", async (c) => { .update(developerVideos) .set({ deletedAt: new Date() }) .where(eq(developerVideos.id, videoId)); + await settleFirstMonthAtDelete(videoId); } return c.json({ success: true }); diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 66eb35ab59a..916b6501ef8 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -168,6 +168,7 @@ import { creditCheckoutSessionParams, } from "@/lib/infra-credit-checkout"; import { normalizeAllowedOrigin } from "@/lib/infra-domain"; +import { settleFirstMonthAtDelete } from "@/lib/infra-storage-minimum"; import { COMPLETION_IN_PROGRESS } from "@/lib/infra-upload"; import { resolveLoomDownload } from "@/lib/loom-csv-import"; import { probeVideoViaMediaServer } from "@/lib/media-client"; @@ -4614,7 +4615,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( const principal = yield* Agent.AgentPrincipal; yield* requireScope(principal, "developer:write", requestId); yield* requireDeveloperAppOwner(principal, path.appId, requestId); - return yield* runAgentMutation({ + const deleted = yield* runAgentMutation({ principal, operation: "delete_developer_video", idempotencyKey: yield* requestIdempotencyKey, @@ -4659,6 +4660,8 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( }; }, }); + yield* Effect.promise(() => settleFirstMonthAtDelete(path.videoId)); + return deleted; }), requestId, ); From 4e6074823853469a82c0ba0e933ab1f6f33dda70 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:38:44 -0500 Subject: [PATCH 26/46] docs(web): say a video deleted while it is being processed is charged its first month --- apps/web/__tests__/unit/infra-landing.test.ts | 5 ++++- apps/web/components/pages/infra/content.ts | 5 ++++- apps/web/content/docs/api/billing.mdx | 2 +- apps/web/content/docs/api/rest-api.mdx | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/__tests__/unit/infra-landing.test.ts b/apps/web/__tests__/unit/infra-landing.test.ts index 93b73b07685..3e129e5c35f 100644 --- a/apps/web/__tests__/unit/infra-landing.test.ts +++ b/apps/web/__tests__/unit/infra-landing.test.ts @@ -152,11 +152,14 @@ describe("the Cap Infra landing page", () => { expect(billing).toContain(content.STORAGE_RULE); expect(getStarted).toContain(content.STORAGE_RULE); + expect(billing).toContain(content.DELETED_WHILE_PROCESSING_RULE); expect( content.infraFaqs.find( (faq) => faq.question === "What does Cap Infra charge for?", )?.answer, - ).toContain(content.STORAGE_RULE); + ).toContain( + `${content.STORAGE_RULE} ${content.DELETED_WHILE_PROCESSING_RULE}`, + ); expect( pageSources().find((source) => source.file === "Pricing.tsx")?.text, ).toContain("{STORAGE_RULE}"); diff --git a/apps/web/components/pages/infra/content.ts b/apps/web/components/pages/infra/content.ts index c51fd17ab6c..df1c152fb7a 100644 --- a/apps/web/components/pages/infra/content.ts +++ b/apps/web/components/pages/infra/content.ts @@ -17,6 +17,9 @@ 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 after that. Uploading and encoding stay free."; +export const DELETED_WHILE_PROCESSING_RULE = + "A video deleted while it is being processed is charged its first month too."; + export const DEFAULT_TIER: InfraResolutionTier = "1080p"; export const TIER_LABEL: Record = { @@ -336,7 +339,7 @@ export const infraFaqs: readonly Faq[] = [ ...PREVIEW_FAQ, { question: "What does Cap Infra charge for?", - answer: `Two things: stored video and speech to text. ${STORAGE_RULE}`, + answer: `Two things: stored video and speech to text. ${STORAGE_RULE} ${DELETED_WHILE_PROCESSING_RULE}`, }, { question: "How does resolution change the price?", diff --git a/apps/web/content/docs/api/billing.mdx b/apps/web/content/docs/api/billing.mdx index 2c785fcafb1..6c61623913c 100644 --- a/apps/web/content/docs/api/billing.mdx +++ b/apps/web/content/docs/api/billing.mdx @@ -32,7 +32,7 @@ Credit belongs to one app. It cannot be moved to another app, and deleting an ap ## Storage -Each video is charged one month of storage when it first becomes ready, then daily after that. Uploading and encoding stay free. A video that is still uploading or encoding, that failed, or that was deleted before it became ready is not charged. +Each video is charged one month of storage when it first becomes ready, then daily after that. Uploading and encoding stay 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 already ready before today are not charged a first month. They keep the daily charge described below. diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 6bdf8e3b3b6..cfae6bd1c03 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -563,7 +563,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`. From 646e49096f6748c16d229e5fe2fc35f5ffb55ab4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:56:25 -0500 Subject: [PATCH 27/46] fix(web): charge a deleted video's first month only when it was mid-encode and created after first month charging began --- .../infra-first-month-database.test.ts | 126 +++++++++++++++++- .../__tests__/unit/developer-actions.test.ts | 16 ++- apps/web/actions/developers/delete-app.ts | 18 ++- apps/web/lib/infra-storage-minimum.ts | 69 ++++++---- 4 files changed, 194 insertions(+), 35 deletions(-) diff --git a/apps/web/__tests__/integration/infra-first-month-database.test.ts b/apps/web/__tests__/integration/infra-first-month-database.test.ts index 6c630be5d76..60d096283fc 100644 --- a/apps/web/__tests__/integration/infra-first-month-database.test.ts +++ b/apps/web/__tests__/integration/infra-first-month-database.test.ts @@ -41,6 +41,8 @@ vi.mock("next/headers", () => ({ 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", })); @@ -61,11 +63,15 @@ const { chargeMissedFirstMonths, settleFirstMonthAtDelete, settleFirstMonthAtReady, - settleFirstMonthsForDeletedApp, + settleFirstMonthsForDeletedVideos, + firstMonthShortfallsSince, + markFirstMonthShortfallsNotified, } = await import("@/lib/infra-storage-minimum"); const { recordAdmittedInfraUpload } = await import("@/lib/infra-video-upload"); 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; @@ -674,6 +680,7 @@ describe.runIf(Boolean(databaseUrl))( 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, @@ -717,6 +724,7 @@ describe.runIf(Boolean(databaseUrl))( 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(), @@ -737,6 +745,7 @@ describe.runIf(Boolean(databaseUrl))( playbackId: null, }); const claimedNotSent = await makeVideo(appId, { + createdAt: new Date(), status: "processing", processing: { ...processing(), @@ -776,18 +785,86 @@ describe.runIf(Boolean(databaseUrl))( expect(await firstMonthRows(accountId)).toHaveLength(0); }); - it("is charged when the whole app is deleted mid-encode", async () => { + 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 settleFirstMonthsForDeletedVideos([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); - await settleFirstMonthsForDeletedApp(appId); - await settleFirstMonthsForDeletedApp(appId); + 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); }); @@ -795,6 +872,7 @@ describe.runIf(Boolean(databaseUrl))( 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(), @@ -812,6 +890,46 @@ describe.runIf(Boolean(databaseUrl))( }); }); + 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 firstMonthShortfallsSince([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 firstMonthShortfallsSince([accountId], since)).get(accountId) + ?.ids, + ).toEqual([first?.id]); + + await markFirstMonthShortfallsNotified( + found.get(accountId)?.ids ?? [], + new Date(), + ); + await markFirstMonthShortfallsNotified( + found.get(accountId)?.ids ?? [], + new Date(Date.now() + 60_000), + ); + expect(await firstMonthShortfallsSince([accountId], since)).toEqual( + new Map(), + ); + }); + it("never throws from the ready path when the charge fails", async () => { const broken = { transaction: async () => { diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts index 6de83e0e7b0..2c02e800e4d 100644 --- a/apps/web/__tests__/unit/developer-actions.test.ts +++ b/apps/web/__tests__/unit/developer-actions.test.ts @@ -6,12 +6,12 @@ vi.mock("@/lib/infra-access", () => ({ const firstMonth = vi.hoisted(() => ({ atDelete: vi.fn(), - forDeletedApp: vi.fn(), + forDeletedVideos: vi.fn(), })); vi.mock("@/lib/infra-storage-minimum", () => ({ settleFirstMonthAtDelete: firstMonth.atDelete, - settleFirstMonthsForDeletedApp: firstMonth.forDeletedApp, + settleFirstMonthsForDeletedVideos: firstMonth.forDeletedVideos, })); vi.mock("@cap/database", () => { @@ -266,6 +266,9 @@ describe("createDeveloperApp", () => { }); }); +const encodingVideos = (rows: { id: string }[]) => + mockDb.where.mockReturnValueOnce(mockDb).mockResolvedValueOnce(rows as never); + describe("deleteDeveloperApp", () => { let deleteDeveloperApp: typeof import("@/actions/developers/delete-app").deleteDeveloperApp; @@ -292,6 +295,7 @@ describe("deleteDeveloperApp", () => { it("soft deletes by setting deletedAt", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); + encodingVideos([]); await deleteDeveloperApp("app-456"); expect(mockDb.update).toHaveBeenCalled(); @@ -303,15 +307,19 @@ describe("deleteDeveloperApp", () => { it("returns success true", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); + encodingVideos([]); const result = await deleteDeveloperApp("app-456"); expect(result).toEqual({ success: true }); }); - it("settles the first month of its videos that were mid-encode after the delete", async () => { + it("settles the first month of only the videos it deleted mid-encode, after the delete", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); + encodingVideos([{ id: "video-encoding" }]); await deleteDeveloperApp("app-456"); - expect(firstMonth.forDeletedApp).toHaveBeenCalledWith("app-456"); + expect(firstMonth.forDeletedVideos).toHaveBeenCalledWith([ + "video-encoding", + ]); }); }); diff --git a/apps/web/actions/developers/delete-app.ts b/apps/web/actions/developers/delete-app.ts index c894cb733b8..44b2fcd0289 100644 --- a/apps/web/actions/developers/delete-app.ts +++ b/apps/web/actions/developers/delete-app.ts @@ -10,7 +10,7 @@ import { import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { canUseInfra } from "@/lib/infra-access"; -import { settleFirstMonthsForDeletedApp } from "@/lib/infra-storage-minimum"; +import { settleFirstMonthsForDeletedVideos } from "@/lib/infra-storage-minimum"; export async function deleteDeveloperApp(appId: string) { const user = await getCurrentUser(); @@ -30,7 +30,18 @@ export async function deleteDeveloperApp(appId: string) { if (!app) throw new Error("App not found"); - await db().transaction(async (tx) => { + const encodingVideoIds = await db().transaction(async (tx) => { + const encoding = await tx + .select({ id: developerVideos.id }) + .from(developerVideos) + .where( + and( + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + eq(developerVideos.status, "processing"), + ), + ); + await tx .update(developerApiKeys) .set({ revokedAt: new Date() }) @@ -55,8 +66,9 @@ export async function deleteDeveloperApp(appId: string) { isNull(developerVideos.deletedAt), ), ); + return encoding.map((video) => video.id); }); - await settleFirstMonthsForDeletedApp(appId); + await settleFirstMonthsForDeletedVideos(encodingVideoIds); revalidatePath("/dashboard/infra", "layout"); return { success: true }; diff --git a/apps/web/lib/infra-storage-minimum.ts b/apps/web/lib/infra-storage-minimum.ts index 3858c771032..7a35d77a795 100644 --- a/apps/web/lib/infra-storage-minimum.ts +++ b/apps/web/lib/infra-storage-minimum.ts @@ -137,12 +137,14 @@ async function settleFirstMonth( processing: developerVideos.processing, storagePaidUntil: developerVideos.storagePaidUntil, deletedAt: developerVideos.deletedAt, + createdAt: developerVideos.createdAt, }) .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 = @@ -151,6 +153,11 @@ async function settleFirstMonth( 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"); } @@ -170,6 +177,7 @@ async function settleFirstMonth( if ( options.deletedBeforeReady || + exemptAtDelete || (options.exemptIfDeleted && video.deletedAt !== null) || video.processing === null || !account @@ -252,6 +260,7 @@ async function settleFirstMonth( owedMicroCredits: settled.owedMicroCredits, chargedMicroCredits: settled.chargedMicroCredits, shortSince: settled.shortSince?.toISOString() ?? null, + fellShort: settled.fellShort, durationSeconds, tier, paidUntil: paidUntil.toISOString(), @@ -301,26 +310,10 @@ export async function settleFirstMonthAtDelete( } } -export async function settleFirstMonthsForDeletedApp(appId: string) { - try { - const videos = await db() - .select({ id: developerVideos.id }) - .from(developerVideos) - .where( - and( - eq(developerVideos.appId, appId), - isNotNull(developerVideos.deletedAt), - inArray(developerVideos.status, ["processing", "ready"]), - 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 settleFirstMonthsForDeletedVideos( + videoIds: readonly string[], +) { + for (const videoId of videoIds) await settleFirstMonthAtDelete(videoId); } export async function settleFirstMonthAtReady( @@ -411,10 +404,11 @@ export async function firstMonthShortfallsSince( since: Date, executor: Pick = db(), ) { - const shortfalls = new Map(); + 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, @@ -429,13 +423,40 @@ export async function firstMonthShortfallsSince( ); for (const row of rows) { const { shortSince } = parseStorageCharge(row); - if (shortSince && shortSince.getTime() >= since.getTime()) { - shortfalls.set(row.accountId, shortSince); - } + 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 markFirstMonthShortfallsNotified( + 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(), From a557a43c56134805baa0e346a448629a605c63be Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:56:25 -0500 Subject: [PATCH 28/46] fix(web): send the out-of-credit email once per new first month shortfall and word it for any storage charge --- .../unit/developer-cron-storage.test.ts | 50 ++++++++++++++++--- .../app/api/cron/developer-storage/route.ts | 9 ++-- .../database/emails/infra-out-of-credit.tsx | 6 +-- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index 8a012693d08..b00915f39cb 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ chargeMissedFirstMonths: vi.fn(), firstMonthShortfallsSince: vi.fn(), openFirstMonthShortfalls: vi.fn(), + markFirstMonthShortfallsNotified: vi.fn(), chargeStorageDay: vi.fn(), latestStorageCharge: vi.fn(), latestStorageCharges: vi.fn(), @@ -91,6 +92,7 @@ vi.mock("@/lib/infra-storage-minimum", () => ({ chargeMissedFirstMonths: mocks.chargeMissedFirstMonths, firstMonthShortfallsSince: mocks.firstMonthShortfallsSince, openFirstMonthShortfalls: mocks.openFirstMonthShortfalls, + markFirstMonthShortfallsNotified: mocks.markFirstMonthShortfallsNotified, })); vi.mock("@/lib/infra-storage-billing", () => ({ chargeStorageDay: mocks.chargeStorageDay, @@ -166,6 +168,7 @@ beforeEach(() => { mocks.chargeMissedFirstMonths.mockResolvedValue(NO_FIRST_MONTHS); mocks.firstMonthShortfallsSince.mockReset(); mocks.firstMonthShortfallsSince.mockResolvedValue(new Map()); + mocks.markFirstMonthShortfallsNotified.mockReset(); mocks.openFirstMonthShortfalls.mockReset(); mocks.openFirstMonthShortfalls.mockResolvedValue(new Set()); mocks.restoredSinceCharge.mockReset(); @@ -471,9 +474,9 @@ describe("developer-storage cron job", () => { }); }); - it("emails an account whose first month fell short in the last day and is still short, with the pause date seven days on", async () => { - const shortSince = new Date(Date.now() - 20 * 60 * 60 * 1000); - mocks.selects = [ + 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" }, @@ -481,6 +484,7 @@ describe("developer-storage cron job", () => { ], [], ]; + mocks.selects = accounts(); mocks.getStorageUsageByApp.mockResolvedValue( new Map([ ["app-1", usage(0)], @@ -488,10 +492,10 @@ describe("developer-storage cron job", () => { ["app-3", usage(0)], ]), ); - mocks.firstMonthShortfallsSince.mockResolvedValue( + mocks.firstMonthShortfallsSince.mockResolvedValueOnce( new Map([ - ["account-2", shortSince], - ["account-3", shortSince], + ["account-2", { shortSince, ids: ["row-2"] }], + ["account-3", { shortSince, ids: ["row-3"] }], ]), ); const short = { @@ -507,6 +511,7 @@ describe("developer-storage cron job", () => { mocks.restoredSinceCharge.mockImplementation( async (_db: unknown, accountId: string) => accountId === "account-3", ); + mocks.sendInfraOutOfCreditEmail.mockResolvedValue(true); const before = Date.now(); await authorized(); @@ -515,7 +520,7 @@ describe("developer-storage cron job", () => { mocks.firstMonthShortfallsSince.mock.calls[0] ?? []; expect(accountIds).toEqual(["account-1", "account-2", "account-3"]); expect(before - (since as Date).getTime()).toBeGreaterThanOrEqual( - 24 * 60 * 60 * 1000 - 1000, + 7 * 24 * 60 * 60 * 1000 - 1000, ); expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledWith({ @@ -523,6 +528,37 @@ describe("developer-storage cron job", () => { shortSince, pausesAt: new Date(shortSince.getTime() + 7 * 24 * 60 * 60 * 1000), }); + expect( + mocks.markFirstMonthShortfallsNotified.mock.calls.map(([ids]) => ids), + ).toEqual([["row-2"], ["row-3"]]); + + mocks.selects = accounts(); + await authorized(); + expect(mocks.sendInfraOutOfCreditEmail).toHaveBeenCalledTimes(1); + }); + + 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.firstMonthShortfallsSince.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.markFirstMonthShortfallsNotified).not.toHaveBeenCalled(); }); it("answers with zero apps when there are none", async () => { diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index 9e36042e3a7..8fe1e81092c 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -27,6 +27,7 @@ import { import { chargeMissedFirstMonths, firstMonthShortfallsSince, + markFirstMonthShortfallsNotified, openFirstMonthShortfalls, } from "@/lib/infra-storage-minimum"; import { utcDateKey } from "@/lib/infra-usage"; @@ -35,7 +36,7 @@ const BATCH_SIZE = 10; const PAGE_SIZE = 200; const DAY_MS = 24 * 60 * 60 * 1000; const GRACE_MS = INFRA_GRACE_DAYS * DAY_MS; -const FIRST_MONTH_NOTICE_LOOKBACK_MS = DAY_MS; +const FIRST_MONTH_NOTICE_LOOKBACK_MS = GRACE_MS; const TOP_UPS_BEFORE_UNTIL_MS = 60_000; const STORAGE_UNTIL_MS = 200_000; @@ -112,19 +113,21 @@ async function sendFirstMonthNotices( accounts.map((account) => account.id), new Date(now.getTime() - FIRST_MONTH_NOTICE_LOOKBACK_MS), ); - for (const accountId of shortfalls.keys()) { + for (const [accountId, { ids }] of shortfalls) { const charge = await latestStorageCharge(db(), accountId); if ( !charge?.shortSince || (await restoredSinceCharge(db(), accountId, charge)) ) { + await markFirstMonthShortfallsNotified(ids, now); continue; } - await sendInfraOutOfCreditEmail({ + const sent = await sendInfraOutOfCreditEmail({ accountId, shortSince: charge.shortSince, pausesAt: new Date(charge.shortSince.getTime() + GRACE_MS), }); + if (sent) await markFirstMonthShortfallsNotified(ids, now); } } 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.