billing.ts 7.8 KB

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