billing.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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. })
  167. })
  168. return amountInMicroCents
  169. }
  170. export const setMonthlyLimit = fn(z.number(), async (input) => {
  171. return await Database.use((tx) =>
  172. tx
  173. .update(BillingTable)
  174. .set({
  175. monthlyLimit: input,
  176. })
  177. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  178. )
  179. })
  180. export const generateCheckoutUrl = fn(
  181. z.object({
  182. successUrl: z.string(),
  183. cancelUrl: z.string(),
  184. amount: z.number().optional(),
  185. }),
  186. async (input) => {
  187. const user = Actor.assert("user")
  188. const { successUrl, cancelUrl, amount } = input
  189. if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) {
  190. throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`)
  191. }
  192. const email = await User.getAuthEmail(user.properties.userID)
  193. const customer = await Billing.get()
  194. const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
  195. const session = await Billing.stripe().checkout.sessions.create({
  196. mode: "payment",
  197. billing_address_collection: "required",
  198. line_items: [
  199. {
  200. price_data: {
  201. currency: "usd",
  202. product_data: { name: ITEM_CREDIT_NAME },
  203. unit_amount: amountInCents,
  204. },
  205. quantity: 1,
  206. },
  207. {
  208. price_data: {
  209. currency: "usd",
  210. product_data: { name: ITEM_FEE_NAME },
  211. unit_amount: calculateFeeInCents(amountInCents),
  212. },
  213. quantity: 1,
  214. },
  215. ],
  216. ...(customer.customerID
  217. ? {
  218. customer: customer.customerID,
  219. customer_update: {
  220. name: "auto",
  221. },
  222. }
  223. : {
  224. customer_email: email!,
  225. customer_creation: "always",
  226. }),
  227. currency: "usd",
  228. invoice_creation: {
  229. enabled: true,
  230. },
  231. payment_intent_data: {
  232. setup_future_usage: "on_session",
  233. },
  234. payment_method_types: ["card"],
  235. payment_method_data: {
  236. allow_redisplay: "always",
  237. },
  238. tax_id_collection: {
  239. enabled: true,
  240. },
  241. metadata: {
  242. workspaceID: Actor.workspace(),
  243. amount: amountInCents.toString(),
  244. },
  245. success_url: successUrl,
  246. cancel_url: cancelUrl,
  247. })
  248. return session.url
  249. },
  250. )
  251. export const generateSessionUrl = fn(
  252. z.object({
  253. returnUrl: z.string(),
  254. }),
  255. async (input) => {
  256. const { returnUrl } = input
  257. const customer = await Billing.get()
  258. if (!customer?.customerID) {
  259. throw new Error("No stripe customer ID")
  260. }
  261. const session = await Billing.stripe().billingPortal.sessions.create({
  262. customer: customer.customerID,
  263. return_url: returnUrl,
  264. })
  265. return session.url
  266. },
  267. )
  268. export const generateReceiptUrl = fn(
  269. z.object({
  270. paymentID: z.string(),
  271. }),
  272. async (input) => {
  273. const { paymentID } = input
  274. const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
  275. if (!intent.latest_charge) throw new Error("No charge found")
  276. const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
  277. if (!charge.receipt_url) throw new Error("No receipt URL found")
  278. return charge.receipt_url
  279. },
  280. )
  281. }