summaryrefslogtreecommitdiffhomepage
path: root/packages/console
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-03-19 18:44:21 -0400
committerFrank <[email protected]>2026-03-19 18:44:24 -0400
commitbd44489ada70cf908b69466f623ca74e800b3fc7 (patch)
tree0819979511404c9aba7d1274da7aa83128a65139 /packages/console
parenta6ef9e92065937dfeb9920abfd6e88bf95b1e572 (diff)
downloadopencode-bd44489ada70cf908b69466f623ca74e800b3fc7.tar.gz
opencode-bd44489ada70cf908b69466f623ca74e800b3fc7.zip
go: upi payment
Diffstat (limited to 'packages/console')
-rw-r--r--packages/console/app/src/component/icon.tsx13
-rw-r--r--packages/console/app/src/component/modal.css1
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts23
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx5
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx14
-rw-r--r--packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css39
-rw-r--r--packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx127
-rw-r--r--packages/console/core/src/billing.ts125
-rw-r--r--packages/console/core/src/lite.ts1
-rw-r--r--packages/console/core/src/schema/billing.sql.ts1
-rw-r--r--packages/console/core/sst-env.d.ts1
-rw-r--r--packages/console/function/sst-env.d.ts1
-rw-r--r--packages/console/resource/sst-env.d.ts1
13 files changed, 273 insertions, 79 deletions
diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx
index df7e067c2..0aaa302b3 100644
--- a/packages/console/app/src/component/icon.tsx
+++ b/packages/console/app/src/component/icon.tsx
@@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
+export function IconUpi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="10 16 100 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path d="M95.678 42.9 110 29.835l-6.784-13.516Z" />
+ <path d="M90.854 42.9 105.176 29.835l-6.784-13.516Z" />
+ <path
+ d="M22.41 16.47 16.38 37.945l21.407.15 5.88-21.625h5.427l-7.05 25.14c-.27.96-1.298 1.74-2.295 1.74H12.31c-1.664 0-2.65-1.3-2.2-2.9l6.724-23.98Zm66.182-.15h5.427l-7.538 27.03h-5.58ZM49.698 27.582l27.136-.15 1.81-5.707H51.054l1.658-5.256 29.4-.27c1.83-.017 2.92 1.4 2.438 3.167L81.78 29.49c-.483 1.766-2.36 3.197-4.19 3.197H53.316L50.454 43.8h-5.28Z"
+ fill-rule="evenodd"
+ />
+ </svg>
+ )
+}
+
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css
index 1f47f395d..e71fd1a19 100644
--- a/packages/console/app/src/component/modal.css
+++ b/packages/console/app/src/component/modal.css
@@ -62,5 +62,6 @@
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
+ text-align: center;
}
}
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index 95cd9da21..47fee05cf 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
customerID,
enrichment: {
type: productID === LiteData.productID() ? "lite" : "subscription",
+ currency: body.data.object.currency === "inr" ? "inr" : undefined,
couponID,
},
}),
@@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
)
if (!workspaceID) throw new Error("Workspace ID not found")
- const amount = await Database.use((tx) =>
+ const payment = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
+ enrichment: PaymentTable.enrichment,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
- .then((rows) => rows[0]?.amount),
+ .then((rows) => rows[0]),
)
- if (!amount) throw new Error("Payment not found")
+ if (!payment) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
@@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
- await tx
- .update(BillingTable)
- .set({
- balance: sql`${BillingTable.balance} - ${amount}`,
- })
- .where(eq(BillingTable.workspaceID, workspaceID))
+ // deduct balance only for top up
+ if (!payment.enrichment?.type) {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} - ${payment.amount}`,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ }
})
}
})()
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
index 50e30585b..4d9b0cabd 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
-import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon"
+import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
@@ -211,6 +211,9 @@ export function BillingSection() {
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<IconWechat style={{ width: "24px", height: "24px" }} />
</Match>
+ <Match when={billingInfo()?.paymentMethodType === "upi"}>
+ <IconUpi style={{ width: "auto", height: "16px" }} />
+ </Match>
</Switch>
</div>
<div data-slot="card-details">
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
index 2311be321..6da5c42ed 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
+function money(amount: number, currency?: string) {
+ const formatter =
+ currency === "inr"
+ ? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
+ : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
+ return formatter.format(amount / 100_000_000)
+}
+
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -81,6 +89,10 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated)
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
+ const currency =
+ payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
+ ? payment.enrichment.currency
+ : undefined
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -88,7 +100,7 @@ export function PaymentSection() {
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
- ${((amount ?? 0) / 100000000).toFixed(2)}
+ {money(amount, currency)}
<Switch>
<Match when={payment.enrichment?.type === "credit"}>
{" "}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
index a760753d0..05daf43b7 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
@@ -188,8 +188,45 @@
line-height: 1.4;
}
+ [data-slot="subscribe-actions"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ margin-top: var(--space-4);
+ }
+
[data-slot="subscribe-button"] {
- align-self: flex-start;
+ align-self: stretch;
+ }
+
+ [data-slot="other-methods"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ }
+
+ [data-slot="other-methods-icons"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ [data-slot="modal-actions"] {
+ display: flex;
+ gap: var(--space-3);
margin-top: var(--space-4);
+
+ button {
+ flex: 1;
+ }
+ }
+
+ [data-slot="method-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--space-2);
+ height: 48px;
}
}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
index ccdda5b45..4a64eb1b2 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
@@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
+import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
+import { IconAlipay, IconUpi } from "~/component/icon"
+
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
-const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return json(
- await withActor(
- () =>
- Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({
- error: e.message as string,
- data: undefined,
- })),
- workspaceID,
- ),
- { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
- )
-}, "liteCheckoutUrl")
+const createLiteCheckoutUrl = action(
+ async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
+ "use server"
+ return json(
+ await withActor(
+ () =>
+ Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
+ )
+ },
+ "liteCheckoutUrl",
+)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -147,23 +153,30 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
- redirecting: false,
+ loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
+ showModal: false,
})
+ const busy = createMemo(() => !!store.loading)
+
async function onClickSession() {
+ setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
- async function onClickSubscribe() {
- const result = await checkoutAction(params.id!, window.location.href, window.location.href)
+ async function onClickSubscribe(method?: "alipay" | "upi") {
+ setStore("loading", method ?? "checkout")
+ const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
return (
@@ -179,12 +192,8 @@ export function LiteSection() {
<div data-slot="section-title">
<div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p>
- <button
- data-color="primary"
- disabled={sessionSubmission.pending || store.redirecting}
- onClick={onClickSession}
- >
- {sessionSubmission.pending || store.redirecting
+ <button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
+ {store.loading === "session"
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
</button>
@@ -282,16 +291,60 @@ export function LiteSection() {
<li>MiniMax M2.7</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
- <button
- data-slot="subscribe-button"
- data-color="primary"
- disabled={checkoutSubmission.pending || store.redirecting}
- onClick={onClickSubscribe}
- >
- {checkoutSubmission.pending || store.redirecting
- ? i18n.t("workspace.lite.promo.subscribing")
- : i18n.t("workspace.lite.promo.subscribe")}
- </button>
+ <div data-slot="subscribe-actions">
+ <button
+ data-slot="subscribe-button"
+ data-color="primary"
+ disabled={checkoutSubmission.pending || busy()}
+ onClick={() => onClickSubscribe()}
+ >
+ {store.loading === "checkout"
+ ? i18n.t("workspace.lite.promo.subscribing")
+ : i18n.t("workspace.lite.promo.subscribe")}
+ </button>
+ <button
+ type="button"
+ data-slot="other-methods"
+ data-color="ghost"
+ onClick={() => setStore("showModal", true)}
+ >
+ <span>Other payment methods</span>
+ <span data-slot="other-methods-icons">
+ <span> </span>
+ <IconAlipay style={{ width: "16px", height: "16px" }} />
+ <span> </span>
+ <IconUpi style={{ width: "auto", height: "10px" }} />
+ </span>
+ </button>
+ </div>
+ <Modal open={store.showModal} onClose={() => setStore("showModal", false)} title="Select payment method">
+ <div data-slot="modal-actions">
+ <button
+ type="button"
+ data-slot="method-button"
+ data-color="ghost"
+ disabled={checkoutSubmission.pending || busy()}
+ onClick={() => onClickSubscribe("alipay")}
+ >
+ <Show when={store.loading !== "alipay"}>
+ <IconAlipay style={{ width: "24px", height: "24px" }} />
+ </Show>
+ {store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
+ </button>
+ <button
+ type="button"
+ data-slot="method-button"
+ data-color="ghost"
+ disabled={checkoutSubmission.pending || busy()}
+ onClick={() => onClickSubscribe("upi")}
+ >
+ <Show when={store.loading !== "upi"}>
+ <IconUpi style={{ width: "auto", height: "16px" }} />
+ </Show>
+ {store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
+ </button>
+ </div>
+ </Modal>
</section>
</Show>
</>
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index ee41652ef..66b980698 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -239,10 +239,11 @@ export namespace Billing {
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
+ method: z.enum(["alipay", "upi"]).optional(),
}),
async (input) => {
const user = Actor.assert("user")
- const { successUrl, cancelUrl } = input
+ const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
@@ -250,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
- const session = await Billing.stripe().checkout.sessions.create({
- mode: "subscription",
- billing_address_collection: "required",
- line_items: [{ price: LiteData.priceID(), quantity: 1 }],
- discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
- ...(billing.customerID
- ? {
- customer: billing.customerID,
- customer_update: {
- name: "auto",
- address: "auto",
- },
+ const createSession = () =>
+ Billing.stripe().checkout.sessions.create({
+ mode: "subscription",
+ discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
+ ...(billing.customerID
+ ? {
+ customer: billing.customerID,
+ customer_update: {
+ name: "auto",
+ address: "auto",
+ },
+ }
+ : {
+ customer_email: email!,
+ }),
+ ...(() => {
+ if (method === "alipay") {
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ payment_method_types: ["alipay"],
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
}
- : {
- customer_email: email!,
- }),
- currency: "usd",
- tax_id_collection: {
- enabled: true,
- },
- success_url: successUrl,
- cancel_url: cancelUrl,
- subscription_data: {
- metadata: {
- workspaceID: Actor.workspace(),
- userID: user.properties.userID,
- type: "lite",
+ if (method === "upi") {
+ return {
+ line_items: [
+ {
+ price_data: {
+ currency: "inr",
+ product: LiteData.productID(),
+ recurring: {
+ interval: "month",
+ interval_count: 1,
+ },
+ unit_amount: LiteData.priceInr(),
+ },
+ quantity: 1,
+ },
+ ],
+ payment_method_types: ["upi"] as any,
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
+ }
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ billing_address_collection: "required",
+ }
+ })(),
+ tax_id_collection: {
+ enabled: true,
},
- },
- })
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ subscription_data: {
+ metadata: {
+ workspaceID: Actor.workspace(),
+ userID: user.properties.userID,
+ type: "lite",
+ },
+ },
+ })
- return session.url
+ try {
+ const session = await createSession()
+ return session.url
+ } catch (e: any) {
+ if (
+ e.type !== "StripeInvalidRequestError" ||
+ !e.message.includes("You cannot combine currencies on a single customer")
+ )
+ throw e
+
+ // get pending payment intent
+ const intents = await Billing.stripe().paymentIntents.search({
+ query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
+ })
+ if (intents.data.length === 0) throw e
+
+ for (const intent of intents.data) {
+ // get checkout session
+ const sessions = await Billing.stripe().checkout.sessions.list({
+ customer: billing.customerID!,
+ payment_intent: intent.id,
+ })
+
+ // delete pending payment intent
+ await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
+ }
+
+ const session = await createSession()
+ return session.url
+ }
},
)
diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts
index 8c5b63d0c..2c4a09f71 100644
--- a/packages/console/core/src/lite.ts
+++ b/packages/console/core/src/lite.ts
@@ -10,6 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
+ export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}
diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts
index a5c70c211..b06ca8966 100644
--- a/packages/console/core/src/schema/billing.sql.ts
+++ b/packages/console/core/src/schema/billing.sql.ts
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type<
| {
type: "subscription" | "lite"
+ currency?: "inr"
couponID?: string
}
| {
diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts
index 5e2693ad8..6b842639a 100644
--- a/packages/console/core/sst-env.d.ts
+++ b/packages/console/core/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts
index 5e2693ad8..6b842639a 100644
--- a/packages/console/function/sst-env.d.ts
+++ b/packages/console/function/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts
index 5e2693ad8..6b842639a 100644
--- a/packages/console/resource/sst-env.d.ts
+++ b/packages/console/resource/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}