billing.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { Stripe } from "stripe"
  2. import { Database, eq, sql } from "./drizzle"
  3. import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
  4. import { Actor } from "./actor"
  5. import { fn } from "./util/fn"
  6. import { z } from "zod"
  7. import { Resource } from "@opencode-ai/console-resource"
  8. import { Identifier } from "./identifier"
  9. import { centsToMicroCents } from "./util/price"
  10. import { User } from "./user"
  11. export namespace Billing {
  12. export const ITEM_CREDIT_NAME = "opencode credits"
  13. export const ITEM_FEE_NAME = "processing fee"
  14. export const RELOAD_AMOUNT = 20
  15. export const RELOAD_AMOUNT_MIN = 10
  16. export const RELOAD_TRIGGER = 5
  17. export const RELOAD_TRIGGER_MIN = 5
  18. export const stripe = () =>
  19. new Stripe(Resource.STRIPE_SECRET_KEY.value, {
  20. apiVersion: "2025-03-31.basil",
  21. httpClient: Stripe.createFetchHttpClient(),
  22. })
  23. export const get = async () => {
  24. return Database.use(async (tx) =>
  25. tx
  26. .select({
  27. customerID: BillingTable.customerID,
  28. subscriptionID: BillingTable.subscriptionID,
  29. paymentMethodID: BillingTable.paymentMethodID,
  30. paymentMethodType: BillingTable.paymentMethodType,
  31. paymentMethodLast4: BillingTable.paymentMethodLast4,
  32. balance: BillingTable.balance,
  33. reload: BillingTable.reload,
  34. reloadAmount: BillingTable.reloadAmount,
  35. reloadTrigger: BillingTable.reloadTrigger,
  36. monthlyLimit: BillingTable.monthlyLimit,
  37. monthlyUsage: BillingTable.monthlyUsage,
  38. timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
  39. reloadError: BillingTable.reloadError,
  40. timeReloadError: BillingTable.timeReloadError,
  41. })
  42. .from(BillingTable)
  43. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  44. .then((r) => r[0]),
  45. )
  46. }
  47. export const payments = async () => {
  48. return await Database.use((tx) =>
  49. tx
  50. .select()
  51. .from(PaymentTable)
  52. .where(eq(PaymentTable.workspaceID, Actor.workspace()))
  53. .orderBy(sql`${PaymentTable.timeCreated} DESC`)
  54. .limit(100),
  55. )
  56. }
  57. export const usages = async (page = 0, pageSize = 50) => {
  58. return await Database.use((tx) =>
  59. tx
  60. .select()
  61. .from(UsageTable)
  62. .where(eq(UsageTable.workspaceID, Actor.workspace()))
  63. .orderBy(sql`${UsageTable.timeCreated} DESC`)
  64. .limit(pageSize)
  65. .offset(page * pageSize),
  66. )
  67. }
  68. export const calculateFeeInCents = (x: number) => {
  69. // math: x = total - (total * 0.044 + 0.30)
  70. // math: x = total * (1-0.044) - 0.30
  71. // math: (x + 0.30) / 0.956 = total
  72. return Math.round(((x + 30) / 0.956) * 0.044 + 30)
  73. }
  74. export const reload = async () => {
  75. const billing = await Database.use((tx) =>
  76. tx
  77. .select({
  78. customerID: BillingTable.customerID,
  79. paymentMethodID: BillingTable.paymentMethodID,
  80. reloadAmount: BillingTable.reloadAmount,
  81. })
  82. .from(BillingTable)
  83. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  84. .then((rows) => rows[0]),
  85. )
  86. const customerID = billing.customerID
  87. const paymentMethodID = billing.paymentMethodID
  88. const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
  89. const paymentID = Identifier.create("payment")
  90. let invoice
  91. try {
  92. const draft = await Billing.stripe().invoices.create({
  93. customer: customerID!,
  94. auto_advance: false,
  95. default_payment_method: paymentMethodID!,
  96. collection_method: "charge_automatically",
  97. currency: "usd",
  98. })
  99. await Billing.stripe().invoiceItems.create({
  100. amount: amountInCents,
  101. currency: "usd",
  102. customer: customerID!,
  103. invoice: draft.id!,
  104. description: ITEM_CREDIT_NAME,
  105. })
  106. await Billing.stripe().invoiceItems.create({
  107. amount: calculateFeeInCents(amountInCents),
  108. currency: "usd",
  109. customer: customerID!,
  110. invoice: draft.id!,
  111. description: ITEM_FEE_NAME,
  112. })
  113. await Billing.stripe().invoices.finalizeInvoice(draft.id!)
  114. invoice = await Billing.stripe().invoices.pay(draft.id!, {
  115. off_session: true,
  116. payment_method: paymentMethodID!,
  117. expand: ["payments"],
  118. })
  119. if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
  120. throw new Error(invoice.last_finalization_error?.message)
  121. } catch (e: any) {
  122. console.error(e)
  123. await Database.use((tx) =>
  124. tx
  125. .update(BillingTable)
  126. .set({
  127. reloadError: e.message ?? "Payment failed.",
  128. timeReloadError: sql`now()`,
  129. })
  130. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  131. )
  132. return
  133. }
  134. await Database.transaction(async (tx) => {
  135. await tx
  136. .update(BillingTable)
  137. .set({
  138. balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
  139. reloadError: null,
  140. timeReloadError: null,
  141. })
  142. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  143. await tx.insert(PaymentTable).values({
  144. workspaceID: Actor.workspace(),
  145. id: paymentID,
  146. amount: centsToMicroCents(amountInCents),
  147. invoiceID: invoice.id!,
  148. paymentID: invoice.payments?.data[0].payment.payment_intent as string,
  149. customerID,
  150. })
  151. })
  152. }
  153. export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
  154. const amountInMicroCents = centsToMicroCents(dollarAmount * 100)
  155. await Database.transaction(async (tx) => {
  156. await tx
  157. .update(BillingTable)
  158. .set({
  159. balance: sql`${BillingTable.balance} + ${amountInMicroCents}`,
  160. })
  161. .where(eq(BillingTable.workspaceID, workspaceID))
  162. await tx.insert(PaymentTable).values({
  163. workspaceID,
  164. id: Identifier.create("payment"),
  165. amount: amountInMicroCents,
  166. enrichment: {
  167. type: "credit",
  168. },
  169. })
  170. })
  171. return amountInMicroCents
  172. }
  173. export const setMonthlyLimit = fn(z.number(), async (input) => {
  174. return await Database.use((tx) =>
  175. tx
  176. .update(BillingTable)
  177. .set({
  178. monthlyLimit: input,
  179. })
  180. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  181. )
  182. })
  183. export const generateCheckoutUrl = fn(
  184. z.object({
  185. successUrl: z.string(),
  186. cancelUrl: z.string(),
  187. amount: z.number().optional(),
  188. }),
  189. async (input) => {
  190. const user = Actor.assert("user")
  191. const { successUrl, cancelUrl, amount } = input
  192. if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) {
  193. throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`)
  194. }
  195. const email = await User.getAuthEmail(user.properties.userID)
  196. const customer = await Billing.get()
  197. const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
  198. const session = await Billing.stripe().checkout.sessions.create({
  199. mode: "payment",
  200. billing_address_collection: "required",
  201. line_items: [
  202. {
  203. price_data: {
  204. currency: "usd",
  205. product_data: { name: ITEM_CREDIT_NAME },
  206. unit_amount: amountInCents,
  207. },
  208. quantity: 1,
  209. },
  210. {
  211. price_data: {
  212. currency: "usd",
  213. product_data: { name: ITEM_FEE_NAME },
  214. unit_amount: calculateFeeInCents(amountInCents),
  215. },
  216. quantity: 1,
  217. },
  218. ],
  219. ...(customer.customerID
  220. ? {
  221. customer: customer.customerID,
  222. customer_update: {
  223. name: "auto",
  224. },
  225. }
  226. : {
  227. customer_email: email!,
  228. customer_creation: "always",
  229. }),
  230. currency: "usd",
  231. invoice_creation: {
  232. enabled: true,
  233. },
  234. payment_intent_data: {
  235. setup_future_usage: "on_session",
  236. },
  237. payment_method_types: ["card"],
  238. payment_method_data: {
  239. allow_redisplay: "always",
  240. },
  241. tax_id_collection: {
  242. enabled: true,
  243. },
  244. metadata: {
  245. workspaceID: Actor.workspace(),
  246. amount: amountInCents.toString(),
  247. },
  248. success_url: successUrl,
  249. cancel_url: cancelUrl,
  250. })
  251. return session.url
  252. },
  253. )
  254. export const generateSessionUrl = fn(
  255. z.object({
  256. returnUrl: z.string(),
  257. }),
  258. async (input) => {
  259. const { returnUrl } = input
  260. const customer = await Billing.get()
  261. if (!customer?.customerID) {
  262. throw new Error("No stripe customer ID")
  263. }
  264. const session = await Billing.stripe().billingPortal.sessions.create({
  265. customer: customer.customerID,
  266. return_url: returnUrl,
  267. })
  268. return session.url
  269. },
  270. )
  271. export const generateReceiptUrl = fn(
  272. z.object({
  273. paymentID: z.string(),
  274. }),
  275. async (input) => {
  276. const { paymentID } = input
  277. const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
  278. if (!intent.latest_charge) throw new Error("No charge found")
  279. const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
  280. if (!charge.receipt_url) throw new Error("No receipt URL found")
  281. return charge.receipt_url
  282. },
  283. )
  284. }