Skip to content

Commit e91cc7e

Browse files
committed
wip: black
1 parent c961072 commit e91cc7e

9 files changed

Lines changed: 1545 additions & 5 deletions

File tree

infra/console.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
7676
"checkout.session.completed",
7777
"checkout.session.expired",
7878
"charge.refunded",
79+
"invoice.payment_succeeded",
7980
"customer.created",
8081
"customer.deleted",
8182
"customer.updated",

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

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
22
import type { APIEvent } from "@solidjs/start/server"
33
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
44
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
5+
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
56
import { Identifier } from "@opencode-ai/console-core/identifier.js"
67
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
78
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -146,6 +147,249 @@ export async function POST(input: APIEvent) {
146147
.where(eq(BillingTable.workspaceID, workspaceID))
147148
})
148149
}
150+
if (body.type === "invoice.payment_succeeded") {
151+
const invoice = body.data.object
152+
if (invoice.billing_reason === "subscription_cycle") {
153+
const invoiceID = invoice.id as string
154+
const amountInCents = invoice.amount_paid
155+
const customerID = invoice.customer as string
156+
const subscriptionID = invoice.parent?.subscription_details?.subscription as string
157+
158+
if (!customerID) throw new Error("Customer ID not found")
159+
if (!invoiceID) throw new Error("Invoice ID not found")
160+
if (!subscriptionID) throw new Error("Subscription ID not found")
161+
162+
const payment = await Billing.stripe().invoicePayments.retrieve(invoiceID)
163+
const paymentID = payment.id as string
164+
if (!paymentID) throw new Error("Payment ID not found")
165+
166+
const workspaceID = await Database.use((tx) =>
167+
tx
168+
.select({ workspaceID: BillingTable.workspaceID })
169+
.from(BillingTable)
170+
.where(eq(BillingTable.customerID, customerID))
171+
.then((rows) => rows[0]?.workspaceID),
172+
)
173+
if (!workspaceID) throw new Error("Workspace ID not found for customer")
174+
175+
await Database.transaction(async (tx) => {
176+
await tx
177+
.update(BillingTable)
178+
.set({
179+
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
180+
})
181+
.where(eq(BillingTable.workspaceID, workspaceID))
182+
await tx.insert(PaymentTable).values({
183+
workspaceID,
184+
id: Identifier.create("payment"),
185+
amount: centsToMicroCents(amountInCents),
186+
paymentID,
187+
invoiceID,
188+
customerID,
189+
})
190+
})
191+
}
192+
}
193+
if (body.type === "customer.subscription.created") {
194+
const data = {
195+
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
196+
object: "event",
197+
api_version: "2025-07-30.basil",
198+
created: 1767766916,
199+
data: {
200+
object: {
201+
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
202+
object: "subscription",
203+
application: null,
204+
application_fee_percent: null,
205+
automatic_tax: {
206+
disabled_reason: null,
207+
enabled: false,
208+
liability: null,
209+
},
210+
billing_cycle_anchor: 1770445200,
211+
billing_cycle_anchor_config: null,
212+
billing_mode: {
213+
flexible: {
214+
proration_discounts: "included",
215+
},
216+
type: "flexible",
217+
updated_at: 1770445200,
218+
},
219+
billing_thresholds: null,
220+
cancel_at: null,
221+
cancel_at_period_end: false,
222+
canceled_at: null,
223+
cancellation_details: {
224+
comment: null,
225+
feedback: null,
226+
reason: null,
227+
},
228+
collection_method: "charge_automatically",
229+
created: 1770445200,
230+
currency: "usd",
231+
customer: "cus_TkKmZZvysJ2wej",
232+
customer_account: null,
233+
days_until_due: null,
234+
default_payment_method: null,
235+
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
236+
default_tax_rates: [],
237+
description: null,
238+
discounts: [],
239+
ended_at: null,
240+
invoice_settings: {
241+
account_tax_ids: null,
242+
issuer: {
243+
type: "self",
244+
},
245+
},
246+
items: {
247+
object: "list",
248+
data: [
249+
{
250+
id: "si_TkKnBKXFX76t0O",
251+
object: "subscription_item",
252+
billing_thresholds: null,
253+
created: 1770445200,
254+
current_period_end: 1772864400,
255+
current_period_start: 1770445200,
256+
discounts: [],
257+
metadata: {},
258+
plan: {
259+
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
260+
object: "plan",
261+
active: true,
262+
amount: 20000,
263+
amount_decimal: "20000",
264+
billing_scheme: "per_unit",
265+
created: 1767725082,
266+
currency: "usd",
267+
interval: "month",
268+
interval_count: 1,
269+
livemode: false,
270+
metadata: {},
271+
meter: null,
272+
nickname: null,
273+
product: "prod_Tk9LjWT1n0DgYm",
274+
tiers_mode: null,
275+
transform_usage: null,
276+
trial_period_days: null,
277+
usage_type: "licensed",
278+
},
279+
price: {
280+
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
281+
object: "price",
282+
active: true,
283+
billing_scheme: "per_unit",
284+
created: 1767725082,
285+
currency: "usd",
286+
custom_unit_amount: null,
287+
livemode: false,
288+
lookup_key: null,
289+
metadata: {},
290+
nickname: null,
291+
product: "prod_Tk9LjWT1n0DgYm",
292+
recurring: {
293+
interval: "month",
294+
interval_count: 1,
295+
meter: null,
296+
trial_period_days: null,
297+
usage_type: "licensed",
298+
},
299+
tax_behavior: "unspecified",
300+
tiers_mode: null,
301+
transform_quantity: null,
302+
type: "recurring",
303+
unit_amount: 20000,
304+
unit_amount_decimal: "20000",
305+
},
306+
quantity: 1,
307+
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
308+
tax_rates: [],
309+
},
310+
],
311+
has_more: false,
312+
total_count: 1,
313+
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
314+
},
315+
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
316+
livemode: false,
317+
metadata: {},
318+
next_pending_invoice_item_invoice: null,
319+
on_behalf_of: null,
320+
pause_collection: null,
321+
payment_settings: {
322+
payment_method_options: null,
323+
payment_method_types: null,
324+
save_default_payment_method: "off",
325+
},
326+
pending_invoice_item_interval: null,
327+
pending_setup_intent: null,
328+
pending_update: null,
329+
plan: {
330+
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
331+
object: "plan",
332+
active: true,
333+
amount: 20000,
334+
amount_decimal: "20000",
335+
billing_scheme: "per_unit",
336+
created: 1767725082,
337+
currency: "usd",
338+
interval: "month",
339+
interval_count: 1,
340+
livemode: false,
341+
metadata: {},
342+
meter: null,
343+
nickname: null,
344+
product: "prod_Tk9LjWT1n0DgYm",
345+
tiers_mode: null,
346+
transform_usage: null,
347+
trial_period_days: null,
348+
usage_type: "licensed",
349+
},
350+
quantity: 1,
351+
schedule: null,
352+
start_date: 1770445200,
353+
status: "active",
354+
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
355+
transfer_data: null,
356+
trial_end: null,
357+
trial_settings: {
358+
end_behavior: {
359+
missing_payment_method: "create_invoice",
360+
},
361+
},
362+
trial_start: null,
363+
},
364+
},
365+
livemode: false,
366+
pending_webhooks: 0,
367+
request: {
368+
id: "req_6YO9stvB155WJD",
369+
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
370+
},
371+
type: "customer.subscription.created",
372+
}
373+
}
374+
if (body.type === "customer.subscription.deleted") {
375+
const subscriptionID = body.data.object.id
376+
if (!subscriptionID) throw new Error("Subscription ID not found")
377+
378+
const workspaceID = await Database.use((tx) =>
379+
tx
380+
.select({ workspaceID: BillingTable.workspaceID })
381+
.from(BillingTable)
382+
.where(eq(BillingTable.subscriptionID, subscriptionID))
383+
.then((rows) => rows[0]?.workspaceID),
384+
)
385+
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
386+
387+
await Database.transaction(async (tx) => {
388+
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
389+
390+
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
391+
})
392+
}
149393
})()
150394
.then((message) => {
151395
return Response.json({ message: message ?? "done" }, { status: 200 })
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
.root {
2+
[data-slot="title-row"] {
3+
display: flex;
4+
justify-content: space-between;
5+
align-items: center;
6+
gap: var(--space-4);
7+
}
28
}

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,57 @@
1+
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
2+
import { createStore } from "solid-js/store"
3+
import { Billing } from "@opencode-ai/console-core/billing.js"
4+
import { withActor } from "~/context/auth.withActor"
5+
import { queryBillingInfo } from "../../common"
16
import styles from "./black-section.module.css"
27

8+
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
9+
"use server"
10+
return json(
11+
await withActor(
12+
() =>
13+
Billing.generateSessionUrl({ returnUrl })
14+
.then((data) => ({ error: undefined, data }))
15+
.catch((e) => ({
16+
error: e.message as string,
17+
data: undefined,
18+
})),
19+
workspaceID,
20+
),
21+
{ revalidate: queryBillingInfo.key },
22+
)
23+
}, "sessionUrl")
24+
325
export function BlackSection() {
26+
const params = useParams()
27+
const sessionAction = useAction(createSessionUrl)
28+
const sessionSubmission = useSubmission(createSessionUrl)
29+
const [store, setStore] = createStore({
30+
sessionRedirecting: false,
31+
})
32+
33+
async function onClickSession() {
34+
const result = await sessionAction(params.id!, window.location.href)
35+
if (result.data) {
36+
setStore("sessionRedirecting", true)
37+
window.location.href = result.data
38+
}
39+
}
40+
441
return (
542
<section class={styles.root}>
643
<div data-slot="section-title">
7-
<h2>Black</h2>
8-
<p>You are subscribed to Black.</p>
44+
<h2>Subscription</h2>
45+
<div data-slot="title-row">
46+
<p>You are subscribed to OpenCode Black for $200 per month.</p>
47+
<button
48+
data-color="primary"
49+
disabled={sessionSubmission.pending || store.sessionRedirecting}
50+
onClick={onClickSession}
51+
>
52+
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
53+
</button>
54+
</div>
955
</div>
1056
</section>
1157
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);

0 commit comments

Comments
 (0)