feat: improve stripe webhooks

pull/715/head
Travis Fischer 2025-06-18 08:45:43 +07:00
rodzic 35ff518ff7
commit 2c8eb5de2d
7 zmienionych plików z 61 dodań i 24 usunięć

Wyświetl plik

@ -19,10 +19,13 @@
},
"scripts": {
"build": "tsup",
"dev": "dotenvx run -- tsx src/server.ts",
"dev:prod": "dotenvx run -o -f .env.production -- tsx src/server.ts",
"start": "dotenvx run -- node dist/server.js",
"start:prod": "dotenvx run -o -f .env.production -- node dist/server.js",
"dev": "run-p dev:*",
"dev:server": "dotenvx run -- tsx src/server.ts",
"dev:stripe": "dotenvx run -- stripe listen --forward-to http://localhost:3001/v1/webhooks/stripe",
"prod": "run-p prod:*",
"prod:server": "dotenvx run -o -f .env.production -- tsx src/server.ts",
"prod:stripe": "dotenvx run -o -f .env.production -- stripe listen --forward-to http://localhost:3001/v1/webhooks/stripe",
"start": "node dist/server.js",
"drizzle-kit": "dotenvx run -- drizzle-kit",
"drizzle-kit:prod": "dotenvx run -o -f .env.production -- drizzle-kit",
"clean": "del dist",

Wyświetl plik

@ -8,7 +8,18 @@ import { env } from '@/lib/env'
import { stripe } from '@/lib/external/stripe'
const relevantStripeEvents = new Set<Stripe.Event.Type>([
'customer.subscription.updated'
'checkout.session.completed',
'checkout.session.expired',
'checkout.session.async_payment_failed',
'checkout.session.async_payment_succeeded',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.paused',
'customer.subscription.resumed',
'customer.subscription.deleted',
'customer.subscription.pending_update_applied',
'customer.subscription.pending_update_expired',
'customer.subscription.trial_will_end'
])
export function registerV1StripeWebhook(app: HonoApp) {
@ -16,7 +27,11 @@ export function registerV1StripeWebhook(app: HonoApp) {
const logger = ctx.get('logger')
const body = await ctx.req.text()
const signature = ctx.req.header('Stripe-Signature')
assert(signature, 400, 'missing signature')
assert(
signature,
400,
'error invalid stripe webhook event: missing signature'
)
let event: Stripe.Event
@ -28,7 +43,7 @@ export function registerV1StripeWebhook(app: HonoApp) {
)
} catch (err) {
throw new HttpError({
message: 'invalid stripe event',
message: 'error invalid stripe webhook event: signature mismatch',
cause: err,
statusCode: 400
})
@ -39,11 +54,10 @@ export function registerV1StripeWebhook(app: HonoApp) {
assert(
event.livemode === env.isStripeLive,
400,
'invalid stripe event: livemode mismatch'
'error invalid stripe webhook event: livemode mismatch'
)
if (!relevantStripeEvents.has(event.type)) {
// TODO
return ctx.json({ status: 'ok' })
}

Wyświetl plik

@ -35,7 +35,7 @@ import { stripe } from '@/lib/external/stripe'
* @note This function assumes that the deployment's pricing config has already
* been validated.
*/
export async function upsertStripePricing({
export async function upsertStripePricingResources({
deployment,
project
}: {
@ -69,7 +69,7 @@ export async function upsertStripePricing({
// Upsert the Stripe Product
if (!project._stripeProductIdMap[pricingPlanLineItemSlug]) {
const productParams: Stripe.ProductCreateParams = {
name: `${project.id} ${pricingPlanLineItemSlug}`,
name: `${project.identifier} ${pricingPlanLineItemSlug}`,
type: 'service',
metadata: {
projectId: project.id,
@ -99,7 +99,7 @@ export async function upsertStripePricing({
if (!project._stripeMeterIdMap[pricingPlanLineItemSlug]) {
const stripeMeter = await stripe.billing.meters.create(
{
display_name: `${project.id} ${pricingPlanLineItem.label || pricingPlanLineItemSlug}`,
display_name: `${project.identifier} ${pricingPlanLineItem.label || pricingPlanLineItemSlug}`,
event_name: `meter-${project.id}-${pricingPlanLineItemSlug}`,
// TODO: This currently isn't taken into account for the slug, so if it
// changes across deployments, the meter will not be updated.
@ -143,6 +143,7 @@ export async function upsertStripePricing({
if (!project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice]) {
const interval = pricingPlan.interval ?? project.defaultPricingInterval
// (nickname is hidden from customers)
const nickname = [
'price',
project.id,
@ -153,9 +154,9 @@ export async function upsertStripePricing({
.join('-')
const priceParams: Stripe.PriceCreateParams = {
nickname,
product: project._stripeProductIdMap[pricingPlanLineItemSlug],
currency: project.pricingCurrency,
nickname,
recurring: {
interval,

Wyświetl plik

@ -14,7 +14,7 @@ import {
import { acl } from '@/lib/acl'
import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer'
import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer'
import { upsertStripePricing } from '@/lib/billing/upsert-stripe-pricing'
import { upsertStripePricingResources } from '@/lib/billing/upsert-stripe-pricing-resources'
import { createConsumerToken } from '@/lib/create-consumer-token'
import { aclPublicProject } from '../acl-public-project'
@ -188,7 +188,7 @@ export async function upsertConsumerStripeCheckout(
)
// Ensure that all Stripe pricing resources exist for this deployment
await upsertStripePricing({ deployment, project })
await upsertStripePricingResources({ deployment, project })
// Ensure that customer and default source are created on the stripe connect account
// TODO: is this necessary?

Wyświetl plik

@ -5,10 +5,12 @@ import { and, db, eq, type RawDeployment, type RawProject, schema } from '@/db'
import { acl } from '@/lib/acl'
import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer'
import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer'
import { upsertStripePricing } from '@/lib/billing/upsert-stripe-pricing'
import { upsertStripePricingResources } from '@/lib/billing/upsert-stripe-pricing-resources'
import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription'
import { createConsumerToken } from '@/lib/create-consumer-token'
import { aclPublicProject } from '../acl-public-project'
export async function upsertConsumer(
c: AuthenticatedHonoContext,
{
@ -51,7 +53,6 @@ export async function upsertConsumer(
410,
`Deployment has been deleted by its owner "${deployment.id}"`
)
await acl(c, deployment, { label: 'Deployment' })
project = deployment.project!
assert(
@ -59,7 +60,17 @@ export async function upsertConsumer(
404,
`Project not found "${projectId}" for deployment "${deploymentId}"`
)
await acl(c, project, { label: 'Project' })
await aclPublicProject(project)
// Validate the deployment only after we're sure the project is publicly
// accessible.
assert(
!deployment.deletedAt,
410,
`Deployment has been deleted by its owner "${deployment.id}"`
)
projectId = project.id
}
if (deploymentId) {
@ -106,7 +117,6 @@ export async function upsertConsumer(
)
}
assert(consumerId)
assert(deploymentId)
assert(projectId)
@ -160,7 +170,7 @@ export async function upsertConsumer(
assert(consumer, 500, 'Error creating consumer')
// Ensure that all Stripe pricing resources exist for this deployment
await upsertStripePricing({ deployment, project })
await upsertStripePricingResources({ deployment, project })
// Ensure that customer and default source are created on the stripe connect account
// TODO: is this necessary?

Wyświetl plik

@ -39,13 +39,22 @@ export function AppConsumerIndex({ consumerId }: { consumerId: string }) {
if (plan) {
firstLoadConsumer.current = false
toast(
`Congrats! You are now subscribed to the "${plan}" plan for project "${consumer.project.name}"`
`Congrats! You are now subscribed to the "${plan}" plan for project "${consumer.project.name}"`,
{
duration: 10_000
}
)
fireConfetti()
// Return the confetti cleanup handler, so if this component is
// unmounted, the confetti will stop as well.
return fireConfetti()
} else {
firstLoadConsumer.current = false
toast(
`Your subscription has been cancelled for project "${consumer.project.name}"`
`Your subscription has been cancelled for project "${consumer.project.name}"`,
{
duration: 10_000
}
)
}
}

Wyświetl plik

@ -56,7 +56,7 @@ export default function RootLayout({
{children}
</main>
<Toaster richColors />
<Toaster richColors duration={5000} />
<Footer />
</div>