すでにDragonPayは動いていた。チェックアウトフローは堅牢で、ポストバックは信頼でき、注文は正しく作成されていた。PayMongoを追加するのは、週末の作業で済むはずだと思っていた。新しいプロバイダーを差し込んで、すべてを再利用するだけだと。
週末以上の時間がかかった。一部はPayMongoの前提が異なるからで、大部分はそもそもDragonPayがどう統合されていたかによるものだった。
DragonPayのドキュメントは機能はするが、薄い。Collectフローは、良くはないが実用的な程度には十分に文書化されている。Payout側は話が違う。ドキュメントを読んで実装するだけでは済まない。彼らのチームに直接連絡を取り、メールでやり取りを重ね、どこにも公に書かれていない具体的なセットアップ手順を聞き出さなければならない。Payoutのポストバック登録はまさにそれの一つだ。必須のステップなのに、ドキュメントには載っておらず、それを見逃すと、APIは文句も言わずにリクエストを受け付け、静かにpayoutを発火させないままになる。エラーもない。何かがおかしいという兆候もない。永遠に来ないpayoutを待ち続け、最終的にサポートを通じて、誰にも教えられていなかったセットアップステップがあったことに気づくことになる。
この件はDragonPay CollectとPayoutの統合記事でより詳しく扱っている。ここでのポイントは、PayMongoの話が出てきた時点で、すでに実際の時間がDragonPayのギャップとの格闘に費やされていたということだ。既存の統合には前提が焼き込まれており、それはクリーンな抽象化ではなく、DragonPayがどう動くかを中心に形作られていた。2つ目のゲートウェイを追加するのは、単に「サービスをもう一つ追加する」だけの話ではなかった。実際に再利用可能なものは何で、汎用コードの衣装をまとったDragonPay固有のロジックは何かを見極めることを意味していた。
この記事は、部分的には実装メモであり、部分的には、なぜ特定の判断がなされたのかを記録しておくことで、後で見返しやすくするためのものだ。
DragonPay対PayMongo:メンタルモデルの違い
この2つのゲートウェイは、単にAPIの形が違うだけではない。「チェックアウト」とは何か、何がいつ存在するのかについて、まったく異なる前提を持っている。
DragonPayでは、フローはこうだ。顧客がカートに追加し、カートのスナップショットが保存され、顧客が決済ページに送られ、決済がバックエンドへのポストバックを発火させ、その後で注文が作成される。お金が動くまで注文は存在しない。孤立した注文もなく、宙に浮いた在庫保持もない。特にeコマースにおいては、途中でフローを放棄するかもしれない相手のために在庫を確保したくない場合、クリーンなモデルだ。
PayMongoはこれを逆転させる。まず注文を作成し、それにpayment intentを紐づける。顧客が支払い、webhookが発火し、すでに作成済みの注文を更新する。注文は誰かが1セントでも払う前から存在している。
どちらが間違っているわけでもなく、単にトレードオフの異なる別のモデルだ。DragonPayのモデルでは、放棄されたチェックアウトのクリーンアップが不要になるが、ポストバックが到着したときに、顧客が何を買おうとしていたのかを再構築する必要があるとコンテキストを失う。PayMongoのモデルでは、決済状態を紐づけるための完全な注文レコードが手に入るが、今度は誰かが注文を作成して一度も支払わないケースを処理する必要が出てくる。つまり、保留中の注文がデータベースに残り、場合によっては在庫を保持し続け、失効させる必要が出てくる。
モバイルアプリの決済待機ページは、完全にDragonPayモデルを前提に書かれていた。トランザクション番号を使ってステータスエンドポイントをポーリングしていた。そのエンドポイントは、バックエンド側にはまだ存在すらしていなかった。ただ存在すると仮定されていただけだ。だから、PayMongoを配線する前に、モバイルがすでに呼び出していたものを構築する必要があった。
環境変数
PayMongoからの認証情報(シークレットキー、公開キー、webhookシークレット、マーチャントID)と、デフォルトでどのゲートウェイをアクティブにするかを制御するフラグが必要になる。サービスは起動時にこれらを読み込む。PayMongoの認証情報が存在しない場合、PayMongoは管理設定レスポンスで利用不可としてマークされる。
これらはバージョン管理の外に保つこと。プライベートリポジトリであっても、どこにもコミットせず、デプロイプラットフォームのシークレットマネージャーを使うこと。
ポーリングエンドポイントの構築、ゲートウェイに依存しない形で
モバイルはチェックアウト後、数秒ごとにステータスエンドポイントをポーリングする。2つの別々のエンドポイントを用意する代わりに、レコードを作成したのがどちらのゲートウェイであっても解決できる1つのエンドポイントを用意する。
// payments.service.ts
async getStatusByTxnid(txnid: string): Promise<{ status: string; order_ids: number[] }> {
// Try DragonPay transaction_number first, then PayMongo gateway_transaction_id
let payment = await this.repository.findByTransactionNumber(txnid);
if (!payment) {
payment = await this.repository.findByGatewayTransactionId(txnid);
}
if (!payment) return { status: 'awaiting_payment', order_ids: [] };
const simplifiedStatus =
payment.status === PaymentStatusEnum.COMPLETED ? 'paid'
: payment.status === PaymentStatusEnum.FAILED ||
payment.status === PaymentStatusEnum.CANCELLED ||
payment.status === PaymentStatusEnum.EXPIRED ? 'failed'
: 'awaiting_payment';
return { status: simplifiedStatus, order_ids: [] };
}
このエンドポイントは公開(JWTなし)であり、トランザクションIDは検索キーとして機能するほど不透明だ。1つ注意すべき点として、もし同じコントローラー内でNestJSの数値:idパラメータルートを使っている場合、名前付きルートが先に宣言されていることを確認すること。そうしないと、フレームワークがルート名を数値としてパースしようとし、リクエストが決して正しいハンドラーに届かなくなる。
// payments.controller.ts
@Public()
@Get('status') // must come before @Get(':id')
getStatus(@Query('txnid') txnid: string) {
return this.service.getStatusByTxnid(txnid);
}
@Get(':id') // this would swallow the named route if ordered first
findById(@Param('id') id: number) { ... }
リターンURLとモバイルディープリンク
GCash/Maya/カード決済の後、PayMongoは決済開始時に指定したURLへ顧客をリダイレクトして戻す。ウェブの場合はこれはフロントエンドのページだ。モバイルの場合は、アプリに戻るディープリンクである必要がある。
決済を開始する際、モバイルはリクエストボディにsourceパラメータを渡す。バックエンドはそれをリターンURLに追加し、リターンハンドラーがその後どこへリダイレクトすればいいかを分かるようにする。
// payments.service.ts
const sourceParam = input.source ? `?source=${input.source}` : '';
const returnUrl = `${backendDomain}/your-return-endpoint${sourceParam}`;
リターンハンドラーは、リダイレクトを構築するためにconfigからモバイルアプリのスキームを読み取る。これはハードコードするのではなく、環境設定に保つこと。
// payments.controller.ts
@Public()
@Get('return')
async handleReturn(
@Query('payment_intent_id') paymentIntentId: string,
@Query('source') source: string,
@Res() res: Response,
) {
const mobileScheme = this.configService.get<string>('MOBILE_APP_SCHEME');
const frontendDomain = this.configService.get<string>('FRONTEND_DOMAIN');
const isMobile = source === 'mobile';
const redirect = (path: string, params: string) =>
isMobile
? `${mobileScheme}://${path}?${params}`
: `${frontendDomain}/payment/${path}?${params}`;
// verify payment with PayMongo, call handlePaymentCallback, then:
if (verification.status === 'paid') {
return res.redirect(redirect('success', `txn=${paymentIntentId}`));
}
// ...
}
Flutter側では、ディープリンクがアプリをフォアグラウンドに戻すとdidChangeAppLifecycleState(resumed)が発火し、即座にポーリングをトリガーする。
// payment_waiting_page.dart
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_isComplete) {
_pollOnce();
}
}
Webhook
PayMongoダッシュボードでwebhookエンドポイントを登録する。ハンドラーは、あなたのwebhookシークレットを使ったHMAC-SHA256で署名ヘッダーを検証し、抽出したトランザクションIDで決済コールバックハンドラーを呼び出す。このエンドポイントにはJWTがなく、署名検証が認証メカニズムとなる。webhookシークレットが万が一露出した場合には必ずローテーションすること。また、検証ステップは何らかの処理を行う前に実行されるようにすること。
ランタイムのゲートウェイ切り替え
管理者保護されたエンドポイントが、ランタイムでアクティブなゲートウェイを管理する。アクティブなゲートウェイはメモリ上に存在し、起動時に環境変数からシードされる。
// payment-settings.controller.ts
let activeGateway: 'dragonpay' | 'paymongo' =
(process.env.DEFAULT_PAYMENT_GATEWAY as any) || 'dragonpay';
export function getActiveGateway() {
return activeGateway;
}
@Get()
getSettings() {
return {
default_gateway: activeGateway,
gateways: {
dragonpay: { available: !!process.env.DRAGONPAY_MERCHANT_ID },
paymongo: { available: !!process.env.PAYMONGO_SECRET_KEY },
},
};
}
@Patch()
updateSettings(@Body() dto: UpdatePaymentSettingsDto) {
activeGateway = dto.default_gateway;
return this.getSettings();
}
再起動時にenvのデフォルトにリセットされるのは意図的だ。どちらのゲートウェイを長期的に使うか決めたら、環境設定でデフォルトを設定すれば再起動を生き延びる。
DTOの修正:ホワイトリスト化されたフィールド
NestJSのバリデーションはforbidNonWhitelisted: trueを使っているため、モバイルが送信するがDTOで宣言されていないフィールドがあると422が発生する。サービス側がまだ使っていなくても、クライアントが送信する追加フィールドは注文DTOに追加すること。
// place-order.dto.ts
@IsOptional() @IsString()
payment_method_code?: string;
@IsOptional() @IsString()
checkout_source?: string;
@IsOptional() @IsArray()
vouchers?: number[];
次に違うやり方をするなら
これをゼロから始めるなら、実装を1つも書く前に決済ゲートウェイのインターフェースを定義するのが理にかなっているだろう。
interface PaymentGateway {
initiatePayment(order, options): Promise<{ checkoutUrl: string; txnid: string }>
verifyPayment(txnid: string): Promise<PaymentStatus>
handleWebhook(payload: any, signature: string): Promise<void>
}
そうすればDragonPayとPayMongoは、そのインターフェースの背後にある2つの実装になるだけだ。実際には、PayMongoはDragonPayの既存の形に無理やり合わせる形で追加された。動きはするが、継ぎ目が見える。
とはいえ、ポーリングエンドポイントは正しい判断だった。1つの契約があり、ゲートウェイのロジックはその背後に隠されている。今3つ目のゲートウェイを追加するのは、2つ目を追加したときよりも大幅に安上がりになるだろう。それは通常、境界線が正しい場所に引かれていたことのサインだ。