LESSON 40分

ストーリー

高橋アーキテクト
決済システムは、1円のズレも許されない世界だ
あなた
二重課金、決済漏れ、データ不整合…どれか1つでも発生すれば、ユーザーの信頼は一瞬で崩壊する。ここでは『絶対に間違えてはいけないシステム』をどう設計するかを学ぶ

要件の整理

interface PaymentSystemRequirements {
  functional: {
    checkout: "商品購入の決済処理";
    refund: "返金処理";
    ledger: "取引台帳の記録";
    reconciliation: "外部決済プロバイダとの照合";
    multiCurrency: "複数通貨対応";
    paymentMethods: "クレジットカード、銀行振込、電子マネー";
  };
  nonFunctional: {
    consistency: "強い一貫性(二重課金を絶対に防ぐ)";
    availability: "99.99%";
    latency: "決済処理 p99 < 2秒";
    auditability: "全取引の完全な監査証跡";
    compliance: "PCI DSS準拠";
    throughput: "ピーク時 1万TPS";
  };
}

ハイレベル設計

┌────────────────────────────────────────────────────────┐
│                                                        │
│  [注文サービス]──→[決済サービス]──→[決済ゲートウェイ]      │
│                        │              │                │
│                        ▼              ▼                │
│                   [台帳サービス]  [外部PSP]              │
│                        │        (Stripe等)             │
│                        ▼                               │
│                   [台帳DB]                              │
│                   (複式簿記)                             │
│                                                        │
│  [冪等性キー管理]  [照合バッチ]  [通知サービス]            │
│  (Redis/DB)       (日次)       (Email/SMS)             │
│                                                        │
└────────────────────────────────────────────────────────┘

詳細設計

冪等性による二重課金防止

// 決済APIは必ず冪等性を保証する
class PaymentService {
  async processPayment(request: PaymentRequest): Promise<PaymentResult> {
    // 1. 冪等性キーで重複チェック
    const existing = await this.idempotencyStore.get(request.idempotencyKey);
    if (existing) {
      return existing; // 既に処理済みの結果を返す
    }

    // 2. ロック取得(同じキーの並行処理を防止)
    const lock = await this.distributedLock.acquire(
      `payment:${request.idempotencyKey}`, { ttl: 30000 }
    );

    try {
      // 3. 決済処理の実行
      const result = await this.executePayment(request);

      // 4. 結果を冪等性ストアに保存
      await this.idempotencyStore.save(request.idempotencyKey, result, {
        ttl: 86400 * 7, // 7日間保持
      });

      return result;
    } finally {
      await lock.release();
    }
  }

  private async executePayment(request: PaymentRequest): Promise<PaymentResult> {
    // ステートマシンによる状態管理
    const payment = await this.createPaymentRecord(request);
    // CREATED → PROCESSING → AUTHORIZED → CAPTURED → COMPLETED
    //                      → DECLINED
    //                      → FAILED

    try {
      // 外部PSPへの課金リクエスト
      const authorization = await this.paymentGateway.authorize(request);
      await this.updateStatus(payment.id, 'AUTHORIZED');

      const capture = await this.paymentGateway.capture(authorization.id);
      await this.updateStatus(payment.id, 'CAPTURED');

      // 台帳への記録
      await this.ledgerService.record(payment);
      await this.updateStatus(payment.id, 'COMPLETED');

      return { status: 'success', paymentId: payment.id };
    } catch (error) {
      await this.updateStatus(payment.id, 'FAILED');
      await this.handleFailure(payment, error);
      throw error;
    }
  }
}

複式簿記の台帳

// 複式簿記: 全ての取引を借方・貸方で記録
interface LedgerEntry {
  id: string;
  transactionId: string;
  accountId: string;
  type: 'DEBIT' | 'CREDIT';
  amount: bigint;          // 通貨の最小単位(円なら1円、ドルなら1セント)
  currency: string;
  timestamp: Date;
  description: string;
}

class LedgerService {
  // 決済完了時の記帳(必ず借方と貸方が一致)
  async recordPayment(payment: Payment): Promise<void> {
    const entries: LedgerEntry[] = [
      {
        // 借方: 顧客の口座から引き落とし
        accountId: payment.customerId,
        type: 'DEBIT',
        amount: payment.amount,
        // ...
      },
      {
        // 貸方: 加盟店の口座に入金
        accountId: payment.merchantId,
        type: 'CREDIT',
        amount: payment.amount - payment.fee,
        // ...
      },
      {
        // 貸方: 手数料を収益口座に計上
        accountId: 'REVENUE_ACCOUNT',
        type: 'CREDIT',
        amount: payment.fee,
        // ...
      },
    ];

    // トランザクション内で全エントリを一括記録
    // 借方合計 = 貸方合計 を検証
    await this.db.transaction(async (tx) => {
      const debitSum = entries.filter(e => e.type === 'DEBIT')
        .reduce((sum, e) => sum + e.amount, 0n);
      const creditSum = entries.filter(e => e.type === 'CREDIT')
        .reduce((sum, e) => sum + e.amount, 0n);

      if (debitSum !== creditSum) {
        throw new Error('借方・貸方の不一致');
      }

      for (const entry of entries) {
        await tx.insert('ledger_entries', entry);
      }
    });
  }
}

照合(Reconciliation)

// 外部PSPとの照合バッチ
class ReconciliationJob {
  // 日次で実行: 自社記録とPSPの記録を突合
  async run(date: Date): Promise<ReconciliationReport> {
    const ourRecords = await this.ledger.getByDate(date);
    const pspRecords = await this.paymentGateway.getSettlementReport(date);

    const mismatches: Mismatch[] = [];

    for (const our of ourRecords) {
      const psp = pspRecords.find(r => r.transactionId === our.transactionId);

      if (!psp) {
        mismatches.push({ type: 'MISSING_IN_PSP', record: our });
      } else if (our.amount !== psp.amount) {
        mismatches.push({ type: 'AMOUNT_MISMATCH', our, psp });
      }
    }

    // PSPにあるが自社にない取引も検出
    for (const psp of pspRecords) {
      if (!ourRecords.find(r => r.transactionId === psp.transactionId)) {
        mismatches.push({ type: 'MISSING_IN_OUR_SYSTEM', record: psp });
      }
    }

    if (mismatches.length > 0) {
      await this.alertService.escalate('照合不一致検出', mismatches);
    }

    return { date, totalRecords: ourRecords.length, mismatches };
  }
}

まとめ

ポイント内容
冪等性Idempotency Keyで二重課金を防止
複式簿記借方=貸方を保証し、1円のズレも検出
ステートマシン決済の状態遷移を明示的に管理
照合外部PSPとの日次照合で不整合を検出

チェックリスト

  • 冪等性による二重課金防止の仕組みを理解した
  • 複式簿記の台帳設計を把握した
  • 決済のステートマシンを説明できた
  • 照合バッチの重要性を理解した

次のステップへ

次は「予約システムの設計」を学びます。限られたリソースを複数ユーザーが同時に予約する際のデータ競合をどう解決するかを掘り下げます。


推定読了時間: 40分