summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-01-07 01:55:36 -0500
committerFrank <[email protected]>2026-01-07 02:45:57 -0500
commite91cc7e5142ea6568836c463de69b06d35fc3fc3 (patch)
treeb4b2f54d1d24eca9c1630425045270e229f184ab /packages/console/app
parentc961072d20f1dcafa5e608a1f28a79a1b6bc7334 (diff)
downloadopencode-e91cc7e5142ea6568836c463de69b06d35fc3fc3.tar.gz
opencode-e91cc7e5142ea6568836c463de69b06d35fc3fc3.zip
wip: black
Diffstat (limited to 'packages/console/app')
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts244
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css6
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx50
3 files changed, 298 insertions, 2 deletions
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index 3260f31b2..8b15d56b5 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.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"
@@ -146,6 +147,249 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
+ if (body.type === "invoice.payment_succeeded") {
+ const invoice = body.data.object
+ if (invoice.billing_reason === "subscription_cycle") {
+ const invoiceID = invoice.id as string
+ const amountInCents = invoice.amount_paid
+ const customerID = invoice.customer as string
+ const subscriptionID = invoice.parent?.subscription_details?.subscription as string
+
+ 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")
+
+ const payment = await Billing.stripe().invoicePayments.retrieve(invoiceID)
+ const paymentID = payment.id as string
+ if (!paymentID) throw new Error("Payment ID not found")
+
+ const workspaceID = await Database.use((tx) =>
+ tx
+ .select({ workspaceID: BillingTable.workspaceID })
+ .from(BillingTable)
+ .where(eq(BillingTable.customerID, customerID))
+ .then((rows) => rows[0]?.workspaceID),
+ )
+ if (!workspaceID) throw new Error("Workspace ID not found for customer")
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ await tx.insert(PaymentTable).values({
+ workspaceID,
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(amountInCents),
+ paymentID,
+ invoiceID,
+ customerID,
+ })
+ })
+ }
+ }
+ if (body.type === "customer.subscription.created") {
+ const data = {
+ 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.deleted") {
+ const subscriptionID = body.data.object.id
+ if (!subscriptionID) throw new Error("Subscription ID not found")
+
+ const workspaceID = await Database.use((tx) =>
+ tx
+ .select({ workspaceID: BillingTable.workspaceID })
+ .from(BillingTable)
+ .where(eq(BillingTable.subscriptionID, subscriptionID))
+ .then((rows) => rows[0]?.workspaceID),
+ )
+ if (!workspaceID) throw new Error("Workspace ID not found for subscription")
+
+ await Database.transaction(async (tx) => {
+ await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
+
+ await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
+ })
+ }
})()
.then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 })
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css
index c3a2af639..c189f0d64 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css
@@ -1,2 +1,8 @@
.root {
+ [data-slot="title-row"] {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+ }
}
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 d4876b242..2eece1b62 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
@@ -1,11 +1,57 @@
+import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
+import { createStore } from "solid-js/store"
+import { Billing } from "@opencode-ai/console-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
+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 },
+ )
+}, "sessionUrl")
+
export function BlackSection() {
+ const params = useParams()
+ const sessionAction = useAction(createSessionUrl)
+ const sessionSubmission = useSubmission(createSessionUrl)
+ const [store, setStore] = createStore({
+ sessionRedirecting: false,
+ })
+
+ async function onClickSession() {
+ const result = await sessionAction(params.id!, window.location.href)
+ if (result.data) {
+ setStore("sessionRedirecting", true)
+ window.location.href = result.data
+ }
+ }
+
return (
<section class={styles.root}>
<div data-slot="section-title">
- <h2>Black</h2>
- <p>You are subscribed to Black.</p>
+ <h2>Subscription</h2>
+ <div data-slot="title-row">
+ <p>You are subscribed to OpenCode Black for $200 per month.</p>
+ <button
+ data-color="primary"
+ disabled={sessionSubmission.pending || store.sessionRedirecting}
+ onClick={onClickSession}
+ >
+ {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
+ </button>
+ </div>
</div>
</section>
)