SaaSPilot uses MongoDB with Prisma ORM. The database schema is defined in /prisma/schema.prisma.
Main user account model with authentication and billing information.
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(USER)
username String @unique
isTwoFactorEnabled Boolean @default(false)
// Stripe billing
stripeCustomerId String?
currentStripeSubscriptionId String?
// Relations
accounts Account[]
twoFactorConfirmation TwoFactorConfirmation?
credits Credit?
purchases Purchase[]
}Fields:
id- Unique MongoDB ObjectIdname- User's display nameemail- Unique email addressemailVerified- Email verification timestampimage- Profile image URLpassword- Hashed password (for credentials auth)role- User role (USER or ADMIN)username- Unique usernameisTwoFactorEnabled- 2FA statusstripeCustomerId- Stripe customer IDcurrentStripeSubscriptionId- Active subscription (future use)
Relations:
- One-to-Many: Account (OAuth accounts)
- One-to-One: Credit (credit balance)
- One-to-Many: Purchase (purchase history)
- One-to-One: TwoFactorConfirmation
OAuth provider accounts linked to users.
model Account {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
type String
provider String // "google", "github", "credentials"
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}Purpose: Stores OAuth provider information (Google, GitHub) and credentials.
Unique Constraint: One account per provider per user.
User credit balance tracking.
model Credit {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId @unique
balance Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}Fields:
balance- Current credit balanceuserId- User who owns these credits
Usage:
- Credits added on purchase via Stripe
- Credits deducted when using features
- One credit record per user
Purchase history for audit and analytics.
model Purchase {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId @unique
stripeId String @unique
stripeSubscriptionId String?
stripePriceId String?
amount Float
creditsAdded Int
packageName String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}Fields:
stripeId- Stripe session or payment intent ID (unique)amount- Cost in dollarscreditsAdded- Number of credits addedpackageName- "Free", "Starter", "Pro"
Purpose: Track all purchases for analytics and support.
Email verification tokens.
model VerificationToken {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String
token String @unique
expires DateTime
@@unique([email, token])
}Purpose: One-time tokens for email verification.
Lifecycle:
- Created on registration
- Sent via email
- Validated on click
- Deleted after use
Password reset tokens.
model PasswordResetToken {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String
token String @unique
expires DateTime
@@unique([email, token])
}Purpose: One-time tokens for password reset.
Lifecycle:
- Created on "Forgot Password"
- Sent via email
- Validated on reset
- Deleted after use or expiration
Two-factor authentication codes.
model TwoFactorToken {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String
token String @unique
expires DateTime
@@unique([email, token])
}Purpose: Temporary 2FA codes for login.
Lifecycle:
- Generated on login (if 2FA enabled)
- Sent via email
- Validated within expiry time
- Deleted after successful validation
Two-factor authentication confirmation state.
model TwoFactorConfirmation {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
}Purpose: Tracks successful 2FA completion for current session.
enum UserRole {
ADMIN
USER
}Values:
USER- Default role for regular usersADMIN- Admin role (set via ADMIN_EMAILS env var)
Usage: Role-based access control for admin features.
User
├── accounts (Account[]) - OAuth providers
├── credits (Credit?) - Credit balance
├── purchases (Purchase[]) - Purchase history
└── twoFactorConfirmation (TwoFactorConfirmation?) - 2FA state
Account → User (many-to-one)
Credit → User (one-to-one)
Purchase → User (many-to-one)
TwoFactorConfirmation → User (one-to-one)
Independent Models:
- VerificationToken
- PasswordResetToken
- TwoFactorToken
Current indexes (implicit from schema):
User.email- Unique indexUser.username- Unique indexAccount.[provider, providerAccountId]- Composite unique indexCredit.userId- Unique indexPurchase.userId- Unique indexPurchase.stripeId- Unique index
Recommended Additional Indexes (for performance):
model User {
@@index([role])
@@index([createdAt])
}
model Purchase {
@@index([createdAt])
@@index([userId, createdAt])
}const user = await db.user.findUnique({
where: { id: userId },
include: { credits: true }
})const purchases = await db.purchase.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})const token = await db.verificationToken.findUnique({
where: { token: tokenString }
})
if (!token || token.expires < new Date()) {
// Invalid or expired
}const user = await db.user.findUnique({
where: { id: userId },
include: {
accounts: true,
credits: true,
purchases: true,
twoFactorConfirmation: true,
}
})- Update
prisma/schema.prisma - Format:
npx prisma format - Generate client:
npx prisma generate - Push to DB:
npx prisma db push
- Update schema
- Run
npx prisma format - Run
npx prisma generate - Push changes:
npx prisma db push - Update affected TypeScript code
# Create migration
npx prisma migrate dev --name migration_name
# Apply migration
npx prisma migrate deploy✅ Always use Prisma client for queries
✅ Use transactions for related operations
✅ Include only needed fields with select
✅ Add indexes for frequently queried fields
✅ Use onDelete: Cascade for dependent records
❌ Don't use raw queries unless necessary ❌ Don't forget to regenerate client after schema changes ❌ Don't store sensitive data unencrypted ❌ Don't create circular dependencies ❌ Don't skip validation before database operations
// On user registration
await db.credit.create({
data: {
userId: newUser.id,
balance: parseInt(process.env.INITIAL_CREDITS_FOR_NEW || '20')
}
})await db.credit.update({
where: { userId },
data: {
balance: { increment: creditsToAdd }
}
})await db.credit.update({
where: { userId },
data: {
balance: { decrement: creditCost }
}
})const credit = await db.credit.findUnique({
where: { userId }
})
if (!credit || credit.balance < requiredAmount) {
throw new Error('Insufficient credits')
}If migrating from subscription model:
- Keep
currentStripeSubscriptionIdfor backward compatibility - Focus on credit-based billing
- Convert existing subscriptions to credit packages
Update /config/stripe.ts with new plans, no schema changes needed.
npx prisma generate
npx prisma db pushrm -rf node_modules/.prisma
npx prisma generate- Verify DATABASE_URL in
.env.local - Check MongoDB is accessible
- Test with
npx prisma db pull