opencode/packages/console/core/src/billing.ts

355 lines
11 KiB
TypeScript
Raw Normal View History

2025-08-09 01:22:54 +08:00
import { Stripe } from "stripe"
2025-10-07 21:17:05 +08:00
import { Database, eq, sql } from "./drizzle"
2026-01-23 05:59:32 +08:00
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
2025-08-09 01:22:54 +08:00
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
2025-10-05 09:33:39 +08:00
import { Resource } from "@opencode-ai/console-resource"
2025-09-16 02:48:00 +08:00
import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
2025-10-03 19:45:35 +08:00
import { User } from "./user"
2026-01-23 05:59:32 +08:00
import { BlackData } from "./black"
2025-08-09 01:22:54 +08:00
export namespace Billing {
2025-11-05 05:51:38 +08:00
export const ITEM_CREDIT_NAME = "opencode credits"
export const ITEM_FEE_NAME = "processing fee"
export const RELOAD_AMOUNT = 20
export const RELOAD_AMOUNT_MIN = 10
export const RELOAD_TRIGGER = 5
export const RELOAD_TRIGGER_MIN = 5
2025-08-09 01:22:54 +08:00
export const stripe = () =>
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
apiVersion: "2025-03-31.basil",
2025-10-11 22:51:17 +08:00
httpClient: Stripe.createFetchHttpClient(),
2025-08-09 01:22:54 +08:00
})
export const get = async () => {
return Database.use(async (tx) =>
tx
2026-01-15 10:20:23 +08:00
.select()
2025-08-09 01:22:54 +08:00
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((r) => r[0]),
)
}
2025-08-29 04:44:55 +08:00
export const payments = async () => {
return await Database.use((tx) =>
tx
.select()
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100),
)
}
2025-11-16 16:29:49 +08:00
export const usages = async (page = 0, pageSize = 50) => {
2025-08-29 04:44:55 +08:00
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
2025-11-16 16:29:49 +08:00
.limit(pageSize)
.offset(page * pageSize),
2025-08-29 04:44:55 +08:00
)
}
2025-11-05 05:51:38 +08:00
export const calculateFeeInCents = (x: number) => {
// math: x = total - (total * 0.044 + 0.30)
// math: x = total * (1-0.044) - 0.30
// math: (x + 0.30) / 0.956 = total
return Math.round(((x + 30) / 0.956) * 0.044 + 30)
}
2025-09-16 02:48:00 +08:00
export const reload = async () => {
2025-11-05 05:51:38 +08:00
const billing = await Database.use((tx) =>
2025-09-16 02:48:00 +08:00
tx
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
2025-11-05 05:51:38 +08:00
reloadAmount: BillingTable.reloadAmount,
2025-09-16 02:48:00 +08:00
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((rows) => rows[0]),
)
2025-11-05 05:51:38 +08:00
const customerID = billing.customerID
const paymentMethodID = billing.paymentMethodID
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
2025-09-16 02:48:00 +08:00
const paymentID = Identifier.create("payment")
2025-09-25 07:09:23 +08:00
let invoice
2025-09-16 02:48:00 +08:00
try {
2025-09-25 07:09:23 +08:00
const draft = await Billing.stripe().invoices.create({
customer: customerID!,
auto_advance: false,
default_payment_method: paymentMethodID!,
collection_method: "charge_automatically",
currency: "usd",
})
await Billing.stripe().invoiceItems.create({
2025-11-05 05:51:38 +08:00
amount: amountInCents,
2025-09-25 07:09:23 +08:00
currency: "usd",
customer: customerID!,
invoice: draft.id!,
2025-11-05 05:51:38 +08:00
description: ITEM_CREDIT_NAME,
2025-09-25 07:09:23 +08:00
})
await Billing.stripe().invoiceItems.create({
2025-11-05 05:51:38 +08:00
amount: calculateFeeInCents(amountInCents),
2025-09-25 07:09:23 +08:00
currency: "usd",
customer: customerID!,
invoice: draft.id!,
2025-11-05 05:51:38 +08:00
description: ITEM_FEE_NAME,
2025-09-25 07:09:23 +08:00
})
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
invoice = await Billing.stripe().invoices.pay(draft.id!, {
off_session: true,
payment_method: paymentMethodID!,
expand: ["payments"],
})
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
throw new Error(invoice.last_finalization_error?.message)
2025-09-16 02:48:00 +08:00
} catch (e: any) {
2025-09-25 07:09:23 +08:00
console.error(e)
2025-09-16 02:48:00 +08:00
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reloadError: e.message ?? "Payment failed.",
timeReloadError: sql`now()`,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
return
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
2025-11-05 05:51:38 +08:00
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
2025-09-16 02:48:00 +08:00
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: paymentID,
2025-11-05 05:51:38 +08:00
amount: centsToMicroCents(amountInCents),
2025-09-25 07:09:23 +08:00
invoiceID: invoice.id!,
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
2025-09-16 02:48:00 +08:00
customerID,
})
})
}
2026-01-06 07:09:37 +08:00
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
const amountInMicroCents = centsToMicroCents(dollarAmount * 100)
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${amountInMicroCents}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: amountInMicroCents,
2026-01-09 12:44:11 +08:00
enrichment: {
type: "credit",
},
2026-01-06 07:09:37 +08:00
})
})
return amountInMicroCents
}
2025-09-16 02:48:00 +08:00
export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) =>
tx
.update(BillingTable)
.set({
monthlyLimit: input,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
})
2025-08-29 04:44:55 +08:00
export const generateCheckoutUrl = fn(
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
2025-11-05 05:51:38 +08:00
amount: z.number().optional(),
2025-08-29 04:44:55 +08:00
}),
async (input) => {
2025-10-03 19:36:12 +08:00
const user = Actor.assert("user")
2025-11-05 05:51:38 +08:00
const { successUrl, cancelUrl, amount } = input
if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) {
throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`)
}
2025-08-29 04:44:55 +08:00
2025-10-17 10:27:28 +08:00
const email = await User.getAuthEmail(user.properties.userID)
2025-08-29 04:44:55 +08:00
const customer = await Billing.get()
2025-11-05 05:51:38 +08:00
const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
2025-08-29 04:44:55 +08:00
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
2025-09-25 07:09:23 +08:00
billing_address_collection: "required",
2025-08-29 04:44:55 +08:00
line_items: [
{
price_data: {
currency: "usd",
2025-11-05 05:51:38 +08:00
product_data: { name: ITEM_CREDIT_NAME },
unit_amount: amountInCents,
2025-09-16 02:48:00 +08:00
},
quantity: 1,
},
{
price_data: {
currency: "usd",
2025-11-05 05:51:38 +08:00
product_data: { name: ITEM_FEE_NAME },
unit_amount: calculateFeeInCents(amountInCents),
2025-08-29 04:44:55 +08:00
},
quantity: 1,
},
],
...(customer.customerID
2025-09-16 02:48:00 +08:00
? {
customer: customer.customerID,
2025-09-27 22:19:58 +08:00
customer_update: {
name: "auto",
2026-01-19 08:18:58 +08:00
address: "auto",
2025-09-27 22:19:58 +08:00
},
2025-09-16 02:48:00 +08:00
}
2025-08-29 04:44:55 +08:00
: {
2025-10-03 19:45:35 +08:00
customer_email: email!,
2025-08-29 04:44:55 +08:00
customer_creation: "always",
}),
currency: "usd",
2025-09-25 07:09:23 +08:00
invoice_creation: {
enabled: true,
},
payment_intent_data: {
setup_future_usage: "on_session",
},
2025-08-29 04:44:55 +08:00
payment_method_types: ["card"],
2025-09-16 02:48:00 +08:00
payment_method_data: {
allow_redisplay: "always",
},
2025-09-27 22:19:58 +08:00
tax_id_collection: {
enabled: true,
},
2025-09-27 05:10:42 +08:00
metadata: {
workspaceID: Actor.workspace(),
2025-11-05 05:51:38 +08:00
amount: amountInCents.toString(),
2025-09-27 05:10:42 +08:00
},
2025-08-29 04:44:55 +08:00
success_url: successUrl,
cancel_url: cancelUrl,
})
return session.url
},
)
2025-09-16 02:48:00 +08:00
export const generateSessionUrl = fn(
2025-08-29 04:44:55 +08:00
z.object({
returnUrl: z.string(),
}),
async (input) => {
const { returnUrl } = input
const customer = await Billing.get()
if (!customer?.customerID) {
throw new Error("No stripe customer ID")
}
const session = await Billing.stripe().billingPortal.sessions.create({
customer: customer.customerID,
return_url: returnUrl,
})
return session.url
},
)
2025-09-17 05:49:37 +08:00
export const generateReceiptUrl = fn(
z.object({
paymentID: z.string(),
}),
async (input) => {
const { paymentID } = input
const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
if (!intent.latest_charge) throw new Error("No charge found")
const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
if (!charge.receipt_url) throw new Error("No receipt URL found")
return charge.receipt_url
},
)
2026-01-23 05:59:32 +08:00
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
})
2025-08-09 01:22:54 +08:00
}