chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
if (!workspaceID) {
|
||||
console.error("Usage: bun script/foo.ts <workspaceID>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Removing from Black waitlist`)
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
subscriptionPlan: BillingTable.subscriptionPlan,
|
||||
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!billing?.timeSubscriptionBooked) {
|
||||
console.error(`Error: Workspace is not on the waitlist`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
subscriptionPlan: null,
|
||||
timeSubscriptionBooked: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
|
||||
console.log(`Done`)
|
||||
115
qimingcode/packages/console/core/script/black-gift.ts
Normal file
115
qimingcode/packages/console/core/script/black-gift.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
|
||||
const plan = "200"
|
||||
const couponID = "JAIr0Pe1"
|
||||
const workspaceID = process.argv[2]
|
||||
const seats = parseInt(process.argv[3])
|
||||
|
||||
console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`)
|
||||
|
||||
if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts <workspaceID> <seats>")
|
||||
|
||||
// Get workspace user
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: UserTable.id,
|
||||
role: UserTable.role,
|
||||
email: AuthTable.subject,
|
||||
})
|
||||
.from(UserTable)
|
||||
.leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`)
|
||||
if (users.length !== seats)
|
||||
throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`)
|
||||
const adminUser = users.find((user) => user.role === "admin")
|
||||
if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`)
|
||||
if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`)
|
||||
|
||||
// Get Billing
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`)
|
||||
if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`)
|
||||
|
||||
// Look up the Stripe customer by email
|
||||
const customerID =
|
||||
billing.customerID ??
|
||||
(await (() =>
|
||||
Billing.stripe()
|
||||
.customers.create({
|
||||
email: adminUser.email,
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
.then((customer) => customer.id))())
|
||||
console.log(`Customer ID: ${customerID}`)
|
||||
|
||||
const subscription = await Billing.stripe().subscriptions.create({
|
||||
customer: customerID!,
|
||||
items: [
|
||||
{
|
||||
price: BlackData.planToPriceID({ plan }),
|
||||
discounts: [{ coupon: couponID }],
|
||||
quantity: seats,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
console.log(`Subscription ID: ${subscription.id}`)
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
// Set customer id, subscription id, and payment method on workspace billing
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID: subscription.id,
|
||||
subscription: { status: "subscribed", coupon: couponID, seats, plan },
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
// Create a row in subscription table
|
||||
for (const user of users) {
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.id,
|
||||
})
|
||||
}
|
||||
//
|
||||
// // Create a row in payments table
|
||||
// await tx.insert(PaymentTable).values({
|
||||
// workspaceID,
|
||||
// id: Identifier.create("payment"),
|
||||
// amount: centsToMicroCents(amountInCents),
|
||||
// customerID,
|
||||
// invoiceID,
|
||||
// paymentID,
|
||||
// enrichment: {
|
||||
// type: "subscription",
|
||||
// couponID,
|
||||
// },
|
||||
// })
|
||||
})
|
||||
|
||||
console.log(`done`)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
if (!workspaceID) {
|
||||
console.error("Usage: bun script/foo.ts <workspaceID>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Onboarding to Black waitlist`)
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
subscriptionPlan: BillingTable.subscriptionPlan,
|
||||
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!billing?.timeSubscriptionBooked) {
|
||||
console.error(`Error: Workspace is not on the waitlist`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
timeSubscriptionSelected: new Date(),
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
|
||||
console.log(`Done`)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const plan = process.argv[2] as (typeof BlackPlans)[number]
|
||||
if (!BlackPlans.includes(plan)) {
|
||||
console.error("Usage: bun foo.ts <count>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workspaces = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(and(eq(BillingTable.subscriptionPlan, plan), isNull(BillingTable.timeSubscriptionSelected)))
|
||||
.orderBy(sql`RAND()`)
|
||||
.limit(100),
|
||||
)
|
||||
|
||||
console.log(`Found ${workspaces.length} workspaces on Black ${plan} waitlist`)
|
||||
|
||||
console.log("== Workspace IDs ==")
|
||||
const ids = workspaces.map((w) => w.workspaceID)
|
||||
for (const id of ids) {
|
||||
console.log(id)
|
||||
}
|
||||
|
||||
console.log("\n== User Emails ==")
|
||||
const emails = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ email: AuthTable.subject })
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(inArray(UserTable.workspaceID, ids)),
|
||||
)
|
||||
|
||||
const unique = new Set(emails.map((row) => row.email))
|
||||
for (const email of unique) {
|
||||
console.log(email)
|
||||
}
|
||||
312
qimingcode/packages/console/core/script/black-stats.ts
Normal file
312
qimingcode/packages/console/core/script/black-stats.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Database, and, eq, inArray, isNotNull, sql } from "../src/drizzle/index.js"
|
||||
import { BillingTable, BlackPlans, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
if (process.argv.length < 3) {
|
||||
console.error("Usage: bun black-stats.ts <plan>")
|
||||
process.exit(1)
|
||||
}
|
||||
const plan = process.argv[2] as (typeof BlackPlans)[number]
|
||||
if (!BlackPlans.includes(plan)) {
|
||||
console.error("Usage: bun black-stats.ts <plan>")
|
||||
process.exit(1)
|
||||
}
|
||||
const cutoff = new Date(Date.UTC(2026, 1, 0, 23, 59, 59, 999))
|
||||
|
||||
// get workspaces
|
||||
const workspaces = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(
|
||||
and(isNotNull(BillingTable.subscriptionID), sql`JSON_UNQUOTE(JSON_EXTRACT(subscription, '$.plan')) = ${plan}`),
|
||||
),
|
||||
)
|
||||
if (workspaces.length === 0) throw new Error(`No active Black ${plan} subscriptions found`)
|
||||
|
||||
const week = sql<number>`YEARWEEK(${UsageTable.timeCreated}, 3)`
|
||||
const workspaceIDs = workspaces.map((row) => row.workspaceID)
|
||||
// Get subscription spend
|
||||
const spend = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: UsageTable.workspaceID,
|
||||
week,
|
||||
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
|
||||
)
|
||||
.groupBy(UsageTable.workspaceID, week),
|
||||
)
|
||||
|
||||
// Get pay per use spend
|
||||
const ppu = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: UsageTable.workspaceID,
|
||||
week,
|
||||
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(UsageTable.workspaceID, workspaceIDs),
|
||||
sql`(${UsageTable.enrichment} IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) != 'sub')`,
|
||||
),
|
||||
)
|
||||
.groupBy(UsageTable.workspaceID, week),
|
||||
)
|
||||
|
||||
const models = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: UsageTable.workspaceID,
|
||||
model: UsageTable.model,
|
||||
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
|
||||
)
|
||||
.groupBy(UsageTable.workspaceID, UsageTable.model),
|
||||
)
|
||||
|
||||
const tokens = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: UsageTable.workspaceID,
|
||||
week,
|
||||
input: sql<number>`COALESCE(SUM(${UsageTable.inputTokens}), 0)`,
|
||||
cacheRead: sql<number>`COALESCE(SUM(${UsageTable.cacheReadTokens}), 0)`,
|
||||
output: sql<number>`COALESCE(SUM(${UsageTable.outputTokens}), 0) + COALESCE(SUM(${UsageTable.reasoningTokens}), 0)`,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
|
||||
)
|
||||
.groupBy(UsageTable.workspaceID, week),
|
||||
)
|
||||
|
||||
const allWeeks = [...spend, ...ppu].map((row) => row.week)
|
||||
const weeks = [...new Set(allWeeks)].sort((a, b) => a - b)
|
||||
const spendMap = new Map<string, Map<number, number>>()
|
||||
const totals = new Map<string, number>()
|
||||
const ppuMap = new Map<string, Map<number, number>>()
|
||||
const ppuTotals = new Map<string, number>()
|
||||
const modelMap = new Map<string, { model: string; amount: number }[]>()
|
||||
const tokenMap = new Map<string, Map<number, { input: number; cacheRead: number; output: number }>>()
|
||||
|
||||
for (const row of spend) {
|
||||
const workspace = spendMap.get(row.workspaceID) ?? new Map<number, number>()
|
||||
const total = totals.get(row.workspaceID) ?? 0
|
||||
const amount = toNumber(row.amount)
|
||||
workspace.set(row.week, amount)
|
||||
totals.set(row.workspaceID, total + amount)
|
||||
spendMap.set(row.workspaceID, workspace)
|
||||
}
|
||||
|
||||
for (const row of ppu) {
|
||||
const workspace = ppuMap.get(row.workspaceID) ?? new Map<number, number>()
|
||||
const total = ppuTotals.get(row.workspaceID) ?? 0
|
||||
const amount = toNumber(row.amount)
|
||||
workspace.set(row.week, amount)
|
||||
ppuTotals.set(row.workspaceID, total + amount)
|
||||
ppuMap.set(row.workspaceID, workspace)
|
||||
}
|
||||
|
||||
for (const row of models) {
|
||||
const current = modelMap.get(row.workspaceID) ?? []
|
||||
current.push({ model: row.model, amount: toNumber(row.amount) })
|
||||
modelMap.set(row.workspaceID, current)
|
||||
}
|
||||
|
||||
for (const row of tokens) {
|
||||
const workspace = tokenMap.get(row.workspaceID) ?? new Map()
|
||||
workspace.set(row.week, {
|
||||
input: toNumber(row.input),
|
||||
cacheRead: toNumber(row.cacheRead),
|
||||
output: toNumber(row.output),
|
||||
})
|
||||
tokenMap.set(row.workspaceID, workspace)
|
||||
}
|
||||
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: SubscriptionTable.workspaceID,
|
||||
subscribed: SubscriptionTable.timeCreated,
|
||||
subscription: BillingTable.subscription,
|
||||
})
|
||||
.from(SubscriptionTable)
|
||||
.innerJoin(BillingTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID))
|
||||
.where(
|
||||
and(inArray(SubscriptionTable.workspaceID, workspaceIDs), sql`${SubscriptionTable.timeCreated} <= ${cutoff}`),
|
||||
),
|
||||
)
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const user of users) {
|
||||
const current = counts.get(user.workspaceID) ?? 0
|
||||
counts.set(user.workspaceID, current + 1)
|
||||
}
|
||||
|
||||
const rows = users
|
||||
.map((user) => {
|
||||
const workspace = spendMap.get(user.workspaceID) ?? new Map<number, number>()
|
||||
const ppuWorkspace = ppuMap.get(user.workspaceID) ?? new Map<number, number>()
|
||||
const count = counts.get(user.workspaceID) ?? 1
|
||||
const amount = (totals.get(user.workspaceID) ?? 0) / count
|
||||
const ppuAmount = (ppuTotals.get(user.workspaceID) ?? 0) / count
|
||||
const monthStart = user.subscribed ? startOfMonth(user.subscribed) : null
|
||||
const modelRows = (modelMap.get(user.workspaceID) ?? []).sort((a, b) => b.amount - a.amount).slice(0, 3)
|
||||
const modelTotal = totals.get(user.workspaceID) ?? 0
|
||||
const modelCells = modelRows.map((row) => ({
|
||||
model: row.model,
|
||||
percent: modelTotal > 0 ? `${((row.amount / modelTotal) * 100).toFixed(1)}%` : "0.0%",
|
||||
}))
|
||||
const modelData = [0, 1, 2].map((index) => modelCells[index] ?? { model: "-", percent: "-" })
|
||||
const weekly = Object.fromEntries(
|
||||
weeks.map((item) => {
|
||||
const value = (workspace.get(item) ?? 0) / count
|
||||
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
|
||||
return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
|
||||
}),
|
||||
)
|
||||
const ppuWeekly = Object.fromEntries(
|
||||
weeks.map((item) => {
|
||||
const value = (ppuWorkspace.get(item) ?? 0) / count
|
||||
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
|
||||
return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
|
||||
}),
|
||||
)
|
||||
const tokenWorkspace = tokenMap.get(user.workspaceID) ?? new Map()
|
||||
const weeklyTokens = Object.fromEntries(
|
||||
weeks.map((item) => {
|
||||
const t = tokenWorkspace.get(item) ?? { input: 0, cacheRead: 0, output: 0 }
|
||||
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
|
||||
return [
|
||||
formatWeek(item),
|
||||
beforeMonth
|
||||
? { input: "-", cacheRead: "-", output: "-" }
|
||||
: {
|
||||
input: Math.round(t.input / count),
|
||||
cacheRead: Math.round(t.cacheRead / count),
|
||||
output: Math.round(t.output / count),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
return {
|
||||
workspaceID: user.workspaceID,
|
||||
useBalance: user.subscription?.useBalance ?? false,
|
||||
subscribed: formatDate(user.subscribed),
|
||||
subscribedAt: user.subscribed?.getTime() ?? 0,
|
||||
amount,
|
||||
ppuAmount,
|
||||
models: modelData,
|
||||
weekly,
|
||||
ppuWeekly,
|
||||
weeklyTokens,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.subscribedAt - b.subscribedAt)
|
||||
|
||||
console.log(`Black ${plan} subscribers: ${rows.length}`)
|
||||
const header = [
|
||||
"workspaceID",
|
||||
"subscribed",
|
||||
"useCredit",
|
||||
"subTotal",
|
||||
"ppuTotal",
|
||||
"model1",
|
||||
"model1%",
|
||||
"model2",
|
||||
"model2%",
|
||||
"model3",
|
||||
"model3%",
|
||||
...weeks.flatMap((item) => [
|
||||
formatWeek(item) + " sub",
|
||||
formatWeek(item) + " ppu",
|
||||
formatWeek(item) + " input",
|
||||
formatWeek(item) + " cache",
|
||||
formatWeek(item) + " output",
|
||||
]),
|
||||
]
|
||||
const lines = [header.map(csvCell).join(",")]
|
||||
for (const row of rows) {
|
||||
const model1 = row.models[0]
|
||||
const model2 = row.models[1]
|
||||
const model3 = row.models[2]
|
||||
const cells = [
|
||||
row.workspaceID,
|
||||
row.subscribed ?? "",
|
||||
row.useBalance ? "yes" : "no",
|
||||
formatMicroCents(row.amount),
|
||||
formatMicroCents(row.ppuAmount),
|
||||
model1.model,
|
||||
model1.percent,
|
||||
model2.model,
|
||||
model2.percent,
|
||||
model3.model,
|
||||
model3.percent,
|
||||
...weeks.flatMap((item) => {
|
||||
const t = row.weeklyTokens[formatWeek(item)] ?? { input: "-", cacheRead: "-", output: "-" }
|
||||
return [
|
||||
row.weekly[formatWeek(item)] ?? "",
|
||||
row.ppuWeekly[formatWeek(item)] ?? "",
|
||||
String(t.input),
|
||||
String(t.cacheRead),
|
||||
String(t.output),
|
||||
]
|
||||
}),
|
||||
]
|
||||
lines.push(cells.map(csvCell).join(","))
|
||||
}
|
||||
const output = `${lines.join("\n")}\n`
|
||||
const file = Bun.file(`black-stats-${plan}.csv`)
|
||||
await file.write(output)
|
||||
console.log(`Wrote ${lines.length - 1} rows to ${file.name}`)
|
||||
const total = rows.reduce((sum, row) => sum + row.amount, 0)
|
||||
const average = rows.length === 0 ? 0 : total / rows.length
|
||||
console.log(`Average spending per user: ${formatMicroCents(average)}`)
|
||||
|
||||
function formatMicroCents(value: number) {
|
||||
return `$${(value / 100000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null | undefined) {
|
||||
if (!value) return null
|
||||
return value.toISOString().split("T")[0]
|
||||
}
|
||||
|
||||
function formatWeek(value: number) {
|
||||
return formatDate(isoWeekStart(value)) ?? ""
|
||||
}
|
||||
|
||||
function startOfMonth(value: Date) {
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
|
||||
}
|
||||
|
||||
function isoWeekStart(value: number) {
|
||||
const year = Math.floor(value / 100)
|
||||
const weekNumber = value % 100
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4))
|
||||
const day = jan4.getUTCDay() || 7
|
||||
const weekStart = new Date(Date.UTC(year, 0, 4 - (day - 1)))
|
||||
weekStart.setUTCDate(weekStart.getUTCDate() + (weekNumber - 1) * 7)
|
||||
return weekStart
|
||||
}
|
||||
|
||||
function toNumber(value: unknown) {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "bigint") return Number(value)
|
||||
if (typeof value === "string") return Number(value)
|
||||
return 0
|
||||
}
|
||||
|
||||
function csvCell(value: string | number) {
|
||||
const text = String(value)
|
||||
if (!/[",\n]/.test(text)) return text
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
163
qimingcode/packages/console/core/script/black-transfer.ts
Normal file
163
qimingcode/packages/console/core/script/black-transfer.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, desc, eq, isNotNull, lt, sql } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const fromWrkID = process.argv[2]
|
||||
const toWrkID = process.argv[3]
|
||||
|
||||
if (!fromWrkID || !toWrkID) {
|
||||
console.error("Usage: bun foo.ts <fromWrkID> <toWrkID>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Transferring subscription from ${fromWrkID} to ${toWrkID}`)
|
||||
|
||||
// Look up the FROM workspace billing
|
||||
const fromBilling = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscription: BillingTable.subscription,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
paymentMethodType: BillingTable.paymentMethodType,
|
||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, fromWrkID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!fromBilling) throw new Error(`Error: FROM workspace has no billing record`)
|
||||
if (!fromBilling.customerID) throw new Error(`Error: FROM workspace has no Stripe customer ID`)
|
||||
if (!fromBilling.subscriptionID) throw new Error(`Error: FROM workspace has no subscription`)
|
||||
|
||||
const fromSubscription = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ userID: SubscriptionTable.userID })
|
||||
.from(SubscriptionTable)
|
||||
.where(eq(SubscriptionTable.workspaceID, fromWrkID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!fromSubscription) throw new Error(`Error: FROM workspace has no subscription`)
|
||||
|
||||
// Look up the previous customer ID in FROM workspace
|
||||
const subscriptionPayment = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: PaymentTable.customerID,
|
||||
timeCreated: PaymentTable.timeCreated,
|
||||
})
|
||||
.from(PaymentTable)
|
||||
.where(and(eq(PaymentTable.workspaceID, fromWrkID), sql`JSON_EXTRACT(enrichment, '$.type') = 'subscription'`))
|
||||
.then((rows) => {
|
||||
if (rows.length > 1) {
|
||||
console.error(`Error: Multiple subscription payments found for workspace ${fromWrkID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return rows[0]
|
||||
}),
|
||||
)
|
||||
const fromPrevPayment = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ customerID: PaymentTable.customerID })
|
||||
.from(PaymentTable)
|
||||
.where(
|
||||
and(
|
||||
eq(PaymentTable.workspaceID, fromWrkID),
|
||||
isNotNull(PaymentTable.customerID),
|
||||
lt(PaymentTable.timeCreated, subscriptionPayment.timeCreated),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(PaymentTable.timeCreated))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!fromPrevPayment?.customerID) throw new Error(`Error: FROM workspace has no previous Stripe customer to revert to`)
|
||||
if (fromPrevPayment.customerID === fromBilling.customerID)
|
||||
throw new Error(`Error: FROM workspace has the same Stripe customer ID as the current one`)
|
||||
|
||||
const fromPrevPaymentMethods = await Billing.stripe().customers.listPaymentMethods(fromPrevPayment.customerID, {})
|
||||
if (fromPrevPaymentMethods.data.length === 0)
|
||||
throw new Error(`Error: FROM workspace has no previous Stripe payment methods`)
|
||||
|
||||
// Look up the TO workspace billing
|
||||
const toBilling = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, toWrkID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!toBilling) throw new Error(`Error: TO workspace has no billing record`)
|
||||
if (toBilling.subscriptionID) throw new Error(`Error: TO workspace already has a subscription`)
|
||||
|
||||
console.log(`FROM:`)
|
||||
console.log(` Old Customer ID: ${fromBilling.customerID}`)
|
||||
console.log(` New Customer ID: ${fromPrevPayment.customerID}`)
|
||||
console.log(`TO:`)
|
||||
console.log(` Old Customer ID: ${toBilling.customerID}`)
|
||||
console.log(` New Customer ID: ${fromBilling.customerID}`)
|
||||
|
||||
// Clear workspaceID from Stripe customer metadata
|
||||
await Billing.stripe().customers.update(fromPrevPayment.customerID, {
|
||||
metadata: {
|
||||
workspaceID: fromWrkID,
|
||||
},
|
||||
})
|
||||
await Billing.stripe().customers.update(fromBilling.customerID, {
|
||||
metadata: {
|
||||
workspaceID: toWrkID,
|
||||
},
|
||||
})
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID: fromPrevPayment.customerID,
|
||||
subscriptionID: null,
|
||||
subscription: null,
|
||||
paymentMethodID: fromPrevPaymentMethods.data[0].id,
|
||||
paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null,
|
||||
paymentMethodType: fromPrevPaymentMethods.data[0].type,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, fromWrkID))
|
||||
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID: fromBilling.customerID,
|
||||
subscriptionID: fromBilling.subscriptionID,
|
||||
subscription: fromBilling.subscription,
|
||||
paymentMethodID: fromBilling.paymentMethodID,
|
||||
paymentMethodLast4: fromBilling.paymentMethodLast4,
|
||||
paymentMethodType: fromBilling.paymentMethodType,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, toWrkID))
|
||||
|
||||
await tx
|
||||
.update(SubscriptionTable)
|
||||
.set({
|
||||
workspaceID: toWrkID,
|
||||
userID: fromSubscription.userID,
|
||||
})
|
||||
.where(eq(SubscriptionTable.workspaceID, fromWrkID))
|
||||
|
||||
await tx
|
||||
.update(PaymentTable)
|
||||
.set({
|
||||
workspaceID: toWrkID,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(PaymentTable.workspaceID, fromWrkID),
|
||||
sql`JSON_EXTRACT(enrichment, '$.type') = 'subscription'`,
|
||||
eq(PaymentTable.amount, 20000000000),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
console.log(`done`)
|
||||
24
qimingcode/packages/console/core/script/create-coupon.ts
Normal file
24
qimingcode/packages/console/core/script/create-coupon.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Database } from "../src/drizzle/index.js"
|
||||
import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
|
||||
|
||||
const email = process.argv[2]
|
||||
const type = process.argv[3] as (typeof CouponType)[number]
|
||||
|
||||
if (!email || !type) {
|
||||
console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!(CouponType as readonly string[]).includes(type)) {
|
||||
console.error(`Error: type must be one of ${CouponType.join(", ")}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx.insert(CouponTable).values({
|
||||
email,
|
||||
type,
|
||||
}),
|
||||
)
|
||||
|
||||
console.log(`Created ${type} coupon for ${email}`)
|
||||
35
qimingcode/packages/console/core/script/credit-workspace.ts
Normal file
35
qimingcode/packages/console/core/script/credit-workspace.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
|
||||
// get input from command line
|
||||
const workspaceID = process.argv[2]
|
||||
const dollarAmount = process.argv[3]
|
||||
|
||||
if (!workspaceID || !dollarAmount) {
|
||||
console.error("Usage: bun credit-workspace.ts <workspaceID> <dollarAmount>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// check workspace exists
|
||||
const workspace = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!workspace) {
|
||||
console.error("Error: Workspace not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const amountInDollars = parseFloat(dollarAmount)
|
||||
if (isNaN(amountInDollars) || amountInDollars <= 0) {
|
||||
console.error("Error: dollarAmount must be a positive number")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Billing.grantCredit(workspaceID, amountInDollars)
|
||||
|
||||
console.log(`Added payment of $${amountInDollars.toFixed(2)} to workspace ${workspaceID}`)
|
||||
34
qimingcode/packages/console/core/script/disable-reload.ts
Normal file
34
qimingcode/packages/console/core/script/disable-reload.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
if (!workspaceID) {
|
||||
console.error("Usage: bun disable-reload.ts <workspaceID>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ reload: BillingTable.reload })
|
||||
.from(BillingTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!billing) {
|
||||
console.error("Error: Workspace or billing record not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!billing.reload) {
|
||||
console.log(`Reload is already disabled for workspace ${workspaceID}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx.update(BillingTable).set({ reload: false }).where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
|
||||
console.log(`Disabled reload for workspace ${workspaceID}`)
|
||||
39
qimingcode/packages/console/core/script/freeze-workspace.ts
Normal file
39
qimingcode/packages/console/core/script/freeze-workspace.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { microCentsToCents } from "../src/util/price.js"
|
||||
|
||||
// get input from command line
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
if (!workspaceID) {
|
||||
console.error("Usage: bun freeze-workspace.ts <workspaceID>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// check workspace exists
|
||||
const workspace = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!workspace) {
|
||||
console.error("Error: Workspace not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
const amountInDollars = microCentsToCents(billing.balance) / 100
|
||||
await Billing.grantCredit(workspaceID, 0 - amountInDollars)
|
||||
|
||||
console.log(`Removed payment of $${amountInDollars.toFixed(2)} from workspace ${workspaceID}`)
|
||||
386
qimingcode/packages/console/core/script/lookup-user.ts
Normal file
386
qimingcode/packages/console/core/script/lookup-user.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import {
|
||||
BillingTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
BlackPlans,
|
||||
UsageTable,
|
||||
LiteTable,
|
||||
} from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { getWeekBounds } from "../src/util/date.js"
|
||||
import { ModelTable } from "../src/schema/model.sql.js"
|
||||
|
||||
// get input from command line
|
||||
const identifier = process.argv[2]
|
||||
const verbose = process.argv[process.argv.length - 1] === "-v"
|
||||
if (!identifier) {
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey> [-v]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// loop up by workspace ID
|
||||
if (identifier.startsWith("wrk_")) {
|
||||
await printWorkspace(identifier)
|
||||
}
|
||||
// lookup by API key ID
|
||||
else if (identifier.startsWith("key_")) {
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.id, identifier))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!key) {
|
||||
console.error("API key not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await printWorkspace(key.workspaceID)
|
||||
}
|
||||
// lookup by API key value
|
||||
else if (identifier.startsWith("sk-")) {
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.key, identifier))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!key) {
|
||||
console.error("API key not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await printWorkspace(key.workspaceID)
|
||||
}
|
||||
// lookup by email
|
||||
else {
|
||||
const authData = await Database.use(async (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
|
||||
)
|
||||
if (authData.length === 0) {
|
||||
console.error("Email not found")
|
||||
process.exit(1)
|
||||
}
|
||||
if (authData.length > 1) console.warn("Multiple users found for email", identifier)
|
||||
|
||||
// Get all auth records for email
|
||||
const accountID = authData[0].accountID
|
||||
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
|
||||
|
||||
// Get all workspaces for this account
|
||||
const users = await printTable("Workspaces", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
userID: UserTable.id,
|
||||
workspaceID: UserTable.workspaceID,
|
||||
workspaceName: WorkspaceTable.name,
|
||||
role: UserTable.role,
|
||||
black: SubscriptionTable.timeCreated,
|
||||
lite: LiteTable.timeCreated,
|
||||
})
|
||||
.from(UserTable)
|
||||
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
|
||||
.where(eq(UserTable.accountID, accountID))
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
userID: row.userID,
|
||||
workspaceID: row.workspaceID,
|
||||
workspaceName: row.workspaceName,
|
||||
role: row.role,
|
||||
black: formatDate(row.black),
|
||||
lite: formatDate(row.lite),
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
for (const user of users) {
|
||||
await printWorkspace(user.workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
async function printWorkspace(workspaceID: string) {
|
||||
const workspace = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
||||
|
||||
await printTable("Users", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
authEmail: AuthTable.subject,
|
||||
inviteEmail: UserTable.email,
|
||||
role: UserTable.role,
|
||||
timeSeen: UserTable.timeSeen,
|
||||
monthlyLimit: UserTable.monthlyLimit,
|
||||
monthlyUsage: UserTable.monthlyUsage,
|
||||
timeDeleted: UserTable.timeDeleted,
|
||||
fixedUsage: SubscriptionTable.fixedUsage,
|
||||
rollingUsage: SubscriptionTable.rollingUsage,
|
||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||
timeSubscriptionCreated: SubscriptionTable.timeCreated,
|
||||
subscription: BillingTable.subscription,
|
||||
})
|
||||
.from(UserTable)
|
||||
.innerJoin(BillingTable, eq(BillingTable.workspaceID, workspace.id))
|
||||
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||
.where(eq(UserTable.workspaceID, workspace.id))
|
||||
.then((rows) =>
|
||||
rows.map((row) => {
|
||||
const subStatus = getSubscriptionStatus(row)
|
||||
return {
|
||||
email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
|
||||
role: row.role,
|
||||
timeSeen: formatDate(row.timeSeen),
|
||||
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
|
||||
subscribed: formatDate(row.timeSubscriptionCreated),
|
||||
subWeekly: subStatus.weekly,
|
||||
subRolling: subStatus.rolling,
|
||||
rateLimited: subStatus.rateLimited,
|
||||
retryIn: subStatus.retryIn,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await printTable("Billing", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
balance: BillingTable.balance,
|
||||
customerID: BillingTable.customerID,
|
||||
reload: BillingTable.reload,
|
||||
blackSubscriptionID: BillingTable.subscriptionID,
|
||||
blackSubscription: {
|
||||
plan: BillingTable.subscriptionPlan,
|
||||
booked: BillingTable.timeSubscriptionBooked,
|
||||
enrichment: BillingTable.subscription,
|
||||
},
|
||||
timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
liteSubscriptionID: BillingTable.liteSubscriptionID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspace.id))
|
||||
.then(
|
||||
(rows) =>
|
||||
rows.map((row) => ({
|
||||
balance: `$${(row.balance / 100000000).toFixed(2)}`,
|
||||
reload: row.reload ? "yes" : "no",
|
||||
customerID: row.customerID,
|
||||
GO: row.liteSubscriptionID,
|
||||
Black: row.blackSubscriptionID
|
||||
? [
|
||||
`Black ${row.blackSubscription.enrichment!.plan}`,
|
||||
row.blackSubscription.enrichment!.seats > 1
|
||||
? `X ${row.blackSubscription.enrichment!.seats} seats`
|
||||
: "",
|
||||
row.blackSubscription.enrichment!.coupon
|
||||
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
|
||||
: "",
|
||||
`(ref: ${row.blackSubscriptionID})`,
|
||||
].join(" ")
|
||||
: row.blackSubscription.booked
|
||||
? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
|
||||
: undefined,
|
||||
}))[0],
|
||||
),
|
||||
)
|
||||
|
||||
await printTable("Payments", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
amount: PaymentTable.amount,
|
||||
paymentID: PaymentTable.paymentID,
|
||||
invoiceID: PaymentTable.invoiceID,
|
||||
customerID: PaymentTable.customerID,
|
||||
timeCreated: PaymentTable.timeCreated,
|
||||
timeRefunded: PaymentTable.timeRefunded,
|
||||
})
|
||||
.from(PaymentTable)
|
||||
.where(eq(PaymentTable.workspaceID, workspace.id))
|
||||
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
|
||||
.limit(100)
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
amount: `$${(row.amount / 100000000).toFixed(2)}`,
|
||||
paymentID: row.paymentID
|
||||
? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
|
||||
: null,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
if (verbose) {
|
||||
await printTable("28-Day Usage", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
|
||||
requests: sql<number>`COUNT(*)`.as("requests"),
|
||||
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
|
||||
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
|
||||
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
|
||||
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
|
||||
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
|
||||
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
|
||||
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UsageTable.workspaceID, workspace.id),
|
||||
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
|
||||
),
|
||||
)
|
||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
|
||||
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
|
||||
.then((rows) => {
|
||||
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
|
||||
const mapped = rows.map((row) => ({
|
||||
...row,
|
||||
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
|
||||
}))
|
||||
if (mapped.length > 0) {
|
||||
mapped.push({
|
||||
date: "TOTAL",
|
||||
requests: null as any,
|
||||
inputTokens: null as any,
|
||||
outputTokens: null as any,
|
||||
reasoningTokens: null as any,
|
||||
cacheReadTokens: null as any,
|
||||
cacheWrite5mTokens: null as any,
|
||||
cacheWrite1hTokens: null as any,
|
||||
cost: `$${(totalCost / 100000000).toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
return mapped
|
||||
}),
|
||||
)
|
||||
await printTable("Disabled Models", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: ModelTable.model,
|
||||
timeCreated: ModelTable.timeCreated,
|
||||
})
|
||||
.from(ModelTable)
|
||||
.where(eq(ModelTable.workspaceID, workspace.id))
|
||||
.orderBy(sql`${ModelTable.timeCreated} DESC`)
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
model: row.model,
|
||||
timeCreated: formatDate(row.timeCreated),
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function formatMicroCents(value: number | null | undefined) {
|
||||
if (value === null || value === undefined) return null
|
||||
return `$${(value / 100000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null | undefined) {
|
||||
if (!value) return null
|
||||
return value.toISOString().split("T")[0]
|
||||
}
|
||||
|
||||
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
|
||||
const usageText = formatMicroCents(usage) ?? "$0.00"
|
||||
if (limit === null || limit === undefined) return `${usageText} / no limit`
|
||||
return `${usageText} / $${limit.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatRetryTime(seconds: number) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
|
||||
function getSubscriptionStatus(row: {
|
||||
subscription: {
|
||||
plan: (typeof BlackPlans)[number]
|
||||
} | null
|
||||
timeSubscriptionCreated: Date | null
|
||||
fixedUsage: number | null
|
||||
rollingUsage: number | null
|
||||
timeFixedUpdated: Date | null
|
||||
timeRollingUpdated: Date | null
|
||||
}) {
|
||||
if (!row.timeSubscriptionCreated || !row.subscription) {
|
||||
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
||||
}
|
||||
|
||||
const black = BlackData.getLimits({ plan: row.subscription.plan })
|
||||
const now = new Date()
|
||||
const week = getWeekBounds(now)
|
||||
|
||||
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
|
||||
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
|
||||
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
|
||||
|
||||
// Calculate current weekly usage (reset if outside current week)
|
||||
const currentWeekly =
|
||||
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
|
||||
|
||||
// Calculate current rolling usage
|
||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||
const currentRolling =
|
||||
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
|
||||
|
||||
// Check rate limiting
|
||||
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
|
||||
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
|
||||
|
||||
let retryIn: string | null = null
|
||||
if (isWeeklyLimited) {
|
||||
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||
retryIn = formatRetryTime(retryAfter)
|
||||
} else if (isRollingLimited && row.timeRollingUpdated) {
|
||||
const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||
retryIn = formatRetryTime(retryAfter)
|
||||
}
|
||||
|
||||
return {
|
||||
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
|
||||
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
|
||||
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
|
||||
retryIn,
|
||||
}
|
||||
}
|
||||
|
||||
function printHeader(title: string) {
|
||||
console.log()
|
||||
console.log("─".repeat(title.length))
|
||||
console.log(`${title}`)
|
||||
console.log("─".repeat(title.length))
|
||||
}
|
||||
|
||||
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any>): Promise<any> {
|
||||
return Database.use(async (tx) => {
|
||||
const data = await callback(tx)
|
||||
console.log(`\n== ${title} ==`)
|
||||
if (data.length === 0) {
|
||||
console.log("(no data)")
|
||||
} else {
|
||||
console.table(data)
|
||||
}
|
||||
return data
|
||||
})
|
||||
}
|
||||
22
qimingcode/packages/console/core/script/promote-limits.ts
Normal file
22
qimingcode/packages/console/core/script/promote-limits.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { Subscription } from "../src/subscription"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||
|
||||
// validate value
|
||||
Subscription.validate(JSON.parse(value))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_LIMITS ${value} --stage ${stage}`
|
||||
33
qimingcode/packages/console/core/script/promote-models.ts
Normal file
33
qimingcode/packages/console/core/script/promote-models.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenData } from "../src/model"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 30
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
|
||||
?.split("=")
|
||||
.slice(1)
|
||||
.join("=")
|
||||
if (!value) throw new Error(`ZEN_MODELS${i + 1} not found`)
|
||||
return value
|
||||
})
|
||||
|
||||
// validate value
|
||||
ZenData.validate(JSON.parse(values.join("")))
|
||||
|
||||
// update the secret
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name} --stage ${stage}`.cwd(root)
|
||||
33
qimingcode/packages/console/core/script/pull-models.ts
Normal file
33
qimingcode/packages/console/core/script/pull-models.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenData } from "../src/model"
|
||||
|
||||
const stage = process.argv[2]
|
||||
if (!stage) throw new Error("Stage is required")
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const PARTS = 20
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
|
||||
?.split("=")
|
||||
.slice(1)
|
||||
.join("=")
|
||||
if (!value) throw new Error(`ZEN_MODELS${i + 1} not found`)
|
||||
return value
|
||||
})
|
||||
|
||||
// validate value
|
||||
ZenData.validate(JSON.parse(values.join("")))
|
||||
|
||||
// update the secret
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(values.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name}`.cwd(root)
|
||||
13
qimingcode/packages/console/core/script/reset-db.ts
Normal file
13
qimingcode/packages/console/core/script/reset-db.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Database } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AccountTable } from "../src/schema/account.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
|
||||
if (Resource.App.stage !== "frank") throw new Error("This script is only for frank")
|
||||
|
||||
for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) {
|
||||
await Database.use((tx) => tx.delete(table))
|
||||
}
|
||||
28
qimingcode/packages/console/core/script/update-limits.ts
Normal file
28
qimingcode/packages/console/core/script/update-limits.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Subscription } from "../src/subscription"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
|
||||
// read value
|
||||
const lines = secrets.split("\n")
|
||||
const oldValue = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1] ?? "{}"
|
||||
if (!oldValue) throw new Error("ZEN_LIMITS not found")
|
||||
|
||||
// store the prettified json to a temp file
|
||||
const filename = `limits-${Date.now()}.json`
|
||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
||||
console.log("tempFile", tempFile.name)
|
||||
|
||||
// open temp file in vim and read the file on close
|
||||
await $`vim ${tempFile.name}`
|
||||
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||
Subscription.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
|
||||
43
qimingcode/packages/console/core/script/update-models.ts
Normal file
43
qimingcode/packages/console/core/script/update-models.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { ZenData } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const PARTS = 30
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
const lines = models.split("\n")
|
||||
const oldValues = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
.find((line) => line.startsWith(`ZEN_MODELS${i + 1}=`))
|
||||
?.split("=")
|
||||
.slice(1)
|
||||
.join("=")
|
||||
if (!value) throw new Error(`ZEN_MODELS${i + 1} not found`)
|
||||
return value
|
||||
})
|
||||
|
||||
// store the prettified json to a temp file
|
||||
const filename = `models-${Date.now()}.json`
|
||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||
await tempFile.write(JSON.stringify(JSON.parse(oldValues.join("")), null, 2))
|
||||
console.log("tempFile", tempFile.name)
|
||||
|
||||
// open temp file in vim and read the file on close
|
||||
await $`vim ${tempFile.name}`
|
||||
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||
ZenData.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
const chunk = Math.ceil(newValue.length / PARTS)
|
||||
const newValues = Array.from({ length: PARTS }, (_, i) =>
|
||||
newValue.slice(chunk * i, i === PARTS - 1 ? undefined : chunk * (i + 1)),
|
||||
)
|
||||
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)
|
||||
Reference in New Issue
Block a user