Frank 4 недель назад
Родитель
Сommit
bd44489ada

+ 1 - 0
infra/console.ts

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

+ 13 - 0
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">

+ 1 - 0
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;
   }
 }

+ 14 - 9
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))
+        }
       })
     }
   })()

+ 4 - 1
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">

+ 13 - 1
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"}>
                             {" "}

+ 38 - 1
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;
   }
 }

+ 90 - 37
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>
     </>

+ 95 - 30
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
+      }
     },
   )
 

+ 1 - 0
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")
 }

+ 1 - 0
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
         }
       | {

+ 1 - 0
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"
     }

+ 1 - 0
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"
     }

+ 1 - 0
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"
     }

+ 1 - 0
packages/enterprise/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"
     }

+ 1 - 0
packages/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"
     }

+ 1 - 0
sst-env.d.ts

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