Files
moeka-project/apps/server/src/routes/stripe.ts
T

310 lines
11 KiB
TypeScript

import type { Env } from '../libs/env'
import type { ConfigKVService } from '../services/config-kv'
import type { FluxService } from '../services/flux'
import type { StripeService } from '../services/stripe'
import type { HonoEnv } from '../types/hono'
import Stripe from 'stripe'
import { Hono } from 'hono'
import { integer, minValue, number, object, pipe, safeParse } from 'valibot'
import { authGuard } from '../middlewares/auth'
import { configGuard } from '../middlewares/config-guard'
import { createBadRequestError, createServiceUnavailableError } from '../utils/error'
const CheckoutBodySchema = object({
amount: pipe(number(), integer(), minValue(1)),
})
export function createStripeRoutes(fluxService: FluxService, stripeService: StripeService, configKV: ConfigKVService, env: Env) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
const fluxConfigGuard = configGuard(configKV, ['FLUX_PER_CENT'], 'Top-up is not available yet')
return new Hono<HonoEnv>()
.get('/packages', async (c) => {
const packages = await configKV.getOptional('FLUX_PACKAGES')
return c.json(packages ?? [])
})
.post('/checkout', authGuard, fluxConfigGuard, async (c) => {
if (!stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const user = c.get('user')!
const body = await c.req.json()
const result = safeParse(CheckoutBodySchema, body)
if (!result.success)
throw createBadRequestError('Invalid checkout amount', 'INVALID_REQUEST', result.issues)
const { amount } = result.output
// Reuse existing stripe customer if available
const customer = await stripeService.getCustomerByUserId(user.id)
const stripeCustomerId = customer?.stripeCustomerId
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Flux Top-up',
},
unit_amount: amount,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${env.CLIENT_URL}/settings/flux?success=true`,
cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`,
customer: stripeCustomerId,
customer_email: stripeCustomerId ? undefined : user.email,
metadata: {
userId: user.id,
},
})
// Persist the checkout session
await stripeService.upsertCheckoutSession({
userId: user.id,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
return c.json({ url: session.url })
})
// ---- Orders / checkout sessions history ----
.get('/orders', authGuard, async (c) => {
const user = c.get('user')!
const sessions = await stripeService.getCheckoutSessionsByUserId(user.id)
return c.json(sessions)
})
// ---- Invoices history ----
.get('/invoices', authGuard, async (c) => {
const user = c.get('user')!
const invoices = await stripeService.getInvoicesByUserId(user.id)
return c.json(invoices)
})
// ---- Customer portal ----
.post('/portal', authGuard, async (c) => {
if (!stripe)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const user = c.get('user')!
const customer = await stripeService.getCustomerByUserId(user.id)
if (!customer)
throw createBadRequestError('No billing account found', 'NO_CUSTOMER')
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${env.CLIENT_URL}/settings/flux`,
})
return c.json({ url: portalSession.url })
})
// ---- Webhook ----
.post('/webhook', async (c) => {
if (!stripe || !env.STRIPE_WEBHOOK_SECRET)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
const sig = c.req.header('stripe-signature')
if (!sig)
throw createBadRequestError('No signature', 'MISSING_SIGNATURE')
let event: Stripe.Event
try {
const body = await c.req.text()
event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET)
}
catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
throw createBadRequestError(`Webhook Error: ${message}`, 'WEBHOOK_ERROR')
}
switch (event.type) {
case 'checkout.session.completed': {
await handleCheckoutSessionCompleted(event.data.object, fluxService, stripeService, configKV)
break
}
case 'customer.created':
case 'customer.updated': {
await handleCustomerEvent(event.data.object, stripeService)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await handleSubscriptionEvent(event.data.object, stripeService)
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.data.object, fluxService, stripeService, configKV)
break
}
}
return c.json({ received: true })
})
}
// ---- Webhook handlers ----
async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
fluxService: FluxService,
stripeService: StripeService,
configKV: ConfigKVService,
) {
const userId = session.metadata?.userId
if (!userId)
return
// Upsert customer record if we got a customer back
if (session.customer) {
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id
await stripeService.upsertCustomer({
userId,
stripeCustomerId,
email: session.customer_email ?? undefined,
})
// Keep the legacy field in sync
await fluxService.updateStripeCustomerId(userId, stripeCustomerId)
}
// Update the checkout session record
await stripeService.upsertCheckoutSession({
userId,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
// Add flux for one-time payments
if (session.mode === 'payment' && session.amount_total) {
const fluxPerCent = await configKV.get('FLUX_PER_CENT')
await fluxService.addFlux(userId, session.amount_total * fluxPerCent)
}
}
async function handleCustomerEvent(
customer: Stripe.Customer | Stripe.DeletedCustomer,
stripeService: StripeService,
) {
if (customer.deleted)
return
// Try to find existing customer to get userId
const existing = await stripeService.getCustomerByStripeId(customer.id)
if (!existing)
return // We don't know the userId yet; will be linked on checkout
await stripeService.upsertCustomer({
userId: existing.userId,
stripeCustomerId: customer.id,
email: customer.email ?? undefined,
name: customer.name ?? undefined,
})
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
) {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
// In newer Stripe API, period info is on subscription items
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
userId: customer.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
status: subscription.status,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
cancelAtPeriodEnd: String(subscription.cancel_at_period_end),
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
fluxService: FluxService,
stripeService: StripeService,
configKV: ConfigKVService,
) {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return
// In newer Stripe API, subscription is under parent.subscription_details
const subDetails = invoice.parent?.subscription_details
const subscriptionId = subDetails
? (typeof subDetails.subscription === 'string' ? subDetails.subscription : subDetails.subscription?.id)
: undefined
await stripeService.upsertInvoice({
userId: customer.userId,
stripeInvoiceId: invoice.id,
stripeCustomerId,
stripeSubscriptionId: subscriptionId,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
invoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
periodStart: new Date(invoice.period_start * 1000),
periodEnd: new Date(invoice.period_end * 1000),
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
})
// Add flux when a subscription invoice is paid
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) {
const fluxPerCent = await configKV.get('FLUX_PER_CENT')
await fluxService.addFlux(customer.userId, invoice.amount_paid * fluxPerCent)
}
}