Menu

注文とサブスクリプション管理

このドキュメントは、注文とサブスクリプションのクエリと管理方法、およびカスタマーポータルを使用してサブスクリプションを管理する方法を説明します。

注文管理

注文データ構造

注文記録はordersテーブルに保存され、以下の主要フィールドを含みます:

{
  id: string;                      // 注文ID(UUID)
  userId: string;                   // ユーザーID
  provider: 'stripe' | 'creem';    // 決済プロバイダー
  providerOrderId: string;         // プロバイダー注文ID(一意)
  orderType: string;                // 注文タイプ
  status: string;                   // 注文ステータス
  planId: string;                   // 関連プランID
  subscriptionId?: string;          // 関連サブスクリプションID(サブスクリプション注文の場合)
  amountTotal: string;              // 注文合計金額
  currency: string;                 // 通貨コード
  createdAt: Date;                 // 作成時刻
  updatedAt: Date;                 // 更新時刻
  metadata: object;                 // メタデータ(JSONB)
}

注文タイプ

システムは以下の注文タイプをサポートしています:

  • one_time_purchase: 一回限りの購入
  • subscription_initial: サブスクリプション初回決済(Stripe)
  • subscription_renewal: サブスクリプション更新(Stripe)
  • recurring: サブスクリプション決済(Creem)
  • refund: 返金

注文ステータス

可能な注文ステータス:

  • succeeded: 決済成功
  • pending: 保留中
  • failed: 決済失敗
  • refunded: 完全返金
  • partially_refunded: 部分返金

ユーザー注文クエリ

ユーザーはgetMyOrders関数を通じて自分の注文をクエリできます:

actions/orders/user.ts
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;
 
  // クエリ条件を構築
  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;
 
  // 注文をクエリ
  const orders = await db
    .select()
    .from(ordersSchema)
    .where(whereClause)
    .orderBy(desc(ordersSchema.createdAt))
    .offset(pageIndex * pageSize)
    .limit(pageSize);
 
  // 総数をクエリ
  const totalCountResult = await db
    .select({ value: count() })
    .from(ordersSchema)
    .where(whereClause);
 
  return actionResponse.success({
    orders,
    totalCount: totalCountResult[0]?.value ?? 0,
  });
}

管理者注文クエリ

管理者はgetOrders関数を通じてすべての注文をクエリできます:

actions/orders/admin.ts
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('管理者権限が必要です。');
  }
 
  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;
 
  // 注文をクエリ(ユーザー情報を含む)
  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);
 
  // 総数をクエリ
  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,
  });
}

注文一覧ページ

ユーザー注文ページ

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>エラー: {result.error}</div>;
  }
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-semibold">マイ注文</h1>
      </div>
 
      <MyOrdersDataTable
        orders={result.data.orders}
        totalCount={result.data.totalCount}
      />
    </div>
  );
}

管理者注文ページ

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>エラー: {result.error}</div>;
  }
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-semibold">注文</h1>
      </div>
 
      <OrdersDataTable
        orders={result.data.orders}
        totalCount={result.data.totalCount}
      />
    </div>
  );
}

サブスクリプション管理

サブスクリプションデータ構造

サブスクリプション記録はsubscriptionsテーブルに保存されます:

{
  id: string;                      // サブスクリプションID(UUID)
  userId: string;                  // ユーザーID
  planId: string;                  // 関連プランID
  provider: 'stripe' | 'creem';   // 決済プロバイダー
  subscriptionId: string;          // プロバイダーサブスクリプションID(一意)
  customerId: string;              // 顧客ID
  status: string;                  // サブスクリプションステータス
  currentPeriodStart: Date;        // 現在の期間開始時刻
  currentPeriodEnd: Date;           // 現在の期間終了時刻
  cancelAtPeriodEnd: boolean;     // 期間終了時にキャンセルするか
  canceledAt?: Date;               // キャンセル時刻
  endedAt?: Date;                  // 終了時刻
  trialStart?: Date;               // トライアル開始時刻
  trialEnd?: Date;                 // トライアル終了時刻
  metadata: object;                // メタデータ(JSONB)
}

サブスクリプションステータス

可能なサブスクリプションステータス:

  • active: アクティブ
  • trialing: トライアル中
  • past_due: 期限切れ
  • canceled: キャンセル済み
  • incomplete: 不完全
  • incomplete_expired: 不完全期限切れ
  • unpaid: 未払い
  • paused: 一時停止(Creem)

サブスクリプション同期

システムはWebhookを通じて自動的にサブスクリプションステータスを同期します。手動で同期することもできます:

Stripeサブスクリプション同期

actions/stripe/index.ts
export async function syncSubscriptionData(
  subscriptionId: string,
  customerId: string,
  initialMetadata?: Record<string, any>
): Promise<void> {
  // 1. Stripeからサブスクリプション情報を取得
  const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
    expand: ['default_payment_method', 'customer']
  });
 
  // 2. ユーザーIDとプランIDを解析
  let userId = subscription.metadata?.userId;
  let planId = subscription.metadata?.planId;
 
  // メタデータにない場合、データベースからクエリを試みる
  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. サブスクリプションデータを構築
  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. サブスクリプション記録を挿入または更新
  await db
    .insert(subscriptionsSchema)
    .values(subscriptionData)
    .onConflictDoUpdate({
      target: subscriptionsSchema.subscriptionId,
      set: subscriptionData,
    });
}

Creemサブスクリプション同期

actions/creem/index.ts
export async function syncCreemSubscriptionData(
  subscriptionId: string,
  initialMetadata?: Record<string, any>
): Promise<void> {
  // 1. Creemからサブスクリプション情報を取得
  const subscription = await retrieveCreemSubscription(subscriptionId);
 
  // 2. ユーザーIDとプラン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. サブスクリプションデータを構築
  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. サブスクリプション記録を挿入または更新
  await db
    .insert(subscriptionsSchema)
    .values(subscriptionData)
    .onConflictDoUpdate({
      target: subscriptionsSchema.subscriptionId,
      set: subscriptionData,
    });
}

カスタマーポータル

カスタマーポータルにより、ユーザーはサブスクリプションを管理できます:

  • 決済方法を更新
  • サブスクリプションをキャンセル
  • インボイスを表示
  • 請求情報を更新

Stripeカスタマーポータル

Stripeカスタマーポータルセッションを作成:

actions/stripe/index.ts
export async function createStripePortalSession(): Promise<void> {
  const session = await getSession();
  const user = session?.user;
  if (!user) {
    redirect('/login');
  }
 
  // 1. ユーザーの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顧客IDが見つかりません');
  }
 
  // 2. 戻り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. ポータルセッションを作成
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: profile.stripeCustomerId,
    return_url: returnUrl,
  });
 
  // 4. ポータルにリダイレクト
  redirect(portalSession.url);
}

Creemカスタマーポータル

Creemカスタマーポータルリンクを作成:

actions/creem/portal.ts
export async function createCreemPortalSession(): Promise<void> {
  const session = await getSession();
  const user = session?.user;
  if (!user) {
    redirect('/login');
  }
 
  // 1. ユーザーのCreemサブスクリプションを取得
  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サブスクリプションが見つかりません');
  }
 
  // 2. ポータルリンクを作成
  const portalUrl = await createCreemCustomerPortalLink(subscription.customerId);
 
  if (!portalUrl) {
    throw new Error('Creemポータルリンクの作成に失敗しました');
  }
 
  // 3. ポータルにリダイレクト
  redirect(portalUrl);
}

サブスクリプション管理ページ

ユーザーの現在のサブスクリプションステータスとポータルエントリーを表示するサブスクリプション管理ページを作成:

app/[locale]/(protected)/dashboard/(user)/subscription/page.tsx
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');
 
  // ユーザー特典を取得
  const benefits = await getUserBenefits(user.id);
 
  // ユーザーのサブスクリプションプロバイダーを取得
  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">サブスクリプション</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">
                  Stripeにリダイレクトして、サブスクリプションの詳細を管理します。
                </p>
              </>
            )}
            {subscriptionProvider === 'creem' && (
              <>
                <PortalButton
                  provider="creem"
                  action={createCreemPortalSession}
                />
                <p className="text-xs text-muted-foreground">
                  サブスクリプションの詳細を管理するためにリダイレクトされます。
                </p>
              </>
            )}
          </>
        ) : (
          <>
            <p>現在、どのプランにもサブスクライブしていません。</p>
            <Button asChild>
              <Link href={process.env.NEXT_PUBLIC_PRICING_PATH!}>
                プランをアップグレード
              </Link>
            </Button>
          </>
        )}
      </div>
    </div>
  );
}

クレジット履歴のクエリ

ユーザーはクレジット使用履歴を表示できます:

actions/usage/logs.ts
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,
  });
}

クレジット履歴ページ

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>エラー: {result.error}</div>;
  }
 
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-semibold">クレジット履歴</h1>
      </div>
 
      <CreditHistoryDataTable
        logs={result.data.logs}
        totalCount={result.data.count}
      />
    </div>
  );
}

よくある質問

Q: 特定のユーザーの注文をクエリするにはどうすればよいですか?

A: 管理者はgetOrders関数を使用し、filterパラメータを通じてユーザー電子メールまたは注文IDで検索できます。

Q: サブスクリプションステータスはどのように更新されますか?

A: サブスクリプションステータスはWebhookを通じて自動的に更新されます。決済プロバイダーがサブスクリプション更新イベントを送信すると、システムは自動的にステータスを同期します。

Q: ユーザーはサブスクリプションをキャンセルできますか?

A: はい。ユーザーはカスタマーポータルを通じてサブスクリプションをキャンセルできます。キャンセル後、サブスクリプションは現在の期間終了時に有効になります。

Q: 詳細なサブスクリプション情報を表示するにはどうすればよいですか?

A: subscriptionsテーブルをクエリして、ステータス、期間、キャンセル時刻などを含む詳細なサブスクリプション情報を取得します。