billing.ts 6.8 KB

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