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

302 lines
9.2 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"
2025-08-29 04:44:55 +08:00
import { BillingTable, PaymentTable, 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"
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
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
2025-10-17 02:59:44 +08:00
paymentMethodType: BillingTable.paymentMethodType,
2025-09-16 02:48:00 +08:00
paymentMethodLast4: BillingTable.paymentMethodLast4,
2025-08-09 01:22:54 +08:00
balance: BillingTable.balance,
reload: BillingTable.reload,
2025-11-05 05:51:38 +08:00
reloadAmount: BillingTable.reloadAmount,
reloadTrigger: BillingTable.reloadTrigger,
2025-09-16 02:48:00 +08:00
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadError: BillingTable.reloadError,
timeReloadError: BillingTable.timeReloadError,
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,
})
})
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",
},
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
},
)
2025-08-09 01:22:54 +08:00
}