Schema Management
Understanding Better Billing's database schema system and customization options
Schema Management
Better Billing uses a flexible schema system that allows plugins to define and extend database schemas. The system supports schema merging, field overrides, and database customization.
How Schema Works
Plugin Schema Definition
Plugins define database schemas using Zod objects:
import { createPlugin } from "better-billing";
import { z } from "zod";
const myPlugin = createPlugin(
() => {
return {
schema: {
customers: z.object({
id: z.string(),
email: z.string().email(),
name: z.string().optional(),
createdAt: z.date(),
}),
subscriptions: z.object({
id: z.string(),
customerId: z.string(),
status: z.string(),
planName: z.string(),
createdAt: z.date(),
}),
},
};
},
{ dependsOn: [] as const }
);Schema Merging
When multiple plugins define schemas, they are automatically merged:
// Plugin 1 defines base fields
const plugin1 = createPlugin(() => ({
schema: {
subscriptions: z.object({
id: z.string(),
status: z.string(),
}),
},
}), { dependsOn: [] as const });
// Plugin 2 adds additional fields
const plugin2 = createPlugin(() => ({
schema: {
subscriptions: z.object({
metadata: z.record(z.string()),
updatedAt: z.date(),
}),
},
}), { dependsOn: [plugin1] as const });
// Result: subscriptions table has id, status, metadata, AND updatedAtField Override
Later plugins can override field types from earlier plugins:
// Plugin 1 defines field as string
const plugin1 = createPlugin(() => ({
schema: {
subscriptions: z.object({
amount: z.string(),
}),
},
}), { dependsOn: [] as const });
// Plugin 2 overrides to number
const plugin2 = createPlugin(() => ({
schema: {
subscriptions: z.object({
amount: z.number(),
}),
},
}), { dependsOn: [plugin1] as const });
// Result: amount field is a number (Plugin 2 wins)Core Schema
The core plugin provides essential billing schema out of the box:
Billables (Polymorphic Entities)
Any entity that can be billed:
{
id: string,
type: string, // "user", "organization", "team", etc.
externalId: string, // Reference to your existing entities
name?: string,
email?: string,
metadata?: Record<string, string>,
}Subscriptions
Links billables to plans:
{
id: string,
billableId: string, // References billables table
planName: string, // References configured plans
status: string, // "active", "canceled", "past_due", etc.
providerId: string, // "stripe", "paddle", etc.
providerSubscriptionId: string,
currentPeriodStart: Date,
currentPeriodEnd: Date,
trialStart?: Date,
trialEnd?: Date,
createdAt: Date,
updatedAt: Date,
}Payment Methods
Stored payment information:
{
id: string,
billableId: string,
type: string, // "card", "bank_account", etc.
brand?: string, // "visa", "mastercard", etc.
lastFour?: string,
expiryMonth?: number,
expiryYear?: number,
isDefault: boolean,
providerId: string,
providerPaymentMethodId: string,
}Database Generation
Getting Merged Schema
Access the final merged schema:
const billing = betterBilling({
adapter: drizzleAdapter(db, { provider: "pg", schema: {} }),
plugins: [corePlugin({}), stripePlugin({...})],
});
const mergedSchema = billing.getMergedSchema();Generating Database Schema
Convert the merged schema to database-specific format:
import { generateDrizzleSchema } from "@better-billing/db/generators/drizzle";
const mergedSchema = billing.getMergedSchema();
const drizzleSchema = generateDrizzleSchema(mergedSchema, adapter);
console.log(drizzleSchema);
// Outputs the actual Drizzle schema code:
// export const subscriptions = pgTable("subscriptions", {
// id: varchar("id").notNull(),
// status: varchar("status").notNull(),
// ...
// });Schema Customization
Table Name Mapping
Map Better Billing tables to your existing database tables:
const billing = betterBilling({
adapter: drizzleAdapter(db, {
provider: "pg",
schema: {},
// Map Better Billing tables to your existing tables
tableNames: {
subscription: "user_subscriptions",
customer: "users",
paymentMethod: "user_payment_methods",
},
}),
// ...
});Field Name Mapping
Map Better Billing fields to your existing column names:
const billing = betterBilling({
adapter: drizzleAdapter(db, {
provider: "pg",
schema: {},
fieldNames: {
subscription: {
billableId: "user_id",
providerId: "payment_provider",
createdAt: "created_timestamp",
},
customer: {
externalId: "id", // Map to your existing user ID column
},
},
}),
// ...
});Working with Existing Databases
Integration with Existing Schema
Better Billing can work with your existing database schema:
// Your existing Drizzle schema
const existingSchema = {
users: pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull(),
name: text("name"),
// ... other user fields
}),
organizations: pgTable("organizations", {
id: text("id").primaryKey(),
name: text("name").notNull(),
// ... other org fields
}),
};
// Use with Better Billing
const billing = betterBilling({
adapter: drizzleAdapter(db, {
provider: "pg",
schema: existingSchema, // Include your existing schema
tableNames: {
customer: "users", // Map customer to existing users table
},
}),
// ...
});Extending Existing Tables
Add billing fields to existing tables through schema merging:
const billingExtensionPlugin = createPlugin(
() => ({
schema: {
users: z.object({
// Add billing-specific fields to existing users table
stripeCustomerId: z.string().optional(),
subscriptionTier: z.string().optional(),
trialEndsAt: z.date().optional(),
}),
},
}),
{ dependsOn: [corePlugin] as const }
);Schema Validation
Zod Field Types
Better Billing supports all Zod field types:
schema: {
myTable: z.object({
// Strings
id: z.string(),
name: z.string().optional(),
email: z.string().email(),
// Numbers
amount: z.number(),
count: z.number().int().positive(),
// Dates
createdAt: z.date(),
expiresAt: z.date().optional(),
// Booleans
isActive: z.boolean(),
// Objects/Records
metadata: z.record(z.string()),
settings: z.object({
theme: z.string(),
notifications: z.boolean(),
}),
// Enums
status: z.enum(["active", "inactive", "pending"]),
}),
}Runtime Validation
Schemas provide runtime validation when inserting/updating data:
// Data is automatically validated against the schema
await billing.database.create("subscriptions", {
id: "sub_123",
billableId: "user_123",
planName: "Pro",
status: "active",
// ... other fields
});
// Invalid data will throw validation errorsBest Practices
- Define schemas in plugins to keep them organized and reusable
- Use meaningful field names that match your domain terminology
- Leverage schema merging to extend functionality without breaking changes
- Map to existing tables when integrating with existing databases
- Use optional fields for backward compatibility when extending schemas
- Validate data types carefully to prevent runtime errors