billing.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. import { Stripe } from "stripe"
  2. import { and, Database, eq, isNull, sql } from "./drizzle"
  3. import {
  4. BillingTable,
  5. CouponTable,
  6. CouponType,
  7. LiteTable,
  8. PaymentTable,
  9. SubscriptionTable,
  10. UsageTable,
  11. } from "./schema/billing.sql"
  12. import { Actor } from "./actor"
  13. import { fn } from "./util/fn"
  14. import { z } from "zod"
  15. import { Resource } from "@opencode-ai/console-resource"
  16. import { Identifier } from "./identifier"
  17. import { centsToMicroCents } from "./util/price"
  18. import { User } from "./user"
  19. import { BlackData } from "./black"
  20. import { LiteData } from "./lite"
  21. export namespace Billing {
  22. export const ITEM_CREDIT_NAME = "opencode credits"
  23. export const ITEM_FEE_NAME = "processing fee"
  24. export const RELOAD_AMOUNT = 20
  25. export const RELOAD_AMOUNT_MIN = 10
  26. export const RELOAD_TRIGGER = 5
  27. export const RELOAD_TRIGGER_MIN = 5
  28. export const stripe = () =>
  29. new Stripe(Resource.STRIPE_SECRET_KEY.value, {
  30. apiVersion: "2025-03-31.basil",
  31. httpClient: Stripe.createFetchHttpClient(),
  32. })
  33. export const get = async () => {
  34. return Database.use(async (tx) =>
  35. tx
  36. .select()
  37. .from(BillingTable)
  38. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  39. .then((r) => r[0]),
  40. )
  41. }
  42. export const payments = async () => {
  43. return await Database.use((tx) =>
  44. tx
  45. .select()
  46. .from(PaymentTable)
  47. .where(eq(PaymentTable.workspaceID, Actor.workspace()))
  48. .orderBy(sql`${PaymentTable.timeCreated} DESC`)
  49. .limit(100),
  50. )
  51. }
  52. export const usages = async (page = 0, pageSize = 50) => {
  53. return await Database.use((tx) =>
  54. tx
  55. .select()
  56. .from(UsageTable)
  57. .where(eq(UsageTable.workspaceID, Actor.workspace()))
  58. .orderBy(sql`${UsageTable.timeCreated} DESC`)
  59. .limit(pageSize)
  60. .offset(page * pageSize),
  61. )
  62. }
  63. export const calculateFeeInCents = (x: number) => {
  64. // math: x = total - (total * 0.044 + 0.30)
  65. // math: x = total * (1-0.044) - 0.30
  66. // math: (x + 0.30) / 0.956 = total
  67. return Math.round(((x + 30) / 0.956) * 0.044 + 30)
  68. }
  69. export const reload = async () => {
  70. const billing = await Database.use((tx) =>
  71. tx
  72. .select({
  73. customerID: BillingTable.customerID,
  74. paymentMethodID: BillingTable.paymentMethodID,
  75. reloadAmount: BillingTable.reloadAmount,
  76. })
  77. .from(BillingTable)
  78. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  79. .then((rows) => rows[0]),
  80. )
  81. const customerID = billing.customerID
  82. const paymentMethodID = billing.paymentMethodID
  83. const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
  84. try {
  85. const draft = await Billing.stripe().invoices.create({
  86. customer: customerID!,
  87. auto_advance: false,
  88. default_payment_method: paymentMethodID!,
  89. collection_method: "charge_automatically",
  90. currency: "usd",
  91. metadata: {
  92. workspaceID: Actor.workspace(),
  93. amount: amountInCents.toString(),
  94. },
  95. })
  96. await Billing.stripe().invoiceItems.create({
  97. amount: amountInCents,
  98. currency: "usd",
  99. customer: customerID!,
  100. invoice: draft.id!,
  101. description: ITEM_CREDIT_NAME,
  102. })
  103. await Billing.stripe().invoiceItems.create({
  104. amount: calculateFeeInCents(amountInCents),
  105. currency: "usd",
  106. customer: customerID!,
  107. invoice: draft.id!,
  108. description: ITEM_FEE_NAME,
  109. })
  110. await Billing.stripe().invoices.finalizeInvoice(draft.id!)
  111. await Billing.stripe().invoices.pay(draft.id!, {
  112. off_session: true,
  113. payment_method: paymentMethodID!,
  114. })
  115. } catch (e: any) {
  116. console.error(e)
  117. await Database.use((tx) =>
  118. tx
  119. .update(BillingTable)
  120. .set({
  121. reload: false,
  122. reloadError: e.message ?? "Payment failed.",
  123. timeReloadError: sql`now()`,
  124. })
  125. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  126. )
  127. return
  128. }
  129. }
  130. export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
  131. const amountInMicroCents = centsToMicroCents(dollarAmount * 100)
  132. await Database.transaction(async (tx) => {
  133. await tx
  134. .update(BillingTable)
  135. .set({
  136. balance: sql`${BillingTable.balance} + ${amountInMicroCents}`,
  137. })
  138. .where(eq(BillingTable.workspaceID, workspaceID))
  139. await tx.insert(PaymentTable).values({
  140. workspaceID,
  141. id: Identifier.create("payment"),
  142. amount: amountInMicroCents,
  143. enrichment: {
  144. type: "credit",
  145. },
  146. })
  147. })
  148. return amountInMicroCents
  149. }
  150. export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
  151. const coupon = await Database.use((tx) =>
  152. tx
  153. .select()
  154. .from(CouponTable)
  155. .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
  156. .then((rows) => rows[0]),
  157. )
  158. if (!coupon) throw new Error("Invalid coupon code")
  159. if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
  160. if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
  161. await Database.use((tx) =>
  162. tx
  163. .update(CouponTable)
  164. .set({ timeRedeemed: sql`now()` })
  165. .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
  166. )
  167. }
  168. export const getCoupons = async (email: string) => {
  169. return await Database.use((tx) =>
  170. tx
  171. .select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
  172. .from(CouponTable)
  173. .where(and(eq(CouponTable.email, email), isNull(CouponTable.timeRedeemed)))
  174. .then((rows) => rows.map((row) => row.type)),
  175. )
  176. }
  177. export const setMonthlyLimit = fn(z.number(), async (input) => {
  178. return await Database.use((tx) =>
  179. tx
  180. .update(BillingTable)
  181. .set({
  182. monthlyLimit: input,
  183. })
  184. .where(eq(BillingTable.workspaceID, Actor.workspace())),
  185. )
  186. })
  187. export const generateCheckoutUrl = fn(
  188. z.object({
  189. successUrl: z.string(),
  190. cancelUrl: z.string(),
  191. amount: z.number().optional(),
  192. }),
  193. async (input) => {
  194. const user = Actor.assert("user")
  195. const { successUrl, cancelUrl, amount } = input
  196. if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) {
  197. throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`)
  198. }
  199. const email = await User.getAuthEmail(user.properties.userID)
  200. const customer = await Billing.get()
  201. const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
  202. const session = await Billing.stripe().checkout.sessions.create({
  203. mode: "payment",
  204. billing_address_collection: "required",
  205. line_items: [
  206. {
  207. price_data: {
  208. currency: "usd",
  209. product_data: { name: ITEM_CREDIT_NAME },
  210. unit_amount: amountInCents,
  211. },
  212. quantity: 1,
  213. },
  214. {
  215. price_data: {
  216. currency: "usd",
  217. product_data: { name: ITEM_FEE_NAME },
  218. unit_amount: calculateFeeInCents(amountInCents),
  219. },
  220. quantity: 1,
  221. },
  222. ],
  223. ...(customer.customerID
  224. ? {
  225. customer: customer.customerID,
  226. customer_update: {
  227. name: "auto",
  228. address: "auto",
  229. },
  230. }
  231. : {
  232. customer_email: email!,
  233. customer_creation: "always",
  234. }),
  235. currency: "usd",
  236. invoice_creation: {
  237. enabled: true,
  238. },
  239. payment_method_options: {
  240. card: {
  241. setup_future_usage: "on_session",
  242. },
  243. },
  244. //payment_method_data: {
  245. // allow_redisplay: "always",
  246. //},
  247. tax_id_collection: {
  248. enabled: true,
  249. },
  250. metadata: {
  251. workspaceID: Actor.workspace(),
  252. amount: amountInCents.toString(),
  253. },
  254. success_url: successUrl,
  255. cancel_url: cancelUrl,
  256. })
  257. return session.url
  258. },
  259. )
  260. export const generateLiteCheckoutUrl = fn(
  261. z.object({
  262. successUrl: z.string(),
  263. cancelUrl: z.string(),
  264. method: z.enum(["alipay", "upi"]).optional(),
  265. }),
  266. async (input) => {
  267. const user = Actor.assert("user")
  268. const { successUrl, cancelUrl, method } = input
  269. const email = (await User.getAuthEmail(user.properties.userID))!
  270. const billing = await Billing.get()
  271. if (billing.subscriptionID) throw new Error("Already subscribed to Black")
  272. if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
  273. const coupons = await Billing.getCoupons(email)
  274. const coupon = coupons.includes("GO12MONTHS100")
  275. ? LiteData.twelveMonths100Coupon
  276. : coupons.includes("GO6MONTHS100")
  277. ? LiteData.sixMonths100Coupon
  278. : coupons.includes("GO3MONTHS100")
  279. ? LiteData.threeMonths100Coupon
  280. : coupons.includes("GOFREEMONTH")
  281. ? LiteData.firstMonth100Coupon
  282. : LiteData.firstMonth50Coupon
  283. const createSession = () =>
  284. Billing.stripe().checkout.sessions.create({
  285. mode: "subscription",
  286. discounts: [{ coupon }],
  287. ...(billing.customerID
  288. ? {
  289. customer: billing.customerID,
  290. customer_update: {
  291. name: "auto",
  292. address: "auto",
  293. },
  294. }
  295. : {
  296. customer_email: email,
  297. }),
  298. ...(() => {
  299. if (method === "alipay") {
  300. return {
  301. line_items: [{ price: LiteData.priceID(), quantity: 1 }],
  302. payment_method_types: ["alipay"],
  303. adaptive_pricing: {
  304. enabled: false,
  305. },
  306. }
  307. }
  308. if (method === "upi") {
  309. return {
  310. line_items: [
  311. {
  312. price_data: {
  313. currency: "inr",
  314. product: LiteData.productID(),
  315. recurring: {
  316. interval: "month",
  317. interval_count: 1,
  318. },
  319. unit_amount: LiteData.priceInr(),
  320. },
  321. quantity: 1,
  322. },
  323. ],
  324. payment_method_types: ["upi"] as any,
  325. adaptive_pricing: {
  326. enabled: false,
  327. },
  328. }
  329. }
  330. return {
  331. line_items: [{ price: LiteData.priceID(), quantity: 1 }],
  332. billing_address_collection: "required",
  333. }
  334. })(),
  335. tax_id_collection: {
  336. enabled: true,
  337. },
  338. success_url: successUrl,
  339. cancel_url: cancelUrl,
  340. subscription_data: {
  341. metadata: {
  342. workspaceID: Actor.workspace(),
  343. userID: user.properties.userID,
  344. userEmail: email,
  345. coupon,
  346. type: "lite",
  347. },
  348. },
  349. })
  350. try {
  351. const session = await createSession()
  352. return session.url
  353. } catch (e: any) {
  354. if (
  355. e.type !== "StripeInvalidRequestError" ||
  356. !e.message.includes("You cannot combine currencies on a single customer")
  357. )
  358. throw e
  359. // get pending payment intent
  360. const intents = await Billing.stripe().paymentIntents.search({
  361. query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
  362. })
  363. if (intents.data.length === 0) throw e
  364. for (const intent of intents.data) {
  365. // get checkout session
  366. const sessions = await Billing.stripe().checkout.sessions.list({
  367. customer: billing.customerID!,
  368. payment_intent: intent.id,
  369. })
  370. // delete pending payment intent
  371. await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
  372. }
  373. const session = await createSession()
  374. return session.url
  375. }
  376. },
  377. )
  378. export const generateSessionUrl = fn(
  379. z.object({
  380. returnUrl: z.string(),
  381. }),
  382. async (input) => {
  383. const { returnUrl } = input
  384. const customer = await Billing.get()
  385. if (!customer?.customerID) {
  386. throw new Error("No stripe customer ID")
  387. }
  388. const session = await Billing.stripe().billingPortal.sessions.create({
  389. customer: customer.customerID,
  390. return_url: returnUrl,
  391. })
  392. return session.url
  393. },
  394. )
  395. export const generateReceiptUrl = fn(
  396. z.object({
  397. paymentID: z.string(),
  398. }),
  399. async (input) => {
  400. const { paymentID } = input
  401. const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
  402. if (!intent.latest_charge) throw new Error("No charge found")
  403. const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
  404. if (!charge.receipt_url) throw new Error("No receipt URL found")
  405. return charge.receipt_url
  406. },
  407. )
  408. export const subscribeBlack = fn(
  409. z.object({
  410. seats: z.number(),
  411. coupon: z.string().optional(),
  412. }),
  413. async ({ seats, coupon }) => {
  414. const user = Actor.assert("user")
  415. const billing = await Database.use((tx) =>
  416. tx
  417. .select({
  418. customerID: BillingTable.customerID,
  419. paymentMethodID: BillingTable.paymentMethodID,
  420. subscriptionID: BillingTable.subscriptionID,
  421. subscriptionPlan: BillingTable.subscriptionPlan,
  422. timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
  423. })
  424. .from(BillingTable)
  425. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  426. .then((rows) => rows[0]),
  427. )
  428. if (!billing) throw new Error("Billing record not found")
  429. if (!billing.timeSubscriptionSelected) throw new Error("Not selected for subscription")
  430. if (billing.subscriptionID) throw new Error("Already subscribed")
  431. if (!billing.customerID) throw new Error("No customer ID")
  432. if (!billing.paymentMethodID) throw new Error("No payment method")
  433. if (!billing.subscriptionPlan) throw new Error("No subscription plan")
  434. const subscription = await Billing.stripe().subscriptions.create({
  435. customer: billing.customerID,
  436. default_payment_method: billing.paymentMethodID,
  437. items: [{ price: BlackData.planToPriceID({ plan: billing.subscriptionPlan }) }],
  438. metadata: {
  439. workspaceID: Actor.workspace(),
  440. },
  441. })
  442. await Database.transaction(async (tx) => {
  443. await tx
  444. .update(BillingTable)
  445. .set({
  446. subscriptionID: subscription.id,
  447. subscription: {
  448. status: "subscribed",
  449. coupon,
  450. seats,
  451. plan: billing.subscriptionPlan!,
  452. },
  453. subscriptionPlan: null,
  454. timeSubscriptionBooked: null,
  455. timeSubscriptionSelected: null,
  456. })
  457. .where(eq(BillingTable.workspaceID, Actor.workspace()))
  458. await tx.insert(SubscriptionTable).values({
  459. workspaceID: Actor.workspace(),
  460. id: Identifier.create("subscription"),
  461. userID: user.properties.userID,
  462. })
  463. })
  464. return subscription.id
  465. },
  466. )
  467. export const unsubscribeBlack = fn(
  468. z.object({
  469. subscriptionID: z.string(),
  470. }),
  471. async ({ subscriptionID }) => {
  472. const workspaceID = await Database.use((tx) =>
  473. tx
  474. .select({ workspaceID: BillingTable.workspaceID })
  475. .from(BillingTable)
  476. .where(eq(BillingTable.subscriptionID, subscriptionID))
  477. .then((rows) => rows[0]?.workspaceID),
  478. )
  479. if (!workspaceID) throw new Error("Workspace ID not found for subscription")
  480. await Database.transaction(async (tx) => {
  481. await tx
  482. .update(BillingTable)
  483. .set({ subscriptionID: null, subscription: null })
  484. .where(eq(BillingTable.workspaceID, workspaceID))
  485. await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
  486. })
  487. },
  488. )
  489. export const unsubscribeLite = fn(
  490. z.object({
  491. subscriptionID: z.string(),
  492. }),
  493. async ({ subscriptionID }) => {
  494. const workspaceID = await Database.use((tx) =>
  495. tx
  496. .select({ workspaceID: BillingTable.workspaceID })
  497. .from(BillingTable)
  498. .where(eq(BillingTable.liteSubscriptionID, subscriptionID))
  499. .then((rows) => rows[0]?.workspaceID),
  500. )
  501. if (!workspaceID) throw new Error("Workspace ID not found for subscription")
  502. await Database.transaction(async (tx) => {
  503. await tx
  504. .update(BillingTable)
  505. .set({ liteSubscriptionID: null, lite: null })
  506. .where(eq(BillingTable.workspaceID, workspaceID))
  507. await tx.delete(LiteTable).where(eq(LiteTable.workspaceID, workspaceID))
  508. })
  509. },
  510. )
  511. }