wip: zen black
This commit is contained in:
1
packages/console/core/migrations/0055_moaning_karnak.sql
Normal file
1
packages/console/core/migrations/0055_moaning_karnak.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `billing` ADD `time_subscription_selected` timestamp(3);
|
||||
1316
packages/console/core/migrations/meta/0055_snapshot.json
Normal file
1316
packages/console/core/migrations/meta/0055_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -386,6 +386,13 @@
|
||||
"when": 1768603665356,
|
||||
"tag": "0054_numerous_annihilus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 55,
|
||||
"version": "5",
|
||||
"when": 1769108945841,
|
||||
"tag": "0055_moaning_karnak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/bil
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { Actor } from "../src/actor.js"
|
||||
|
||||
const plan = "200"
|
||||
const couponID = "JAIr0Pe1"
|
||||
const workspaceID = process.argv[2]
|
||||
const seats = parseInt(process.argv[3])
|
||||
|
||||
@@ -61,16 +64,18 @@ const customerID =
|
||||
.then((customer) => customer.id))())
|
||||
console.log(`Customer ID: ${customerID}`)
|
||||
|
||||
const couponID = "JAIr0Pe1"
|
||||
const subscription = await Billing.stripe().subscriptions.create({
|
||||
customer: customerID!,
|
||||
items: [
|
||||
{
|
||||
price: `price_1SmfyI2StuRr0lbXovxJNeZn`,
|
||||
price: BlackData.planToPriceID({ plan }),
|
||||
discounts: [{ coupon: couponID }],
|
||||
quantity: seats,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
console.log(`Subscription ID: ${subscription.id}`)
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
const email = process.argv[3]
|
||||
|
||||
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
||||
|
||||
if (!workspaceID || !email) {
|
||||
console.error("Usage: bun foo.ts <workspaceID> <email>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Look up the Stripe customer by email
|
||||
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
|
||||
if (!customers.data) {
|
||||
console.error(`Error: No Stripe customer found for email ${email}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
|
||||
if (!customer) {
|
||||
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const customerID = customer.id
|
||||
const subscription = customer.subscriptions!.data[0]
|
||||
const subscriptionID = subscription.id
|
||||
|
||||
// Validate the subscription is $200
|
||||
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
|
||||
if (amountInCents !== 20000) {
|
||||
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
|
||||
const couponID =
|
||||
typeof subscriptionData.discounts[0] === "string"
|
||||
? subscriptionData.discounts[0]
|
||||
: subscriptionData.discounts[0]?.coupon?.id
|
||||
|
||||
// Check if subscription is already tied to another workspace
|
||||
const existingSubscription = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (existingSubscription) {
|
||||
console.error(
|
||||
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Look up the workspace billing and check if it already has a customer id or subscription
|
||||
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?.subscriptionID) {
|
||||
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (billing?.customerID) {
|
||||
console.warn(
|
||||
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Get the latest invoice and payment from the subscription
|
||||
const invoices = await Billing.stripe().invoices.list({
|
||||
subscription: subscriptionID,
|
||||
limit: 1,
|
||||
expand: ["data.payments"],
|
||||
})
|
||||
const invoice = invoices.data[0]
|
||||
const invoiceID = invoice?.id
|
||||
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
|
||||
|
||||
// Get the default payment method from the customer
|
||||
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
|
||||
| string
|
||||
| null
|
||||
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
|
||||
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
||||
const paymentMethodType = paymentMethod?.type ?? null
|
||||
|
||||
// Look up the user in the workspace
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
if (users.length === 0) {
|
||||
console.error(`Error: No users found in workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
|
||||
if (!user) {
|
||||
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Set workspaceID in Stripe customer metadata
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
// Set customer id, subscription id, and payment method on workspace billing
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
paymentMethodID,
|
||||
paymentMethodLast4,
|
||||
paymentMethodType,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon: couponID,
|
||||
seats: 1,
|
||||
plan: "200",
|
||||
},
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
// Create a row in subscription table
|
||||
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(`Successfully onboarded workspace ${workspaceID}`)
|
||||
console.log(` Customer ID: ${customerID}`)
|
||||
console.log(` Subscription ID: ${subscriptionID}`)
|
||||
console.log(
|
||||
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
|
||||
)
|
||||
console.log(` User ID: ${user.id}`)
|
||||
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
|
||||
console.log(` Payment ID: ${paymentID ?? "(none)"}`)
|
||||
@@ -244,7 +244,7 @@ function getSubscriptionStatus(row: {
|
||||
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
||||
}
|
||||
|
||||
const black = BlackData.get({ plan: row.subscription.plan })
|
||||
const black = BlackData.getLimits({ plan: row.subscription.plan })
|
||||
const now = new Date()
|
||||
const week = getWeekBounds(now)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Stripe } from "stripe"
|
||||
import { Database, eq, sql } from "./drizzle"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { fn } from "./util/fn"
|
||||
import { z } from "zod"
|
||||
@@ -8,6 +8,7 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { User } from "./user"
|
||||
import { BlackData } from "./black"
|
||||
|
||||
export namespace Billing {
|
||||
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||
@@ -288,4 +289,66 @@ export namespace Billing {
|
||||
return charge.receipt_url
|
||||
},
|
||||
)
|
||||
|
||||
export const subscribe = fn(z.object({
|
||||
seats: z.number(),
|
||||
coupon: z.string().optional(),
|
||||
}), async ({ seats, coupon }) => {
|
||||
const user = Actor.assert("user")
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscriptionPlan: BillingTable.subscriptionPlan,
|
||||
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!billing) throw new Error("Billing record not found")
|
||||
if (!billing.timeSubscriptionSelected) throw new Error("Not selected for subscription")
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed")
|
||||
if (!billing.customerID) throw new Error("No customer ID")
|
||||
if (!billing.paymentMethodID) throw new Error("No payment method")
|
||||
if (!billing.subscriptionPlan) throw new Error("No subscription plan")
|
||||
|
||||
const subscription = await Billing.stripe().subscriptions.create({
|
||||
customer: billing.customerID,
|
||||
default_payment_method: billing.paymentMethodID,
|
||||
items: [{ price: BlackData.planToPriceID({ plan: billing.subscriptionPlan }) }],
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
})
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
subscriptionID: subscription.id,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon,
|
||||
seats,
|
||||
plan: billing.subscriptionPlan!,
|
||||
},
|
||||
subscriptionPlan: null,
|
||||
timeSubscriptionBooked: null,
|
||||
timeSubscriptionSelected: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID: Actor.workspace(),
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.properties.userID,
|
||||
})
|
||||
})
|
||||
|
||||
return subscription.id
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,15 +28,28 @@ export namespace BlackData {
|
||||
return input
|
||||
})
|
||||
|
||||
export const get = fn(
|
||||
z.object({
|
||||
export const getLimits = fn(z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
}),
|
||||
({ plan }) => {
|
||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
||||
return Schema.parse(json)[plan]
|
||||
},
|
||||
)
|
||||
}), ({ plan }) => {
|
||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
||||
return Schema.parse(json)[plan]
|
||||
})
|
||||
|
||||
export const planToPriceID = fn(z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
}), ({ plan }) => {
|
||||
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
|
||||
if (plan === "100") return Resource.ZEN_BLACK_PRICE.plan100
|
||||
return Resource.ZEN_BLACK_PRICE.plan20
|
||||
})
|
||||
|
||||
export const priceIDToPlan = fn(z.object({
|
||||
priceID: z.string(),
|
||||
}), ({ priceID }) => {
|
||||
if (priceID === Resource.ZEN_BLACK_PRICE.plan200) return "200"
|
||||
if (priceID === Resource.ZEN_BLACK_PRICE.plan100) return "100"
|
||||
return "20"
|
||||
})
|
||||
}
|
||||
|
||||
export namespace Black {
|
||||
@@ -48,7 +61,7 @@ export namespace Black {
|
||||
}),
|
||||
({ plan, usage, timeUpdated }) => {
|
||||
const now = new Date()
|
||||
const black = BlackData.get({ plan })
|
||||
const black = BlackData.getLimits({ plan })
|
||||
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
||||
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
|
||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||
@@ -83,7 +96,7 @@ export namespace Black {
|
||||
timeUpdated: z.date(),
|
||||
}),
|
||||
({ plan, usage, timeUpdated }) => {
|
||||
const black = BlackData.get({ plan })
|
||||
const black = BlackData.getLimits({ plan })
|
||||
const now = new Date()
|
||||
const week = getWeekBounds(now)
|
||||
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
|
||||
|
||||
@@ -31,6 +31,7 @@ export const BillingTable = mysqlTable(
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
timeSubscriptionSelected: utc("time_subscription_selected"),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
|
||||
Reference in New Issue
Block a user