Skip to content

Commit bd44489

Browse files
committed
go: upi payment
1 parent a6ef9e9 commit bd44489

17 files changed

Lines changed: 277 additions & 79 deletions

File tree

infra/console.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
122122
properties: {
123123
product: zenLiteProduct.id,
124124
price: zenLitePrice.id,
125+
priceInr: 92900,
125126
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
126127
},
127128
})

packages/console/app/src/component/icon.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
7676
)
7777
}
7878

79+
export function IconUpi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
80+
return (
81+
<svg {...props} viewBox="10 16 100 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
82+
<path d="M95.678 42.9 110 29.835l-6.784-13.516Z" />
83+
<path d="M90.854 42.9 105.176 29.835l-6.784-13.516Z" />
84+
<path
85+
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"
86+
fill-rule="evenodd"
87+
/>
88+
</svg>
89+
)
90+
}
91+
7992
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
8093
return (
8194
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">

packages/console/app/src/component/modal.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,6 @@
6262
font-size: var(--font-size-lg);
6363
font-weight: 600;
6464
color: var(--color-text);
65+
text-align: center;
6566
}
6667
}

packages/console/app/src/routes/stripe/webhook.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
244244
customerID,
245245
enrichment: {
246246
type: productID === LiteData.productID() ? "lite" : "subscription",
247+
currency: body.data.object.currency === "inr" ? "inr" : undefined,
247248
couponID,
248249
},
249250
}),
@@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
331332
)
332333
if (!workspaceID) throw new Error("Workspace ID not found")
333334

334-
const amount = await Database.use((tx) =>
335+
const payment = await Database.use((tx) =>
335336
tx
336337
.select({
337338
amount: PaymentTable.amount,
339+
enrichment: PaymentTable.enrichment,
338340
})
339341
.from(PaymentTable)
340342
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
341-
.then((rows) => rows[0]?.amount),
343+
.then((rows) => rows[0]),
342344
)
343-
if (!amount) throw new Error("Payment not found")
345+
if (!payment) throw new Error("Payment not found")
344346

345347
await Database.transaction(async (tx) => {
346348
await tx
@@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
350352
})
351353
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
352354

353-
await tx
354-
.update(BillingTable)
355-
.set({
356-
balance: sql`${BillingTable.balance} - ${amount}`,
357-
})
358-
.where(eq(BillingTable.workspaceID, workspaceID))
355+
// deduct balance only for top up
356+
if (!payment.enrichment?.type) {
357+
await tx
358+
.update(BillingTable)
359+
.set({
360+
balance: sql`${BillingTable.balance} - ${payment.amount}`,
361+
})
362+
.where(eq(BillingTable.workspaceID, workspaceID))
363+
}
359364
})
360365
}
361366
})()

packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
33
import { createStore } from "solid-js/store"
44
import { Billing } from "@opencode-ai/console-core/billing.js"
55
import { withActor } from "~/context/auth.withActor"
6-
import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon"
6+
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
77
import styles from "./billing-section.module.css"
88
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
99
import { useI18n } from "~/context/i18n"
@@ -211,6 +211,9 @@ export function BillingSection() {
211211
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
212212
<IconWechat style={{ width: "24px", height: "24px" }} />
213213
</Match>
214+
<Match when={billingInfo()?.paymentMethodType === "upi"}>
215+
<IconUpi style={{ width: "auto", height: "16px" }} />
216+
</Match>
214217
</Switch>
215218
</div>
216219
<div data-slot="card-details">

packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
66
import styles from "./payment-section.module.css"
77
import { useI18n } from "~/context/i18n"
88

9+
function money(amount: number, currency?: string) {
10+
const formatter =
11+
currency === "inr"
12+
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
13+
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
14+
return formatter.format(amount / 100_000_000)
15+
}
16+
917
const getPaymentsInfo = query(async (workspaceID: string) => {
1018
"use server"
1119
return withActor(async () => {
@@ -81,14 +89,18 @@ export function PaymentSection() {
8189
const date = new Date(payment.timeCreated)
8290
const amount =
8391
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
92+
const currency =
93+
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
94+
? payment.enrichment.currency
95+
: undefined
8496
return (
8597
<tr>
8698
<td data-slot="payment-date" title={formatDateUTC(date)}>
8799
{formatDateForTable(date)}
88100
</td>
89101
<td data-slot="payment-id">{payment.id}</td>
90102
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
91-
${((amount ?? 0) / 100000000).toFixed(2)}
103+
{money(amount, currency)}
92104
<Switch>
93105
<Match when={payment.enrichment?.type === "credit"}>
94106
{" "}

packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,45 @@
188188
line-height: 1.4;
189189
}
190190

191+
[data-slot="subscribe-actions"] {
192+
display: flex;
193+
align-items: center;
194+
gap: var(--space-4);
195+
margin-top: var(--space-4);
196+
}
197+
191198
[data-slot="subscribe-button"] {
192-
align-self: flex-start;
199+
align-self: stretch;
200+
}
201+
202+
[data-slot="other-methods"] {
203+
display: inline-flex;
204+
align-items: center;
205+
justify-content: center;
206+
gap: var(--space-2);
207+
}
208+
209+
[data-slot="other-methods-icons"] {
210+
display: inline-flex;
211+
align-items: center;
212+
gap: 4px;
213+
}
214+
215+
[data-slot="modal-actions"] {
216+
display: flex;
217+
gap: var(--space-3);
193218
margin-top: var(--space-4);
219+
220+
button {
221+
flex: 1;
222+
}
223+
}
224+
225+
[data-slot="method-button"] {
226+
display: flex;
227+
align-items: center;
228+
justify-content: flex-start;
229+
gap: var(--space-2);
230+
height: 48px;
194231
}
195232
}

packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx

Lines changed: 90 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
22
import { createStore } from "solid-js/store"
33
import { createMemo, For, Show } from "solid-js"
4+
import { Modal } from "~/component/modal"
45
import { Billing } from "@opencode-ai/console-core/billing.js"
56
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
67
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
1415
import { useLanguage } from "~/context/language"
1516
import { formError } from "~/lib/form-error"
1617

18+
import { IconAlipay, IconUpi } from "~/component/icon"
19+
1720
const queryLiteSubscription = query(async (workspaceID: string) => {
1821
"use server"
1922
return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
7881
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
7982
}
8083

81-
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
82-
"use server"
83-
return json(
84-
await withActor(
85-
() =>
86-
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
87-
.then((data) => ({ error: undefined, data }))
88-
.catch((e) => ({
89-
error: e.message as string,
90-
data: undefined,
91-
})),
92-
workspaceID,
93-
),
94-
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
95-
)
96-
}, "liteCheckoutUrl")
84+
const createLiteCheckoutUrl = action(
85+
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
86+
"use server"
87+
return json(
88+
await withActor(
89+
() =>
90+
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
91+
.then((data) => ({ error: undefined, data }))
92+
.catch((e) => ({
93+
error: e.message as string,
94+
data: undefined,
95+
})),
96+
workspaceID,
97+
),
98+
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
99+
)
100+
},
101+
"liteCheckoutUrl",
102+
)
97103

98104
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
99105
"use server"
@@ -147,23 +153,30 @@ export function LiteSection() {
147153
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
148154
const useBalanceSubmission = useSubmission(setLiteUseBalance)
149155
const [store, setStore] = createStore({
150-
redirecting: false,
156+
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
157+
showModal: false,
151158
})
152159

160+
const busy = createMemo(() => !!store.loading)
161+
153162
async function onClickSession() {
163+
setStore("loading", "session")
154164
const result = await sessionAction(params.id!, window.location.href)
155165
if (result.data) {
156-
setStore("redirecting", true)
157166
window.location.href = result.data
167+
return
158168
}
169+
setStore("loading", undefined)
159170
}
160171

161-
async function onClickSubscribe() {
162-
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
172+
async function onClickSubscribe(method?: "alipay" | "upi") {
173+
setStore("loading", method ?? "checkout")
174+
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
163175
if (result.data) {
164-
setStore("redirecting", true)
165176
window.location.href = result.data
177+
return
166178
}
179+
setStore("loading", undefined)
167180
}
168181

169182
return (
@@ -179,12 +192,8 @@ export function LiteSection() {
179192
<div data-slot="section-title">
180193
<div data-slot="title-row">
181194
<p>{i18n.t("workspace.lite.subscription.message")}</p>
182-
<button
183-
data-color="primary"
184-
disabled={sessionSubmission.pending || store.redirecting}
185-
onClick={onClickSession}
186-
>
187-
{sessionSubmission.pending || store.redirecting
195+
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
196+
{store.loading === "session"
188197
? i18n.t("workspace.lite.loading")
189198
: i18n.t("workspace.lite.subscription.manage")}
190199
</button>
@@ -282,16 +291,60 @@ export function LiteSection() {
282291
<li>MiniMax M2.7</li>
283292
</ul>
284293
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
285-
<button
286-
data-slot="subscribe-button"
287-
data-color="primary"
288-
disabled={checkoutSubmission.pending || store.redirecting}
289-
onClick={onClickSubscribe}
290-
>
291-
{checkoutSubmission.pending || store.redirecting
292-
? i18n.t("workspace.lite.promo.subscribing")
293-
: i18n.t("workspace.lite.promo.subscribe")}
294-
</button>
294+
<div data-slot="subscribe-actions">
295+
<button
296+
data-slot="subscribe-button"
297+
data-color="primary"
298+
disabled={checkoutSubmission.pending || busy()}
299+
onClick={() => onClickSubscribe()}
300+
>
301+
{store.loading === "checkout"
302+
? i18n.t("workspace.lite.promo.subscribing")
303+
: i18n.t("workspace.lite.promo.subscribe")}
304+
</button>
305+
<button
306+
type="button"
307+
data-slot="other-methods"
308+
data-color="ghost"
309+
onClick={() => setStore("showModal", true)}
310+
>
311+
<span>Other payment methods</span>
312+
<span data-slot="other-methods-icons">
313+
<span> </span>
314+
<IconAlipay style={{ width: "16px", height: "16px" }} />
315+
<span> </span>
316+
<IconUpi style={{ width: "auto", height: "10px" }} />
317+
</span>
318+
</button>
319+
</div>
320+
<Modal open={store.showModal} onClose={() => setStore("showModal", false)} title="Select payment method">
321+
<div data-slot="modal-actions">
322+
<button
323+
type="button"
324+
data-slot="method-button"
325+
data-color="ghost"
326+
disabled={checkoutSubmission.pending || busy()}
327+
onClick={() => onClickSubscribe("alipay")}
328+
>
329+
<Show when={store.loading !== "alipay"}>
330+
<IconAlipay style={{ width: "24px", height: "24px" }} />
331+
</Show>
332+
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
333+
</button>
334+
<button
335+
type="button"
336+
data-slot="method-button"
337+
data-color="ghost"
338+
disabled={checkoutSubmission.pending || busy()}
339+
onClick={() => onClickSubscribe("upi")}
340+
>
341+
<Show when={store.loading !== "upi"}>
342+
<IconUpi style={{ width: "auto", height: "16px" }} />
343+
</Show>
344+
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
345+
</button>
346+
</div>
347+
</Modal>
295348
</section>
296349
</Show>
297350
</>

0 commit comments

Comments
 (0)