wip: zen black
This commit is contained in:
@@ -216,141 +216,71 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "customer.subscription.created") {
|
if (body.type === "customer.subscription.created") {
|
||||||
const data = {
|
/*
|
||||||
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
|
{
|
||||||
object: "event",
|
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
|
||||||
api_version: "2025-07-30.basil",
|
object: "event",
|
||||||
created: 1767766916,
|
api_version: "2025-07-30.basil",
|
||||||
data: {
|
created: 1767766916,
|
||||||
object: {
|
data: {
|
||||||
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
object: {
|
||||||
object: "subscription",
|
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||||
application: null,
|
object: "subscription",
|
||||||
application_fee_percent: null,
|
application: null,
|
||||||
automatic_tax: {
|
application_fee_percent: null,
|
||||||
disabled_reason: null,
|
automatic_tax: {
|
||||||
enabled: false,
|
disabled_reason: null,
|
||||||
liability: null,
|
enabled: false,
|
||||||
},
|
liability: null,
|
||||||
billing_cycle_anchor: 1770445200,
|
},
|
||||||
billing_cycle_anchor_config: null,
|
billing_cycle_anchor: 1770445200,
|
||||||
billing_mode: {
|
billing_cycle_anchor_config: null,
|
||||||
flexible: {
|
billing_mode: {
|
||||||
proration_discounts: "included",
|
flexible: {
|
||||||
},
|
proration_discounts: "included",
|
||||||
type: "flexible",
|
},
|
||||||
updated_at: 1770445200,
|
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,
|
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,
|
created: 1770445200,
|
||||||
currency: "usd",
|
current_period_end: 1772864400,
|
||||||
customer: "cus_TkKmZZvysJ2wej",
|
current_period_start: 1770445200,
|
||||||
customer_account: null,
|
|
||||||
days_until_due: null,
|
|
||||||
default_payment_method: null,
|
|
||||||
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
|
|
||||||
default_tax_rates: [],
|
|
||||||
description: null,
|
|
||||||
discounts: [],
|
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: {},
|
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: {
|
plan: {
|
||||||
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||||
object: "plan",
|
object: "plan",
|
||||||
@@ -372,29 +302,101 @@ export async function POST(input: APIEvent) {
|
|||||||
trial_period_days: null,
|
trial_period_days: null,
|
||||||
usage_type: "licensed",
|
usage_type: "licensed",
|
||||||
},
|
},
|
||||||
quantity: 1,
|
price: {
|
||||||
schedule: null,
|
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||||
start_date: 1770445200,
|
object: "price",
|
||||||
status: "active",
|
active: true,
|
||||||
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
|
billing_scheme: "per_unit",
|
||||||
transfer_data: null,
|
created: 1767725082,
|
||||||
trial_end: null,
|
currency: "usd",
|
||||||
trial_settings: {
|
custom_unit_amount: null,
|
||||||
end_behavior: {
|
livemode: false,
|
||||||
missing_payment_method: "create_invoice",
|
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",
|
||||||
},
|
},
|
||||||
trial_start: null,
|
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,
|
livemode: false,
|
||||||
pending_webhooks: 0,
|
metadata: {},
|
||||||
request: {
|
meter: null,
|
||||||
id: "req_6YO9stvB155WJD",
|
nickname: null,
|
||||||
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
|
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",
|
||||||
},
|
},
|
||||||
type: "customer.subscription.created",
|
},
|
||||||
}
|
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") {
|
if (body.type === "customer.subscription.deleted") {
|
||||||
const subscriptionID = body.data.object.id
|
const subscriptionID = body.data.object.id
|
||||||
@@ -419,7 +421,7 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "invoice.payment_succeeded") {
|
if (body.type === "invoice.payment_succeeded") {
|
||||||
if (body.data.object.billing_reason === "subscription_cycle") {
|
if (body.data.object.billing_reason === "subscription_cycle" || body.data.object.billing_reason === "subscription_create") {
|
||||||
const invoiceID = body.data.object.id as string
|
const invoiceID = body.data.object.id as string
|
||||||
const amountInCents = body.data.object.amount_paid
|
const amountInCents = body.data.object.amount_paid
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Black } from "@opencode-ai/console-core/black.js"
|
|||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { queryBillingInfo } from "../../common"
|
import { queryBillingInfo } from "../../common"
|
||||||
import styles from "./black-section.module.css"
|
import styles from "./black-section.module.css"
|
||||||
|
import waitlistStyles from "./black-waitlist-section.module.css"
|
||||||
|
|
||||||
const querySubscription = query(async (workspaceID: string) => {
|
const querySubscription = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -27,7 +28,7 @@ const querySubscription = query(async (workspaceID: string) => {
|
|||||||
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
|
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
|
||||||
.then((r) => r[0]),
|
.then((r) => r[0]),
|
||||||
)
|
)
|
||||||
if (!row.subscription) return null
|
if (!row?.subscription) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: row.subscription.plan,
|
plan: row.subscription.plan,
|
||||||
@@ -58,6 +59,37 @@ function formatResetTime(seconds: number) {
|
|||||||
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
|
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelWaitlist = action(async (workspaceID: string) => {
|
||||||
|
"use server"
|
||||||
|
return json(
|
||||||
|
await withActor(async () => {
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
subscriptionPlan: null,
|
||||||
|
timeSubscriptionBooked: null,
|
||||||
|
timeSubscriptionSelected: null,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||||
|
)
|
||||||
|
return { error: undefined }
|
||||||
|
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||||
|
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||||
|
)
|
||||||
|
}, "cancelWaitlist")
|
||||||
|
|
||||||
|
const enroll = action(async (workspaceID: string) => {
|
||||||
|
"use server"
|
||||||
|
return json(
|
||||||
|
await withActor(async () => {
|
||||||
|
await Billing.subscribe({ seats: 1 })
|
||||||
|
return { error: undefined }
|
||||||
|
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||||
|
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||||
|
)
|
||||||
|
}, "enroll")
|
||||||
|
|
||||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return json(
|
return json(
|
||||||
@@ -71,17 +103,24 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
|||||||
})),
|
})),
|
||||||
workspaceID,
|
workspaceID,
|
||||||
),
|
),
|
||||||
{ revalidate: queryBillingInfo.key },
|
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||||
)
|
)
|
||||||
}, "sessionUrl")
|
}, "sessionUrl")
|
||||||
|
|
||||||
export function BlackSection() {
|
export function BlackSection() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const billing = createAsync(() => queryBillingInfo(params.id!))
|
||||||
|
const subscription = createAsync(() => querySubscription(params.id!))
|
||||||
const sessionAction = useAction(createSessionUrl)
|
const sessionAction = useAction(createSessionUrl)
|
||||||
const sessionSubmission = useSubmission(createSessionUrl)
|
const sessionSubmission = useSubmission(createSessionUrl)
|
||||||
const subscription = createAsync(() => querySubscription(params.id!))
|
const cancelAction = useAction(cancelWaitlist)
|
||||||
|
const cancelSubmission = useSubmission(cancelWaitlist)
|
||||||
|
const enrollAction = useAction(enroll)
|
||||||
|
const enrollSubmission = useSubmission(enroll)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
sessionRedirecting: false,
|
sessionRedirecting: false,
|
||||||
|
cancelled: false,
|
||||||
|
enrolled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onClickSession() {
|
async function onClickSession() {
|
||||||
@@ -92,11 +131,25 @@ export function BlackSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onClickCancel() {
|
||||||
|
const result = await cancelAction(params.id!)
|
||||||
|
if (!result.error) {
|
||||||
|
setStore("cancelled", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickEnroll() {
|
||||||
|
const result = await enrollAction(params.id!)
|
||||||
|
if (!result.error) {
|
||||||
|
setStore("enrolled", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class={styles.root}>
|
<>
|
||||||
<Show when={subscription()}>
|
<Show when={subscription()}>
|
||||||
{(sub) => (
|
{(sub) => (
|
||||||
<>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>Subscription</h2>
|
<h2>Subscription</h2>
|
||||||
<div data-slot="title-row">
|
<div data-slot="title-row">
|
||||||
@@ -132,9 +185,45 @@ export function BlackSection() {
|
|||||||
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
|
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</section>
|
<Show when={billing()?.timeSubscriptionBooked}>
|
||||||
|
<section class={waitlistStyles.root}>
|
||||||
|
<div data-slot="section-title">
|
||||||
|
<h2>Waitlist</h2>
|
||||||
|
<div data-slot="title-row">
|
||||||
|
<p>
|
||||||
|
{billing()?.timeSubscriptionSelected
|
||||||
|
? `We're ready to enroll you into the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`
|
||||||
|
: `You are on the waitlist for the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
data-color="danger"
|
||||||
|
disabled={cancelSubmission.pending || store.cancelled}
|
||||||
|
onClick={onClickCancel}
|
||||||
|
>
|
||||||
|
{cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={billing()?.timeSubscriptionSelected}>
|
||||||
|
<div data-slot="enroll-section">
|
||||||
|
<button
|
||||||
|
data-slot="enroll-button"
|
||||||
|
data-color="primary"
|
||||||
|
disabled={enrollSubmission.pending || store.enrolled}
|
||||||
|
onClick={onClickEnroll}
|
||||||
|
>
|
||||||
|
{enrollSubmission.pending ? "Enrolling..." : store.enrolled ? "Enrolled" : "Enroll"}
|
||||||
|
</button>
|
||||||
|
<p data-slot="enroll-note">
|
||||||
|
When you click Enroll, your subscription starts immediately and your card will be charged.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="enroll-section"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="enroll-button"] {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="enroll-note"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { action, useParams, useAction, useSubmission, json, createAsync } from "@solidjs/router"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
|
||||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
|
||||||
import { withActor } from "~/context/auth.withActor"
|
|
||||||
import { queryBillingInfo } from "../../common"
|
|
||||||
import styles from "./black-waitlist-section.module.css"
|
|
||||||
|
|
||||||
const cancelWaitlist = action(async (workspaceID: string) => {
|
|
||||||
"use server"
|
|
||||||
return json(
|
|
||||||
await withActor(async () => {
|
|
||||||
await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.update(BillingTable)
|
|
||||||
.set({
|
|
||||||
subscriptionPlan: null,
|
|
||||||
timeSubscriptionBooked: null,
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
|
||||||
)
|
|
||||||
return { error: undefined }
|
|
||||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
|
||||||
{ revalidate: queryBillingInfo.key },
|
|
||||||
)
|
|
||||||
}, "cancelWaitlist")
|
|
||||||
|
|
||||||
export function BlackWaitlistSection() {
|
|
||||||
const params = useParams()
|
|
||||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
|
||||||
const cancelAction = useAction(cancelWaitlist)
|
|
||||||
const cancelSubmission = useSubmission(cancelWaitlist)
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
cancelled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onClickCancel() {
|
|
||||||
const result = await cancelAction(params.id!)
|
|
||||||
if (!result.error) {
|
|
||||||
setStore("cancelled", true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section class={styles.root}>
|
|
||||||
<div data-slot="section-title">
|
|
||||||
<h2>Waitlist</h2>
|
|
||||||
<div data-slot="title-row">
|
|
||||||
<p>You are on the waitlist for the ${billingInfo()?.subscriptionPlan} per month OpenCode Black plan.</p>
|
|
||||||
<button data-color="danger" disabled={cancelSubmission.pending || store.cancelled} onClick={onClickCancel}>
|
|
||||||
{cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { BillingSection } from "./billing-section"
|
|||||||
import { ReloadSection } from "./reload-section"
|
import { ReloadSection } from "./reload-section"
|
||||||
import { PaymentSection } from "./payment-section"
|
import { PaymentSection } from "./payment-section"
|
||||||
import { BlackSection } from "./black-section"
|
import { BlackSection } from "./black-section"
|
||||||
import { BlackWaitlistSection } from "./black-waitlist-section"
|
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { createAsync, useParams } from "@solidjs/router"
|
import { createAsync, useParams } from "@solidjs/router"
|
||||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||||
@@ -17,12 +16,9 @@ export default function () {
|
|||||||
<div data-page="workspace-[id]">
|
<div data-page="workspace-[id]">
|
||||||
<div data-slot="sections">
|
<div data-slot="sections">
|
||||||
<Show when={sessionInfo()?.isAdmin}>
|
<Show when={sessionInfo()?.isAdmin}>
|
||||||
<Show when={billingInfo()?.subscriptionID}>
|
<Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
|
||||||
<BlackSection />
|
<BlackSection />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={billingInfo()?.timeSubscriptionBooked}>
|
|
||||||
<BlackWaitlistSection />
|
|
||||||
</Show>
|
|
||||||
<BillingSection />
|
<BillingSection />
|
||||||
<Show when={billingInfo()?.customerID}>
|
<Show when={billingInfo()?.customerID}>
|
||||||
<ReloadSection />
|
<ReloadSection />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Show, createMemo } from "solid-js"
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
|
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
|
||||||
import { NewUserSection } from "./new-user-section"
|
import { NewUserSection } from "./new-user-section"
|
||||||
@@ -43,9 +43,8 @@ export default function () {
|
|||||||
</span>
|
</span>
|
||||||
<Show when={userInfo()?.isAdmin}>
|
<Show when={userInfo()?.isAdmin}>
|
||||||
<span data-slot="billing-info">
|
<span data-slot="billing-info">
|
||||||
<Show
|
<Switch>
|
||||||
when={billingInfo()?.reload}
|
<Match when={!billingInfo()?.customerID}>
|
||||||
fallback={
|
|
||||||
<button
|
<button
|
||||||
data-color="primary"
|
data-color="primary"
|
||||||
data-size="sm"
|
data-size="sm"
|
||||||
@@ -54,12 +53,13 @@ export default function () {
|
|||||||
>
|
>
|
||||||
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
|
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
|
||||||
</button>
|
</button>
|
||||||
}
|
</Match>
|
||||||
>
|
<Match when={!billingInfo()?.subscriptionID}>
|
||||||
<span data-slot="balance">
|
<span data-slot="balance">
|
||||||
Current balance <b>${balance()}</b>
|
Current balance <b>${balance()}</b>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Match>
|
||||||
|
</Switch>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
|
|||||||
subscriptionID: billing.subscriptionID,
|
subscriptionID: billing.subscriptionID,
|
||||||
subscriptionPlan: billing.subscriptionPlan,
|
subscriptionPlan: billing.subscriptionPlan,
|
||||||
timeSubscriptionBooked: billing.timeSubscriptionBooked,
|
timeSubscriptionBooked: billing.timeSubscriptionBooked,
|
||||||
|
timeSubscriptionSelected: billing.timeSubscriptionSelected,
|
||||||
}
|
}
|
||||||
}, workspaceID)
|
}, workspaceID)
|
||||||
}, "billing.get")
|
}, "billing.get")
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ export async function handler(
|
|||||||
...(authInfo.subscription
|
...(authInfo.subscription
|
||||||
? (() => {
|
? (() => {
|
||||||
const plan = authInfo.billing.subscription!.plan
|
const plan = authInfo.billing.subscription!.plan
|
||||||
const black = BlackData.get({ plan })
|
const black = BlackData.getLimits({ plan })
|
||||||
const week = getWeekBounds(new Date())
|
const week = getWeekBounds(new Date())
|
||||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||||
return [
|
return [
|
||||||
|
|||||||
1
packages/console/core/migrations/0055_moaning_karnak.sql
Normal file
1
packages/console/core/migrations/0055_moaning_karnak.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `billing` ADD `time_subscription_selected` timestamp(3);
|
||||||
1316
packages/console/core/migrations/meta/0055_snapshot.json
Normal file
1316
packages/console/core/migrations/meta/0055_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -386,6 +386,13 @@
|
|||||||
"when": 1768603665356,
|
"when": 1768603665356,
|
||||||
"tag": "0054_numerous_annihilus",
|
"tag": "0054_numerous_annihilus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 55,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1769108945841,
|
||||||
|
"tag": "0055_moaning_karnak",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,11 @@ import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/bil
|
|||||||
import { Identifier } from "../src/identifier.js"
|
import { Identifier } from "../src/identifier.js"
|
||||||
import { centsToMicroCents } from "../src/util/price.js"
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
|
import { BlackData } from "../src/black.js"
|
||||||
|
import { Actor } from "../src/actor.js"
|
||||||
|
|
||||||
const plan = "200"
|
const plan = "200"
|
||||||
|
const couponID = "JAIr0Pe1"
|
||||||
const workspaceID = process.argv[2]
|
const workspaceID = process.argv[2]
|
||||||
const seats = parseInt(process.argv[3])
|
const seats = parseInt(process.argv[3])
|
||||||
|
|
||||||
@@ -61,16 +64,18 @@ const customerID =
|
|||||||
.then((customer) => customer.id))())
|
.then((customer) => customer.id))())
|
||||||
console.log(`Customer ID: ${customerID}`)
|
console.log(`Customer ID: ${customerID}`)
|
||||||
|
|
||||||
const couponID = "JAIr0Pe1"
|
|
||||||
const subscription = await Billing.stripe().subscriptions.create({
|
const subscription = await Billing.stripe().subscriptions.create({
|
||||||
customer: customerID!,
|
customer: customerID!,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
price: `price_1SmfyI2StuRr0lbXovxJNeZn`,
|
price: BlackData.planToPriceID({ plan }),
|
||||||
discounts: [{ coupon: couponID }],
|
discounts: [{ coupon: couponID }],
|
||||||
quantity: seats,
|
quantity: seats,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
metadata: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
console.log(`Subscription ID: ${subscription.id}`)
|
console.log(`Subscription ID: ${subscription.id}`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import { Billing } from "../src/billing.js"
|
|
||||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
|
||||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
|
||||||
import { Identifier } from "../src/identifier.js"
|
|
||||||
import { centsToMicroCents } from "../src/util/price.js"
|
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
|
||||||
|
|
||||||
const workspaceID = process.argv[2]
|
|
||||||
const email = process.argv[3]
|
|
||||||
|
|
||||||
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
|
||||||
|
|
||||||
if (!workspaceID || !email) {
|
|
||||||
console.error("Usage: bun foo.ts <workspaceID> <email>")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the Stripe customer by email
|
|
||||||
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
|
|
||||||
if (!customers.data) {
|
|
||||||
console.error(`Error: No Stripe customer found for email ${email}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
|
|
||||||
if (!customer) {
|
|
||||||
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerID = customer.id
|
|
||||||
const subscription = customer.subscriptions!.data[0]
|
|
||||||
const subscriptionID = subscription.id
|
|
||||||
|
|
||||||
// Validate the subscription is $200
|
|
||||||
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
|
|
||||||
if (amountInCents !== 20000) {
|
|
||||||
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
|
|
||||||
const couponID =
|
|
||||||
typeof subscriptionData.discounts[0] === "string"
|
|
||||||
? subscriptionData.discounts[0]
|
|
||||||
: subscriptionData.discounts[0]?.coupon?.id
|
|
||||||
|
|
||||||
// Check if subscription is already tied to another workspace
|
|
||||||
const existingSubscription = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({ workspaceID: BillingTable.workspaceID })
|
|
||||||
.from(BillingTable)
|
|
||||||
.where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`)
|
|
||||||
.then((rows) => rows[0]),
|
|
||||||
)
|
|
||||||
if (existingSubscription) {
|
|
||||||
console.error(
|
|
||||||
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the workspace billing and check if it already has a customer id or subscription
|
|
||||||
const billing = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
|
|
||||||
.from(BillingTable)
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
.then((rows) => rows[0]),
|
|
||||||
)
|
|
||||||
if (billing?.subscriptionID) {
|
|
||||||
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
if (billing?.customerID) {
|
|
||||||
console.warn(
|
|
||||||
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the latest invoice and payment from the subscription
|
|
||||||
const invoices = await Billing.stripe().invoices.list({
|
|
||||||
subscription: subscriptionID,
|
|
||||||
limit: 1,
|
|
||||||
expand: ["data.payments"],
|
|
||||||
})
|
|
||||||
const invoice = invoices.data[0]
|
|
||||||
const invoiceID = invoice?.id
|
|
||||||
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
|
|
||||||
|
|
||||||
// Get the default payment method from the customer
|
|
||||||
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
|
|
||||||
| string
|
|
||||||
| null
|
|
||||||
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
|
|
||||||
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
|
||||||
const paymentMethodType = paymentMethod?.type ?? null
|
|
||||||
|
|
||||||
// Look up the user in the workspace
|
|
||||||
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))),
|
|
||||||
)
|
|
||||||
if (users.length === 0) {
|
|
||||||
console.error(`Error: No users found in workspace ${workspaceID}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
|
|
||||||
if (!user) {
|
|
||||||
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set workspaceID in Stripe customer metadata
|
|
||||||
await Billing.stripe().customers.update(customerID, {
|
|
||||||
metadata: {
|
|
||||||
workspaceID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
|
||||||
// Set customer id, subscription id, and payment method on workspace billing
|
|
||||||
await tx
|
|
||||||
.update(BillingTable)
|
|
||||||
.set({
|
|
||||||
customerID,
|
|
||||||
subscriptionID,
|
|
||||||
paymentMethodID,
|
|
||||||
paymentMethodLast4,
|
|
||||||
paymentMethodType,
|
|
||||||
subscription: {
|
|
||||||
status: "subscribed",
|
|
||||||
coupon: couponID,
|
|
||||||
seats: 1,
|
|
||||||
plan: "200",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
|
|
||||||
// Create a row in subscription table
|
|
||||||
await tx.insert(SubscriptionTable).values({
|
|
||||||
workspaceID,
|
|
||||||
id: Identifier.create("subscription"),
|
|
||||||
userID: user.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a row in payments table
|
|
||||||
await tx.insert(PaymentTable).values({
|
|
||||||
workspaceID,
|
|
||||||
id: Identifier.create("payment"),
|
|
||||||
amount: centsToMicroCents(amountInCents),
|
|
||||||
customerID,
|
|
||||||
invoiceID,
|
|
||||||
paymentID,
|
|
||||||
enrichment: {
|
|
||||||
type: "subscription",
|
|
||||||
couponID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Successfully onboarded workspace ${workspaceID}`)
|
|
||||||
console.log(` Customer ID: ${customerID}`)
|
|
||||||
console.log(` Subscription ID: ${subscriptionID}`)
|
|
||||||
console.log(
|
|
||||||
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
|
|
||||||
)
|
|
||||||
console.log(` User ID: ${user.id}`)
|
|
||||||
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
|
|
||||||
console.log(` Payment ID: ${paymentID ?? "(none)"}`)
|
|
||||||
@@ -244,7 +244,7 @@ function getSubscriptionStatus(row: {
|
|||||||
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const black = BlackData.get({ plan: row.subscription.plan })
|
const black = BlackData.getLimits({ plan: row.subscription.plan })
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const week = getWeekBounds(now)
|
const week = getWeekBounds(now)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Stripe } from "stripe"
|
import { Stripe } from "stripe"
|
||||||
import { Database, eq, sql } from "./drizzle"
|
import { Database, eq, sql } from "./drizzle"
|
||||||
import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
|
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||||
import { Actor } from "./actor"
|
import { Actor } from "./actor"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -8,6 +8,7 @@ import { Resource } from "@opencode-ai/console-resource"
|
|||||||
import { Identifier } from "./identifier"
|
import { Identifier } from "./identifier"
|
||||||
import { centsToMicroCents } from "./util/price"
|
import { centsToMicroCents } from "./util/price"
|
||||||
import { User } from "./user"
|
import { User } from "./user"
|
||||||
|
import { BlackData } from "./black"
|
||||||
|
|
||||||
export namespace Billing {
|
export namespace Billing {
|
||||||
export const ITEM_CREDIT_NAME = "opencode credits"
|
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||||
@@ -288,4 +289,66 @@ export namespace Billing {
|
|||||||
return charge.receipt_url
|
return charge.receipt_url
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const subscribe = fn(z.object({
|
||||||
|
seats: z.number(),
|
||||||
|
coupon: z.string().optional(),
|
||||||
|
}), async ({ seats, coupon }) => {
|
||||||
|
const user = Actor.assert("user")
|
||||||
|
const billing = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
customerID: BillingTable.customerID,
|
||||||
|
paymentMethodID: BillingTable.paymentMethodID,
|
||||||
|
subscriptionID: BillingTable.subscriptionID,
|
||||||
|
subscriptionPlan: BillingTable.subscriptionPlan,
|
||||||
|
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!billing) throw new Error("Billing record not found")
|
||||||
|
if (!billing.timeSubscriptionSelected) throw new Error("Not selected for subscription")
|
||||||
|
if (billing.subscriptionID) throw new Error("Already subscribed")
|
||||||
|
if (!billing.customerID) throw new Error("No customer ID")
|
||||||
|
if (!billing.paymentMethodID) throw new Error("No payment method")
|
||||||
|
if (!billing.subscriptionPlan) throw new Error("No subscription plan")
|
||||||
|
|
||||||
|
const subscription = await Billing.stripe().subscriptions.create({
|
||||||
|
customer: billing.customerID,
|
||||||
|
default_payment_method: billing.paymentMethodID,
|
||||||
|
items: [{ price: BlackData.planToPriceID({ plan: billing.subscriptionPlan }) }],
|
||||||
|
metadata: {
|
||||||
|
workspaceID: Actor.workspace(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
subscriptionID: subscription.id,
|
||||||
|
subscription: {
|
||||||
|
status: "subscribed",
|
||||||
|
coupon,
|
||||||
|
seats,
|
||||||
|
plan: billing.subscriptionPlan!,
|
||||||
|
},
|
||||||
|
subscriptionPlan: null,
|
||||||
|
timeSubscriptionBooked: null,
|
||||||
|
timeSubscriptionSelected: null,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||||
|
|
||||||
|
await tx.insert(SubscriptionTable).values({
|
||||||
|
workspaceID: Actor.workspace(),
|
||||||
|
id: Identifier.create("subscription"),
|
||||||
|
userID: user.properties.userID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return subscription.id
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,15 +28,28 @@ export namespace BlackData {
|
|||||||
return input
|
return input
|
||||||
})
|
})
|
||||||
|
|
||||||
export const get = fn(
|
export const getLimits = fn(z.object({
|
||||||
z.object({
|
|
||||||
plan: z.enum(SubscriptionPlan),
|
plan: z.enum(SubscriptionPlan),
|
||||||
}),
|
}), ({ plan }) => {
|
||||||
({ plan }) => {
|
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
||||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
return Schema.parse(json)[plan]
|
||||||
return Schema.parse(json)[plan]
|
})
|
||||||
},
|
|
||||||
)
|
export const planToPriceID = fn(z.object({
|
||||||
|
plan: z.enum(SubscriptionPlan),
|
||||||
|
}), ({ plan }) => {
|
||||||
|
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
|
||||||
|
if (plan === "100") return Resource.ZEN_BLACK_PRICE.plan100
|
||||||
|
return Resource.ZEN_BLACK_PRICE.plan20
|
||||||
|
})
|
||||||
|
|
||||||
|
export const priceIDToPlan = fn(z.object({
|
||||||
|
priceID: z.string(),
|
||||||
|
}), ({ priceID }) => {
|
||||||
|
if (priceID === Resource.ZEN_BLACK_PRICE.plan200) return "200"
|
||||||
|
if (priceID === Resource.ZEN_BLACK_PRICE.plan100) return "100"
|
||||||
|
return "20"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Black {
|
export namespace Black {
|
||||||
@@ -48,7 +61,7 @@ export namespace Black {
|
|||||||
}),
|
}),
|
||||||
({ plan, usage, timeUpdated }) => {
|
({ plan, usage, timeUpdated }) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const black = BlackData.get({ plan })
|
const black = BlackData.getLimits({ plan })
|
||||||
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
||||||
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
|
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
|
||||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||||
@@ -83,7 +96,7 @@ export namespace Black {
|
|||||||
timeUpdated: z.date(),
|
timeUpdated: z.date(),
|
||||||
}),
|
}),
|
||||||
({ plan, usage, timeUpdated }) => {
|
({ plan, usage, timeUpdated }) => {
|
||||||
const black = BlackData.get({ plan })
|
const black = BlackData.getLimits({ plan })
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const week = getWeekBounds(now)
|
const week = getWeekBounds(now)
|
||||||
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
|
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const BillingTable = mysqlTable(
|
|||||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||||
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
||||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||||
|
timeSubscriptionSelected: utc("time_subscription_selected"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
|
|||||||
Reference in New Issue
Block a user