kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: improve stripe webhooks
rodzic
35ff518ff7
commit
2c8eb5de2d
|
@ -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",
|
||||
|
|
|
@ -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' })
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
@ -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?
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ export default function RootLayout({
|
|||
{children}
|
||||
</main>
|
||||
|
||||
<Toaster richColors />
|
||||
<Toaster richColors duration={5000} />
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue