Compare commits
1 commit
dev
...
go-webhook
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc915bcf7 |
3 changed files with 207 additions and 79 deletions
40
packages/console/app/src/routes/stripe/lite-subscription.ts
Normal file
40
packages/console/app/src/routes/stripe/lite-subscription.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Stripe } from "stripe"
|
||||||
|
|
||||||
|
export function liteCheckoutSubscriptionToProvision(
|
||||||
|
type: "checkout.session.completed" | "checkout.session.async_payment_succeeded",
|
||||||
|
session: Pick<Stripe.Checkout.Session, "mode" | "payment_status" | "subscription">,
|
||||||
|
) {
|
||||||
|
if (session.mode !== "subscription") return
|
||||||
|
if (typeof session.subscription !== "string") return
|
||||||
|
if (type === "checkout.session.completed" && session.payment_status === "unpaid") return
|
||||||
|
return session.subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liteSubscriptionProvisioningData(
|
||||||
|
subscription: Pick<
|
||||||
|
Stripe.Subscription,
|
||||||
|
"id" | "status" | "customer" | "default_payment_method" | "metadata"
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if (subscription.metadata.type !== "lite") return
|
||||||
|
if (subscription.status !== "active" && subscription.status !== "trialing") return
|
||||||
|
if (typeof subscription.customer !== "string") throw new Error("Customer ID not found")
|
||||||
|
|
||||||
|
const workspaceID = subscription.metadata.workspaceID
|
||||||
|
const userID = subscription.metadata.userID
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
if (!userID) throw new Error("User ID not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscriptionID: subscription.id,
|
||||||
|
customerID: subscription.customer,
|
||||||
|
workspaceID,
|
||||||
|
userID,
|
||||||
|
userEmail: subscription.metadata.userEmail || undefined,
|
||||||
|
coupon: subscription.metadata.coupon || undefined,
|
||||||
|
paymentMethodID:
|
||||||
|
typeof subscription.default_payment_method === "string"
|
||||||
|
? subscription.default_payment_method
|
||||||
|
: subscription.default_payment_method?.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,10 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||||
|
import {
|
||||||
|
liteCheckoutSubscriptionToProvision,
|
||||||
|
liteSubscriptionProvisioningData,
|
||||||
|
} from "./lite-subscription.js"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
|
|
@ -20,6 +24,10 @@ export async function POST(input: APIEvent) {
|
||||||
console.log(body.type, JSON.stringify(body, null, 2))
|
console.log(body.type, JSON.stringify(body, null, 2))
|
||||||
|
|
||||||
return (async () => {
|
return (async () => {
|
||||||
|
if (body.type === "checkout.session.completed" || body.type === "checkout.session.async_payment_succeeded") {
|
||||||
|
const subscriptionID = liteCheckoutSubscriptionToProvision(body.type, body.data.object)
|
||||||
|
if (subscriptionID) await provisionLiteSubscription(subscriptionID)
|
||||||
|
}
|
||||||
if (body.type === "customer.updated") {
|
if (body.type === "customer.updated") {
|
||||||
// check default payment method changed
|
// check default payment method changed
|
||||||
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
|
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
|
||||||
|
|
@ -105,85 +113,6 @@ export async function POST(input: APIEvent) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
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 userEmail = body.data.object.metadata?.userEmail
|
|
||||||
const coupon = body.data.object.metadata?.coupon
|
|
||||||
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
|
|
||||||
const paymentMethodID = body.data.object.default_payment_method as string
|
|
||||||
|
|
||||||
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")
|
|
||||||
if (!paymentMethodID) throw new Error("Payment method ID not found")
|
|
||||||
|
|
||||||
// get payment method for the payment intent
|
|
||||||
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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(LiteTable).values({
|
|
||||||
workspaceID,
|
|
||||||
id: Identifier.create("lite"),
|
|
||||||
userID: userID,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (userEmail) {
|
|
||||||
if (coupon === LiteData.firstMonth50Coupon) {
|
|
||||||
await Billing.redeemCoupon(userEmail, "GO1MONTH50")
|
|
||||||
} else if (coupon === LiteData.firstMonth100Coupon) {
|
|
||||||
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
|
|
||||||
} else if (coupon === LiteData.threeMonths100Coupon) {
|
|
||||||
await Billing.redeemCoupon(userEmail, "GO3MONTHS100")
|
|
||||||
} else if (coupon === LiteData.sixMonths100Coupon) {
|
|
||||||
await Billing.redeemCoupon(userEmail, "GO6MONTHS100")
|
|
||||||
} else if (coupon === LiteData.twelveMonths100Coupon) {
|
|
||||||
await Billing.redeemCoupon(userEmail, "GO12MONTHS100")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await Referral.completeFromLiteSubscription({
|
|
||||||
workspaceID,
|
|
||||||
userID,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error("Referral sync failed", error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
|
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
|
||||||
const subscriptionID = body.data.object.id
|
const subscriptionID = body.data.object.id
|
||||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
@ -221,6 +150,10 @@ export async function POST(input: APIEvent) {
|
||||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
if (body.data.object.billing_reason === "subscription_create" && productID === LiteData.productID()) {
|
||||||
|
await provisionLiteSubscription(subscriptionID)
|
||||||
|
}
|
||||||
|
|
||||||
// get coupon id from subscription
|
// get coupon id from subscription
|
||||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
expand: ["discounts", "payments"],
|
expand: ["discounts", "payments"],
|
||||||
|
|
@ -378,3 +311,92 @@ export async function POST(input: APIEvent) {
|
||||||
return Response.json({ message: error.message }, { status: 500 })
|
return Response.json({ message: error.message }, { status: 500 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function provisionLiteSubscription(subscriptionID: string) {
|
||||||
|
const stripe = Billing.stripe()
|
||||||
|
const subscription = liteSubscriptionProvisioningData(await stripe.subscriptions.retrieve(subscriptionID))
|
||||||
|
if (!subscription) return
|
||||||
|
|
||||||
|
await Actor.provide("system", { workspaceID: subscription.workspaceID }, async () => {
|
||||||
|
const billing = await Billing.get()
|
||||||
|
if (!billing) throw new Error(`Workspace with ID ${subscription.workspaceID} not found`)
|
||||||
|
if (billing.customerID && billing.customerID !== subscription.customerID) throw new Error("Customer ID mismatch")
|
||||||
|
if (billing.liteSubscriptionID === subscription.subscriptionID) return
|
||||||
|
|
||||||
|
const paymentMethod = subscription.paymentMethodID
|
||||||
|
? await stripe.paymentMethods.retrieve(subscription.paymentMethodID)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!billing.customerID) {
|
||||||
|
await stripe.customers.update(subscription.customerID, {
|
||||||
|
metadata: {
|
||||||
|
workspaceID: subscription.workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const provisioned = await Database.transaction(async (tx) => {
|
||||||
|
const current = await tx
|
||||||
|
.select({
|
||||||
|
customerID: BillingTable.customerID,
|
||||||
|
liteSubscriptionID: BillingTable.liteSubscriptionID,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.workspaceID, subscription.workspaceID))
|
||||||
|
.for("update")
|
||||||
|
.then((rows) => rows[0])
|
||||||
|
if (!current) throw new Error(`Workspace with ID ${subscription.workspaceID} not found`)
|
||||||
|
if (current.customerID && current.customerID !== subscription.customerID) throw new Error("Customer ID mismatch")
|
||||||
|
if (current.liteSubscriptionID === subscription.subscriptionID) return false
|
||||||
|
if (current.liteSubscriptionID) throw new Error("Workspace already has another Go subscription")
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
customerID: subscription.customerID,
|
||||||
|
liteSubscriptionID: subscription.subscriptionID,
|
||||||
|
lite: {},
|
||||||
|
...(paymentMethod
|
||||||
|
? {
|
||||||
|
paymentMethodID: paymentMethod.id,
|
||||||
|
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||||
|
paymentMethodType: paymentMethod.type,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, subscription.workspaceID))
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.insert(LiteTable)
|
||||||
|
.ignore()
|
||||||
|
.values({
|
||||||
|
workspaceID: subscription.workspaceID,
|
||||||
|
id: Identifier.create("lite"),
|
||||||
|
userID: subscription.userID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (subscription.userEmail) {
|
||||||
|
if (subscription.coupon === LiteData.firstMonth50Coupon) {
|
||||||
|
await Billing.redeemCoupon(subscription.userEmail, "GO1MONTH50")
|
||||||
|
} else if (subscription.coupon === LiteData.firstMonth100Coupon) {
|
||||||
|
await Billing.redeemCoupon(subscription.userEmail, "GOFREEMONTH")
|
||||||
|
} else if (subscription.coupon === LiteData.threeMonths100Coupon) {
|
||||||
|
await Billing.redeemCoupon(subscription.userEmail, "GO3MONTHS100")
|
||||||
|
} else if (subscription.coupon === LiteData.sixMonths100Coupon) {
|
||||||
|
await Billing.redeemCoupon(subscription.userEmail, "GO6MONTHS100")
|
||||||
|
} else if (subscription.coupon === LiteData.twelveMonths100Coupon) {
|
||||||
|
await Billing.redeemCoupon(subscription.userEmail, "GO12MONTHS100")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (!provisioned) return
|
||||||
|
|
||||||
|
await Referral.completeFromLiteSubscription({
|
||||||
|
workspaceID: subscription.workspaceID,
|
||||||
|
userID: subscription.userID,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Referral sync failed", error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
66
packages/console/app/test/liteSubscription.test.ts
Normal file
66
packages/console/app/test/liteSubscription.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
liteCheckoutSubscriptionToProvision,
|
||||||
|
liteSubscriptionProvisioningData,
|
||||||
|
} from "../src/routes/stripe/lite-subscription"
|
||||||
|
|
||||||
|
test("waits for an unpaid Checkout Session's asynchronous payment", () => {
|
||||||
|
const session = {
|
||||||
|
mode: "subscription" as const,
|
||||||
|
payment_status: "unpaid" as const,
|
||||||
|
subscription: "sub_go",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(liteCheckoutSubscriptionToProvision("checkout.session.completed", session)).toBeUndefined()
|
||||||
|
expect(liteCheckoutSubscriptionToProvision("checkout.session.async_payment_succeeded", session)).toBe("sub_go")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("provisions a paid Checkout Session immediately", () => {
|
||||||
|
expect(
|
||||||
|
liteCheckoutSubscriptionToProvision("checkout.session.completed", {
|
||||||
|
mode: "subscription",
|
||||||
|
payment_status: "paid",
|
||||||
|
subscription: "sub_go",
|
||||||
|
}),
|
||||||
|
).toBe("sub_go")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("provisions an active Go subscription without a default payment method", () => {
|
||||||
|
expect(
|
||||||
|
liteSubscriptionProvisioningData({
|
||||||
|
id: "sub_go",
|
||||||
|
status: "active",
|
||||||
|
customer: "cus_go",
|
||||||
|
default_payment_method: null,
|
||||||
|
metadata: {
|
||||||
|
type: "lite",
|
||||||
|
workspaceID: "wrk_go",
|
||||||
|
userID: "usr_go",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
subscriptionID: "sub_go",
|
||||||
|
customerID: "cus_go",
|
||||||
|
workspaceID: "wrk_go",
|
||||||
|
userID: "usr_go",
|
||||||
|
userEmail: undefined,
|
||||||
|
coupon: undefined,
|
||||||
|
paymentMethodID: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("waits for an asynchronous Go subscription to become active", () => {
|
||||||
|
expect(
|
||||||
|
liteSubscriptionProvisioningData({
|
||||||
|
id: "sub_go",
|
||||||
|
status: "incomplete",
|
||||||
|
customer: "cus_go",
|
||||||
|
default_payment_method: null,
|
||||||
|
metadata: {
|
||||||
|
type: "lite",
|
||||||
|
workspaceID: "wrk_go",
|
||||||
|
userID: "usr_go",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined()
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue