billing.ts 7.9 KB

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