ストーリー
要件の整理
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分