feat(server): stripe service, more stripe table
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
CREATE TABLE "stripe_checkout_session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"stripe_session_id" text NOT NULL,
|
||||
"stripe_customer_id" text,
|
||||
"mode" text NOT NULL,
|
||||
"status" text,
|
||||
"payment_status" text,
|
||||
"amount_total" integer,
|
||||
"currency" text,
|
||||
"success_url" text,
|
||||
"cancel_url" text,
|
||||
"stripe_payment_intent_id" text,
|
||||
"stripe_subscription_id" text,
|
||||
"metadata" text,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stripe_checkout_session_stripe_session_id_unique" UNIQUE("stripe_session_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stripe_customer" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"stripe_customer_id" text NOT NULL,
|
||||
"email" text,
|
||||
"name" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stripe_customer_stripe_customer_id_unique" UNIQUE("stripe_customer_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stripe_invoice" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"stripe_invoice_id" text NOT NULL,
|
||||
"stripe_customer_id" text,
|
||||
"stripe_subscription_id" text,
|
||||
"status" text,
|
||||
"amount_due" integer,
|
||||
"amount_paid" integer,
|
||||
"currency" text,
|
||||
"invoice_url" text,
|
||||
"invoice_pdf" text,
|
||||
"period_start" timestamp,
|
||||
"period_end" timestamp,
|
||||
"paid_at" timestamp,
|
||||
"metadata" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stripe_invoice_stripe_invoice_id_unique" UNIQUE("stripe_invoice_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stripe_subscription" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"stripe_subscription_id" text NOT NULL,
|
||||
"stripe_customer_id" text NOT NULL,
|
||||
"stripe_price_id" text,
|
||||
"status" text NOT NULL,
|
||||
"current_period_start" timestamp,
|
||||
"current_period_end" timestamp,
|
||||
"cancel_at_period_end" text,
|
||||
"canceled_at" timestamp,
|
||||
"ended_at" timestamp,
|
||||
"metadata" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stripe_subscription_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "stripe_checkout_session" ADD CONSTRAINT "stripe_checkout_session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_customer" ADD CONSTRAINT "stripe_customer_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_invoice" ADD CONSTRAINT "stripe_invoice_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_subscription" ADD CONSTRAINT "stripe_subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1772532802115,
|
||||
"tag": "0001_magenta_skrulls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1772634952890,
|
||||
"tag": "0002_mean_tigra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
-3
@@ -28,6 +28,7 @@ import { createChatService } from './services/chats'
|
||||
import { createConfigKVService } from './services/config-kv'
|
||||
import { createFluxService } from './services/flux'
|
||||
import { createProviderService } from './services/providers'
|
||||
import { createStripeService } from './services/stripe'
|
||||
import { ApiError, createInternalError } from './utils/error'
|
||||
import { getTrustedOrigin } from './utils/origin'
|
||||
|
||||
@@ -37,6 +38,7 @@ type ChatService = ReturnType<typeof createChatService>
|
||||
type ProviderService = ReturnType<typeof createProviderService>
|
||||
type FluxService = ReturnType<typeof createFluxService>
|
||||
type ConfigKVService = ReturnType<typeof createConfigKVService>
|
||||
type StripeDBService = ReturnType<typeof createStripeService>
|
||||
|
||||
type OtelMetrics = ReturnType<typeof initOtel>
|
||||
|
||||
@@ -46,11 +48,12 @@ interface AppDeps {
|
||||
chatService: ChatService
|
||||
providerService: ProviderService
|
||||
fluxService: FluxService
|
||||
stripeService: StripeDBService
|
||||
configKV: ConfigKVService
|
||||
env: Env
|
||||
}
|
||||
|
||||
function buildApp({ auth, characterService, chatService, providerService, fluxService, configKV, env }: AppDeps) {
|
||||
function buildApp({ auth, characterService, chatService, providerService, fluxService, stripeService, configKV, env }: AppDeps) {
|
||||
const logger = useLogger('app').useGlobalConfig()
|
||||
|
||||
const app = new Hono<HonoEnv>()
|
||||
@@ -128,7 +131,7 @@ function buildApp({ auth, characterService, chatService, providerService, fluxSe
|
||||
/**
|
||||
* Stripe routes.
|
||||
*/
|
||||
.route('/api/stripe', createStripeRoutes(fluxService, configKV, env))
|
||||
.route('/api/stripe', createStripeRoutes(fluxService, stripeService, configKV, env))
|
||||
}
|
||||
|
||||
export type AppType = ReturnType<typeof buildApp>
|
||||
@@ -199,19 +202,25 @@ async function createApp() {
|
||||
build: ({ dependsOn }) => createConfigKVService(dependsOn.redis),
|
||||
})
|
||||
|
||||
const stripeService = injeca.provide('services:stripe', {
|
||||
dependsOn: { db },
|
||||
build: ({ dependsOn }) => createStripeService(dependsOn.db),
|
||||
})
|
||||
|
||||
const fluxService = injeca.provide('services:flux', {
|
||||
dependsOn: { db, configKV },
|
||||
build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.configKV),
|
||||
})
|
||||
|
||||
await injeca.start()
|
||||
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, configKV, env: parsedEnv })
|
||||
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, stripeService, configKV, env: parsedEnv })
|
||||
const app = buildApp({
|
||||
auth: resolved.auth,
|
||||
characterService: resolved.characterService,
|
||||
chatService: resolved.chatService,
|
||||
providerService: resolved.providerService,
|
||||
fluxService: resolved.fluxService,
|
||||
stripeService: resolved.stripeService,
|
||||
configKV: resolved.configKV,
|
||||
env: resolved.env,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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'
|
||||
@@ -14,7 +16,7 @@ const CheckoutBodySchema = object({
|
||||
amount: pipe(number(), integer(), minValue(1)),
|
||||
})
|
||||
|
||||
export function createStripeRoutes(fluxService: FluxService, env: Env) {
|
||||
export function createStripeRoutes(fluxService: FluxService, stripeService: StripeService, configKV: ConfigKVService, env: Env) {
|
||||
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
|
||||
|
||||
return new Hono<HonoEnv>()
|
||||
@@ -31,6 +33,10 @@ export function createStripeRoutes(fluxService: FluxService, env: Env) {
|
||||
|
||||
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: [
|
||||
@@ -48,14 +54,67 @@ export function createStripeRoutes(fluxService: FluxService, env: Env) {
|
||||
mode: 'payment',
|
||||
success_url: `${env.CLIENT_URL}/settings/flux?success=true`,
|
||||
cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`,
|
||||
customer_email: user.email,
|
||||
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')
|
||||
@@ -74,21 +133,164 @@ export function createStripeRoutes(fluxService: FluxService, env: Env) {
|
||||
throw createBadRequestError(`Webhook Error: ${message}`, 'WEBHOOK_ERROR')
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object
|
||||
const userId = session.metadata?.userId
|
||||
const amount = session.amount_total
|
||||
|
||||
if (userId && amount) {
|
||||
await fluxService.addFlux(userId, amount * env.FLUX_PER_CENT)
|
||||
|
||||
if (session.customer) {
|
||||
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
|
||||
await fluxService.updateStripeCustomerId(userId, customerId)
|
||||
}
|
||||
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
|
||||
|
||||
await stripeService.upsertSubscription({
|
||||
userId: customer.userId,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripeCustomerId,
|
||||
stripePriceId: subscription.items.data[0]?.price?.id,
|
||||
status: subscription.status,
|
||||
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||||
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
|
||||
|
||||
const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription?.id
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './characters'
|
||||
export * from './chats'
|
||||
export * from './flux'
|
||||
export * from './providers'
|
||||
export * from './stripe'
|
||||
export * from './user-character'
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
||||
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
|
||||
import { nanoid } from '../utils/id'
|
||||
import { user } from './accounts'
|
||||
|
||||
/**
|
||||
* Stripe customers linked to our users.
|
||||
*/
|
||||
export const stripeCustomer = pgTable('stripe_customer', {
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
stripeCustomerId: text('stripe_customer_id').notNull().unique(),
|
||||
email: text('email'),
|
||||
name: text('name'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Stripe checkout sessions – every checkout attempt is recorded.
|
||||
*/
|
||||
export const stripeCheckoutSession = pgTable('stripe_checkout_session', {
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
stripeSessionId: text('stripe_session_id').notNull().unique(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
mode: text('mode').notNull(), // 'payment' | 'subscription' | 'setup'
|
||||
status: text('status'), // 'open' | 'complete' | 'expired'
|
||||
paymentStatus: text('payment_status'), // 'paid' | 'unpaid' | 'no_payment_required'
|
||||
amountTotal: integer('amount_total'), // in cents
|
||||
currency: text('currency'),
|
||||
successUrl: text('success_url'),
|
||||
cancelUrl: text('cancel_url'),
|
||||
stripePaymentIntentId: text('stripe_payment_intent_id'),
|
||||
stripeSubscriptionId: text('stripe_subscription_id'),
|
||||
metadata: text('metadata'), // JSON stringified
|
||||
expiresAt: timestamp('expires_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Stripe subscriptions.
|
||||
*/
|
||||
export const stripeSubscription = pgTable('stripe_subscription', {
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
stripeSubscriptionId: text('stripe_subscription_id').notNull().unique(),
|
||||
stripeCustomerId: text('stripe_customer_id').notNull(),
|
||||
stripePriceId: text('stripe_price_id'),
|
||||
status: text('status').notNull(), // 'active' | 'past_due' | 'canceled' | 'incomplete' | etc
|
||||
currentPeriodStart: timestamp('current_period_start'),
|
||||
currentPeriodEnd: timestamp('current_period_end'),
|
||||
cancelAtPeriodEnd: text('cancel_at_period_end'), // 'true' | 'false'
|
||||
canceledAt: timestamp('canceled_at'),
|
||||
endedAt: timestamp('ended_at'),
|
||||
metadata: text('metadata'), // JSON stringified
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Stripe invoices – both one-time and subscription invoices.
|
||||
*/
|
||||
export const stripeInvoice = pgTable('stripe_invoice', {
|
||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
stripeInvoiceId: text('stripe_invoice_id').notNull().unique(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
stripeSubscriptionId: text('stripe_subscription_id'),
|
||||
status: text('status'), // 'draft' | 'open' | 'paid' | 'uncollectible' | 'void'
|
||||
amountDue: integer('amount_due'), // in cents
|
||||
amountPaid: integer('amount_paid'), // in cents
|
||||
currency: text('currency'),
|
||||
invoiceUrl: text('invoice_url'),
|
||||
invoicePdf: text('invoice_pdf'),
|
||||
periodStart: timestamp('period_start'),
|
||||
periodEnd: timestamp('period_end'),
|
||||
paidAt: timestamp('paid_at'),
|
||||
metadata: text('metadata'), // JSON stringified
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
// ---------- Relations ----------
|
||||
|
||||
export const stripeCustomerRelations = relations(stripeCustomer, ({ one, many }) => ({
|
||||
user: one(user, { fields: [stripeCustomer.userId], references: [user.id] }),
|
||||
checkoutSessions: many(stripeCheckoutSession),
|
||||
subscriptions: many(stripeSubscription),
|
||||
invoices: many(stripeInvoice),
|
||||
}))
|
||||
|
||||
export const stripeCheckoutSessionRelations = relations(stripeCheckoutSession, ({ one }) => ({
|
||||
user: one(user, { fields: [stripeCheckoutSession.userId], references: [user.id] }),
|
||||
customer: one(stripeCustomer, { fields: [stripeCheckoutSession.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
|
||||
}))
|
||||
|
||||
export const stripeSubscriptionRelations = relations(stripeSubscription, ({ one }) => ({
|
||||
user: one(user, { fields: [stripeSubscription.userId], references: [user.id] }),
|
||||
customer: one(stripeCustomer, { fields: [stripeSubscription.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
|
||||
}))
|
||||
|
||||
export const stripeInvoiceRelations = relations(stripeInvoice, ({ one }) => ({
|
||||
user: one(user, { fields: [stripeInvoice.userId], references: [user.id] }),
|
||||
customer: one(stripeCustomer, { fields: [stripeInvoice.stripeCustomerId], references: [stripeCustomer.stripeCustomerId] }),
|
||||
}))
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
export type StripeCustomer = InferSelectModel<typeof stripeCustomer>
|
||||
export type NewStripeCustomer = InferInsertModel<typeof stripeCustomer>
|
||||
|
||||
export type StripeCheckoutSession = InferSelectModel<typeof stripeCheckoutSession>
|
||||
export type NewStripeCheckoutSession = InferInsertModel<typeof stripeCheckoutSession>
|
||||
|
||||
export type StripeSubscription = InferSelectModel<typeof stripeSubscription>
|
||||
export type NewStripeSubscription = InferInsertModel<typeof stripeSubscription>
|
||||
|
||||
export type StripeInvoice = InferSelectModel<typeof stripeInvoice>
|
||||
export type NewStripeInvoice = InferInsertModel<typeof stripeInvoice>
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Database } from '../libs/db'
|
||||
import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
import * as schema from '../schemas/stripe'
|
||||
|
||||
export function createStripeService(db: Database) {
|
||||
return {
|
||||
// ---- Customer ----
|
||||
|
||||
async upsertCustomer(data: NewStripeCustomer) {
|
||||
const existing = await db.query.stripeCustomer.findFirst({
|
||||
where: eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(schema.stripeCustomer)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeCustomer)
|
||||
.values(data)
|
||||
.returning()
|
||||
return created
|
||||
},
|
||||
|
||||
async getCustomerByUserId(userId: string) {
|
||||
return db.query.stripeCustomer.findFirst({
|
||||
where: eq(schema.stripeCustomer.userId, userId),
|
||||
})
|
||||
},
|
||||
|
||||
async getCustomerByStripeId(stripeCustomerId: string) {
|
||||
return db.query.stripeCustomer.findFirst({
|
||||
where: eq(schema.stripeCustomer.stripeCustomerId, stripeCustomerId),
|
||||
})
|
||||
},
|
||||
|
||||
// ---- Checkout Session ----
|
||||
|
||||
async upsertCheckoutSession(data: NewStripeCheckoutSession) {
|
||||
const existing = await db.query.stripeCheckoutSession.findFirst({
|
||||
where: eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(schema.stripeCheckoutSession)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeCheckoutSession)
|
||||
.values(data)
|
||||
.returning()
|
||||
return created
|
||||
},
|
||||
|
||||
async getCheckoutSessionsByUserId(userId: string) {
|
||||
return db.query.stripeCheckoutSession.findMany({
|
||||
where: eq(schema.stripeCheckoutSession.userId, userId),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
// ---- Subscription ----
|
||||
|
||||
async upsertSubscription(data: NewStripeSubscription) {
|
||||
const existing = await db.query.stripeSubscription.findFirst({
|
||||
where: eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(schema.stripeSubscription)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeSubscription)
|
||||
.values(data)
|
||||
.returning()
|
||||
return created
|
||||
},
|
||||
|
||||
async getActiveSubscription(userId: string) {
|
||||
return db.query.stripeSubscription.findFirst({
|
||||
where: eq(schema.stripeSubscription.userId, userId),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
|
||||
// ---- Invoice ----
|
||||
|
||||
async upsertInvoice(data: NewStripeInvoice) {
|
||||
const existing = await db.query.stripeInvoice.findFirst({
|
||||
where: eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(schema.stripeInvoice)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId))
|
||||
.returning()
|
||||
return updated
|
||||
}
|
||||
|
||||
const [created] = await db.insert(schema.stripeInvoice)
|
||||
.values(data)
|
||||
.returning()
|
||||
return created
|
||||
},
|
||||
|
||||
async getInvoicesByUserId(userId: string) {
|
||||
return db.query.stripeInvoice.findMany({
|
||||
where: eq(schema.stripeInvoice.userId, userId),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type StripeService = ReturnType<typeof createStripeService>
|
||||
Reference in New Issue
Block a user