Order and Subscription Management
This document explains how to query and manage orders and subscriptions, as well as how to use the customer portal to manage subscriptions.
Order Management
Order Data Structure
Order records are stored in the orders table, containing the following key fields:
{
id: string; // Order ID (UUID)
userId: string; // User ID
provider: 'stripe' | 'creem'; // Payment provider
providerOrderId: string; // Provider order ID (unique)
orderType: string; // Order type
status: string; // Order status
planId: string; // Associated plan ID
subscriptionId?: string; // Associated subscription ID (if subscription order)
amountTotal: string; // Order total amount
currency: string; // Currency code
createdAt: Date; // Creation time
updatedAt: Date; // Update time
metadata: object; // Metadata (JSONB)
}Order Types
The system supports the following order types:
one_time_purchase: One-time purchasesubscription_initial: Subscription initial payment (Stripe)subscription_renewal: Subscription renewal (Stripe)recurring: Subscription payment (Creem)refund: Refund
Order Status
Possible order statuses:
succeeded: Payment succeededpending: Pendingfailed: Payment failedrefunded: Fully refundedpartially_refunded: Partially refunded
User Order Query
Users can query their own orders through the getMyOrders function:
export async function getMyOrders(params: {
pageIndex?: number;
pageSize?: number;
filter?: string;
provider?: string;
orderType?: string;
status?: string;
}): Promise<GetMyOrdersResult> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
const { pageIndex = 0, pageSize = 10, filter, provider, orderType, status } = params;
// Build query conditions
const baseWhere = eq(ordersSchema.userId, user.id);
const optionalConditions: SQL[] = [];
if (provider) {
optionalConditions.push(eq(ordersSchema.provider, provider));
}
if (orderType) {
optionalConditions.push(eq(ordersSchema.orderType, orderType));
}
if (status) {
optionalConditions.push(eq(ordersSchema.status, status));
}
if (filter) {
optionalConditions.push(
or(
ilike(ordersSchema.providerOrderId, `%${filter}%`),
sql`CAST(${ordersSchema.id} AS TEXT) ILIKE ${`%${filter}%`}`
) as SQL
);
}
const whereClause = optionalConditions.length
? (and(baseWhere, ...optionalConditions) as SQL)
: baseWhere;
// Query orders
const orders = await db
.select()
.from(ordersSchema)
.where(whereClause)
.orderBy(desc(ordersSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
// Query total count
const totalCountResult = await db
.select({ value: count() })
.from(ordersSchema)
.where(whereClause);
return actionResponse.success({
orders,
totalCount: totalCountResult[0]?.value ?? 0,
});
}Admin Order Query
Admins can query all orders through the getOrders function:
export async function getOrders(params: {
pageIndex?: number;
pageSize?: number;
filter?: string;
provider?: string;
orderType?: string;
status?: string;
}): Promise<GetOrdersResult> {
if (!(await isAdmin())) {
return actionResponse.forbidden('Admin privileges required.');
}
const { pageIndex = 0, pageSize = 10, filter, provider, orderType, status } = params;
const conditions = [];
if (provider) {
conditions.push(eq(ordersSchema.provider, provider));
}
if (orderType) {
conditions.push(eq(ordersSchema.orderType, orderType));
}
if (status) {
conditions.push(eq(ordersSchema.status, status));
}
if (filter) {
conditions.push(
or(
ilike(userSchema.email, `%${filter}%`),
ilike(ordersSchema.providerOrderId, `%${filter}%`),
sql`CAST(${ordersSchema.id} AS TEXT) ILIKE ${`%${filter}%`}`
)
);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// Query orders (including user information)
const results = await db
.select({
order: ordersSchema,
user: { email: userSchema.email, name: userSchema.name },
})
.from(ordersSchema)
.leftJoin(userSchema, eq(ordersSchema.userId, userSchema.id))
.where(whereClause)
.orderBy(desc(ordersSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
// Query total count
const totalCountResult = await db
.select({ value: count() })
.from(ordersSchema)
.leftJoin(userSchema, eq(ordersSchema.userId, userSchema.id))
.where(whereClause);
const ordersData = results.map((r) => ({
...r.order,
users: r.user,
}));
return actionResponse.success({
orders: ordersData as unknown as OrderWithUser[],
totalCount: totalCountResult[0].value,
});
}Order List Pages
User Order Page
Create app/[locale]/(protected)/dashboard/(user)/my-orders/page.tsx:
import { getMyOrders } from '@/actions/orders/user';
import { MyOrdersDataTable } from './DataTable';
export default async function MyOrdersPage({
searchParams
}: {
searchParams: { page?: string; filter?: string; provider?: string; status?: string }
}) {
const pageIndex = parseInt(searchParams.page || '0');
const result = await getMyOrders({
pageIndex,
pageSize: 10,
filter: searchParams.filter,
provider: searchParams.provider,
status: searchParams.status,
});
if (!result.success) {
return <div>Error: {result.error}</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">My Orders</h1>
</div>
<MyOrdersDataTable
orders={result.data.orders}
totalCount={result.data.totalCount}
/>
</div>
);
}Admin Order Page
Create app/[locale]/(protected)/dashboard/(admin)/orders/page.tsx:
import { getOrders } from '@/actions/orders/admin';
import { OrdersDataTable } from './DataTable';
export default async function OrdersPage({
searchParams
}: {
searchParams: { page?: string; filter?: string; provider?: string; status?: string }
}) {
const pageIndex = parseInt(searchParams.page || '0');
const result = await getOrders({
pageIndex,
pageSize: 10,
filter: searchParams.filter,
provider: searchParams.provider,
status: searchParams.status,
});
if (!result.success) {
return <div>Error: {result.error}</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Orders</h1>
</div>
<OrdersDataTable
orders={result.data.orders}
totalCount={result.data.totalCount}
/>
</div>
);
}Subscription Management
Subscription Data Structure
Subscription records are stored in the subscriptions table:
{
id: string; // Subscription ID (UUID)
userId: string; // User ID
planId: string; // Associated plan ID
provider: 'stripe' | 'creem'; // Payment provider
subscriptionId: string; // Provider subscription ID (unique)
customerId: string; // Customer ID
status: string; // Subscription status
currentPeriodStart: Date; // Current period start time
currentPeriodEnd: Date; // Current period end time
cancelAtPeriodEnd: boolean; // Whether to cancel at period end
canceledAt?: Date; // Cancellation time
endedAt?: Date; // End time
trialStart?: Date; // Trial start time
trialEnd?: Date; // Trial end time
metadata: object; // Metadata (JSONB)
}Subscription Status
Possible subscription statuses:
active: Activetrialing: Trialingpast_due: Past duecanceled: Canceledincomplete: Incompleteincomplete_expired: Incomplete expiredunpaid: Unpaidpaused: Paused (Creem)
Subscription Synchronization
The system automatically synchronizes subscription status through webhooks. You can also manually synchronize:
Stripe Subscription Synchronization
export async function syncSubscriptionData(
subscriptionId: string,
customerId: string,
initialMetadata?: Record<string, any>
): Promise<void> {
// 1. Get subscription information from Stripe
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method', 'customer']
});
// 2. Parse user ID and plan ID
let userId = subscription.metadata?.userId;
let planId = subscription.metadata?.planId;
// If not in metadata, try querying from database
if (!userId) {
const userData = await db
.select({ id: userSchema.id })
.from(userSchema)
.where(eq(userSchema.stripeCustomerId, customerId))
.limit(1);
userId = userData[0]?.id;
}
if (!planId) {
const priceId = subscription.items.data[0].price.id;
const planData = await db
.select({ id: pricingPlansSchema.id })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.stripePriceId, priceId))
.limit(1);
planId = planData[0]?.id;
}
// 3. Build subscription data
const subscriptionData = {
userId,
planId,
provider: 'stripe',
subscriptionId: subscription.id,
customerId: typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id,
priceId: 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: 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,
trialStart: subscription.trial_start
? new Date(subscription.trial_start * 1000)
: null,
trialEnd: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
metadata: {
...subscription.metadata,
...(initialMetadata && { checkoutSessionMetadata: initialMetadata })
},
};
// 4. Insert or update subscription record
await db
.insert(subscriptionsSchema)
.values(subscriptionData)
.onConflictDoUpdate({
target: subscriptionsSchema.subscriptionId,
set: subscriptionData,
});
}Creem Subscription Synchronization
export async function syncCreemSubscriptionData(
subscriptionId: string,
initialMetadata?: Record<string, any>
): Promise<void> {
// 1. Get subscription information from Creem
const subscription = await retrieveCreemSubscription(subscriptionId);
// 2. Parse user ID and plan ID
let userId = subscription.metadata?.userId;
let planId = subscription.metadata?.planId;
let productId = subscription.product.id;
if (!userId) {
const storeSubscription = await db
.select({ userId: subscriptionsSchema.userId })
.from(subscriptionsSchema)
.where(eq(subscriptionsSchema.subscriptionId, subscriptionId))
.limit(1);
userId = storeSubscription[0]?.userId;
}
if (!planId) {
const planRow = await db
.select({ id: pricingPlansSchema.id })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.creemProductId, productId))
.limit(1);
planId = planRow[0]?.id;
}
// 3. Build subscription data
const subscriptionData = {
userId,
planId: planId ?? null,
provider: 'creem',
subscriptionId: subscription.id,
customerId: subscription.customer.id,
priceId: subscription.items?.[0]?.price_id ?? '',
productId: productId,
status: subscription.status,
currentPeriodStart: toDate(subscription.current_period_start_date),
currentPeriodEnd: toDate(subscription.current_period_end_date),
cancelAtPeriodEnd: subscription.status === 'scheduled_cancel',
canceledAt: toDate(subscription.canceled_at),
endedAt: subscription.status === 'canceled'
? toDate(subscription.current_period_end_date)
: null,
trialStart: null,
trialEnd: null,
metadata: {
...metadata,
creemSubscriptionId: subscription.id,
creemCustomerId: subscription.customer.id,
creemProductId: productId,
},
};
// 4. Insert or update subscription record
await db
.insert(subscriptionsSchema)
.values(subscriptionData)
.onConflictDoUpdate({
target: subscriptionsSchema.subscriptionId,
set: subscriptionData,
});
}Customer Portal
The customer portal allows users to manage their subscriptions, including:
- Update payment methods
- Cancel subscriptions
- View invoices
- Update billing information
Stripe Customer Portal
Create a Stripe customer portal session:
export async function createStripePortalSession(): Promise<void> {
const session = await getSession();
const user = session?.user;
if (!user) {
redirect('/login');
}
// 1. Get user's Stripe Customer ID
const profile = await db
.select({ stripeCustomerId: userSchema.stripeCustomerId })
.from(userSchema)
.where(eq(userSchema.id, user.id))
.limit(1);
if (!profile?.stripeCustomerId) {
throw new Error('Stripe customer ID not found');
}
// 2. Build return URL
const headersList = await headers();
const domain = headersList.get('x-forwarded-host') || headersList.get('host');
const protocol = headersList.get('x-forwarded-proto') || 'https';
const returnUrl = `${protocol}://${domain}${process.env.STRIPE_CUSTOMER_PORTAL_URL}`;
// 3. Create portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: profile.stripeCustomerId,
return_url: returnUrl,
});
// 4. Redirect to portal
redirect(portalSession.url);
}Creem Customer Portal
Create a Creem customer portal link:
export async function createCreemPortalSession(): Promise<void> {
const session = await getSession();
const user = session?.user;
if (!user) {
redirect('/login');
}
// 1. Get user's Creem subscription
const subscriptionResults = await db
.select({
customerId: subscriptionsSchema.customerId,
})
.from(subscriptionsSchema)
.where(
and(
eq(subscriptionsSchema.userId, user.id),
eq(subscriptionsSchema.provider, 'creem')
)
)
.orderBy(desc(subscriptionsSchema.createdAt))
.limit(1);
const subscription = subscriptionResults[0];
if (!subscription) {
throw new Error('Creem subscription not found');
}
// 2. Create portal link
const portalUrl = await createCreemCustomerPortalLink(subscription.customerId);
if (!portalUrl) {
throw new Error('Failed to create Creem portal link');
}
// 3. Redirect to portal
redirect(portalUrl);
}Subscription Management Page
Create a subscription management page that displays the user's current subscription status and portal entry:
import { createCreemPortalSession } from '@/actions/creem/portal';
import { createStripePortalSession } from '@/actions/stripe';
import { getUserBenefits } from '@/actions/usage/benefits';
import CurrentUserBenefitsDisplay from '@/components/layout/CurrentUserBenefitsDisplay';
import { Button } from '@/components/ui/button';
import { getSession } from '@/lib/auth/server';
import { db } from '@/lib/db';
import { subscriptions as subscriptionsSchema } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { PortalButton } from './PortalButton';
export default async function SubscriptionPage() {
const session = await getSession();
const user = session?.user;
if (!user) redirect('/login');
// Get user benefits
const benefits = await getUserBenefits(user.id);
// Get user's subscription provider
const subscriptionResults = await db
.select({ provider: subscriptionsSchema.provider })
.from(subscriptionsSchema)
.where(eq(subscriptionsSchema.userId, user.id))
.orderBy(desc(subscriptionsSchema.createdAt))
.limit(1);
const subscriptionProvider = subscriptionResults[0]?.provider || null;
const isMember =
benefits.subscriptionStatus === 'active' ||
benefits.subscriptionStatus === 'trialing';
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Subscription</h1>
</div>
<div className="rounded-lg border p-6 space-y-4">
{isMember ? (
<>
<CurrentUserBenefitsDisplay />
{subscriptionProvider === 'stripe' && (
<>
<PortalButton
provider="stripe"
action={createStripePortalSession}
/>
<p className="text-xs text-muted-foreground">
You will be redirected to Stripe to manage your subscription details.
</p>
</>
)}
{subscriptionProvider === 'creem' && (
<>
<PortalButton
provider="creem"
action={createCreemPortalSession}
/>
<p className="text-xs text-muted-foreground">
You will be redirected to manage your subscription details.
</p>
</>
)}
</>
) : (
<>
<p>You are currently not subscribed to any plan.</p>
<Button asChild>
<Link href={process.env.NEXT_PUBLIC_PRICING_PATH!}>
Upgrade Plan
</Link>
</Button>
</>
)}
</div>
</div>
);
}Credit History Query
Users can view their credit usage history:
export async function getCreditLogs({
pageIndex = 0,
pageSize = 20,
}: ListCreditLogsParams = {}): Promise<ListCreditLogsResult> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
const logs = await db
.select()
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id))
.orderBy(desc(creditLogsSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
const totalCount = await db
.select({ value: count() })
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id));
return actionResponse.success({
logs: logs || [],
count: totalCount[0]?.value ?? 0,
});
}Credit History Page
Create app/[locale]/(protected)/dashboard/(user)/credit-history/page.tsx:
import { getCreditLogs } from '@/actions/usage/logs';
import { CreditHistoryDataTable } from './CreditHistoryDataTable';
export default async function CreditHistoryPage({
searchParams
}: {
searchParams: { page?: string }
}) {
const pageIndex = parseInt(searchParams.page || '0');
const result = await getCreditLogs({
pageIndex,
pageSize: 20,
});
if (!result.success) {
return <div>Error: {result.error}</div>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Credit History</h1>
</div>
<CreditHistoryDataTable
logs={result.data.logs}
totalCount={result.data.count}
/>
</div>
);
}Frequently Asked Questions
Q: How to query orders for a specific user?
A: Admins can use the getOrders function and search by user email or order ID through the filter parameter.
Q: How is subscription status updated?
A: Subscription status is automatically updated through webhooks. When payment providers send subscription update events, the system automatically synchronizes the status.
Q: Can users cancel subscriptions?
A: Yes. Users can cancel subscriptions through the customer portal. After cancellation, the subscription will take effect at the end of the current period.
Q: How to view detailed subscription information?
A: Query the subscriptions table to get detailed subscription information, including status, period, cancellation time, etc.