go: upi payment

This commit is contained in:
Frank 2026-03-19 18:44:21 -04:00
parent a6ef9e9206
commit bd44489ada
17 changed files with 278 additions and 80 deletions

View File

@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: { properties: {
product: zenLiteProduct.id, product: zenLiteProduct.id,
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id, firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
}, },
}) })

View File

@ -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>) { export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">

View File

@ -62,5 +62,6 @@
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: 600; font-weight: 600;
color: var(--color-text); color: var(--color-text);
text-align: center;
} }
} }

View File

@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
customerID, customerID,
enrichment: { enrichment: {
type: productID === LiteData.productID() ? "lite" : "subscription", type: productID === LiteData.productID() ? "lite" : "subscription",
currency: body.data.object.currency === "inr" ? "inr" : undefined,
couponID, couponID,
}, },
}), }),
@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
) )
if (!workspaceID) throw new Error("Workspace ID not found") if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) => const payment = await Database.use((tx) =>
tx tx
.select({ .select({
amount: PaymentTable.amount, amount: PaymentTable.amount,
enrichment: PaymentTable.enrichment,
}) })
.from(PaymentTable) .from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) .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 Database.transaction(async (tx) => {
await tx await tx
@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
}) })
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx // deduct balance only for top up
.update(BillingTable) if (!payment.enrichment?.type) {
.set({ await tx
balance: sql`${BillingTable.balance} - ${amount}`, .update(BillingTable)
}) .set({
.where(eq(BillingTable.workspaceID, workspaceID)) balance: sql`${BillingTable.balance} - ${payment.amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
}
}) })
} }
})() })()

View File

@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor" 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 styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common" import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
@ -211,6 +211,9 @@ export function BillingSection() {
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}> <Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<IconWechat style={{ width: "24px", height: "24px" }} /> <IconWechat style={{ width: "24px", height: "24px" }} />
</Match> </Match>
<Match when={billingInfo()?.paymentMethodType === "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Match>
</Switch> </Switch>
</div> </div>
<div data-slot="card-details"> <div data-slot="card-details">

View File

@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css" import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n" 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) => { const getPaymentsInfo = query(async (workspaceID: string) => {
"use server" "use server"
return withActor(async () => { return withActor(async () => {
@ -81,6 +89,10 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated) const date = new Date(payment.timeCreated)
const amount = const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.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 ( return (
<tr> <tr>
<td data-slot="payment-date" title={formatDateUTC(date)}> <td data-slot="payment-date" title={formatDateUTC(date)}>
@ -88,7 +100,7 @@ export function PaymentSection() {
</td> </td>
<td data-slot="payment-id">{payment.id}</td> <td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}> <td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((amount ?? 0) / 100000000).toFixed(2)} {money(amount, currency)}
<Switch> <Switch>
<Match when={payment.enrichment?.type === "credit"}> <Match when={payment.enrichment?.type === "credit"}>
{" "} {" "}

View File

@ -188,8 +188,45 @@
line-height: 1.4; line-height: 1.4;
} }
[data-slot="subscribe-button"] { [data-slot="subscribe-actions"] {
align-self: flex-start; display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-4); margin-top: var(--space-4);
} }
[data-slot="subscribe-button"] {
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;
}
} }

View File

@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.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" 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 { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error" import { formError } from "~/lib/form-error"
import { IconAlipay, IconUpi } from "~/component/icon"
const queryLiteSubscription = query(async (workspaceID: string) => { const queryLiteSubscription = query(async (workspaceID: string) => {
"use server" "use server"
return withActor(async () => { 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")}` 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) => { const createLiteCheckoutUrl = action(
"use server" async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
return json( "use server"
await withActor( return json(
() => await withActor(
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl }) () =>
.then((data) => ({ error: undefined, data })) Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
.catch((e) => ({ .then((data) => ({ error: undefined, data }))
error: e.message as string, .catch((e) => ({
data: undefined, error: e.message as string,
})), data: undefined,
workspaceID, })),
), workspaceID,
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, ),
) { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
}, "liteCheckoutUrl") )
},
"liteCheckoutUrl",
)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server" "use server"
@ -147,23 +153,30 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl) const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance) const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
redirecting: false, loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
}) })
const busy = createMemo(() => !!store.loading)
async function onClickSession() { async function onClickSession() {
setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href) const result = await sessionAction(params.id!, window.location.href)
if (result.data) { if (result.data) {
setStore("redirecting", true)
window.location.href = result.data window.location.href = result.data
return
} }
setStore("loading", undefined)
} }
async function onClickSubscribe() { async function onClickSubscribe(method?: "alipay" | "upi") {
const result = await checkoutAction(params.id!, window.location.href, window.location.href) setStore("loading", method ?? "checkout")
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) { if (result.data) {
setStore("redirecting", true)
window.location.href = result.data window.location.href = result.data
return
} }
setStore("loading", undefined)
} }
return ( return (
@ -179,12 +192,8 @@ export function LiteSection() {
<div data-slot="section-title"> <div data-slot="section-title">
<div data-slot="title-row"> <div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p> <p>{i18n.t("workspace.lite.subscription.message")}</p>
<button <button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
data-color="primary" {store.loading === "session"
disabled={sessionSubmission.pending || store.redirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.redirecting
? i18n.t("workspace.lite.loading") ? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")} : i18n.t("workspace.lite.subscription.manage")}
</button> </button>
@ -282,16 +291,60 @@ export function LiteSection() {
<li>MiniMax M2.7</li> <li>MiniMax M2.7</li>
</ul> </ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p> <p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<button <div data-slot="subscribe-actions">
data-slot="subscribe-button" <button
data-color="primary" data-slot="subscribe-button"
disabled={checkoutSubmission.pending || store.redirecting} data-color="primary"
onClick={onClickSubscribe} disabled={checkoutSubmission.pending || busy()}
> onClick={() => onClickSubscribe()}
{checkoutSubmission.pending || store.redirecting >
? i18n.t("workspace.lite.promo.subscribing") {store.loading === "checkout"
: i18n.t("workspace.lite.promo.subscribe")} ? i18n.t("workspace.lite.promo.subscribing")
</button> : 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> </section>
</Show> </Show>
</> </>

View File

@ -239,10 +239,11 @@ export namespace Billing {
z.object({ z.object({
successUrl: z.string(), successUrl: z.string(),
cancelUrl: z.string(), cancelUrl: z.string(),
method: z.enum(["alipay", "upi"]).optional(),
}), }),
async (input) => { async (input) => {
const user = Actor.assert("user") const user = Actor.assert("user")
const { successUrl, cancelUrl } = input const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID) const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get() const billing = await Billing.get()
@ -250,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black") if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite") if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const session = await Billing.stripe().checkout.sessions.create({ const createSession = () =>
mode: "subscription", Billing.stripe().checkout.sessions.create({
billing_address_collection: "required", mode: "subscription",
line_items: [{ price: LiteData.priceID(), quantity: 1 }], discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
discounts: [{ coupon: LiteData.firstMonth50Coupon() }], ...(billing.customerID
...(billing.customerID ? {
? { customer: billing.customerID,
customer: billing.customerID, customer_update: {
customer_update: { name: "auto",
name: "auto", address: "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,
},
}
} }
: { if (method === "upi") {
customer_email: email!, return {
}), line_items: [
currency: "usd", {
tax_id_collection: { price_data: {
enabled: true, currency: "inr",
}, product: LiteData.productID(),
success_url: successUrl, recurring: {
cancel_url: cancelUrl, interval: "month",
subscription_data: { interval_count: 1,
metadata: { },
workspaceID: Actor.workspace(), unit_amount: LiteData.priceInr(),
userID: user.properties.userID, },
type: "lite", 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
}
}, },
) )

View File

@ -10,6 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product) export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price) 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 firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite") export const planName = fn(z.void(), () => "lite")
} }

View File

@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type< enrichment: json("enrichment").$type<
| { | {
type: "subscription" | "lite" type: "subscription" | "lite"
currency?: "inr"
couponID?: string couponID?: string
} }
| { | {

View File

@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }

View File

@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }

View File

@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }

View File

@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }

View File

@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }

1
sst-env.d.ts vendored
View File

@ -171,6 +171,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }