diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e9f44da..972f995 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,6 +23,9 @@ jobs: - run: pnpm run check - run: pnpm run lint - run: pnpm -r build + - run: pnpm --filter stack-dashboard run db:migrate + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} - run: pnpm --filter stack-dashboard run cf:deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 501fd77..ae3db2e 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -40,7 +40,8 @@ CLOUDFLARE_API_TOKEN="" # Cloudflare -> Top-right user icon -> Profile -> API To AUTUMN_ENABLED="false" AUTUMN_SECRET="" AUTUMN_DEFAULT_PLAN_ID="stack" # This should not be changed. -AUTUMN_SERVER_ENTITY_FEATURE_ID="active_servers" # This should not be changed. +AUTUMN_SERVER_ENTITY_FEATURE_ID="active_servers" # "vms" when pointing at the proposal-v2 sandbox. +AUTUMN_CREDITS_FEATURE_ID="" # Set to "credits" once the credit-system catalog is live; enables the credits UI. # Internal cron jobs INTERNAL_CRON_SECRET="" # random strings diff --git a/apps/dashboard/drizzle/0029_boring_sersi.sql b/apps/dashboard/drizzle/0029_boring_sersi.sql new file mode 100644 index 0000000..210361f --- /dev/null +++ b/apps/dashboard/drizzle/0029_boring_sersi.sql @@ -0,0 +1,3 @@ +ALTER TABLE "billing_meters" ADD COLUMN "cap_period_start" bigint;--> statement-breakpoint +ALTER TABLE "billing_meters" ADD COLUMN "cap_period_end" bigint;--> statement-breakpoint +ALTER TABLE "billing_meters" ADD COLUMN "hours_this_period" numeric DEFAULT '0' NOT NULL; \ No newline at end of file diff --git a/apps/dashboard/drizzle/meta/0029_snapshot.json b/apps/dashboard/drizzle/meta/0029_snapshot.json new file mode 100644 index 0000000..697006e --- /dev/null +++ b/apps/dashboard/drizzle/meta/0029_snapshot.json @@ -0,0 +1,2711 @@ +{ + "id": "249b9997-153b-4546-994f-7dcbb07ecf2a", + "prevId": "fa8d27a2-da3c-42f8-87e7-8436f32155d6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "api_tokens_user_id_index": { + "name": "api_tokens_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.base_images": { + "name": "base_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bg-gray-600'" + }, + "is_official": { + "name": "is_official", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_svg": { + "name": "logo_svg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#6b7280'" + }, + "image_type": { + "name": "image_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'qcow2'" + }, + "secure_boot": { + "name": "secure_boot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "isa": { + "name": "isa", + "type": "vm_isa", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_meters": { + "name": "billing_meters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "billing_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "units": { + "name": "units", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_metered_at": { + "name": "last_metered_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cap_period_start": { + "name": "cap_period_start", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cap_period_end": { + "name": "cap_period_end", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "hours_this_period": { + "name": "hours_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_meters_resource_index": { + "name": "billing_meters_resource_index", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_meters_active_last_metered_at_index": { + "name": "billing_meters_active_last_metered_at_index", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_metered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_meters_project_active_index": { + "name": "billing_meters_project_active_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_usage_events": { + "name": "billing_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "billing_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "billing_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "billing_usage_events_idempotency_key_index": { + "name": "billing_usage_events_idempotency_key_index", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_usage_events_project_created_at_index": { + "name": "billing_usage_events_project_created_at_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_usage_events_sync_status_created_at_index": { + "name": "billing_usage_events_sync_status_created_at_index", + "columns": [ + { + "expression": "sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_usage_events_resource_index": { + "name": "billing_usage_events_resource_index", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_assignments": { + "name": "ip_assignments", + "schema": "", + "columns": { + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": true + }, + "ip_block_id": { + "name": "ip_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "associated_vm_id": { + "name": "associated_vm_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ip_assignments_ip_block_id_index": { + "name": "ip_assignments_ip_block_id_index", + "columns": [ + { + "expression": "ip_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ip_assignments_associated_vm_id_index": { + "name": "ip_assignments_associated_vm_id_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ip_assignments_ip_block_id_ip_blocks_id_fk": { + "name": "ip_assignments_ip_block_id_ip_blocks_id_fk", + "tableFrom": "ip_assignments", + "tableTo": "ip_blocks", + "columnsFrom": [ + "ip_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ip_assignments_associated_vm_id_vms_id_fk": { + "name": "ip_assignments_associated_vm_id_vms_id_fk", + "tableFrom": "ip_assignments", + "tableTo": "vms", + "columnsFrom": [ + "associated_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_blocks": { + "name": "ip_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ip_block": { + "name": "ip_block", + "type": "cidr", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ipam_allocations": { + "name": "ipam_allocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ipam_prefix_id": { + "name": "ipam_prefix_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "associated_vm_id": { + "name": "associated_vm_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family": { + "name": "family", + "type": "ip_family", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "cidr", + "primaryKey": false, + "notNull": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "ipam_allocations_ipam_prefix_id_index": { + "name": "ipam_allocations_ipam_prefix_id_index", + "columns": [ + { + "expression": "ipam_prefix_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_associated_vm_id_index": { + "name": "ipam_allocations_associated_vm_id_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_vm_family_index": { + "name": "ipam_allocations_vm_family_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "family", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_address_index": { + "name": "ipam_allocations_address_index", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_prefix_index": { + "name": "ipam_allocations_prefix_index", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ipam_allocations_ipam_prefix_id_ipam_prefixes_id_fk": { + "name": "ipam_allocations_ipam_prefix_id_ipam_prefixes_id_fk", + "tableFrom": "ipam_allocations", + "tableTo": "ipam_prefixes", + "columnsFrom": [ + "ipam_prefix_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ipam_allocations_associated_vm_id_vms_id_fk": { + "name": "ipam_allocations_associated_vm_id_vms_id_fk", + "tableFrom": "ipam_allocations", + "tableTo": "vms", + "columnsFrom": [ + "associated_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ipam_prefixes": { + "name": "ipam_prefixes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cidr": { + "name": "cidr", + "type": "cidr", + "primaryKey": false, + "notNull": true + }, + "family": { + "name": "family", + "type": "ip_family", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ipv6_use_transit_address": { + "name": "ipv6_use_transit_address", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "whitelist_start": { + "name": "whitelist_start", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "whitelist_end": { + "name": "whitelist_end", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "gateway_address": { + "name": "gateway_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "bunny_dns_zone": { + "name": "bunny_dns_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "ipam_prefixes_cidr_index": { + "name": "ipam_prefixes_cidr_index", + "columns": [ + { + "expression": "cidr", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_prefixes_family_disabled_index": { + "name": "ipam_prefixes_family_disabled_index", + "columns": [ + { + "expression": "family", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "disabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ipam_ptr_records": { + "name": "ipam_ptr_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ipam_allocation_id": { + "name": "ipam_allocation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "inet", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bunny_record_id": { + "name": "bunny_record_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "ipam_ptr_records_ipam_allocation_id_index": { + "name": "ipam_ptr_records_ipam_allocation_id_index", + "columns": [ + { + "expression": "ipam_allocation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_ptr_records_address_index": { + "name": "ipam_ptr_records_address_index", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ipam_ptr_records_ipam_allocation_id_ipam_allocations_id_fk": { + "name": "ipam_ptr_records_ipam_allocation_id_ipam_allocations_id_fk", + "tableFrom": "ipam_ptr_records", + "tableTo": "ipam_allocations", + "columnsFrom": [ + "ipam_allocation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ipam_settings": { + "name": "ipam_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "default_ptr_format_ipv4": { + "name": "default_ptr_format_ipv4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_ptr_format_ipv6": { + "name": "default_ptr_format_ipv6", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_periods": { + "name": "payment_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "vm_id": { + "name": "vm_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate": { + "name": "rate", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "cap": { + "name": "cap", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_periods_vm_id_index": { + "name": "payment_periods_vm_id_index", + "columns": [ + { + "expression": "vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_periods_user_id_index": { + "name": "payment_periods_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_periods_vm_id_vms_id_fk": { + "name": "payment_periods_vm_id_vms_id_fk", + "tableFrom": "payment_periods", + "tableTo": "vms", + "columnsFrom": [ + "vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_billing_customers": { + "name": "project_billing_customers", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "autumn_customer_id": { + "name": "autumn_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "billing_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "project_billing_customers_autumn_customer_id_index": { + "name": "project_billing_customers_autumn_customer_id_index", + "columns": [ + { + "expression": "autumn_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_keys": { + "name": "ssh_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ssh_keys_user_id_index": { + "name": "ssh_keys_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vm_types": { + "name": "vm_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isa": { + "name": "isa", + "type": "vm_isa", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cores": { + "name": "cores", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ram_capacity": { + "name": "ram_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_amount": { + "name": "storage_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "cap": { + "name": "cap", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "autumn_feature_id": { + "name": "autumn_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vms": { + "name": "vms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proxmox_id": { + "name": "proxmox_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_known_ipv4": { + "name": "last_known_ipv4", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "last_known_ipv6": { + "name": "last_known_ipv6", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "last_known_status": { + "name": "last_known_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_known_uptime": { + "name": "last_known_uptime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_known_at": { + "name": "last_known_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "owner_project_id": { + "name": "owner_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vm_type_id": { + "name": "vm_type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creation_date": { + "name": "creation_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "backend": { + "name": "backend", + "type": "vm_backend", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "vm_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "status_error": { + "name": "status_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "vms_owner_project_id_index": { + "name": "vms_owner_project_id_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vms_owner_project_active_index": { + "name": "vms_owner_project_active_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vms_proxmox_id_index": { + "name": "vms_proxmox_id_index", + "columns": [ + { + "expression": "proxmox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vms_owner_project_id_organization_id_fk": { + "name": "vms_owner_project_id_organization_id_fk", + "tableFrom": "vms", + "tableTo": "organization", + "columnsFrom": [ + "owner_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vms_vm_type_id_vm_types_id_fk": { + "name": "vms_vm_type_id_vm_types_id_fk", + "tableFrom": "vms", + "tableTo": "vm_types", + "columnsFrom": [ + "vm_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volumes": { + "name": "volumes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_project_id": { + "name": "owner_project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "associated_vm_id": { + "name": "associated_vm_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "volumes_owner_project_id_index": { + "name": "volumes_owner_project_id_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volumes_associated_vm_id_index": { + "name": "volumes_associated_vm_id_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volumes_owner_project_id_organization_id_fk": { + "name": "volumes_owner_project_id_organization_id_fk", + "tableFrom": "volumes", + "tableTo": "organization", + "columnsFrom": [ + "owner_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "volumes_associated_vm_id_vms_id_fk": { + "name": "volumes_associated_vm_id_vms_id_fk", + "tableFrom": "volumes", + "tableTo": "vms", + "columnsFrom": [ + "associated_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_exempt": { + "name": "billing_exempt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_exempt": { + "name": "billing_exempt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.billing_resource_type": { + "name": "billing_resource_type", + "schema": "public", + "values": [ + "vm", + "volume" + ] + }, + "public.billing_sync_status": { + "name": "billing_sync_status", + "schema": "public", + "values": [ + "pending", + "synced", + "failed", + "abandoned" + ] + }, + "public.ip_family": { + "name": "ip_family", + "schema": "public", + "values": [ + "ipv4", + "ipv6" + ] + }, + "public.vm_backend": { + "name": "vm_backend", + "schema": "public", + "values": [ + "proxmox" + ] + }, + "public.vm_isa": { + "name": "vm_isa", + "schema": "public", + "values": [ + "x86", + "arm", + "risc-v" + ] + }, + "public.vm_status": { + "name": "vm_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "error", + "deleting" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/dashboard/drizzle/meta/_journal.json b/apps/dashboard/drizzle/meta/_journal.json index 32f2866..c2d5fbc 100644 --- a/apps/dashboard/drizzle/meta/_journal.json +++ b/apps/dashboard/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1785817075068, "tag": "0028_flashy_sleeper", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1786341821469, + "tag": "0029_boring_sersi", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/dashboard/src/app.d.ts b/apps/dashboard/src/app.d.ts index 11db741..9878d8a 100644 --- a/apps/dashboard/src/app.d.ts +++ b/apps/dashboard/src/app.d.ts @@ -44,6 +44,7 @@ declare global { AUTUMN_SECRET?: string; AUTUMN_DEFAULT_PLAN_ID?: string; AUTUMN_SERVER_ENTITY_FEATURE_ID?: string; + AUTUMN_CREDITS_FEATURE_ID?: string; HYPERDRIVE: { connectionString: string; }; diff --git a/apps/dashboard/src/lib/components/buy-credits-dialog.svelte b/apps/dashboard/src/lib/components/buy-credits-dialog.svelte new file mode 100644 index 0000000..e0d46b0 --- /dev/null +++ b/apps/dashboard/src/lib/components/buy-credits-dialog.svelte @@ -0,0 +1,116 @@ + + + + + +
+ +
+ Buy credits + + Prepaid credits are drawn down before any pay-as-you-go usage is billed. They don't expire + at the end of the billing cycle. + +
+ +
+ + +

+ {#if estimatedCost != null} + Billed as {formatCost(estimatedCost)}{credits % billingUnits !== 0 + ? ` (rounded up to the next ${billingUnits} credits)` + : ''}. + {:else} + Sold in packs of {billingUnits} credits. + {/if} +

+
+ + {#if actionError} +
+ {actionError} +
+ {/if} + + + + + +
+
diff --git a/apps/dashboard/src/lib/remote/admin-billing.remote.ts b/apps/dashboard/src/lib/remote/admin-billing.remote.ts index 3def661..75d518b 100644 --- a/apps/dashboard/src/lib/remote/admin-billing.remote.ts +++ b/apps/dashboard/src/lib/remote/admin-billing.remote.ts @@ -1,10 +1,16 @@ import { command, getRequestEvent, query } from '$app/server'; import { error } from '@sveltejs/kit'; import { type } from 'arktype'; -import { and, desc, eq, gt, lte } from 'drizzle-orm'; +import { and, desc, eq, gt, isNotNull, lt, lte, sql } from 'drizzle-orm'; import { requireAdmin } from '$lib/server/auth-context'; import { initDrizzle } from '$lib/server/db'; -import { billingUsageEvents, organization, vms, vmTypes } from '$lib/server/db/schema'; +import { + billingMeters, + billingUsageEvents, + organization, + vms, + vmTypes +} from '$lib/server/db/schema'; import { syncUsageEvent } from '$lib/server/billing/metering'; export type VmBillingUsage = { @@ -173,6 +179,21 @@ export const reverseVmBillingUsage = command(reverseParams, async (params) => { .returning(); if (!event) error(409, 'An identical reversal was already recorded'); + await db + .update(billingMeters) + .set({ + hoursThisPeriod: sql`greatest(0, ${billingMeters.hoursThisPeriod} - ${usage.reversibleHours})` + }) + .where( + and( + eq(billingMeters.resourceType, 'vm'), + eq(billingMeters.resourceId, params.vmId), + eq(billingMeters.active, true), + isNotNull(billingMeters.capPeriodStart), + lt(billingMeters.capPeriodStart, params.periodEnd) + ) + ); + const syncStatus = await syncUsageEvent(event.id); return { diff --git a/apps/dashboard/src/lib/remote/billing.remote.ts b/apps/dashboard/src/lib/remote/billing.remote.ts index 2c8cf4f..7a9a5e0 100644 --- a/apps/dashboard/src/lib/remote/billing.remote.ts +++ b/apps/dashboard/src/lib/remote/billing.remote.ts @@ -4,6 +4,7 @@ import { type } from 'arktype'; import { requireProjectAccess } from '$lib/server/auth-context'; import { openProjectBillingPortal, + purchaseProjectCredits, setupProjectPayment, validateProjectDiscountCode } from '$lib/server/billing/autumn'; @@ -17,6 +18,9 @@ import { const projectParams = type({ projectId: 'string' }); const setupParams = type({ projectId: 'string', returnTo: 'string?', discountCode: 'string?' }); +const purchaseCreditsParams = type({ projectId: 'string', credits: 'number.integer > 0' }); + +const MAX_CREDITS_PER_PURCHASE = 1_000_000; function safeReturnPath(value: string | undefined, fallback: string) { if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback; @@ -52,6 +56,25 @@ export const openBillingPortal = command(projectParams, async (params) => { return { url }; }); +export const purchaseCredits = command(purchaseCreditsParams, async (params) => { + const event = getRequestEvent(); + if (!event?.locals.user) error(401, 'Authentication required'); + if (params.credits > MAX_CREDITS_PER_PURCHASE) { + error( + 400, + `Credit purchases are limited to ${MAX_CREDITS_PER_PURCHASE.toLocaleString('en')} credits at a time.` + ); + } + + const db = initDrizzle(); + await requireProjectAccess(db, event.locals.user.id, params.projectId, 'owner'); + + const successUrl = `${event.url.origin}/projects/${params.projectId}/billing?billing_credits=complete`; + const url = await purchaseProjectCredits(params.projectId, params.credits, successUrl); + + return { url }; +}); + export const setupProjectBillingPayment = command(setupParams, async (params) => { const event = getRequestEvent(); if (!event?.locals.user) error(401, 'Authentication required'); diff --git a/apps/dashboard/src/lib/server/billing/autumn.ts b/apps/dashboard/src/lib/server/billing/autumn.ts index 76b7afc..3af66af 100644 --- a/apps/dashboard/src/lib/server/billing/autumn.ts +++ b/apps/dashboard/src/lib/server/billing/autumn.ts @@ -38,6 +38,10 @@ function defaultPlanId() { return getRuntimeEnv().AUTUMN_DEFAULT_PLAN_ID; } +export function creditsFeatureId() { + return getRuntimeEnv().AUTUMN_CREDITS_FEATURE_ID || null; +} + function isActiveSubscription(subscription: { planId?: string | null; status?: string | null; @@ -428,6 +432,84 @@ export async function requireProjectBillingActive(projectId: string) { } } +export async function getProjectCreditsBalance(projectId: string) { + const featureId = creditsFeatureId(); + if (!featureId || !isBillingConfigured()) return null; + + try { + const customer = await createAutumnClient().customers.get({ customerId: projectId }); + const balance = customer.balances?.[featureId]; + if (!balance) return null; + + const breakdowns = balance.breakdown ?? []; + const overage = breakdowns.find((item) => item.price?.billingMethod === 'usage_based'); + const prepaid = breakdowns.find((item) => item.price?.billingMethod === 'prepaid'); + const overageRate = + overage?.price?.amount != null && overage.price.billingUnits > 0 + ? overage.price.amount / overage.price.billingUnits + : null; + const overageUsage = overage?.usage ?? 0; + + return { + featureId, + remaining: balance.remaining ?? 0, + usage: balance.usage ?? 0, + overageUsage, + overageRate, + estimatedOverageCost: + overageRate != null ? Number((overageUsage * overageRate).toFixed(2)) : null, + prepaidPrice: prepaid?.price + ? { amount: prepaid.price.amount ?? null, billingUnits: prepaid.price.billingUnits } + : null, + nextResetAt: balance.nextResetAt ?? null + }; + } catch { + return null; + } +} + +export async function purchaseProjectCredits( + projectId: string, + credits: number, + successUrl: string +) { + const featureId = creditsFeatureId(); + const planId = defaultPlanId(); + if (!isBillingConfigured() || !featureId || !planId) { + error(501, 'Credit purchases are not configured in this environment.'); + } + + await ensureProjectCustomer(projectId); + + const response = await createAutumnClient().billing.attach({ + customerId: projectId, + planId, + featureQuantities: [{ featureId, quantity: credits }], + redirectMode: 'if_required', + successUrl + }); + + invalidateProjectBillingState(projectId); + return response.paymentUrl ?? null; +} + +export async function getProjectBillingPeriodAnchor(projectId: string) { + if (!isBillingConfigured()) return null; + + const planId = defaultPlanId(); + if (!planId) return null; + + try { + const customer = await createAutumnClient().customers.get({ customerId: projectId }); + const subscription = customer.subscriptions.find((item) => item.planId === planId); + if (!subscription?.currentPeriodStart || !subscription.currentPeriodEnd) return null; + + return { start: subscription.currentPeriodStart, end: subscription.currentPeriodEnd }; + } catch { + return null; + } +} + export async function openProjectBillingPortal(projectId: string, returnUrl: string) { if (!isBillingConfigured()) error(501, 'Billing is not configured in this environment.'); diff --git a/apps/dashboard/src/lib/server/billing/caps.ts b/apps/dashboard/src/lib/server/billing/caps.ts new file mode 100644 index 0000000..98ace54 --- /dev/null +++ b/apps/dashboard/src/lib/server/billing/caps.ts @@ -0,0 +1,92 @@ +import { hoursBetween } from './features'; + +export type CapPeriod = { start: number; end: number }; + +export type CapMeterState = { + capPeriodStart: number | null; + capPeriodEnd: number | null; + hoursThisPeriod: number | string; +}; + +export type CapSegment = { + periodStart: number; + periodEnd: number; + billableHours: number; + capped: boolean; +}; + +export function capHoursFor( + rate: number | string | null | undefined, + cap: number | string | null | undefined +) { + const rateValue = Number(rate); + const capValue = Number(cap); + if (!Number.isFinite(rateValue) || rateValue <= 0) return Infinity; + if (!Number.isFinite(capValue) || capValue <= 0) return Infinity; + + return capValue / rateValue; +} + +export function calendarMonthPeriod(at: number): CapPeriod { + const date = new Date(at); + return { + start: Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1), + end: Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) + }; +} + +export function billingCyclePeriod(anchor: CapPeriod, at: number): CapPeriod { + const length = anchor.end - anchor.start; + if (length <= 0) return calendarMonthPeriod(at); + + const cycles = Math.floor((at - anchor.start) / length); + const start = anchor.start + cycles * length; + + return { start, end: start + length }; +} + +export function sliceCapUsage(input: { + from: number; + to: number; + capHours: number; + state: CapMeterState; + periodAt: (at: number) => CapPeriod; +}) { + const segments: CapSegment[] = []; + let periodStart = input.state.capPeriodStart; + let periodEnd = input.state.capPeriodEnd; + let hours = Number(input.state.hoursThisPeriod); + if (!Number.isFinite(hours) || hours < 0) hours = 0; + + let cursor = input.from; + while (cursor < input.to) { + if (periodStart == null || periodEnd == null || cursor >= periodEnd) { + const period = input.periodAt(cursor); + periodStart = period.start; + periodEnd = Math.max(period.end, cursor + 1); + hours = 0; + } + + const segmentEnd = Math.min(input.to, periodEnd); + const elapsed = hoursBetween(cursor, segmentEnd); + const billableHours = Math.min(elapsed, Math.max(0, input.capHours - hours)); + segments.push({ + periodStart: cursor, + periodEnd: segmentEnd, + billableHours, + capped: billableHours < elapsed + }); + + hours += elapsed; + cursor = segmentEnd; + } + + return { + segments, + state: { + capPeriodStart: periodStart, + capPeriodEnd: periodEnd, + hoursThisPeriod: hours + } + }; +} diff --git a/apps/dashboard/src/lib/server/billing/features.ts b/apps/dashboard/src/lib/server/billing/features.ts index 2160794..a393cc4 100644 --- a/apps/dashboard/src/lib/server/billing/features.ts +++ b/apps/dashboard/src/lib/server/billing/features.ts @@ -18,8 +18,8 @@ export function hoursBetween(start: number, end: number) { return Math.max(0, (end - start) / 3_600_000); } -export function usageQuantity(units: number | string, periodStart: number, periodEnd: number) { - const quantity = Number(units) * hoursBetween(periodStart, periodEnd); +export function billedQuantity(units: number | string, billableHours: number) { + const quantity = Number(units) * Math.max(0, billableHours); return Number(quantity.toFixed(6)); } diff --git a/apps/dashboard/src/lib/server/billing/metering.ts b/apps/dashboard/src/lib/server/billing/metering.ts index 7d5afc0..6ad7c6d 100644 --- a/apps/dashboard/src/lib/server/billing/metering.ts +++ b/apps/dashboard/src/lib/server/billing/metering.ts @@ -8,13 +8,22 @@ import { vmTypes, type billingResourceTypeEnum } from '$lib/server/db/schema'; -import { requireVmFeatureId, usageIdempotencyKey, usageQuantity } from './features'; +import { billedQuantity, requireVmFeatureId, usageIdempotencyKey } from './features'; +import { + billingCyclePeriod, + calendarMonthPeriod, + capHoursFor, + sliceCapUsage, + type CapPeriod, + type CapSegment +} from './caps'; import { autumnStatus, createAutumnClient, ensureProjectCustomer, ensureProjectServerEntity, formatAutumnError, + getProjectBillingPeriodAnchor, isBillingConfigured, isProjectBillingExempt } from './autumn'; @@ -23,50 +32,80 @@ type BillingResourceType = (typeof billingResourceTypeEnum.enumValues)[number]; type BillingMeter = typeof billingMeters.$inferSelect; type BillingUsageEvent = typeof billingUsageEvents.$inferSelect; -async function recordMeterUsage(meter: BillingMeter, now: number) { - const db = initDrizzle(); - if (now <= meter.lastMeteredAt) return null; +const CAPPED_USAGE_NOTE = 'Clamped to the monthly price cap'; + +type SegmentInsert = Pick< + BillingMeter, + 'projectId' | 'resourceType' | 'resourceId' | 'featureId' | 'units' +>; - const quantity = usageQuantity(meter.units, meter.lastMeteredAt, now); +function segmentEventValues(meter: SegmentInsert, segment: CapSegment, now: number) { + const quantity = billedQuantity(meter.units, segment.billableHours); if (quantity <= 0) return null; - const idempotencyKey = usageIdempotencyKey({ + return { + projectId: meter.projectId, resourceType: meter.resourceType, resourceId: meter.resourceId, featureId: meter.featureId, - units: meter.units, - periodStart: meter.lastMeteredAt, - periodEnd: now + quantity: quantity.toString(), + periodStart: segment.periodStart, + periodEnd: segment.periodEnd, + idempotencyKey: usageIdempotencyKey({ + resourceType: meter.resourceType, + resourceId: meter.resourceId, + featureId: meter.featureId, + units: meter.units, + periodStart: segment.periodStart, + periodEnd: segment.periodEnd + }), + note: segment.capped ? CAPPED_USAGE_NOTE : null, + createdAt: now + }; +} + +async function recordMeterUsage( + meter: BillingMeter, + now: number, + capHours: number, + periodAt: (at: number) => CapPeriod +) { + const db = initDrizzle(); + if (now <= meter.lastMeteredAt) return []; + + const { segments, state } = sliceCapUsage({ + from: meter.lastMeteredAt, + to: now, + capHours, + state: meter, + periodAt }); + const values = segments + .map((segment) => segmentEventValues(meter, segment, now)) + .filter((value) => value != null); + return db.transaction(async (tx) => { const claimed = await tx .update(billingMeters) - .set({ lastMeteredAt: now }) + .set({ + lastMeteredAt: now, + capPeriodStart: state.capPeriodStart, + capPeriodEnd: state.capPeriodEnd, + hoursThisPeriod: state.hoursThisPeriod.toFixed(6) + }) .where( and(eq(billingMeters.id, meter.id), eq(billingMeters.lastMeteredAt, meter.lastMeteredAt)) ) .returning({ id: billingMeters.id }); - if (claimed.length === 0) return null; + if (claimed.length === 0 || values.length === 0) return []; - const [event] = await tx + return tx .insert(billingUsageEvents) - .values({ - projectId: meter.projectId, - resourceType: meter.resourceType, - resourceId: meter.resourceId, - featureId: meter.featureId, - quantity: quantity.toString(), - periodStart: meter.lastMeteredAt, - periodEnd: now, - idempotencyKey, - createdAt: now - }) + .values(values) .onConflictDoNothing({ target: billingUsageEvents.idempotencyKey }) .returning(); - - return event ?? null; }); } @@ -142,6 +181,31 @@ export async function reconcileMissingMeters(now = Date.now(), limit = 100, proj return { created }; } +async function meterCapContext(meter: BillingMeter) { + const db = initDrizzle(); + + let capHours = Infinity; + if (meter.resourceType === 'vm') { + const [vmType] = await db + .select({ rate: vmTypes.rate, cap: vmTypes.cap }) + .from(vms) + .innerJoin(vmTypes, eq(vmTypes.id, vms.vmTypeId)) + .where(eq(vms.id, meter.resourceId)) + .limit(1); + capHours = capHoursFor(vmType?.rate, vmType?.cap); + } + + const anchor = Number.isFinite(capHours) + ? await getProjectBillingPeriodAnchor(meter.projectId) + : null; + + return { capHours, periodAt: makePeriodAt(anchor) }; +} + +function makePeriodAt(anchor: CapPeriod | null) { + return (at: number) => (anchor ? billingCyclePeriod(anchor, at) : calendarMonthPeriod(at)); +} + export async function meterResourceThrough( resourceType: BillingResourceType, resourceId: string, @@ -158,56 +222,60 @@ export async function meterResourceThrough( if (!meter) return null; - const event = await db.transaction(async (tx) => { + const { capHours, periodAt } = await meterCapContext(meter); + + const events = await db.transaction(async (tx) => { const [locked] = await tx .select() .from(billingMeters) .where(and(eq(billingMeters.id, meter.id), eq(billingMeters.active, true))) .for('update'); - if (!locked) return null; - - let inserted: BillingUsageEvent | null = null; - const quantity = usageQuantity(locked.units, locked.lastMeteredAt, now); - if (now > locked.lastMeteredAt && quantity > 0) { - const idempotencyKey = usageIdempotencyKey({ - resourceType: locked.resourceType, - resourceId: locked.resourceId, - featureId: locked.featureId, - units: locked.units, - periodStart: locked.lastMeteredAt, - periodEnd: now - }); + if (!locked) return []; - const rows = await tx - .insert(billingUsageEvents) - .values({ - projectId: locked.projectId, - resourceType: locked.resourceType, - resourceId: locked.resourceId, - featureId: locked.featureId, - quantity: quantity.toString(), - periodStart: locked.lastMeteredAt, - periodEnd: now, - idempotencyKey, - createdAt: now - }) - .onConflictDoNothing({ target: billingUsageEvents.idempotencyKey }) - .returning(); - inserted = rows[0] ?? null; - } + const { segments, state } = sliceCapUsage({ + from: locked.lastMeteredAt, + to: Math.max(now, locked.lastMeteredAt), + capHours, + state: locked, + periodAt + }); + + const values = segments + .map((segment) => segmentEventValues(locked, segment, now)) + .filter((value) => value != null); + + const inserted = + values.length > 0 + ? await tx + .insert(billingUsageEvents) + .values(values) + .onConflictDoNothing({ target: billingUsageEvents.idempotencyKey }) + .returning() + : []; await tx .update(billingMeters) - .set({ lastMeteredAt: now, active: false, endedAt: now }) + .set({ + lastMeteredAt: now, + capPeriodStart: state.capPeriodStart, + capPeriodEnd: state.capPeriodEnd, + hoursThisPeriod: state.hoursThisPeriod.toFixed(6), + active: false, + endedAt: now + }) .where(eq(billingMeters.id, meter.id)); return inserted; }); - const syncStatus = event ? await syncUsageEvent(event.id) : null; + let syncStatus: Awaited> = null; + for (const event of events) { + const status = await syncUsageEvent(event.id); + if (syncStatus !== 'failed') syncStatus = status; + } - return { event, syncStatus }; + return { events, syncStatus }; } export async function reconcileOrphanedMeters(now = Date.now(), limit = 100) { @@ -288,8 +356,10 @@ export async function meterActiveResources(now = Date.now(), limit = 100) { await reconcileMissingMeters(now, limit); const meters = await db - .select() + .select({ meter: billingMeters, rate: vmTypes.rate, cap: vmTypes.cap }) .from(billingMeters) + .leftJoin(vms, eq(vms.id, billingMeters.resourceId)) + .leftJoin(vmTypes, eq(vmTypes.id, vms.vmTypeId)) .where( and( eq(billingMeters.resourceType, 'vm'), @@ -300,13 +370,25 @@ export async function meterActiveResources(now = Date.now(), limit = 100) { .orderBy(asc(billingMeters.lastMeteredAt)) .limit(limit); - const events: BillingUsageEvent[] = []; - await runBounded(meters, METER_CONCURRENCY, async (meter) => { - const event = await recordMeterUsage(meter, now); - if (event) events.push(event); + const anchors = new Map>(); + const anchorFor = (projectId: string) => { + let anchor = anchors.get(projectId); + if (!anchor) { + anchor = getProjectBillingPeriodAnchor(projectId); + anchors.set(projectId, anchor); + } + return anchor; + }; + + let events = 0; + await runBounded(meters, METER_CONCURRENCY, async ({ meter, rate, cap }) => { + const capHours = capHoursFor(rate, cap); + const anchor = Number.isFinite(capHours) ? await anchorFor(meter.projectId) : null; + const inserted = await recordMeterUsage(meter, now, capHours, makePeriodAt(anchor)); + events += inserted.length; }); - return { meters: meters.length, events: events.length }; + return { meters: meters.length, events }; } type ProjectTargetStatus = 'ok' | 'failed' | 'gone'; diff --git a/apps/dashboard/src/lib/server/billing/overview.ts b/apps/dashboard/src/lib/server/billing/overview.ts index 17c2355..5c8d404 100644 --- a/apps/dashboard/src/lib/server/billing/overview.ts +++ b/apps/dashboard/src/lib/server/billing/overview.ts @@ -2,6 +2,7 @@ import { and, eq, sum } from 'drizzle-orm'; import { ensureProjectCustomer, getProjectBillingState, + getProjectCreditsBalance, invalidateProjectBillingState } from './autumn'; import { initDrizzle } from '$lib/server/db'; @@ -32,7 +33,10 @@ export async function getProjectBillingOverview(projectId: string) { const db = initDrizzle(); const now = Date.now(); - const billingState = await getProjectBillingState(projectId); + const [billingState, credits] = await Promise.all([ + getProjectBillingState(projectId), + getProjectCreditsBalance(projectId) + ]); const activeMeters = await db .select({ @@ -105,6 +109,7 @@ export async function getProjectBillingOverview(projectId: string) { syncError: billingState.syncError, lastUpdatedAt: now, activeResourceCount: activeMeters.length, - activeResources + activeResources, + credits }; } diff --git a/apps/dashboard/src/lib/server/db/schema.ts b/apps/dashboard/src/lib/server/db/schema.ts index 37d12ec..95cd617 100644 --- a/apps/dashboard/src/lib/server/db/schema.ts +++ b/apps/dashboard/src/lib/server/db/schema.ts @@ -196,6 +196,9 @@ export const billingMeters = pgTable( featureId: text('feature_id').notNull(), units: numeric('units').notNull(), lastMeteredAt: bigint('last_metered_at', { mode: 'number' }).notNull(), + capPeriodStart: bigint('cap_period_start', { mode: 'number' }), + capPeriodEnd: bigint('cap_period_end', { mode: 'number' }), + hoursThisPeriod: numeric('hours_this_period').notNull().default('0'), active: boolean('active').notNull().default(true), createdAt: bigint('created_at', { mode: 'number' }).notNull(), endedAt: bigint('ended_at', { mode: 'number' }) diff --git a/apps/dashboard/src/lib/server/env.ts b/apps/dashboard/src/lib/server/env.ts index 44de243..19c9b8f 100644 --- a/apps/dashboard/src/lib/server/env.ts +++ b/apps/dashboard/src/lib/server/env.ts @@ -25,6 +25,7 @@ export type RuntimeEnv = { AUTUMN_SECRET?: string; AUTUMN_DEFAULT_PLAN_ID?: string; AUTUMN_SERVER_ENTITY_FEATURE_ID?: string; + AUTUMN_CREDITS_FEATURE_ID?: string; DATABASE_URL?: string; HYPERDRIVE?: { connectionString: string; @@ -71,6 +72,7 @@ export function getRuntimeEnv(): RuntimeEnv { : required('AUTUMN_SECRET', platformEnv.AUTUMN_SECRET), AUTUMN_DEFAULT_PLAN_ID: platformEnv.AUTUMN_DEFAULT_PLAN_ID, AUTUMN_SERVER_ENTITY_FEATURE_ID: platformEnv.AUTUMN_SERVER_ENTITY_FEATURE_ID, + AUTUMN_CREDITS_FEATURE_ID: platformEnv.AUTUMN_CREDITS_FEATURE_ID, HYPERDRIVE: platformEnv.HYPERDRIVE, FEATURE_FLAGS: platformEnv.FEATURE_FLAGS, INTERNAL_CRON_SECRET: platformEnv.INTERNAL_CRON_SECRET, @@ -101,6 +103,7 @@ export function getRuntimeEnv(): RuntimeEnv { AUTUMN_SECRET: privateEnv.AUTUMN_SECRET, AUTUMN_DEFAULT_PLAN_ID: privateEnv.AUTUMN_DEFAULT_PLAN_ID, AUTUMN_SERVER_ENTITY_FEATURE_ID: privateEnv.AUTUMN_SERVER_ENTITY_FEATURE_ID, + AUTUMN_CREDITS_FEATURE_ID: privateEnv.AUTUMN_CREDITS_FEATURE_ID, DATABASE_URL: required('DATABASE_URL', privateEnv.DATABASE_URL), INTERNAL_CRON_SECRET: privateEnv.INTERNAL_CRON_SECRET, GITHUB_CLIENT_ID: privateEnv.GITHUB_CLIENT_ID, diff --git a/apps/dashboard/src/lib/server/project-deletion.ts b/apps/dashboard/src/lib/server/project-deletion.ts index 744f1b7..0b56460 100644 --- a/apps/dashboard/src/lib/server/project-deletion.ts +++ b/apps/dashboard/src/lib/server/project-deletion.ts @@ -56,7 +56,7 @@ export async function softDeleteOrganizationResources( console.warn(`Failed to meter VM ${vm.id} during project delete`, err); return null; }); - if (!metered?.event || metered.syncStatus === 'synced') { + if (!metered || metered.events.length === 0 || metered.syncStatus === 'synced') { await deleteProjectServerEntity(organizationId, vm.id).catch((err) => { console.warn(`Failed to delete Autumn entity for VM ${vm.id}`, err); }); diff --git a/apps/dashboard/src/lib/server/vm-deletion.ts b/apps/dashboard/src/lib/server/vm-deletion.ts index c86afac..029e6a6 100644 --- a/apps/dashboard/src/lib/server/vm-deletion.ts +++ b/apps/dashboard/src/lib/server/vm-deletion.ts @@ -96,7 +96,10 @@ async function deleteVmResources(row: DeletableVm): Promise { console.warn(`Failed to meter VM ${row.id} during deletion`, err); return null; }); - if (row.ownerProjectId && (!metered?.event || metered.syncStatus === 'synced')) { + if ( + row.ownerProjectId && + (!metered || metered.events.length === 0 || metered.syncStatus === 'synced') + ) { await deleteProjectServerEntity(row.ownerProjectId, row.id).catch((err) => { console.warn(`Failed to delete Autumn entity for VM ${row.id}`, err); }); diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/billing/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/billing/+page.svelte index bc75e9f..dc7ce80 100644 --- a/apps/dashboard/src/routes/(app)/projects/[projectid]/billing/+page.svelte +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/billing/+page.svelte @@ -1,7 +1,9 @@