summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app
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/app
parenta6ef9e92065937dfeb9920abfd6e88bf95b1e572 (diff)
downloadopencode-bd44489ada70cf908b69466f623ca74e800b3fc7.tar.gz
opencode-bd44489ada70cf908b69466f623ca74e800b3fc7.zip
go: upi payment
Diffstat (limited to 'packages/console/app')
-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
7 files changed, 173 insertions, 49 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>
</>