summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/routes
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-02-24 04:45:39 -0500
committerFrank <[email protected]>2026-02-24 04:45:41 -0500
commitfb6d201ee03d73967c554394742be360e2ff782d (patch)
tree25cbda2e9ec598dfa5a5493f67cb432c5287d624 /packages/console/app/src/routes
parentcda2af2589ddef9265ca2db379ecd4ab556f6be8 (diff)
downloadopencode-fb6d201ee03d73967c554394742be360e2ff782d.tar.gz
opencode-fb6d201ee03d73967c554394742be360e2ff782d.zip
wip: zen lite
Diffstat (limited to 'packages/console/app/src/routes')
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts362
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/index.tsx9
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css160
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx269
-rw-r--r--packages/console/app/src/routes/workspace/[id]/graph-section.tsx62
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.tsx36
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx2
-rw-r--r--packages/console/app/src/routes/zen/lite/v1/messages.ts12
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts246
10 files changed, 779 insertions, 381 deletions
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index 828eb4c71..47ca442ec 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -1,13 +1,13 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
-import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
-import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { BillingTable, LiteTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
-import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
-import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
+import { LiteData } from "@opencode-ai/console-core/lite.js"
+import { BlackData } from "@opencode-ai/console-core/black.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -103,310 +103,93 @@ export async function POST(input: APIEvent) {
})
})
}
- if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
- const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
- const amountInCents = body.data.object.amount_total as number
- const customerID = body.data.object.customer as string
- const customerEmail = body.data.object.customer_details?.email as string
- const invoiceID = body.data.object.invoice as string
- const subscriptionID = body.data.object.subscription as string
- const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
-
- if (!workspaceID) throw new Error("Workspace ID not found")
- if (!customerID) throw new Error("Customer ID not found")
- if (!amountInCents) throw new Error("Amount not found")
- if (!invoiceID) throw new Error("Invoice ID not found")
- if (!subscriptionID) throw new Error("Subscription ID not found")
-
- // get payment id from invoice
- const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
- expand: ["payments"],
- })
- const paymentID = invoice.payments?.data[0].payment.payment_intent as string
- if (!paymentID) throw new Error("Payment ID not found")
-
- // get payment method for the payment intent
- const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
- expand: ["payment_method"],
- })
- const paymentMethod = paymentIntent.payment_method
- if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
-
- // get coupon id from promotion code
- const couponID = await (async () => {
- if (!promoCode) return
- const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
- const couponID = coupon.coupon.id
- if (!couponID) throw new Error("Coupon not found for promotion code")
- return couponID
- })()
-
- await Actor.provide("system", { workspaceID }, async () => {
- // look up current billing
- const billing = await Billing.get()
- if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
+ if (body.type === "customer.subscription.created") {
+ const type = body.data.object.metadata?.type
+ if (type === "lite") {
+ const workspaceID = body.data.object.metadata?.workspaceID
+ const userID = body.data.object.metadata?.userID
+ const customerID = body.data.object.customer as string
+ const invoiceID = body.data.object.latest_invoice as string
+ const subscriptionID = body.data.object.id as string
- // Temporarily skip this check because during Black drop, user can checkout
- // as a new customer
- //if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
+ if (!workspaceID) throw new Error("Workspace ID not found")
+ if (!userID) throw new Error("User ID not found")
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!invoiceID) throw new Error("Invoice ID not found")
+ if (!subscriptionID) throw new Error("Subscription ID not found")
- // Temporarily check the user to apply to. After Black drop, we will allow
- // look up the user to apply to
- const users = await Database.use((tx) =>
- tx
- .select({ id: UserTable.id, email: AuthTable.subject })
- .from(UserTable)
- .innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
- .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
- )
- const user = users.find((u) => u.email === customerEmail) ?? users[0]
- if (!user) {
- console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
- process.exit(1)
- }
+ // get payment id from invoice
+ const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
+ expand: ["payments"],
+ })
+ const paymentID = invoice.payments?.data[0].payment.payment_intent as string
+ if (!paymentID) throw new Error("Payment ID not found")
- // set customer metadata
- if (!billing?.customerID) {
- await Billing.stripe().customers.update(customerID, {
- metadata: {
- workspaceID,
- },
- })
- }
+ // get payment method for the payment intent
+ const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
+ expand: ["payment_method"],
+ })
+ const paymentMethod = paymentIntent.payment_method
+ if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
- await Database.transaction(async (tx) => {
- await tx
- .update(BillingTable)
- .set({
- customerID,
- subscriptionID,
- subscription: {
- status: "subscribed",
- coupon: couponID,
- seats: 1,
- plan: "200",
+ await Actor.provide("system", { workspaceID }, async () => {
+ // look up current billing
+ const billing = await Billing.get()
+ if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
+ if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch")
+
+ // set customer metadata
+ if (!billing?.customerID) {
+ await Billing.stripe().customers.update(customerID, {
+ metadata: {
+ workspaceID,
},
- paymentMethodID: paymentMethod.id,
- paymentMethodLast4: paymentMethod.card?.last4 ?? null,
- paymentMethodType: paymentMethod.type,
})
- .where(eq(BillingTable.workspaceID, workspaceID))
+ }
- await tx.insert(SubscriptionTable).values({
- workspaceID,
- id: Identifier.create("subscription"),
- userID: user.id,
- })
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ customerID,
+ liteSubscriptionID: subscriptionID,
+ lite: {},
+ paymentMethodID: paymentMethod.id,
+ paymentMethodLast4: paymentMethod.card?.last4 ?? null,
+ paymentMethodType: paymentMethod.type,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
- await tx.insert(PaymentTable).values({
- workspaceID,
- id: Identifier.create("payment"),
- amount: centsToMicroCents(amountInCents),
- paymentID,
- invoiceID,
- customerID,
- enrichment: {
- type: "subscription",
- couponID,
- },
+ await tx.insert(LiteTable).values({
+ workspaceID,
+ id: Identifier.create("lite"),
+ userID: userID,
+ })
})
})
- })
- }
- if (body.type === "customer.subscription.created") {
- /*
-{
- id: "evt_1Smq802SrMQ2Fneksse5FMNV",
- object: "event",
- api_version: "2025-07-30.basil",
- created: 1767766916,
- data: {
- object: {
- id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
- object: "subscription",
- application: null,
- application_fee_percent: null,
- automatic_tax: {
- disabled_reason: null,
- enabled: false,
- liability: null,
- },
- billing_cycle_anchor: 1770445200,
- billing_cycle_anchor_config: null,
- billing_mode: {
- flexible: {
- proration_discounts: "included",
- },
- type: "flexible",
- updated_at: 1770445200,
- },
- billing_thresholds: null,
- cancel_at: null,
- cancel_at_period_end: false,
- canceled_at: null,
- cancellation_details: {
- comment: null,
- feedback: null,
- reason: null,
- },
- collection_method: "charge_automatically",
- created: 1770445200,
- currency: "usd",
- customer: "cus_TkKmZZvysJ2wej",
- customer_account: null,
- days_until_due: null,
- default_payment_method: null,
- default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
- default_tax_rates: [],
- description: null,
- discounts: [],
- ended_at: null,
- invoice_settings: {
- account_tax_ids: null,
- issuer: {
- type: "self",
- },
- },
- items: {
- object: "list",
- data: [
- {
- id: "si_TkKnBKXFX76t0O",
- object: "subscription_item",
- billing_thresholds: null,
- created: 1770445200,
- current_period_end: 1772864400,
- current_period_start: 1770445200,
- discounts: [],
- metadata: {},
- plan: {
- id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
- object: "plan",
- active: true,
- amount: 20000,
- amount_decimal: "20000",
- billing_scheme: "per_unit",
- created: 1767725082,
- currency: "usd",
- interval: "month",
- interval_count: 1,
- livemode: false,
- metadata: {},
- meter: null,
- nickname: null,
- product: "prod_Tk9LjWT1n0DgYm",
- tiers_mode: null,
- transform_usage: null,
- trial_period_days: null,
- usage_type: "licensed",
- },
- price: {
- id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
- object: "price",
- active: true,
- billing_scheme: "per_unit",
- created: 1767725082,
- currency: "usd",
- custom_unit_amount: null,
- livemode: false,
- lookup_key: null,
- metadata: {},
- nickname: null,
- product: "prod_Tk9LjWT1n0DgYm",
- recurring: {
- interval: "month",
- interval_count: 1,
- meter: null,
- trial_period_days: null,
- usage_type: "licensed",
- },
- tax_behavior: "unspecified",
- tiers_mode: null,
- transform_quantity: null,
- type: "recurring",
- unit_amount: 20000,
- unit_amount_decimal: "20000",
- },
- quantity: 1,
- subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
- tax_rates: [],
- },
- ],
- has_more: false,
- total_count: 1,
- url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
- },
- latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
- livemode: false,
- metadata: {},
- next_pending_invoice_item_invoice: null,
- on_behalf_of: null,
- pause_collection: null,
- payment_settings: {
- payment_method_options: null,
- payment_method_types: null,
- save_default_payment_method: "off",
- },
- pending_invoice_item_interval: null,
- pending_setup_intent: null,
- pending_update: null,
- plan: {
- id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
- object: "plan",
- active: true,
- amount: 20000,
- amount_decimal: "20000",
- billing_scheme: "per_unit",
- created: 1767725082,
- currency: "usd",
- interval: "month",
- interval_count: 1,
- livemode: false,
- metadata: {},
- meter: null,
- nickname: null,
- product: "prod_Tk9LjWT1n0DgYm",
- tiers_mode: null,
- transform_usage: null,
- trial_period_days: null,
- usage_type: "licensed",
- },
- quantity: 1,
- schedule: null,
- start_date: 1770445200,
- status: "active",
- test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
- transfer_data: null,
- trial_end: null,
- trial_settings: {
- end_behavior: {
- missing_payment_method: "create_invoice",
- },
- },
- trial_start: null,
- },
- },
- livemode: false,
- pending_webhooks: 0,
- request: {
- id: "req_6YO9stvB155WJD",
- idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
- },
- type: "customer.subscription.created",
-}
- */
+ }
}
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
- await Billing.unsubscribe({ subscriptionID })
+ const productID = body.data.object.items.data[0].price.product as string
+ if (productID === LiteData.productID()) {
+ await Billing.unsubscribeLite({ subscriptionID })
+ } else if (productID === BlackData.productID()) {
+ await Billing.unsubscribeBlack({ subscriptionID })
+ }
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
- await Billing.unsubscribe({ subscriptionID })
+ const productID = body.data.object.items.data[0].price.product as string
+ if (productID === LiteData.productID()) {
+ await Billing.unsubscribeLite({ subscriptionID })
+ } else if (productID === BlackData.productID()) {
+ await Billing.unsubscribeBlack({ subscriptionID })
+ }
}
if (body.type === "invoice.payment_succeeded") {
if (
@@ -430,6 +213,7 @@ export async function POST(input: APIEvent) {
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
+ const productID = subscriptionData.items.data[0].price.product as string
// get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
@@ -459,7 +243,7 @@ export async function POST(input: APIEvent) {
invoiceID,
customerID,
enrichment: {
- type: "subscription",
+ type: productID === LiteData.productID() ? "lite" : "subscription",
couponID,
},
}),
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
index 5326306e2..b8f089864 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
@@ -90,7 +90,7 @@ const enroll = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
- await Billing.subscribe({ seats: 1 })
+ await Billing.subscribeBlack({ seats: 1 })
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
index a252a0234..9fbdad2ef 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
@@ -3,7 +3,8 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
-import { Show } from "solid-js"
+import { LiteSection } from "./lite-section"
+import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -11,14 +12,18 @@ export default function () {
const params = useParams()
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
+ const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={sessionInfo()?.isAdmin}>
- <Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
+ <Show when={isBlack()}>
<BlackSection />
</Show>
+ <Show when={!isBlack() && sessionInfo()?.isBeta}>
+ <LiteSection />
+ </Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css
new file mode 100644
index 000000000..20662ab61
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css
@@ -0,0 +1,160 @@
+.root {
+ [data-slot="title-row"] {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+ }
+
+ [data-slot="usage"] {
+ display: flex;
+ gap: var(--space-6);
+ margin-top: var(--space-4);
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+ gap: var(--space-4);
+ }
+ }
+
+ [data-slot="usage-item"] {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+
+ [data-slot="usage-header"] {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ }
+
+ [data-slot="usage-label"] {
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ color: var(--color-text);
+ }
+
+ [data-slot="usage-value"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="progress"] {
+ height: 8px;
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ overflow: hidden;
+ }
+
+ [data-slot="progress-bar"] {
+ height: 100%;
+ background-color: var(--color-accent);
+ border-radius: var(--border-radius-sm);
+ transition: width 0.3s ease;
+ }
+
+ [data-slot="reset-time"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="setting-row"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+
+ p {
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+ margin: 0;
+ }
+ }
+
+ [data-slot="toggle-label"] {
+ position: relative;
+ display: inline-block;
+ width: 2.5rem;
+ height: 1.5rem;
+ cursor: pointer;
+ flex-shrink: 0;
+
+ input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ span {
+ position: absolute;
+ inset: 0;
+ background-color: #ccc;
+ border: 1px solid #bbb;
+ border-radius: 1.5rem;
+ transition: all 0.3s ease;
+ cursor: pointer;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 0.125rem;
+ width: 1.25rem;
+ height: 1.25rem;
+ background-color: white;
+ border: 1px solid #ddd;
+ border-radius: 50%;
+ transform: translateY(-50%);
+ transition: all 0.3s ease;
+ }
+ }
+
+ input:checked + span {
+ background-color: #21ad0e;
+ border-color: #148605;
+
+ &::before {
+ transform: translateX(1rem) translateY(-50%);
+ }
+ }
+
+ &:hover span {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+
+ input:checked:hover + span {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
+ }
+
+ &:has(input:disabled) {
+ cursor: not-allowed;
+ }
+
+ input:disabled + span {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ [data-slot="other-message"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ line-height: 1.5;
+ }
+
+ [data-slot="promo-description"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ line-height: 1.5;
+ margin-top: var(--space-2);
+ }
+
+ [data-slot="subscribe-button"] {
+ align-self: flex-start;
+ margin-top: var(--space-4);
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx
new file mode 100644
index 000000000..c9192fdcf
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx
@@ -0,0 +1,269 @@
+import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
+import { createStore } from "solid-js/store"
+import { Show } from "solid-js"
+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"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { Subscription } from "@opencode-ai/console-core/subscription.js"
+import { LiteData } from "@opencode-ai/console-core/lite.js"
+import { withActor } from "~/context/auth.withActor"
+import { queryBillingInfo } from "../../common"
+import styles from "./lite-section.module.css"
+import { useI18n } from "~/context/i18n"
+
+const queryLiteSubscription = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ const row = await Database.use((tx) =>
+ tx
+ .select({
+ userID: LiteTable.userID,
+ rollingUsage: LiteTable.rollingUsage,
+ weeklyUsage: LiteTable.weeklyUsage,
+ monthlyUsage: LiteTable.monthlyUsage,
+ timeRollingUpdated: LiteTable.timeRollingUpdated,
+ timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
+ timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
+ timeCreated: LiteTable.timeCreated,
+ lite: BillingTable.lite,
+ })
+ .from(BillingTable)
+ .innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
+ .where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
+ .then((r) => r[0]),
+ )
+ if (!row) return null
+
+ const limits = LiteData.getLimits()
+ const mine = row.userID === Actor.userID()
+
+ return {
+ mine,
+ useBalance: row.lite?.useBalance ?? false,
+ rollingUsage: Subscription.analyzeRollingUsage({
+ limit: limits.rollingLimit,
+ window: limits.rollingWindow,
+ usage: row.rollingUsage ?? 0,
+ timeUpdated: row.timeRollingUpdated ?? new Date(),
+ }),
+ weeklyUsage: Subscription.analyzeWeeklyUsage({
+ limit: limits.weeklyLimit,
+ usage: row.weeklyUsage ?? 0,
+ timeUpdated: row.timeWeeklyUpdated ?? new Date(),
+ }),
+ monthlyUsage: Subscription.analyzeMonthlyUsage({
+ limit: limits.monthlyLimit,
+ usage: row.monthlyUsage ?? 0,
+ timeUpdated: row.timeMonthlyUpdated ?? new Date(),
+ timeSubscribed: row.timeCreated,
+ }),
+ }
+ }, workspaceID)
+}, "lite.subscription.get")
+
+function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
+ const days = Math.floor(seconds / 86400)
+ if (days >= 1) {
+ const hours = Math.floor((seconds % 86400) / 3600)
+ return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
+ }
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ if (hours >= 1)
+ return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
+ if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
+ 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 createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
+ "use server"
+ return json(
+ await withActor(
+ () =>
+ Billing.generateSessionUrl({ returnUrl })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
+ )
+}, "liteSessionUrl")
+
+const setLiteUseBalance = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ const useBalance = form.get("useBalance")?.toString() === "true"
+
+ return json(
+ await withActor(async () => {
+ await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ lite: useBalance ? { useBalance: true } : {},
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID)),
+ )
+ return { error: undefined }
+ }, workspaceID).catch((e) => ({ error: e.message as string })),
+ { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
+ )
+}, "setLiteUseBalance")
+
+export function LiteSection() {
+ const params = useParams()
+ const i18n = useI18n()
+ const lite = createAsync(() => queryLiteSubscription(params.id!))
+ const sessionAction = useAction(createSessionUrl)
+ const sessionSubmission = useSubmission(createSessionUrl)
+ const checkoutAction = useAction(createLiteCheckoutUrl)
+ const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
+ const useBalanceSubmission = useSubmission(setLiteUseBalance)
+ const [store, setStore] = createStore({
+ redirecting: false,
+ })
+
+ async function onClickSession() {
+ const result = await sessionAction(params.id!, window.location.href)
+ if (result.data) {
+ setStore("redirecting", true)
+ window.location.href = result.data
+ }
+ }
+
+ async function onClickSubscribe() {
+ const result = await checkoutAction(params.id!, window.location.href, window.location.href)
+ if (result.data) {
+ setStore("redirecting", true)
+ window.location.href = result.data
+ }
+ }
+
+ return (
+ <>
+ <Show when={lite() && lite()!.mine && lite()!}>
+ {(sub) => (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.lite.subscription.title")}</h2>
+ <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
+ ? i18n.t("workspace.lite.loading")
+ : i18n.t("workspace.lite.subscription.manage")}
+ </button>
+ </div>
+ </div>
+ <div data-slot="usage">
+ <div data-slot="usage-item">
+ <div data-slot="usage-header">
+ <span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
+ <span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
+ </div>
+ <div data-slot="progress">
+ <div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
+ </div>
+ <span data-slot="reset-time">
+ {i18n.t("workspace.lite.subscription.resetsIn")}{" "}
+ {formatResetTime(sub().rollingUsage.resetInSec, i18n)}
+ </span>
+ </div>
+ <div data-slot="usage-item">
+ <div data-slot="usage-header">
+ <span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
+ <span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
+ </div>
+ <div data-slot="progress">
+ <div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
+ </div>
+ <span data-slot="reset-time">
+ {i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
+ </span>
+ </div>
+ <div data-slot="usage-item">
+ <div data-slot="usage-header">
+ <span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
+ <span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
+ </div>
+ <div data-slot="progress">
+ <div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
+ </div>
+ <span data-slot="reset-time">
+ {i18n.t("workspace.lite.subscription.resetsIn")}{" "}
+ {formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
+ </span>
+ </div>
+ </div>
+ <form action={setLiteUseBalance} method="post" data-slot="setting-row">
+ <p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
+ <label data-slot="toggle-label">
+ <input
+ type="checkbox"
+ checked={sub().useBalance}
+ disabled={useBalanceSubmission.pending}
+ onChange={(e) => e.currentTarget.form?.requestSubmit()}
+ />
+ <span></span>
+ </label>
+ </form>
+ </section>
+ )}
+ </Show>
+ <Show when={lite() && !lite()!.mine}>
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.lite.other.title")}</h2>
+ </div>
+ <p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
+ </section>
+ </Show>
+ <Show when={lite() === null}>
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.lite.promo.title")}</h2>
+ </div>
+ <p data-slot="promo-description">{i18n.t("workspace.lite.promo.description")}</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>
+ </section>
+ </Show>
+ </>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
index f26c7291d..56a31cdd0 100644
--- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
@@ -36,7 +36,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
- subscription: sql<boolean>`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
+ plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
})
.from(UsageTable)
.where(
@@ -50,13 +50,13 @@ async function getCosts(workspaceID: string, year: number, month: number) {
sql`DATE(${UsageTable.timeCreated})`,
UsageTable.model,
UsageTable.keyID,
- sql`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
+ sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
- subscription: Boolean(r.subscription),
+ plan: r.plan as "sub" | "lite" | "byok" | null,
})),
),
)
@@ -218,18 +218,21 @@ export function GraphSection() {
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
+ const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
+ const dailyDataRegular = new Map<string, Map<string, number>>()
const dailyDataSub = new Map<string, Map<string, number>>()
- const dailyDataNonSub = new Map<string, Map<string, number>>()
+ const dailyDataLite = new Map<string, Map<string, number>>()
for (const dateKey of dates) {
+ dailyDataRegular.set(dateKey, new Map())
dailyDataSub.set(dateKey, new Map())
- dailyDataNonSub.set(dateKey, new Map())
+ dailyDataLite.set(dateKey, new Map())
}
data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
- const targetMap = row.subscription ? dailyDataSub : dailyDataNonSub
+ const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
const dayMap = targetMap.get(row.date)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
@@ -237,15 +240,15 @@ export function GraphSection() {
const filteredModels = store.model === null ? getModels() : [store.model]
- // Create datasets: non-subscription first, then subscription (with hatched pattern effect via opacity)
+ // Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
const datasets = [
...filteredModels
- .filter((model) => dates.some((date) => (dailyDataNonSub.get(date)?.get(model) || 0) > 0))
+ .filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0))
.map((model) => {
const color = getModelColor(model)
return {
label: model,
- data: dates.map((date) => (dailyDataNonSub.get(date)?.get(model) || 0) / 100_000_000),
+ data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
@@ -266,6 +269,21 @@ export function GraphSection() {
stack: "subscription",
}
}),
+ ...filteredModels
+ .filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0))
+ .map((model) => {
+ const color = getModelColor(model)
+ return {
+ label: `${model}${liteSuffix}`,
+ data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000),
+ backgroundColor: addOpacityToColor(color, 0.35),
+ hoverBackgroundColor: addOpacityToColor(color, 0.55),
+ borderWidth: 1,
+ borderColor: addOpacityToColor(color, 0.7),
+ borderDash: [4, 2],
+ stack: "lite",
+ }
+ }),
]
return {
@@ -347,9 +365,18 @@ export function GraphSection() {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(subSuffix)
- const model = isSub ? label.slice(0, -subSuffix.length) : label
+ const isLite = label.endsWith(liteSuffix)
+ const model = isSub
+ ? label.slice(0, -subSuffix.length)
+ : isLite
+ ? label.slice(0, -liteSuffix.length)
+ : label
const baseColor = getModelColor(model)
- const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
+ const originalColor = isSub
+ ? addOpacityToColor(baseColor, 0.5)
+ : isLite
+ ? addOpacityToColor(baseColor, 0.35)
+ : baseColor
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
@@ -363,9 +390,18 @@ export function GraphSection() {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(subSuffix)
- const model = isSub ? label.slice(0, -subSuffix.length) : label
+ const isLite = label.endsWith(liteSuffix)
+ const model = isSub
+ ? label.slice(0, -subSuffix.length)
+ : isLite
+ ? label.slice(0, -liteSuffix.length)
+ : label
const baseColor = getModelColor(model)
- const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
+ const color = isSub
+ ? addOpacityToColor(baseColor, 0.5)
+ : isLite
+ ? addOpacityToColor(baseColor, 0.35)
+ : baseColor
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
index 3d6637610..a20a5bf0d 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
@@ -1,6 +1,6 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
-import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
+import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
@@ -175,23 +175,23 @@ export function UsageSection() {
</div>
</td>
<td data-slot="usage-cost">
- <Show
- when={usage.enrichment?.plan === "sub"}
- fallback={
- <Show
- when={usage.enrichment?.plan === "byok"}
- fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
- >
- {i18n.t("workspace.usage.byok", {
- amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
- })}
- </Show>
- }
- >
- {i18n.t("workspace.usage.subscription", {
- amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
- })}
- </Show>
+ <Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}>
+ <Match when={usage.enrichment?.plan === "sub"}>
+ {i18n.t("workspace.usage.subscription", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
+ </Match>
+ <Match when={usage.enrichment?.plan === "lite"}>
+ {i18n.t("workspace.usage.lite", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
+ </Match>
+ <Match when={usage.enrichment?.plan === "byok"}>
+ {i18n.t("workspace.usage.byok", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
+ </Match>
+ </Switch>
</td>
<td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
</tr>
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index 5cbd67183..d41793dd9 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -115,6 +115,8 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
subscriptionPlan: billing.subscriptionPlan,
timeSubscriptionBooked: billing.timeSubscriptionBooked,
timeSubscriptionSelected: billing.timeSubscriptionSelected,
+ lite: billing.lite,
+ liteSubscriptionID: billing.liteSubscriptionID,
}
}, workspaceID)
}, "billing.get")
diff --git a/packages/console/app/src/routes/zen/lite/v1/messages.ts b/packages/console/app/src/routes/zen/lite/v1/messages.ts
new file mode 100644
index 000000000..ee401e6aa
--- /dev/null
+++ b/packages/console/app/src/routes/zen/lite/v1/messages.ts
@@ -0,0 +1,12 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/util/handler"
+
+export function POST(input: APIEvent) {
+ return handler(input, {
+ format: "anthropic",
+ modelList: "lite",
+ parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
+ parseModel: (url: string, body: any) => body.model,
+ parseIsStream: (url: string, body: any) => !!body.stream,
+ })
+}
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index 6096d7378..80a4b3ab7 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -1,9 +1,9 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
-import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { BillingTable, LiteTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
-import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
+import { getMonthlyBounds, getWeekBounds } from "@opencode-ai/console-core/util/date.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -33,13 +33,14 @@ import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
import { createTrialLimiter } from "./trialLimiter"
import { createStickyTracker } from "./stickyProviderTracker"
+import { LiteData } from "@opencode-ai/console-core/lite.js"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
excludeProviders: string[]
retryCount: number
}
-type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "balance"
+type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
export async function handler(
input: APIEvent,
@@ -454,6 +455,7 @@ export async function handler(
reloadTrigger: BillingTable.reloadTrigger,
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
subscription: BillingTable.subscription,
+ lite: BillingTable.lite,
},
user: {
id: UserTable.id,
@@ -461,13 +463,23 @@ export async function handler(
monthlyUsage: UserTable.monthlyUsage,
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
},
- subscription: {
+ black: {
id: SubscriptionTable.id,
rollingUsage: SubscriptionTable.rollingUsage,
fixedUsage: SubscriptionTable.fixedUsage,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
},
+ lite: {
+ id: LiteTable.id,
+ timeCreated: LiteTable.timeCreated,
+ rollingUsage: LiteTable.rollingUsage,
+ weeklyUsage: LiteTable.weeklyUsage,
+ monthlyUsage: LiteTable.monthlyUsage,
+ timeRollingUpdated: LiteTable.timeRollingUpdated,
+ timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
+ timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
+ },
provider: {
credentials: ProviderTable.credentials,
},
@@ -495,6 +507,14 @@ export async function handler(
isNull(SubscriptionTable.timeDeleted),
),
)
+ .leftJoin(
+ LiteTable,
+ and(
+ eq(LiteTable.workspaceID, KeyTable.workspaceID),
+ eq(LiteTable.userID, KeyTable.userID),
+ isNull(LiteTable.timeDeleted),
+ ),
+ )
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
@@ -503,8 +523,19 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
- isSubscription: data.subscription ? true : false,
- subscription: data.billing.subscription?.plan,
+ ...(() => {
+ if (data.billing.subscription)
+ return {
+ isSubscription: true,
+ subscription: data.billing.subscription.plan,
+ }
+ if (data.billing.lite)
+ return {
+ isSubscription: true,
+ subscription: "lite",
+ }
+ return {}
+ })(),
})
return {
@@ -512,7 +543,8 @@ export async function handler(
workspaceID: data.workspaceID,
billing: data.billing,
user: data.user,
- subscription: data.subscription,
+ black: data.black,
+ lite: data.lite,
provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled,
@@ -525,21 +557,21 @@ export async function handler(
if (authInfo.isFree) return "free"
if (modelInfo.allowAnonymous) return "free"
- // Validate subscription billing
- if (authInfo.billing.subscription && authInfo.subscription) {
+ const formatRetryTime = (seconds: number) => {
+ const days = Math.floor(seconds / 86400)
+ if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.ceil((seconds % 3600) / 60)
+ if (hours >= 1) return `${hours}hr ${minutes}min`
+ return `${minutes}min`
+ }
+
+ // Validate black subscription billing
+ if (authInfo.billing.subscription && authInfo.black) {
try {
- const sub = authInfo.subscription
+ const sub = authInfo.black
const plan = authInfo.billing.subscription.plan
- const formatRetryTime = (seconds: number) => {
- const days = Math.floor(seconds / 86400)
- if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
- const hours = Math.floor(seconds / 3600)
- const minutes = Math.ceil((seconds % 3600) / 60)
- if (hours >= 1) return `${hours}hr ${minutes}min`
- return `${minutes}min`
- }
-
// Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) {
const blackData = BlackData.getLimits({ plan })
@@ -577,6 +609,62 @@ export async function handler(
}
}
+ // Validate lite subscription billing
+ if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) {
+ try {
+ const sub = authInfo.lite
+ const liteData = LiteData.getLimits()
+
+ // Check weekly limit
+ if (sub.weeklyUsage && sub.timeWeeklyUpdated) {
+ const result = Subscription.analyzeWeeklyUsage({
+ limit: liteData.weeklyLimit,
+ usage: sub.weeklyUsage,
+ timeUpdated: sub.timeWeeklyUpdated,
+ })
+ if (result.status === "rate-limited")
+ throw new SubscriptionUsageLimitError(
+ `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+ result.resetInSec,
+ )
+ }
+
+ // Check monthly limit
+ if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
+ const result = Subscription.analyzeMonthlyUsage({
+ limit: liteData.monthlyLimit,
+ usage: sub.monthlyUsage,
+ timeUpdated: sub.timeMonthlyUpdated,
+ timeSubscribed: sub.timeCreated,
+ })
+ if (result.status === "rate-limited")
+ throw new SubscriptionUsageLimitError(
+ `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+ result.resetInSec,
+ )
+ }
+
+ // Check rolling limit
+ if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
+ const result = Subscription.analyzeRollingUsage({
+ limit: liteData.rollingLimit,
+ window: liteData.rollingWindow,
+ usage: sub.monthlyUsage,
+ timeUpdated: sub.timeMonthlyUpdated,
+ })
+ if (result.status === "rate-limited")
+ throw new SubscriptionUsageLimitError(
+ `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+ result.resetInSec,
+ )
+ }
+
+ return "lite"
+ } catch (e) {
+ if (!authInfo.billing.lite.useBalance) throw e
+ }
+ }
+
// Validate pay as you go billing
const billing = authInfo.billing
if (!billing.paymentMethodID)
@@ -743,6 +831,7 @@ export async function handler(
enrichment: (() => {
if (billingSource === "subscription") return { plan: "sub" }
if (billingSource === "byok") return { plan: "byok" }
+ if (billingSource === "lite") return { plan: "lite" }
return undefined
})(),
}),
@@ -750,74 +839,115 @@ export async function handler(
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
- ...(billingSource === "subscription"
- ? (() => {
- const plan = authInfo.billing.subscription!.plan
- const black = BlackData.getLimits({ plan })
- const week = getWeekBounds(new Date())
- const rollingWindowSeconds = black.rollingWindow * 3600
- return [
- db
- .update(SubscriptionTable)
- .set({
- fixedUsage: sql`
+ ...(() => {
+ if (billingSource === "subscription") {
+ const plan = authInfo.billing.subscription!.plan
+ const black = BlackData.getLimits({ plan })
+ const week = getWeekBounds(new Date())
+ const rollingWindowSeconds = black.rollingWindow * 3600
+ return [
+ db
+ .update(SubscriptionTable)
+ .set({
+ fixedUsage: sql`
CASE
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
ELSE ${cost}
END
`,
- timeFixedUpdated: sql`now()`,
- rollingUsage: sql`
+ timeFixedUpdated: sql`now()`,
+ rollingUsage: sql`
CASE
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
ELSE ${cost}
END
`,
- timeRollingUpdated: sql`
+ timeRollingUpdated: sql`
CASE
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
ELSE now()
END
`,
- })
- .where(
- and(
- eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
- eq(SubscriptionTable.userID, authInfo.user.id),
- ),
+ })
+ .where(
+ and(
+ eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
+ eq(SubscriptionTable.userID, authInfo.user.id),
),
- ]
- })()
- : [
+ ),
+ ]
+ }
+ if (billingSource === "lite") {
+ const lite = LiteData.getLimits()
+ const week = getWeekBounds(new Date())
+ const month = getMonthlyBounds(new Date(), authInfo.lite!.timeCreated)
+ const rollingWindowSeconds = lite.rollingWindow * 3600
+ return [
db
- .update(BillingTable)
+ .update(LiteTable)
.set({
- balance:
- billingSource === "free" || billingSource === "byok"
- ? sql`${BillingTable.balance} - ${0}`
- : sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
- WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
+ WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.start} THEN ${LiteTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
- timeMonthlyUsageUpdated: sql`now()`,
+ timeMonthlyUpdated: sql`now()`,
+ weeklyUsage: sql`
+ CASE
+ WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.start} THEN ${LiteTable.weeklyUsage} + ${cost}
+ ELSE ${cost}
+ END
+ `,
+ timeWeeklyUpdated: sql`now()`,
+ rollingUsage: sql`
+ CASE
+ WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.rollingUsage} + ${cost}
+ ELSE ${cost}
+ END
+ `,
+ timeRollingUpdated: sql`
+ CASE
+ WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.timeRollingUpdated}
+ ELSE now()
+ END
+ `,
})
- .where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
- db
- .update(UserTable)
- .set({
- monthlyUsage: sql`
+ .where(and(eq(LiteTable.workspaceID, authInfo.workspaceID), eq(LiteTable.userID, authInfo.user.id))),
+ ]
+ }
+
+ return [
+ db
+ .update(BillingTable)
+ .set({
+ balance:
+ billingSource === "free" || billingSource === "byok"
+ ? sql`${BillingTable.balance} - ${0}`
+ : sql`${BillingTable.balance} - ${cost}`,
+ monthlyUsage: sql`
+ CASE
+ WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
+ ELSE ${cost}
+ END
+ `,
+ timeMonthlyUsageUpdated: sql`now()`,
+ })
+ .where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
+ db
+ .update(UserTable)
+ .set({
+ monthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
- timeMonthlyUsageUpdated: sql`now()`,
- })
- .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
- ]),
+ timeMonthlyUsageUpdated: sql`now()`,
+ })
+ .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
+ ]
+ })(),
]),
)