Skip to main content

Payments & Subscriptions Schema Deep Dive

Overview

The payments module handles the full subscription lifecycle: payment providers, customer accounts, subscriptions with trial support, auto-renewal management, and a complete subscription history audit trail. The system supports multiple payment providers (Stripe, Solidgate, LemonSqueezy, Polar).

Source file: template/lib/db/schema.ts Constants: template/lib/constants/payment.ts Relations file: template/lib/db/migrations/relations.ts


Tables in This Module

TablePurpose
paymentProvidersRegistry of available payment providers
paymentAccountsLinks users to their payment provider customer IDs
subscriptionsActive and historical subscription records
subscriptionHistoryAudit trail of subscription lifecycle events

Table: paymentProviders

Registry of supported payment providers.

Columns

ColumnDB NameTypeNullableDefaultConstraints
ididtextNocrypto.randomUUID()Primary Key
namenametextNo'stripe'Unique
isActiveis_activebooleanNotrue-
createdAtcreated_attimestampNonow()-
updatedAtupdated_attimestampNonow()-

Indexes

NameColumnsType
paymentProviders_name_uniquenameUnique
payment_provider_active_idxisActiveB-tree
payment_provider_created_at_idxcreatedAtB-tree

Supported Providers (Enum)

export enum PaymentProvider {
STRIPE = 'stripe',
SOLIDGATE = 'solidgate',
LEMONSQUEEZY = 'lemonsqueezy',
POLAR = 'polar'
}

Table: paymentAccounts

Links users to their external payment provider customer accounts.

Columns

ColumnDB NameTypeNullableDefaultConstraints
ididtextNocrypto.randomUUID()Primary Key
userIduserIdtextNo-FK -> users.id (CASCADE)
providerIdproviderIdtextNo-FK -> paymentProviders.id (CASCADE)
customerIdcustomerIdtextNo-External customer ID
accountIdaccountIdtextYes-Optional account identifier
lastUsedlastUsedtimestampYes--
createdAtcreated_attimestampNonow()-
updatedAtupdated_attimestampNonow()-

Indexes

NameColumnsType
user_provider_unique_idx(userId, providerId)Unique
customer_provider_unique_idx(customerId, providerId)Unique
payment_account_customer_id_idxcustomerIdB-tree
payment_account_provider_idxproviderIdB-tree
payment_account_created_at_idxcreatedAtB-tree

Key Constraints

  • One account per provider per user: The user_provider_unique_idx ensures a user can only have one customer account per payment provider.
  • Unique customer IDs per provider: The customer_provider_unique_idx ensures no duplicate customer IDs within a provider.

Table: subscriptions

The core subscription table with comprehensive support for trials, auto-renewal, cancellation, and multi-provider billing.

Columns

ColumnDB NameTypeNullableDefaultConstraints
ididtextNocrypto.randomUUID()Primary Key
userIduserIdtextNo-FK -> users.id (CASCADE)
planIdplan_idtextNo'free'Plan identifier
statusstatustextNo'pending'Subscription status
startDatestart_datetimestampNonow()-
endDateend_datetimestampYes--
paymentProviderpayment_providertextNo'stripe'-
subscriptionIdsubscription_idtextYes-External subscription ID
invoiceIdinvoice_idtextYes-External invoice ID
amountDueamount_dueintegerYes0In cents
amountPaidamount_paidintegerYes0In cents
priceIdprice_idtextYes-External price ID
customerIdcustomer_idtextYes-External customer ID
currencycurrencytextYes'usd'ISO currency code
amountamountintegerYes0In cents
intervalintervaltextYes'month'Billing interval
intervalCountinterval_countintegerYes1-
trialStarttrial_starttimestampYes--
trialEndtrial_endtimestampYes--
autoRenewalauto_renewalbooleanYestrue-
renewalReminderSentrenewal_reminder_sentbooleanYesfalse-
lastRenewalAttemptlast_renewal_attempttimestamp (tz)Yes--
failedPaymentCountfailed_payment_countintegerYes0-
cancelledAtcancelled_attimestampYes--
cancelAtPeriodEndcancel_at_period_endbooleanYesfalse-
cancelReasoncancel_reasontextYes--
hostedInvoiceUrlhosted_invoice_urltextYes--
invoicePdfinvoice_pdftextYes--
metadatametadatatextYes-JSON string
createdAtcreated_attimestampNonow()-
updatedAtupdated_attimestampNonow()-

Indexes

NameColumnsType
user_subscription_idxuserIdB-tree
subscription_status_idxstatusB-tree
provider_subscription_idx(paymentProvider, subscriptionId)Unique
subscription_plan_idxplanIdB-tree
subscription_created_at_idxcreatedAtB-tree

Check Constraints

-- auto_renewal and cancel_at_period_end cannot both be true
CHECK (NOT (auto_renewal AND cancel_at_period_end))

Status Enum

export const SubscriptionStatus = {
ACTIVE: 'active',
CANCELLED: 'cancelled',
EXPIRED: 'expired',
PENDING: 'pending',
PAUSED: 'paused'
} as const;

Plan Enum

export enum PaymentPlan {
FREE = 'free',
STANDARD = 'standard',
PREMIUM = 'premium'
}

TypeScript Types

export type Subscription = typeof subscriptions.$inferSelect;
export type NewSubscription = typeof subscriptions.$inferInsert;
export type SubscriptionWithUser = Subscription & {
user: typeof users.$inferSelect;
};

Table: subscriptionHistory

Immutable audit trail of every subscription lifecycle event.

Columns

ColumnDB NameTypeNullableDefaultConstraints
ididtextNocrypto.randomUUID()Primary Key
subscriptionIdsubscription_idtextNo-FK -> subscriptions.id (CASCADE)
actionactiontextNo-Event description
previousStatusprevious_statustextYes-Status before change
newStatusnew_statustextYes-Status after change
previousPlanprevious_plantextYes-Plan before change
newPlannew_plantextYes-Plan after change
reasonreasontextYes--
metadatametadatatextYes-JSON string
createdAtcreated_attimestampNonow()-

Indexes

NameColumnsType
subscription_history_idxsubscriptionIdB-tree
subscription_action_idxactionB-tree
subscription_history_created_at_idxcreatedAtB-tree

TypeScript Types

export type SubscriptionHistory = typeof subscriptionHistory.$inferSelect;
export type NewSubscriptionHistory = typeof subscriptionHistory.$inferInsert;

Relations Diagram


Subscription Lifecycle


Query Examples

Get active subscription for a user

import { db } from '@/lib/db/drizzle';
import { subscriptions } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';

const activeSub = await db
.select()
.from(subscriptions)
.where(
and(
eq(subscriptions.userId, userId),
eq(subscriptions.status, 'active')
)
)
.limit(1);

Create a new subscription

await db.insert(subscriptions).values({
userId,
planId: 'standard',
status: 'active',
paymentProvider: 'stripe',
subscriptionId: stripeSubscription.id,
customerId: stripeCustomer.id,
priceId: stripePriceId,
amount: 1999, // $19.99 in cents
currency: 'usd',
interval: 'month',
});

Log a subscription change

await db.insert(subscriptionHistory).values({
subscriptionId: sub.id,
action: 'plan_upgrade',
previousStatus: 'active',
newStatus: 'active',
previousPlan: 'free',
newPlan: 'standard',
reason: 'User upgraded via billing page',
});

Find a payment account by Stripe customer ID

import { paymentAccounts } from '@/lib/db/schema';

const account = await db
.select()
.from(paymentAccounts)
.where(eq(paymentAccounts.customerId, stripeCustomerId))
.limit(1);