Frank 6 месяцев назад
Родитель
Сommit
6c99b833e4

+ 71 - 12
packages/console/app/src/routes/workspace/member-section.tsx

@@ -56,25 +56,36 @@ const removeMember = action(async (form: FormData) => {
   )
 }, "member.remove")
 
-const updateMemberRole = action(async (form: FormData) => {
+const updateMember = action(async (form: FormData) => {
   "use server"
+  console.log("!@#!@ Form data entries:")
+  for (const [key, value] of form.entries()) {
+    console.log(`!@#!@ ${key}:`, value)
+  }
+
   const id = form.get("id")?.toString()
   if (!id) return { error: "ID is required" }
   const workspaceID = form.get("workspaceID")?.toString()
   if (!workspaceID) return { error: "Workspace ID is required" }
   const role = form.get("role")?.toString() as (typeof UserRole)[number]
   if (!role) return { error: "Role is required" }
+  const limit = form.get("limit")?.toString()
+  const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
+  if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
+
+  console.log({ id, role, monthlyLimit, limit })
+
   return json(
     await withActor(
       () =>
-        User.updateRole({ id, role })
+        User.update({ id, role, monthlyLimit })
           .then((data) => ({ error: undefined, data }))
           .catch((e) => ({ error: e.message as string })),
       workspaceID,
     ),
     { revalidate: listMembers.key },
   )
-}, "member.updateRole")
+}, "member.update")
 
 export function MemberCreateForm() {
   const params = useParams()
@@ -155,7 +166,7 @@ export function MemberCreateForm() {
 
 function MemberRow(props: { member: any; workspaceID: string; currentUserID: string | null }) {
   const [editing, setEditing] = createSignal(false)
-  const submission = useSubmission(updateMemberRole)
+  const submission = useSubmission(updateMember)
   const isCurrentUser = () => props.currentUserID === props.member.id
 
   createEffect(() => {
@@ -164,6 +175,29 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
     }
   })
 
+  function getUsageDisplay() {
+    const currentUsage = (() => {
+      const dateLastUsed = props.member.timeMonthlyUsageUpdated
+      if (!dateLastUsed) return 0
+
+      const current = new Date().toLocaleDateString("en-US", {
+        year: "numeric",
+        month: "long",
+        timeZone: "UTC",
+      })
+      const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+        year: "numeric",
+        month: "long",
+        timeZone: "UTC",
+      })
+      if (current !== lastUsed) return 0
+      return ((props.member.monthlyUsage ?? 0) / 100000000).toFixed(2)
+    })()
+
+    const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
+    return `$${currentUsage} / ${limit}`
+  }
+
   return (
     <Show
       when={editing()}
@@ -171,6 +205,7 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
         <tr>
           <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
           <td data-slot="member-role">{props.member.role}</td>
+          <td data-slot="member-usage">{getUsageDisplay()}</td>
           <Show when={!props.member.timeSeen} fallback={<td data-slot="member-joined"></td>}>
             <td data-slot="member-joined">invited</td>
           </Show>
@@ -190,12 +225,21 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
       }
     >
       <tr>
-        <td colspan="4">
-          <form action={updateMemberRole} method="post">
+        <td colspan="5">
+          <form action={updateMember} method="post">
             <div data-slot="edit-member-email">{props.member.accountEmail ?? props.member.email}</div>
             <input type="hidden" name="id" value={props.member.id} />
             <input type="hidden" name="workspaceID" value={props.workspaceID} />
-            <Show when={!isCurrentUser()} fallback={<div data-slot="current-user-role">Role: {props.member.role}</div>}>
+
+            <Show
+              when={!isCurrentUser()}
+              fallback={
+                <>
+                  <div data-slot="current-user-role">Role: {props.member.role}</div>
+                  <input type="hidden" name="role" value={props.member.role} />
+                </>
+              }
+            >
               <div data-slot="role-selector">
                 <label>
                   <input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
@@ -213,18 +257,32 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
                 </label>
               </div>
             </Show>
+
+            <div data-slot="limit-selector">
+              <label>
+                <strong>Monthly Limit</strong>
+                <input
+                  type="number"
+                  name="limit"
+                  value={props.member.monthlyLimit ?? ""}
+                  placeholder="No limit"
+                  min="0"
+                />
+                <p>Set a monthly spending limit for this user</p>
+              </label>
+            </div>
+
             <Show when={submission.result && submission.result.error}>
               {(err) => <div data-slot="form-error">{err()}</div>}
             </Show>
+
             <div data-slot="form-actions">
               <button type="button" data-color="ghost" onClick={() => setEditing(false)}>
                 Cancel
               </button>
-              <Show when={!isCurrentUser()}>
-                <button type="submit" data-color="primary" disabled={submission.pending}>
-                  {submission.pending ? "Saving..." : "Save"}
-                </button>
-              </Show>
+              <button type="submit" data-color="primary" disabled={submission.pending}>
+                {submission.pending ? "Saving..." : "Save"}
+              </button>
             </div>
           </form>
         </td>
@@ -258,6 +316,7 @@ export function MemberSection() {
               <tr>
                 <th>Email</th>
                 <th>Role</th>
+                <th>Usage</th>
                 <th></th>
                 <th></th>
               </tr>

+ 45 - 4
packages/console/app/src/routes/zen/handler.ts

@@ -11,6 +11,7 @@ import { Billing } from "../../../../core/src/billing"
 import { Actor } from "@opencode-ai/console-core/actor.js"
 import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
 import { ZenModel } from "@opencode-ai/console-core/model.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
 
 export async function handler(
   input: APIEvent,
@@ -33,6 +34,7 @@ export async function handler(
   class AuthError extends Error {}
   class CreditsError extends Error {}
   class MonthlyLimitError extends Error {}
+  class UserLimitError extends Error {}
   class ModelError extends Error {}
 
   type Model = z.infer<typeof ZenModel.ModelSchema>
@@ -181,6 +183,7 @@ export async function handler(
       error instanceof AuthError ||
       error instanceof CreditsError ||
       error instanceof MonthlyLimitError ||
+      error instanceof UserLimitError ||
       error instanceof ModelError
     )
       return new Response(
@@ -243,10 +246,15 @@ export async function handler(
           monthlyLimit: BillingTable.monthlyLimit,
           monthlyUsage: BillingTable.monthlyUsage,
           timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
+          userID: UserTable.id,
+          userMonthlyLimit: UserTable.monthlyLimit,
+          userMonthlyUsage: UserTable.monthlyUsage,
+          timeUserMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
         })
         .from(KeyTable)
         .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
         .innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
+        .innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
         .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
         .then((rows) => rows[0]),
     )
@@ -269,6 +277,12 @@ export async function handler(
         monthlyUsage: data.monthlyUsage,
         timeMonthlyUsageUpdated: data.timeMonthlyUsageUpdated,
       },
+      user: {
+        id: data.userID,
+        monthlyLimit: data.userMonthlyLimit,
+        monthlyUsage: data.userMonthlyUsage,
+        timeMonthlyUsageUpdated: data.timeUserMonthlyUsageUpdated,
+      },
       isFree,
     }
   }
@@ -280,19 +294,34 @@ export async function handler(
     const billing = authInfo.billing
     if (!billing.paymentMethodID) throw new CreditsError("No payment method")
     if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+
+    const now = new Date()
+    const currentYear = now.getUTCFullYear()
+    const currentMonth = now.getUTCMonth()
     if (
       billing.monthlyLimit &&
       billing.monthlyUsage &&
       billing.timeMonthlyUsageUpdated &&
       billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
     ) {
-      const now = new Date()
-      const currentYear = now.getUTCFullYear()
-      const currentMonth = now.getUTCMonth()
       const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
       const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
       if (currentYear === dateYear && currentMonth === dateMonth)
-        throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
+        throw new MonthlyLimitError(
+          `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`,
+        )
+    }
+
+    if (
+      authInfo.user.monthlyLimit &&
+      authInfo.user.monthlyUsage &&
+      authInfo.user.timeMonthlyUsageUpdated &&
+      authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
+    ) {
+      const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
+      const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
+      if (currentYear === dateYear && currentMonth === dateMonth)
+        throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`)
     }
   }
 
@@ -386,6 +415,18 @@ export async function handler(
           timeMonthlyUsageUpdated: sql`now()`,
         })
         .where(eq(BillingTable.workspaceID, authInfo.workspaceID))
+      await tx
+        .update(UserTable)
+        .set({
+          monthlyUsage: sql`
+              CASE
+                WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
+                ELSE ${cost}
+              END
+            `,
+          timeMonthlyUsageUpdated: sql`now()`,
+        })
+        .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
     })
 
     await Database.use((tx) =>

+ 3 - 0
packages/console/core/migrations/0029_panoramic_harrier.sql

@@ -0,0 +1,3 @@
+ALTER TABLE `user` ADD `monthly_limit` int;--> statement-breakpoint
+ALTER TABLE `user` ADD `monthly_usage` bigint;--> statement-breakpoint
+ALTER TABLE `user` ADD `time_monthly_usage_updated` timestamp(3);

+ 730 - 0
packages/console/core/migrations/meta/0029_snapshot.json

@@ -0,0 +1,730 @@
+{
+  "version": "5",
+  "dialect": "mysql",
+  "id": "33551b4c-fc2e-4753-8d9d-0971f333e65d",
+  "prevId": "a331e38c-c2e3-406d-a1ff-b0af7229cd85",
+  "tables": {
+    "account": {
+      "name": "account",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "email": {
+          "name": "email",
+          "columns": [
+            "email"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "billing": {
+      "name": "billing",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_id": {
+          "name": "payment_method_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_last4": {
+          "name": "payment_method_last4",
+          "type": "varchar(4)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "balance": {
+          "name": "balance",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "monthly_limit": {
+          "name": "monthly_limit",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "monthly_usage": {
+          "name": "monthly_usage",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_monthly_usage_updated": {
+          "name": "time_monthly_usage_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload": {
+          "name": "reload",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload_error": {
+          "name": "reload_error",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_error": {
+          "name": "time_reload_error",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_locked_till": {
+          "name": "time_reload_locked_till",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_customer_id": {
+          "name": "global_customer_id",
+          "columns": [
+            "customer_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "billing_workspace_id_id_pk": {
+          "name": "billing_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "payment": {
+      "name": "payment",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "invoice_id": {
+          "name": "invoice_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_id": {
+          "name": "payment_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "amount": {
+          "name": "amount",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_refunded": {
+          "name": "time_refunded",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "payment_workspace_id_id_pk": {
+          "name": "payment_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "usage": {
+      "name": "usage",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reasoning_tokens": {
+          "name": "reasoning_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_5m_tokens": {
+          "name": "cache_write_5m_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_1h_tokens": {
+          "name": "cache_write_1h_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost": {
+          "name": "cost",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_workspace_id_id_pk": {
+          "name": "usage_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "key": {
+      "name": "key",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_used": {
+          "name": "time_used",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_key": {
+          "name": "global_key",
+          "columns": [
+            "key"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "key_workspace_id_id_pk": {
+          "name": "key_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "user": {
+      "name": "user",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "account_id": {
+          "name": "account_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_seen": {
+          "name": "time_seen",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "color": {
+          "name": "color",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "enum('admin','member')",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "monthly_limit": {
+          "name": "monthly_limit",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "monthly_usage": {
+          "name": "monthly_usage",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_monthly_usage_updated": {
+          "name": "time_monthly_usage_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "user_account_id": {
+          "name": "user_account_id",
+          "columns": [
+            "workspace_id",
+            "account_id"
+          ],
+          "isUnique": true
+        },
+        "user_email": {
+          "name": "user_email",
+          "columns": [
+            "workspace_id",
+            "email"
+          ],
+          "isUnique": true
+        },
+        "global_account_id": {
+          "name": "global_account_id",
+          "columns": [
+            "account_id"
+          ],
+          "isUnique": false
+        },
+        "global_email": {
+          "name": "global_email",
+          "columns": [
+            "email"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "user_workspace_id_id_pk": {
+          "name": "user_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "workspace": {
+      "name": "workspace",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "slug": {
+          "name": "slug",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "slug": {
+          "name": "slug",
+          "columns": [
+            "slug"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "workspace_id": {
+          "name": "workspace_id",
+          "columns": [
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    }
+  },
+  "views": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "tables": {},
+    "indexes": {}
+  }
+}

+ 7 - 0
packages/console/core/migrations/meta/_journal.json

@@ -204,6 +204,13 @@
       "when": 1759805025276,
       "tag": "0028_careful_cerise",
       "breakpoints": true
+    },
+    {
+      "idx": 29,
+      "version": "5",
+      "when": 1759811835558,
+      "tag": "0029_panoramic_harrier",
+      "breakpoints": true
     }
   ]
 }

+ 1 - 1
packages/console/core/src/billing.ts

@@ -1,5 +1,5 @@
 import { Stripe } from "stripe"
-import { and, Database, eq, sql } from "./drizzle"
+import { Database, eq, sql } from "./drizzle"
 import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
 import { Actor } from "./actor"
 import { fn } from "./util/fn"

+ 4 - 1
packages/console/core/src/schema/user.sql.ts

@@ -1,4 +1,4 @@
-import { mysqlTable, uniqueIndex, varchar, int, mysqlEnum, index } from "drizzle-orm/mysql-core"
+import { mysqlTable, uniqueIndex, varchar, int, mysqlEnum, index, bigint } from "drizzle-orm/mysql-core"
 import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
 import { workspaceIndexes } from "./workspace.sql"
 
@@ -15,6 +15,9 @@ export const UserTable = mysqlTable(
     timeSeen: utc("time_seen"),
     color: int("color"),
     role: mysqlEnum("role", UserRole).notNull(),
+    monthlyLimit: int("monthly_limit"),
+    monthlyUsage: bigint("monthly_usage", { mode: "number" }),
+    timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
   },
   (table) => [
     ...workspaceIndexes(table),

+ 4 - 3
packages/console/core/src/user.ts

@@ -174,18 +174,19 @@ export namespace User {
     )
   })
 
-  export const updateRole = fn(
+  export const update = fn(
     z.object({
       id: z.string(),
       role: z.enum(UserRole),
+      monthlyLimit: z.number().nullable(),
     }),
-    async ({ id, role }) => {
+    async ({ id, role, monthlyLimit }) => {
       await assertAdmin()
       if (role === "member") assertNotSelf(id)
       return await Database.use((tx) =>
         tx
           .update(UserTable)
-          .set({ role })
+          .set({ role, monthlyLimit })
           .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))),
       )
     },