diff options
| author | Frank <[email protected]> | 2026-03-19 18:44:21 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2026-03-19 18:44:24 -0400 |
| commit | bd44489ada70cf908b69466f623ca74e800b3fc7 (patch) | |
| tree | 0819979511404c9aba7d1274da7aa83128a65139 /packages/console | |
| parent | a6ef9e92065937dfeb9920abfd6e88bf95b1e572 (diff) | |
| download | opencode-bd44489ada70cf908b69466f623ca74e800b3fc7.tar.gz opencode-bd44489ada70cf908b69466f623ca74e800b3fc7.zip | |
go: upi payment
Diffstat (limited to 'packages/console')
| -rw-r--r-- | packages/console/app/src/component/icon.tsx | 13 | ||||
| -rw-r--r-- | packages/console/app/src/component/modal.css | 1 | ||||
| -rw-r--r-- | packages/console/app/src/routes/stripe/webhook.ts | 23 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx | 5 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx | 14 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css | 39 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx | 127 | ||||
| -rw-r--r-- | packages/console/core/src/billing.ts | 125 | ||||
| -rw-r--r-- | packages/console/core/src/lite.ts | 1 | ||||
| -rw-r--r-- | packages/console/core/src/schema/billing.sql.ts | 1 | ||||
| -rw-r--r-- | packages/console/core/sst-env.d.ts | 1 | ||||
| -rw-r--r-- | packages/console/function/sst-env.d.ts | 1 | ||||
| -rw-r--r-- | packages/console/resource/sst-env.d.ts | 1 |
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" } |
