billing.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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. address: "auto",
  210. },
  211. }
  212. : {
  213. customer_email: email!,
  214. customer_creation: "always",
  215. }),
  216. currency: "usd",
  217. invoice_creation: {
  218. enabled: true,
  219. },
  220. payment_intent_data: {
  221. setup_future_usage: "on_session",
  222. },
  223. payment_method_types: ["card"],
  224. payment_method_data: {
  225. allow_redisplay: "always",
  226. },
  227. tax_id_collection: {
  228. enabled: true,
  229. },
  230. metadata: {
  231. workspaceID: Actor.workspace(),
  232. amount: amountInCents.toString(),
  233. },
  234. success_url: successUrl,
  235. cancel_url: cancelUrl,
  236. })
  237. return session.url
  238. },
  239. )
  240. export const generateSessionUrl = fn(
  241. z.object({
  242. returnUrl: z.string(),
  243. }),
  244. async (input) => {
  245. const { returnUrl } = input
  246. const customer = await Billing.get()
  247. if (!customer?.customerID) {
  248. throw new Error("No stripe customer ID")
  249. }
  250. const session = await Billing.stripe().billingPortal.sessions.create({
  251. customer: customer.customerID,
  252. return_url: returnUrl,
  253. })
  254. return session.url
  255. },
  256. )
  257. export const generateReceiptUrl = fn(
  258. z.object({
  259. paymentID: z.string(),
  260. }),
  261. async (input) => {
  262. const { paymentID } = input
  263. const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
  264. if (!intent.latest_charge) throw new Error("No charge found")
  265. const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
  266. if (!charge.receipt_url) throw new Error("No receipt URL found")
  267. return charge.receipt_url
  268. },
  269. )
  270. }