Skip to content

決済システム ​

マダミス.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

プロバイダー別の差異 ​

項目Stripefincode
カード入力PaymentElement(SDK の UI)fincode.js + 独自フォーム
PayPayStripe 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}/paymentcampaignId(ログイン不要)

決済を受け付ける取引は次の 2 種類。

取引予約の状態支払い期限
予約時・人数変更時の事前決済(会員の予約・プラグイン経由のゲスト予約)未確定。決済成功で確定あり。過ぎると取引が無効になる
店舗が手入力した予約に後から発行した決済リンク(enableReservationPayment)確定済みなし。公演開始後も支払える

決済リンクは acceptPayment を true にするだけで、期限は付けない。ゲスト決済ページは「確定済み・期限なし・未決済」の取引を決済待ちとして扱う。

ステータス管理 ​

Payment のステータスは、タイムスタンプの組み合わせで判定する(enum の列ではない)。

ステータス判定条件説明
CREATED初期状態決済作成直後
AUTHORIZEDauthorizedAt != nullオーソリ完了(キャプチャ待ち)
CAPTUREDcapturedAt != null売上確定
CANCELEDcanceledAt != nullキャンセル済み
REFUNDEDlastRefundedAt != 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

決済プロバイダーの追加手順 ​

  1. providers/ に新しいプロバイダークラスを作り、PaymentProvider インターフェースを実装する
  2. clients/ に SDK の初期化コードを足す
  3. factory.ts の getPaymentProviderByType() に分岐を足す
  4. types.ts の PaymentProviderType に新しい値を足す
  5. フロントエンドに決済フォームのコンポーネントを足す
  6. routes/paymentWebhooks/ に Webhook のハンドラを足す