S
Shineos Tech Blog
サブスクリプションビジネスを支える柔軟な課金システムのデータベース設計 - 拡張性とメンテナンス性を両立する実践手法

サブスクリプションビジネスを支える柔軟な課金システムのデータベース設計 - 拡張性とメンテナンス性を両立する実践手法

| Shineos Dev Team
Share:

はじめに

SaaSプロダクトを開発していると、必ず直面するのが 課金システムのデータベース設計 という課題です。最初は「月額固定プラン」と「年額プラン」だけで十分と考えていても、ビジネスが成長するにつれて「従量課金を追加したい」「特定顧客に割引を適用したい」「無料トライアルを延長したい」といった要望が次々と出てきます。

この時、データベース設計が柔軟性に欠けていると、機能追加のたびに大規模なマイグレーションが必要になり、開発スピードが著しく低下してしまいます。私たちShineosでも、複数のSaaS開発プロジェクトで課金システムの設計に携わる中で、こうした「後から拡張できない」問題に何度も遭遇してきました。

本記事では、SaaSプロダクトの成長段階に応じて柔軟に拡張できる課金システムのデータベース設計パターンを、実装例とともに詳しく解説します。プラン変更、従量課金、クーポン・割引、請求書発行といった実務で必須となる機能を、どのようなテーブル構造で実現すべきかを具体的に紹介します。

サブスクリプション課金システムとは

サブスクリプション課金システムとは、ユーザーが定期的に利用料金を支払うことでサービスを継続利用できる仕組みです。Netflix、Salesforce、Slackなど、多くのSaaSプロダクトで採用されている課金モデルで、継続的な収益(MRR: Monthly Recurring Revenue)を生み出すビジネスの根幹を支える重要なシステムです。

単なる「月額料金の引き落とし」だけでなく、以下のような複雑な要件に対応する必要があります。

  • 複数の料金プラン(ベーシック、プロ、エンタープライズなど)
  • プラン変更時の日割り計算(アップグレード/ダウングレード)
  • 従量課金の組み合わせ(APIコール数、ストレージ容量など)
  • 無料トライアル期間の管理
  • クーポン・割引コードの適用
  • 請求書の発行と決済処理
  • 解約時の返金処理

これらの要件を満たすには、最初から拡張性を考慮したデータベース設計が不可欠です。

まとめ

項目内容
対象読者SaaS開発エンジニア、プロダクトマネージャー、データベース設計担当者
解決する課題課金システムの要件変更に対応できない硬直的なDB設計
主要な設計パターンプランテーブル、サブスクリプションテーブル、請求テーブル、従量課金テーブル
実装のポイント状態管理(active/canceled/expired)、日割り計算、履歴保持
想定する技術スタックPostgreSQL、MySQL、TypeScript/Node.js(サンプルコード)

課金システムDB設計の基本方針

拡張性の高い課金システムを設計する上で、以下の3つの基本方針を守ることが重要です。

1. プランとサブスクリプションを分離する

初心者がやりがちな設計ミスは、ユーザーテーブルに直接「プランID」を持たせてしまうことです。この設計では、プラン変更の履歴が残らず、いつ誰がどのプランに変更したのか追跡できません。

NG例: ユーザーテーブルに直接プラン情報を持つ

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255),
  plan_id INTEGER, -- ❌ プラン変更履歴が残らない
  plan_started_at TIMESTAMP
);

推奨: サブスクリプションテーブルを分離

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE subscriptions (
  id SERIAL PRIMARY KEY,
  user_id INTEGER REFERENCES users(id),
  plan_id INTEGER REFERENCES plans(id),
  status VARCHAR(20), -- active, canceled, expired, trial
  started_at TIMESTAMP,
  ended_at TIMESTAMP,
  trial_ends_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

このように分離することで、ユーザーのサブスクリプション履歴を完全に保持でき、過去のプラン変更やキャンセルの分析が可能になります。

2. 価格情報はプランテーブルで管理する

料金プランの価格や機能制限(APIコール数、ストレージ容量など)は、別テーブルで一元管理します。これにより、価格改定時に既存サブスクリプションに影響を与えず、新規契約のみに新価格を適用することができます。

CREATE TABLE plans (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100), -- 'Basic', 'Pro', 'Enterprise'
  slug VARCHAR(50) UNIQUE, -- 'basic', 'pro', 'enterprise'
  price_monthly INTEGER, -- 月額料金(セント単位)
  price_yearly INTEGER, -- 年額料金(セント単位)
  features JSONB, -- {"api_calls": 10000, "storage_gb": 100}
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

features カラムをJSONB型にすることで、プランごとに異なる機能制限を柔軟に管理できます。新しい機能が追加された際も、テーブル定義を変更せずに対応可能です。

3. 請求と決済を明確に分離する

請求書(Invoice)の発行と、実際の決済(Payment)処理は別テーブルで管理します。これにより、決済失敗時の再請求や、請求書の再発行などの処理が容易になります。

CREATE TABLE invoices (
  id SERIAL PRIMARY KEY,
  subscription_id INTEGER REFERENCES subscriptions(id),
  amount INTEGER, -- 請求金額(セント単位)
  tax_amount INTEGER, -- 消費税額
  total_amount INTEGER, -- 合計金額
  billing_period_start DATE,
  billing_period_end DATE,
  status VARCHAR(20), -- draft, open, paid, void, uncollectible
  due_date DATE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE payments (
  id SERIAL PRIMARY KEY,
  invoice_id INTEGER REFERENCES invoices(id),
  amount INTEGER,
  payment_method VARCHAR(50), -- card, bank_transfer, etc.
  payment_provider VARCHAR(50), -- stripe, paypal, etc.
  transaction_id VARCHAR(255), -- 決済サービス側のトランザクションID
  status VARCHAR(20), -- pending, succeeded, failed
  paid_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

この設計により、「請求書は発行されたが決済は失敗した」「後日、別の決済手段で支払われた」といった複雑な状況にも対応できます。

従量課金への対応

SaaSプロダクトでは、基本料金に加えて従量課金(APIコール数、ストレージ使用量など)を組み合わせるケースが多くあります。この要件に対応するには、使用量を記録するテーブルが必要です。

使用量記録テーブルの設計

CREATE TABLE usage_records (
  id SERIAL PRIMARY KEY,
  subscription_id INTEGER REFERENCES subscriptions(id),
  metric_name VARCHAR(100), -- 'api_calls', 'storage_gb', 'active_users'
  quantity INTEGER, -- 使用量
  recorded_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_subscription_metric (subscription_id, metric_name, recorded_at)
);

このテーブルに使用量を記録しておくことで、請求時に集計して従量課金の金額を計算できます。

従量課金の計算例(TypeScript)

interface UsageRecord {
  subscription_id: number;
  metric_name: string;
  quantity: number;
  recorded_at: Date;
}

async function calculateUsageCharge(
  subscriptionId: number,
  billingPeriodStart: Date,
  billingPeriodEnd: Date
): Promise<number> {
  // 期間内の使用量を集計
  const usageRecords = await db.query<UsageRecord>(
    `
    SELECT metric_name, SUM(quantity) as total_quantity
    FROM usage_records
    WHERE subscription_id = $1
      AND recorded_at >= $2
      AND recorded_at < $3
    GROUP BY metric_name
    `,
    [subscriptionId, billingPeriodStart, billingPeriodEnd]
  );

  let totalCharge = 0;

  for (const record of usageRecords) {
    // メトリックごとの単価を取得(例: API 1000コールあたり500円)
    const unitPrice = await getUnitPrice(record.metric_name);
    const charge = Math.ceil((record.total_quantity / 1000) * unitPrice);
    totalCharge += charge;
  }

  return totalCharge;
}

このように、使用量を柔軟に記録・集計できる設計にすることで、新しい従量課金メトリックの追加にも容易に対応できます。

サブスクリプション課金システムのデータベーススキーマ構成図

プラン変更時の処理パターン

ユーザーがプランを変更(アップグレード/ダウングレード)する際の処理は、SaaSビジネスにおいて非常に重要です。特に、日割り計算や返金処理を正確に行う必要があります。

プランアップグレード時の処理フロー

  1. 現在のサブスクリプションを終了

    • subscriptions.statuscanceled に更新
    • ended_at に現在日時を設定
  2. 日割り計算で残存期間の金額を算出

function calculateProration(
  currentPlanPrice: number,
  newPlanPrice: number,
  daysRemaining: number,
  totalDaysInPeriod: number
): number {
  // 現在プランの残存期間分を返金
  const refundAmount = (currentPlanPrice * daysRemaining) / totalDaysInPeriod;
  
  // 新プランの残存期間分を請求
  const newChargeAmount = (newPlanPrice * daysRemaining) / totalDaysInPeriod;
  
  // 差額を計算(追加請求額)
  return Math.round(newChargeAmount - refundAmount);
}
  1. 新しいサブスクリプションを作成

    • 新しい subscription レコードを active ステータスで作成
    • 次回請求日を設定
  2. 差額を請求

    • プロレーション(日割り計算)で算出した差額で新しい請求書を発行

プランダウングレード時の考慮点

ダウングレードの場合、すぐに変更を適用するのではなく、現在の課金期間が終了するまで待つのが一般的です。これにより、ユーザーが既に支払った料金を無駄にせず、良好な顧客体験を提供できます。

async function scheduleDowngrade(
  subscriptionId: number,
  newPlanId: number
): Promise<void> {
  // サブスクリプションに「次回更新時に変更」フラグを設定
  await db.query(
    `
    UPDATE subscriptions
    SET pending_plan_id = $1
    WHERE id = $2
    `,
    [newPlanId, subscriptionId]
  );
  
  // 現在の期間が終了するまでは既存プランを継続
  // 更新日に自動的にプラン変更を実行
}

クーポン・割引機能の実装

マーケティング施策やカスタマーサポート対応として、クーポンコードや特別割引を提供する機能も必須です。この機能を拡張性高く実装するには、専用のテーブル設計が必要です。

クーポンテーブルの設計

CREATE TABLE coupons (
  id SERIAL PRIMARY KEY,
  code VARCHAR(50) UNIQUE, -- 'SUMMER2024', 'WELCOME10'
  discount_type VARCHAR(20), -- 'percentage', 'fixed_amount'
  discount_value INTEGER, -- パーセント(20 = 20%)または固定額(セント単位)
  max_redemptions INTEGER, -- 最大利用回数(NULL = 無制限)
  redemptions_count INTEGER DEFAULT 0, -- 現在の利用回数
  valid_from TIMESTAMP,
  valid_until TIMESTAMP,
  applicable_plans JSONB, -- 適用可能なプランIDの配列
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE coupon_redemptions (
  id SERIAL PRIMARY KEY,
  coupon_id INTEGER REFERENCES coupons(id),
  subscription_id INTEGER REFERENCES subscriptions(id),
  discount_amount INTEGER, -- 実際に割引された金額
  redeemed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

クーポン適用のビジネスロジック

async function applyCoupon(
  subscriptionId: number,
  couponCode: string,
  invoiceAmount: number
): Promise<{ discountAmount: number; finalAmount: number }> {
  // クーポンの有効性をチェック
  const coupon = await db.query(
    `
    SELECT * FROM coupons
    WHERE code = $1
      AND is_active = true
      AND valid_from <= NOW()
      AND valid_until >= NOW()
      AND (max_redemptions IS NULL OR redemptions_count < max_redemptions)
    `,
    [couponCode]
  );

  if (!coupon) {
    throw new Error('Invalid or expired coupon');
  }

  // 割引額を計算
  let discountAmount = 0;
  if (coupon.discount_type === 'percentage') {
    discountAmount = Math.round(invoiceAmount * (coupon.discount_value / 100));
  } else if (coupon.discount_type === 'fixed_amount') {
    discountAmount = Math.min(coupon.discount_value, invoiceAmount);
  }

  const finalAmount = invoiceAmount - discountAmount;

  // クーポン利用履歴を記録
  await db.query(
    `
    INSERT INTO coupon_redemptions (coupon_id, subscription_id, discount_amount)
    VALUES ($1, $2, $3)
    `,
    [coupon.id, subscriptionId, discountAmount]
  );

  // クーポンの利用回数を更新
  await db.query(
    `
    UPDATE coupons SET redemptions_count = redemptions_count + 1
    WHERE id = $1
    `,
    [coupon.id]
  );

  return { discountAmount, finalAmount };
}

料金プランの階層比較図

実務での運用ポイント

データベース設計だけでなく、実際の運用で注意すべきポイントもいくつかあります。

1. トランザクション処理の徹底

課金処理は複数のテーブルを跨いで更新するため、必ずトランザクション内で実行し、データの整合性を保つことが重要です。

async function processSubscriptionUpgrade(
  subscriptionId: number,
  newPlanId: number
): Promise<void> {
  const client = await db.getClient();
  
  try {
    await client.query('BEGIN');
    
    // 1. 現在のサブスクリプションを終了
    await client.query(
      `UPDATE subscriptions SET status = 'canceled', ended_at = NOW() WHERE id = $1`,
      [subscriptionId]
    );
    
    // 2. 新しいサブスクリプションを作成
    const result = await client.query(
      `INSERT INTO subscriptions (user_id, plan_id, status, started_at) VALUES (...) RETURNING id`
    );
    
    // 3. プロレーション請求書を作成
    await client.query(
      `INSERT INTO invoices (subscription_id, amount, ...) VALUES (...)`
    );
    
    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

2. インデックスの適切な設定

課金システムでは、特定のユーザーのアクティブなサブスクリプションを頻繁に検索します。適切なインデックスを設定することで、パフォーマンスを大幅に改善できます。

-- ユーザーIDとステータスの複合インデックス
CREATE INDEX idx_subscriptions_user_status ON subscriptions(user_id, status);

-- 請求書の検索用インデックス
CREATE INDEX idx_invoices_subscription_status ON invoices(subscription_id, status);

-- 従量課金の集計用インデックス
CREATE INDEX idx_usage_subscription_metric_date ON usage_records(subscription_id, metric_name, recorded_at);

3. 監査ログの記録

課金に関する操作は、すべて監査ログとして記録することを推奨します。トラブル発生時の原因究明や、不正利用の検知に役立ちます。

CREATE TABLE audit_logs (
  id SERIAL PRIMARY KEY,
  entity_type VARCHAR(50), -- 'subscription', 'invoice', 'payment'
  entity_id INTEGER,
  action VARCHAR(50), -- 'created', 'updated', 'deleted', 'refunded'
  changes JSONB, -- 変更内容の詳細
  performed_by INTEGER REFERENCES users(id),
  performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

よくある質問

既存ユーザーに新料金を適用せず、新規ユーザーのみに適用する方法は?

プランテーブルに created_atversion カラムを持たせ、サブスクリプション作成時のプラン情報をスナップショットとして保存する方法があります。あるいは、plan_price_snapshots テーブルを作成し、サブスクリプションごとに契約時点の価格を記録する設計もあります。

CREATE TABLE plan_price_snapshots (
  id SERIAL PRIMARY KEY,
  subscription_id INTEGER REFERENCES subscriptions(id),
  plan_id INTEGER REFERENCES plans(id),
  price_monthly INTEGER, -- 契約時点の月額料金
  price_yearly INTEGER, -- 契約時点の年額料金
  features JSONB, -- 契約時点の機能制限
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

無料トライアル期間中の課金処理はどうすべき?

サブスクリプションテーブルに trial_ends_at カラムを用意し、トライアル期間中は請求書を発行しないロジックを実装します。トライアル終了時に自動的に有料プランへ移行する処理を、バッチジョブで実行するのが一般的です。

プランの機能制限(API制限など)をどう管理すべき?

プランテーブルの features カラム(JSONB型)に、機能ごとの制限値を格納します。アプリケーション側で、ユーザーの現在プランの制限値を参照し、APIコール時などに制限チェックを行います。

{
  "api_calls_per_month": 10000,
  "storage_gb": 100,
  "team_members": 5,
  "custom_domain": false
}

複数通貨に対応する場合の設計は?

プランテーブルに currency カラムを追加し、通貨ごとに別レコードを作成する方法が一般的です。または、plan_prices という別テーブルを作成し、1つのプランに対して複数の通貨価格を持たせる設計もあります。

CREATE TABLE plan_prices (
  id SERIAL PRIMARY KEY,
  plan_id INTEGER REFERENCES plans(id),
  currency VARCHAR(3), -- 'JPY', 'USD', 'EUR'
  price_monthly INTEGER,
  price_yearly INTEGER
);

おわりに

本記事では、SaaSプロダクトの成長に対応できる柔軟な課金システムのデータベース設計について解説しました。プランとサブスクリプションの分離、請求と決済の明確な区別、従量課金への対応、クーポン機能の実装など、実務で必要となる設計パターンを具体例とともに紹介しました。

最初から完璧な設計を目指す必要はありません。まずは基本的なプラン・サブスクリプション・請求の3テーブルから始め、ビジネスの成長に合わせて従量課金やクーポン機能を追加していくアプローチで十分です。重要なのは、後から拡張できる柔軟な設計にしておくことです。

私たちShineosでは、SaaSプロダクトの開発支援を行っており、課金システムの設計・実装についても豊富な経験があります。プロダクト開発や技術的な課題でお悩みの方は、ぜひお気軽にご相談ください。

参考リンク