何を作るのか
このチュートリアルを終える頃には、NestJS + Next.jsスタックで動作するDragonpay統合を手に入れているはずだ。以下をサポートする。
- Collect、Dragonpayの決済ページ経由で顧客から支払いを受け取る
- Payout、受取人の銀行口座または電子ウォレットへ資金を支払う
ここでは、決済URLの生成、ポストバックの検証、注文状態の処理、Next.jsでのポーリングページの構築、そして公式ドキュメントに書かれていない重要なPayoutの落とし穴までを扱う。
前提条件
- Dragonpayマーチャントアカウント(テスト用のサンドボックス認証情報)
ConfigServiceがセットアップされたNestJSバックエンド- Next.jsフロントエンド(App Router)
- 環境変数:
DRAGONPAY_MERCHANT_ID、DRAGONPAY_PASSWORD、DRAGONPAY_BASE_URL、DRAGONPAY_PAYOUT_URL
パート1: Dragonpay Collect
ステップ1: Collectフローを理解する
コードを書く前に、この流れを頭に入れておこう。
- 顧客が注文を出す。バックエンドが
pending状態の注文を作成し、Dragonpayの決済URLを生成する - 顧客がDragonpayのホスト型決済ページにリダイレクトされる
- 顧客が支払いを完了する(または放棄する)
- Dragonpayが登録済みのURLへGETポストバックを発火する
- バックエンドがポストバックのダイジェストを検証し、注文ステータスを更新し、顧客が成功または失敗ページへリダイレクトされる
重要な洞察はこれだ。顧客が支払いを開始した時点では、注文はまだ存在していない。 txnidをキーとするカートスナップショットを作成し、ポストバックが成功を確認した時点で初めて実際の注文に変換する。決済コードを1行も書く前に、このフローを設計しておこう。
ステップ2: Dragonpayの決済URLを生成する
決済URLにはSHA-1ダイジェストが必要だ。特定のフィールドを特定の順序で、マーチャントパスワードをソルトとしてハッシュ化したものだ。これを間違えると、Dragonpayは何のエラーも出さずにリクエストを拒否する。
dragonpay.service.tsを作成する:
import * as crypto from 'crypto';
generateCheckoutUrl(params: {
txnid: string;
amount: number;
currency: string;
description: string;
email: string;
}): string {
const { txnid, amount, currency, description, email } = params;
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
const base = this.configService.get('DRAGONPAY_BASE_URL'); // e.g. https://gw.dragonpay.ph/Pay.aspx
const amountFormatted = amount.toFixed(2);
// Digest order: merchantId:txnid:amount:currency:description:email:password
const digestStr = [merchantId, txnid, amountFormatted, currency, description, email, password].join(':');
const digest = crypto.createHash('sha1').update(digestStr).digest('hex');
const queryParams = new URLSearchParams({
merchantid: merchantId,
txnid,
amount: amountFormatted,
ccy: currency,
description,
email,
digest,
});
return `${base}?${queryParams.toString()}`;
}
注意: descriptionフィールドには文字数制限があり、特殊文字が除去される。短く、プレーンなASCIIに保つこと。Dragonpay側でこれが切り詰められると、彼らが計算するダイジェストとあなたのものが一致せず、トランザクションは何のエラーも出さずに失敗する。
ステップ3: リダイレクトする前にカートスナップショットを保存する
顧客をDragonpayにリダイレクトする前に、彼らが何を購入しようとしていたかを永続化する。これは、後でポストバックが到着したとき(顧客があなたのサイトを離れてから数秒後かもしれないし、数分後かもしれない)に必要になる。
// In your orders or payments service
async initiateCheckout(cartItems: CartItem[], email: string) {
const txnid = generateUniqueTxnId(); // e.g. ulid() or uuid()
const amount = calculateTotal(cartItems);
// Save cart snapshot keyed by txnid
await this.cartSnapshotRepository.save({ txnid, cartItems, email, status: 'pending' });
const paymentUrl = this.dragonpayService.generateCheckoutUrl({
txnid,
amount,
currency: 'PHP',
description: 'Order payment',
email,
});
return { paymentUrl, txnid };
}
ステップ4: ポストバックエンドポイントをセットアップする
Dragonpayは各トランザクションの後に、あなたのポストバックURLへGETリクエストを発火させる。あなたのエンドポイントは次のことをしなければならない。
- 彼らが送ってくるダイジェストを検証する
- リテラルな文字列
result=OKまたはresult=errorを返す(JSONではなく、HTTPステータスでもなく、正確な文字列)
payments.controller.tsにて:
@Public() // bypass JWT auth - this comes from Dragonpay, not your users
@Get('dragonpay/postback')
async handleDragonpayPostback(@Query() query: DragonpayPostbackDto) {
const { txnid, refno, status, message, digest } = query;
const isValid = this.dragonpayService.validatePostbackDigest({ txnid, refno, status, message, digest });
if (!isValid) {
this.logger.warn(`Invalid postback digest for txnid: ${txnid}`);
return 'result=error';
}
await this.paymentsService.handleDragonpayPostback({ txnid, refno, status });
return 'result=OK';
}
dragonpay.service.tsに、ダイジェストバリデーターを追加する:
validatePostbackDigest(params: {
txnid: string; refno: string; status: string; message: string; digest: string;
}): boolean {
const { txnid, refno, status, message, digest } = params;
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
// Postback digest order: merchantId:txnid:refno:status:message:password
const digestStr = [merchantId, txnid, refno, status, message, password].join(':');
const expected = crypto.createHash('sha1').update(digestStr).digest('hex');
return expected === digest;
}
ステップ5: ポストバックをべき等に処理する
Dragonpayは実際に重複したポストバックを発火させる。あなたのハンドラーは、同じtxnidに対して複数回呼び出されても安全でなければならない。
// payments.service.ts
async handleDragonpayPostback(params: { txnid: string; refno: string; status: string }) {
const { txnid, status } = params;
// S = Success, F = Failure, U = Unknown, V = Void, others exist
if (status !== 'S') {
await this.cartSnapshotRepository.markFailed(txnid);
return;
}
const snapshot = await this.cartSnapshotRepository.findByTxnid(txnid);
if (!snapshot) {
this.logger.error(`No cart snapshot for txnid: ${txnid}`);
return;
}
// Guard: skip if already fulfilled (idempotency)
if (snapshot.status === 'fulfilled') return;
// Create the real order inside a DB transaction
await this.dataSource.transaction(async (manager) => {
const order = await this.ordersService.createFromSnapshot(snapshot, manager);
await this.cartSnapshotRepository.markFulfilled(txnid, order.id, manager);
});
}
ステップ6: Next.jsの待機ページを構築する
顧客をDragonpayにリダイレクトした後、「確認を待っています」という画面を表示する。注文が確認されるか失敗するまで、数秒ごとにバックエンドをポーリングする。
// app/payment/waiting/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function PaymentWaitingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const txnid = searchParams.get('txnid');
const [message, setMessage] = useState('Waiting for payment confirmation...');
useEffect(() => {
if (!txnid) return;
// Auto-timeout after 10 minutes
const timeout = setTimeout(() => {
clearInterval(interval);
setMessage('Payment confirmation timed out. Please contact support.');
}, 10 * 60 * 1000);
const interval = setInterval(async () => {
const res = await fetch(`/api/payments/status?txnid=${txnid}`);
const data = await res.json();
if (data.status === 'paid') {
clearInterval(interval);
clearTimeout(timeout);
router.push(`/orders/${data.orderId}`);
} else if (data.status === 'failed') {
clearInterval(interval);
clearTimeout(timeout);
router.push('/payment/failed');
}
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [txnid]);
return {message}
;
}
バックエンドには、txnidを現在の注文ステータスにマッピングするステータスエンドポイントが必要だ。このルートは、NestJS内のあらゆる:idワイルドカードルートより前に登録されていることを確認すること。そうしないと、パラメータマッチャーに飲み込まれてしまう。
パート2: Dragonpay Payout
ステップ7: まずこれをやる — Dragonpayにメールしてポストバックの登録を依頼する
これは、どこにも文書化されていないステップだ。Dragonpay Payoutにはセルフサーブのwebhook設定がない。 彼らのサポートチームに連絡し、あなたのpayoutポストバックURLを彼ら側で手動登録してもらう必要がある。
これをスキップしても、エラーも警告も、失敗したレスポンスもない。あなたのpayout APIコールは成功するが、ポストバックは単に永遠に届かない。これは非常に見逃しやすい。
Payoutのコードを書く前にこれをやること。 最初のメッセージにポストバックURLを含めること。時間がかかることがあり、これが完了するまでエンドツーエンドのテストはできない。
ステップ8: Payoutリクエストを行う
PayoutはCollectとはまったく異なる認証メカニズムを使う。ダイジェストベースのURL署名の代わりに、マーチャント認証情報を使ったHTTP Basic Authを使う。
// dragonpay.service.ts
async initiatePayout(params: {
txnid: string;
amount: number;
currency: string;
description: string;
recipientName: string;
bankCode: string; // Dragonpay processor ID, e.g. 'BPIA', 'GCSH'
accountNumber: string;
}): Promise<{ refno: string; status: string }> {
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
const baseUrl = this.configService.get('DRAGONPAY_PAYOUT_URL');
const auth = Buffer.from(`${merchantId}:${password}`).toString('base64');
const response = await fetch(`${baseUrl}/api/collect/v1/${merchantId}/disburse`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
TxnId: params.txnid,
Amount: params.amount.toFixed(2),
Currency: params.currency,
Description: params.description,
ProcId: params.bankCode,
Param1: params.recipientName,
Param2: params.accountNumber,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Dragonpay Payout failed: ${error}`);
}
return response.json();
}
ステップ9: payoutポストバックを処理する
Dragonpayがあなたのポストバック URLを登録した後(ステップ7)、payoutが完了するとGETリクエストがそこへ発火する。Collectポストバックと同じ形式、同じダイジェスト検証だ。
// payments.controller.ts
@Public()
@Get('dragonpay/payout-postback')
async handlePayoutPostback(@Query() query: DragonpayPostbackDto) {
const { txnid, refno, status, message, digest } = query;
const isValid = this.dragonpayService.validatePostbackDigest({ txnid, refno, status, message, digest });
if (!isValid) {
this.logger.warn(`Invalid payout postback digest for txnid: ${txnid}`);
return 'result=error';
}
await this.payoutsService.handlePostback({ txnid, refno, status });
return 'result=OK';
}
すべてのステータスコードを明示的に処理すること。S以外を単純な二値的失敗として扱わないこと。
S、成功: payoutを完了としてマークするF、失敗: 失敗としてマークし、オペレーターに表示するU、不明: ログに記録し、手動レビューまたはリトライのためにキューに入れる。これを自動的に失敗扱いにしないことV、無効: 無効としてマークする
リファレンス: 覚えておくべき主要なルール
Collectのダイジェストフィールド順序(決済URL生成用):
merchantId : txnid : amount : currency : description : email : password
ポストバックのダイジェストフィールド順序(CollectとPayoutで共通):
merchantId : txnid : refno : status : message : password
ポストバックのレスポンス文字列(JSONではなく、正確でなければならない):
result=OK
result=error
認証の違い:
- Collect、URLクエリパラメータ内のダイジェスト
- Payout、HTTP Basic Authヘッダー
事前に知っておかないとやけどする3つのこと:
- Payoutのコードを書く前に、Dragonpayにメールしてpayoutポストバック URLの登録を依頼すること
- CollectとPayoutは対称ではない。認証も、セットアップも、落とし穴も異なる
- 決済統合に触れる前に、注文の状態マシン(pending、confirmed、failed)を設計すること