billing.ts 8.6 KB

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