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

275 lines
7.9 KiB
TypeScript
Raw Normal View History

2025-08-09 01:22:54 +08:00
import { Stripe } from "stripe"
2025-10-03 19:36:12 +08:00
import { and, 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-09-25 07:09:23 +08:00
export const CHARGE_NAME = "opencode credits"
export const CHARGE_FEE_NAME = "processing fee"
2025-09-16 02:48:00 +08:00
export const CHARGE_AMOUNT = 2000 // $20
export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
export const CHARGE_THRESHOLD = 500 // $5
2025-08-09 01:22:54 +08:00
export const stripe = () =>
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
apiVersion: "2025-03-31.basil",
})
export const get = async () => {
return Database.use(async (tx) =>
tx
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
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-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),
)
}
export const usages = async () => {
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(100),
)
}
2025-09-16 02:48:00 +08:00
export const reload = async () => {
const { customerID, paymentMethodID } = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((rows) => rows[0]),
)
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({
amount: Billing.CHARGE_AMOUNT,
currency: "usd",
customer: customerID!,
description: CHARGE_NAME,
invoice: draft.id!,
})
await Billing.stripe().invoiceItems.create({
amount: Billing.CHARGE_FEE,
currency: "usd",
customer: customerID!,
description: CHARGE_FEE_NAME,
invoice: draft.id!,
})
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({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(CHARGE_AMOUNT)}`,
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: paymentID,
amount: centsToMicroCents(CHARGE_AMOUNT),
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,
})
})
}
export const disableReload = async () => {
return await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: false,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
}
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(),
}),
async (input) => {
2025-10-03 19:36:12 +08:00
const user = Actor.assert("user")
2025-08-29 04:44:55 +08:00
const { successUrl, cancelUrl } = input
2025-10-03 19:45:35 +08:00
const email = await User.getAccountEmail(user.properties.userID)
2025-08-29 04:44:55 +08:00
const customer = await Billing.get()
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",
product_data: {
2025-09-25 07:09:23 +08:00
name: CHARGE_NAME,
2025-08-29 04:44:55 +08:00
},
2025-09-16 02:48:00 +08:00
unit_amount: CHARGE_AMOUNT,
},
quantity: 1,
},
{
price_data: {
currency: "usd",
product_data: {
2025-09-25 07:09:23 +08:00
name: CHARGE_FEE_NAME,
2025-09-16 02:48:00 +08:00
},
unit_amount: CHARGE_FEE,
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-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
}