決済システム
マダミス.jp の決済システムは、Strategy パターン による共通化レイヤーで Stripe と fincode byGMO の両方を扱う。決済の仕様(プロバイダーの違い・流れ・ステータス)を先に、実装(共通化の仕組み・スキーマ・ファイル構成)を後に書く。
アーキテクチャ
text
User → Frontend (React) → GraphQL API → Service Layer → PaymentProvider Interface
│
┌───────────────────┴───────────────────┐
│ │
StripeProvider FincodeProvider
├─ PaymentIntent ├─ Payment API
├─ Refund API ├─ Cancel API
└─ Customer Sessions └─ Card Storageプロバイダー別の差異
| 項目 | Stripe | fincode |
|---|---|---|
| カード入力 | PaymentElement(SDK の UI) | fincode.js + 独自フォーム |
| PayPay | Stripe PayPay 対応 | 未対応 |
| カード保存 | CustomerSession 経由 | cards API で直接管理 |
| 部分返金 | refunds.create(amount) | payments.changeAmount() |
| 全額返金 | refunds.create() | payments.cancel() |
| 手数料取得 | Balance Transaction から | 自前計算(CARD: 3.0%、PAYPAY: 3.6% + 税) |
| 署名検証 | SDK 組込み | HMAC-SHA256 カスタム実装 |
決済フロー
1. 支払い作成
text
ユーザーが支払い方法を選択
↓
createPaymentIntent Mutation
├─ CARD + fincode 有効 → fincode
├─ CARD + fincode 無効 → Stripe
└─ PAYPAY → Stripe(常に)
↓
顧客作成 or 取得(初回は UserContact に保存)
↓
Payment レコード作成(externalPaymentId 保存)2. 決済実行 → Webhook 同期
text
フロントエンドで決済実行
├─ Stripe: stripe.confirmPayment() → 3DS 認証
└─ fincode: fincode.payments() → 3DS 認証
↓
Webhook 受信
↓
syncPayment(externalPaymentId) ← イベント種類に依存しない状態駆動同期
├─ API から最新の決済状態を取得
├─ DB の Payment レコードを更新
├─ reservationTransaction.activatedAt を設定
└─ 旧決済があればキャンセル/返金(ホールド方式)syncPayment の設計思想
Webhook のイベント種類ごとに処理を分岐するのではなく、常に最新状態を API から取得して DB に反映する 状態駆動 の作りにしている。これにより冪等性が自然に担保され、イベントの順序の問題も避けられる。
3. 返金・キャンセル
ts
// AUTHORIZED 状態 → キャンセル(売上未確定)
// CAPTURED 状態 → 返金(部分返金にも対応)
processPaymentRefundOrCancel(prisma, payment, amount?)決済ページと決済リンク
お客様の決済ページは予約者の種別で 2 つに分かれる。URL は lib/reservationPaymentUrl.ts だけで組み立てる(店舗管理画面は ReservationTransaction.paymentUrl を表示する)。
| 予約者 | ページ | 認可 |
|---|---|---|
| 会員 | /reservationTransaction/{取引ID}/payment | ログイン + 予約者本人 |
| ゲスト(非会員) | /guest/reservations/{campaignId}/payment | campaignId(ログイン不要) |
決済を受け付ける取引は次の 2 種類。
| 取引 | 予約の状態 | 支払い期限 |
|---|---|---|
| 予約時・人数変更時の事前決済(会員の予約・プラグイン経由のゲスト予約) | 未確定。決済成功で確定 | あり。過ぎると取引が無効になる |
店舗が手入力した予約に後から発行した決済リンク(enableReservationPayment) | 確定済み | なし。公演開始後も支払える |
決済リンクは acceptPayment を true にするだけで、期限は付けない。ゲスト決済ページは「確定済み・期限なし・未決済」の取引を決済待ちとして扱う。
ステータス管理
Payment のステータスは、タイムスタンプの組み合わせで判定する(enum の列ではない)。
| ステータス | 判定条件 | 説明 |
|---|---|---|
CREATED | 初期状態 | 決済作成直後 |
AUTHORIZED | authorizedAt != null | オーソリ完了(キャプチャ待ち) |
CAPTURED | capturedAt != null | 売上確定 |
CANCELED | canceledAt != null | キャンセル済み |
REFUNDED | lastRefundedAt != null | 返金済み(部分含む) |
判定の優先順位は CAPTURED > REFUNDED > CANCELED > AUTHORIZED > CREATED。
共通化パターン
PaymentProvider インターフェース
両プロバイダーが実装する共通インターフェース。
ts
type PaymentProvider = {
createPayment(params, logger): Promise<CreatePaymentResult>
capturePayment(params, logger): Promise<CapturePaymentResult>
cancelPayment(params, logger): Promise<CancelPaymentResult>
refundPayment(params, logger): Promise<RefundPaymentResult>
createCustomer(params, logger): Promise<CreateCustomerResult>
createOrRetrieveCustomer(params, logger): Promise<CreateCustomerResult>
createCustomerSession(params, logger): Promise<CreateCustomerSessionResult>
getPaymentFee(params, logger): Promise<GetPaymentFeeResult>
getPayment(params, logger): Promise<GetPaymentResult>
listCustomerCards(params, logger): Promise<ListCustomerCardsResult>
}Factory パターン(キャッシュ付き)
ts
// デフォルトプロバイダー: 環境変数 DEFAULT_PAYMENT_PROVIDER で切替
const providerType = getDefaultPaymentProviderType(); // 'STRIPE' | 'FINCODE'
// プロバイダー取得(キャッシュでシングルトン)
const provider = getPaymentProviderByType(payment.paymentProvider);Discriminated Union(型安全な分岐)
ts
type CreatePaymentResult =
| { providerType: 'STRIPE'; clientSecret: string; ... }
| { providerType: 'FINCODE'; orderId: string; accessId: string; ... }DB スキーマ
prisma
model Payment {
id Int
reservationTransactionId Int @unique
paymentProvider PaymentProviderType // STRIPE | FINCODE
externalPaymentId String @unique // 統一外部ID
// プロバイダー固有
stripePaymentIntentClientSecret String?
fincodeAccessId String?
amount Int
currency String @default("JPY")
captureMethod PaymentCaptureMethod // MANUAL | AUTOMATIC
paymentMethodType PaymentMethodType // CARD | PAYPAY
// ステータス(タイムスタンプベース)
authorizedAt DateTime?
capturedAt DateTime?
captureBefore DateTime?
canceledAt DateTime?
refundedAmount Int? @default(0)
lastRefundedAt DateTime?
}ディレクトリ構成
text
javascript/mdms_jp/
├── backend/src/
│ ├── lib/payment-provider/ # 共通化レイヤー
│ │ ├── interface.ts # PaymentProvider インターフェース
│ │ ├── types.ts # 共通型定義
│ │ ├── factory.ts # Factory + キャッシュ
│ │ ├── clients/
│ │ │ ├── stripe.ts # Stripe SDK 初期化
│ │ │ └── fincode.ts # fincode SDK 初期化
│ │ └── providers/
│ │ ├── stripe.ts # Stripe 実装(約 470 行)
│ │ └── fincode.ts # fincode 実装(約 550 行)
│ │
│ ├── lib/getPaymentStatus.ts # ステータス判定ロジック
│ │
│ ├── services/payment/
│ │ ├── createPaymentIntentWithPaymentMethod.ts # 決済作成
│ │ └── refundService.ts # 返金・キャンセル
│ │
│ ├── routes/paymentWebhooks/
│ │ ├── common/
│ │ │ ├── syncPayment.ts # 統一同期関数
│ │ │ └── handleStatusTransition.ts
│ │ ├── stripe/handler.ts # Stripe Webhook
│ │ └── fincode/handler.ts # fincode Webhook
│ │
│ └── schema/payment/
│ ├── mutations/
│ │ ├── create_payment_intent.ts
│ │ ├── confirm_payment.ts
│ │ └── shop_refund_payment.ts
│ └── query.ts # fincodeDefaultCard
│
└── frontend/src/app/(main)/reservationTransaction/.../payment/
├── client.tsx # 決済ページメインロジック
├── PaymentMethodSelector.tsx # 支払い方法選択
├── StripePaymentForm.tsx # Stripe フォーム
├── FincodePaymentForm.tsx # fincode カードフォーム
└── FincodePayPayForm.tsx # fincode PayPay フォーム環境変数
bash
# Stripe
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
# fincode
FINCODE_SECRET_KEY
FINCODE_PUBLIC_KEY
FINCODE_SHOP_ID
FINCODE_WEBHOOK_SECRET
# プロバイダー選択(未設定時は STRIPE)
DEFAULT_PAYMENT_PROVIDER=fincode決済プロバイダーの追加手順
providers/に新しいプロバイダークラスを作り、PaymentProviderインターフェースを実装するclients/に SDK の初期化コードを足すfactory.tsのgetPaymentProviderByType()に分岐を足すtypes.tsのPaymentProviderTypeに新しい値を足す- フロントエンドに決済フォームのコンポーネントを足す
routes/paymentWebhooks/に Webhook のハンドラを足す