billing.ts 8.6 KB

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