billing.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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 CHARGE_NAME = "opencode credits"
  13. export const CHARGE_FEE_NAME = "processing fee"
  14. export const CHARGE_AMOUNT = 2000 // $20
  15. export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
  16. export const CHARGE_THRESHOLD = 500 // $5
  17. export const stripe = () =>
  18. new Stripe(Resource.STRIPE_SECRET_KEY.value, {
  19. apiVersion: "2025-03-31.basil",
  20. httpClient: Stripe.createFetchHttpClient(),
  21. })
  22. export const get = async () => {
  23. return Database.use(async (tx) =>
  24. tx
  25. .select({
  26. customerID: BillingTable.customerID,
  27. paymentMethodID: BillingTable.paymentMethodID,
  28. paymentMethodType: BillingTable.paymentMethodType,
  29. paymentMethodLast4: BillingTable.paymentMethodLast4,
  30. balance: BillingTable.balance,
  31. reload: BillingTable.reload,
  32. monthlyLimit: BillingTable.monthlyLimit,
  33. monthlyUsage: BillingTable.monthlyUsage,
  34. timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
  35. reloadError: BillingTable.reloadError,
  36. timeReloadError: BillingTable.timeReloadError,
  37. })
  38. .from(BillingTable)
  39. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  40. .then((r) => r[0]),
  41. )
  42. }
  43. export const payments = async () => {
  44. return await Database.use((tx) =>
  45. tx
  46. .select()
  47. .from(PaymentTable)
  48. .where(eq(PaymentTable.workspaceID, Actor.workspace()))
  49. .orderBy(sql`${PaymentTable.timeCreated} DESC`)
  50. .limit(100),
  51. )
  52. }
  53. export const usages = async () => {
  54. return await Database.use((tx) =>
  55. tx
  56. .select()
  57. .from(UsageTable)
  58. .where(eq(UsageTable.workspaceID, Actor.workspace()))
  59. .orderBy(sql`${UsageTable.timeCreated} DESC`)
  60. .limit(100),
  61. )
  62. }
  63. export const reload = async () => {
  64. const { customerID, paymentMethodID } = await Database.use((tx) =>
  65. tx
  66. .select({
  67. customerID: BillingTable.customerID,
  68. paymentMethodID: BillingTable.paymentMethodID,
  69. })
  70. .from(BillingTable)
  71. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  72. .then((rows) => rows[0]),
  73. )
  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: Billing.CHARGE_AMOUNT,
  86. currency: "usd",
  87. customer: customerID!,
  88. description: CHARGE_NAME,
  89. invoice: draft.id!,
  90. })
  91. await Billing.stripe().invoiceItems.create({
  92. amount: Billing.CHARGE_FEE,
  93. currency: "usd",
  94. customer: customerID!,
  95. description: CHARGE_FEE_NAME,
  96. invoice: draft.id!,
  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(CHARGE_AMOUNT)}`,
  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(CHARGE_AMOUNT),
  132. invoiceID: invoice.id!,
  133. paymentID: invoice.payments?.data[0].payment.payment_intent as string,
  134. customerID,
  135. })
  136. })
  137. }
  138. export const disableReload = async () => {
  139. return await Database.use((tx) =>
  140. tx
  141. .update(BillingTable)
  142. .set({
  143. reload: false,
  144. })
  145. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  146. )
  147. }
  148. export const setMonthlyLimit = fn(z.number(), async (input) => {
  149. return await Database.use((tx) =>
  150. tx
  151. .update(BillingTable)
  152. .set({
  153. monthlyLimit: input,
  154. })
  155. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  156. )
  157. })
  158. export const generateCheckoutUrl = fn(
  159. z.object({
  160. successUrl: z.string(),
  161. cancelUrl: z.string(),
  162. }),
  163. async (input) => {
  164. const user = Actor.assert("user")
  165. const { successUrl, cancelUrl } = input
  166. const email = await User.getAuthEmail(user.properties.userID)
  167. const customer = await Billing.get()
  168. const session = await Billing.stripe().checkout.sessions.create({
  169. mode: "payment",
  170. billing_address_collection: "required",
  171. line_items: [
  172. {
  173. price_data: {
  174. currency: "usd",
  175. product_data: {
  176. name: CHARGE_NAME,
  177. },
  178. unit_amount: CHARGE_AMOUNT,
  179. },
  180. quantity: 1,
  181. },
  182. {
  183. price_data: {
  184. currency: "usd",
  185. product_data: {
  186. name: CHARGE_FEE_NAME,
  187. },
  188. unit_amount: CHARGE_FEE,
  189. },
  190. quantity: 1,
  191. },
  192. ],
  193. ...(customer.customerID
  194. ? {
  195. customer: customer.customerID,
  196. customer_update: {
  197. name: "auto",
  198. },
  199. }
  200. : {
  201. customer_email: email!,
  202. customer_creation: "always",
  203. }),
  204. currency: "usd",
  205. invoice_creation: {
  206. enabled: true,
  207. },
  208. payment_intent_data: {
  209. setup_future_usage: "on_session",
  210. },
  211. payment_method_types: ["card"],
  212. payment_method_data: {
  213. allow_redisplay: "always",
  214. },
  215. tax_id_collection: {
  216. enabled: true,
  217. },
  218. metadata: {
  219. workspaceID: Actor.workspace(),
  220. },
  221. success_url: successUrl,
  222. cancel_url: cancelUrl,
  223. })
  224. return session.url
  225. },
  226. )
  227. export const generateSessionUrl = fn(
  228. z.object({
  229. returnUrl: z.string(),
  230. }),
  231. async (input) => {
  232. const { returnUrl } = input
  233. const customer = await Billing.get()
  234. if (!customer?.customerID) {
  235. throw new Error("No stripe customer ID")
  236. }
  237. const session = await Billing.stripe().billingPortal.sessions.create({
  238. customer: customer.customerID,
  239. return_url: returnUrl,
  240. })
  241. return session.url
  242. },
  243. )
  244. export const generateReceiptUrl = fn(
  245. z.object({
  246. paymentID: z.string(),
  247. }),
  248. async (input) => {
  249. const { paymentID } = input
  250. const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
  251. if (!intent.latest_charge) throw new Error("No charge found")
  252. const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
  253. if (!charge.receipt_url) throw new Error("No receipt URL found")
  254. return charge.receipt_url
  255. },
  256. )
  257. }