billing.ts 8.6 KB

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