決済機能を初めて設計する人向けの、事故防止策メモ
決済機能を作って運用していくと、稀に次のような事故が起こります。
・ユーザーに表示した金額と、実際に請求した金額が違う ・同じ注文に対して二重に決済される ・自システム上の決済記録と、stripe等の決済代行システム上の決済記録が一致しない
こうした事故は、実装ミスひとつで簡単に起きますが、ちょっとした工夫で防げることも多いです。 この記事では、決済機能を初めて設計する実装者(昔の自分)向けに、最低限押さえたい設計原則を5つに絞って整理します。
1. 決済時の金額と注文内容はスナップショットで保存する
非常に初歩的な内容ですが、特に初期プロダクトでたまに見るのが、決済確定時の金額をDBに保存せず、注文履歴等の表示で毎回動的計算している実装です。
これは以下が変わった時に、当時実際に請求した金額を算出できなくなるため避けておく必要があります。 ・商品価格 ・税率や送料 ・割引ロジック
決済時に注文の内容と金額を、その時点のスナップショットとして保存した方が安全です。
table Receipt {
id
paymentId
totalPrice
tax
discount
currency
createdAt
}
// 「合計だけあるが内訳が再現できない」状態も、運用ではかなりつらいため、商品名等含めて可能な限りの情報を保存しておきましょう。
table ReceiptItem {
id
receiptId
productId
productName
unitPrice
quantity
lineTotal
}
2. 金額計算ロジックは一箇所に集約する
決済まわりで危険なのは、ソースコード上で金額計算処理を複数箇所で別々に実装し始めることです。
たとえば次の3箇所で別実装されていることがあります。
・フロントエンドの注文確認画面 ・バックエンドの請求処理 ・通知システムの注文完了メール
こうなってしまうと、将来計算ロジックの変更時にズレていくことがあります。
・税の丸め方が違う ・割引適用順が違う
そのため、金額計算は1つの関数または1つの責務に集約した方がよいです。
type PriceCalculationResult = {
totalPrice: number
detail: {
products: Product[]
tax: number
discount: number
shippingFee: number
}
}
function calcPrice(input: {
productIds: number[]
couponCode?: string
shippingAddressId?: number
}): PriceCalculationResult {
// 商品合計、税、割引、送料をここで一元計算する
}
3. 決済確定時に、表示額と請求額が一致しない場合は決済を止める
稀にユーザーに注文確認画面で表示した金額と、実際に請求した金額がずれる事象が発生します。
ずれが起こる典型例はこうです。
・ユーザーが注文確認画面を開いたまましばらく放置した ・その間に価格、クーポン条件、送料や税計算条件の変更が入った、セールが終わった ・ユーザーが決済ボタンを押した頃には、変更後の金額になっている
これの対策として、ユーザーに表示した金額とバックエンドの購入処理時に再計算した結果を突合して、差分がある場合は画面更新を促す方が安全です。
念の為ですが、フロントサイドから渡されたpriceをそのまま信じて請求するという話ではありません。 必ずバックエンドで算出した値と比較するようにしてください。
// FE
const priceSummary = await fetchPrice({ productIds: [1, 5] })
display(priceSummary)
function onPaymentClick() => {
await purchase({
productIds: [1, 5],
expectedTotalPrice: priceSummary.totalPrice,
})
}
// BE
async function purchase(input: {
productIds: number[]
expectedTotalPrice: number
}) {
const latestPrice = calcPrice({ productIds: input.productIds })
if (latestPrice.totalPrice !== input.expectedTotalPrice) {
throw new PriceMismatchError('表示中の金額が更新されています')
}
await charge(latestPrice.totalPrice)
}
4. 多重決済対策は「自システム内部制約」と「外部API冪等性」を分けて設計する
典型的な多重決済の起こるタイミングとしては、実際には次のような原因があると思います
・ユーザー起因によるタイムアウト等での決済ボタン連打 ・システム起因によるwebhookやバッチ等のリトライ処理
自システム内と決済代行システムへの2層で、それぞれ重複防止と冪等性で対策することを考えます。
まず、自分たちのDB側で「何を1回きりとみなすのか」を明確にし、ユニーク制約で守ります。 たとえばECなら、「同じカートセッションに対する決済は1回まで」という形にできます。
table Order {
id
userId
cartSessionId
totalPrice
@@unique([userId, cartSessionId])
}
サブスクなどの周期的な決済なら、「同じユーザーの同じ請求期間の課金は1回まで」という制約を置けます。
table SubscriptionCharge {
id
userId
billingMonth // 2026-03 のような単位
amount
@@unique([userId, billingMonth])
}
これらを決済実行前にinsertすることでDB上で、シンプルで間違いにくい制約を貼ることができます。
また、決済代行システムへの送信は以下のように対策します
function charge(orderId: number, amount: number) {
const idempotencyKey = `order:${orderId}`
return paymentProvider.charge({
amount,
idempotencyKey,
})
}
5. 自システムと決済代行システムの決済金額不整合を定期的に検知する
どれだけ注意して実装しても、決済まわりでは不整合が起こり得ます。
・自システムでは成功扱いだが、実際には請求失敗している ・決済代行システムでは請求済みだが、自システム側のDBに存在しない ・決済代行システムと自システムで金額がずれている
この手の問題は、ユーザー問い合わせや月次締めで初めて発覚するとかなりつらいです。 防止策としては、これまで述べたようなものが考えられますが、早期発見目的で不整合を検知する定期バッチを用意しておくことも有用です
table Receipt {
id
paymentProviderPaymentId
totalPrice
status
}
async function reconcilePayments(targetDate: Date) {
const receipts = await getReceiptsByDate(targetDate)
for (const receipt of receipts) {
const providerPayment = await fetchPaymentFromProvider(
receipt.paymentProviderPaymentId
)
if (receipt.totalPrice !== providerPayment.amount) {
throw new Error(`金額不一致: receiptId=${receipt.id}`)
}
if (receipt.status !== providerPayment.status) {
throw new Error(`ステータス不一致: receiptId=${receipt.id}`)
}
}
}
また、決済代行システムの同期レスポンスだけを真実として扱わず、最終的な成功・失敗反映は(あれば)webhookを含めて確認するなどが考えられます。
【Javascript】オブジェクト更新のデバッグ方法
経緯
担当しているTypescript/Javascriptのシステムで、予期せずトップの.envファイルが読み込まれ、どこかの処理でprocess.envに値がセットされている事象が発生しました。
原因特定のため、デバッガーや二分探索を使って.envファイルを読んでいる処理を特定しようとしていたのですが、グローバルオブジェクトである故になかなか見当がつけられませんでした。
どうするか
より効率いい方法がないか考えた結果、Proxyオブジェクトを使えばいいことに気がつきました
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy
// 一連処理のトップレベルで呼び出し
const envHandler = {
set(target, key, value) {
console.trace(`Environment variable ${key} was set with value: ${value}`)
target[key] = value
return true
},
}
process.env = new Proxy(process.env, envHandler)
これで値がセットされた際に、traceログが出力されるようになり問題の特定につながりました。 process.envに限らず、特にグローバルオブジェクトや変数の謎更新のデバッグに使えそうなのでメモとして残しておきます
おまけ
本件はPrismaというライブラリがimportされると勝手に.envを読んでることが原因でした
https://www.prisma.io/docs/guides/development-environment/environment-variables#env-file-locations
Github actionsから、AWS WAFで設定したIP制限を突破する
概要
cloudfront等で、AWS WAFによってIPアクセス制限がかかっているリソースに対して、github actions hostedな環境からリクエストを送りたい場合があると思います。
github actionsが利用するグローバルIPの範囲は公開されていて、こちら全てをIP許可リストに追加することも可能ですが、保守面やセキュリティ観点としても推奨されるものではないと思います。
GitHub の IP アドレスはときどき変更されます。 IP アドレスによる許可はお勧めしません
GitHubのIPアドレスについて - GitHub Docs
そこで、実行中のgithub actions環境のIPを取得し、動的にIP許可リストに追加する方法を紹介します。
ちなみに、そもそもIPでなく専用のHTTPヘッダーを利用してWAFを突破するような方法も考えられますが、ここでは割愛します。
前提
以下はterraformの例ですが、WAF IPセットと許可ルールによるリソース構成になっている場合を想定します。
resource "aws_wafv2_ip_set" "my_project_github_actions" { provider = aws.virginia name = "github-actions" scope = "CLOUDFRONT" ip_address_version = "IPV4" // 許可IPリストはgithub actions内の実行時に動的に追加/削除するためignoreする lifecycle { ignore_changes = [ description, addresses, ] } } resource "aws_wafv2_web_acl" "my_project" { provider = aws.virginia name = "my-project-waf" scope = "CLOUDFRONT" ~~中略~~ rule { name = "allow-github-actions-ip" priority = 1 action { allow { } } statement { ip_set_reference_statement { arn = aws_wafv2_ip_set.my_project_github_actions.arn } } } ~~中略~~
結果
~~ 中略 ~~ jobs: sample: runs-on: ubuntu-latest timeout-minutes: 10 env: WAF_IPSET_SCOPE: {YOUR_WAF_IPSET_SCOPE} WAF_IPSET_REGION: {YOUR_WAF_IPSET_REGION} WAF_IPSET_ID: {YOUR_WAF_IPSET_ID} WAF_IPSET_NAME: {YOUR_WAF_IPSET_NAME} permissions: id-token: write contents: read steps: - uses: actions/checkout@v3 - name: authenticate to aws uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ${{ secrets.AWS_DEFAULT_REGION }} role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }} - name: Get IP address id: get-ip run: echo "ip=$(curl -s https://checkip.amazonaws.com)" >> $GITHUB_OUTPUT shell: bash - name: Add IP address to ipset shell: bash run: | current_ip=${{ steps.get-ip.outputs.ip }}/32 # このワークフローを並列で同時実行しても動くように、元から存在するIPをリストから消さないように更新する lock_token=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.LockToken') existing_ips=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.IPSet.Addresses[]') aws wafv2 update-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME --addresses $existing_ips $current_ip --lock-token $lock_token sleep 10 # ipsetの更新が反映されるまで待つ # ~~ IP制限しているリソースへのリクエスト付きの処理 ~~ - name: Delete IP address from ipset if: always() shell: bash run: | ip_to_remove=${{ steps.get-ip.outputs.ip }}/32 lock_token=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.LockToken') existing_ips=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.IPSet.Addresses[]') # このワークフローを並列で同時実行しても動くように、元から存在するIPをリストから消さないように更新する updated_ips=() for ip in $existing_ips; do if [ "$ip" != "$ip_to_remove" ]; then updated_ips+=("$ip") fi done if [ ${#updated_ips[@]} -eq 0 ]; then updated_ips_string='[]' else updated_ips_string=$(printf "%s " "${updated_ips[@]}") fi aws wafv2 update-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME --addresses $updated_ips_string --lock-token $lock_token
設定値は以下です
・YOUR_WAF_IPSET_SCOPE: CLOUDFRONT か REGIONAL
・YOUR_WAF_IPSET_REGION: ipsetの存在するリージョン(CLOUDFRONTの場合、globalではなくus-east-1を指定する必要がありました)
・YOUR_WAF_IPSET_ID: 添付図参照
・YOUR_WAF_IPSET_NAME: 添付図参照

解説
・処理の流れは大きく以下になってます
- 実行環境のIP取得
- 1で取得したIPをipsetにinsert
- IP制限環境へのリクエスト
- 2で登録したIPをipsetから削除
・aws wafv2 では、v1やセキュリティグループの仕様とは違って、ipsetのリストへそのまま任意のIPを挿入/削除することはできないようでした。 (おそらく、AWSの内部的なデータ整合性の観点だと思われます)
そのため、一度入れ替え更新のようなコードになっています。
This operation completely replaces the mutable specifications that you already have for the IP set with the ones that you provide to this call. To modify an IP set, do the following: 1. Retrieve it by calling GetIPSet 2. Update its settings as needed 3. Provide the complete IP set specification to this call
Nextjsで、app routerとpagesを併用するとページ遷移時に リロードされる
nextjs: 13.x
[2404追記]
公式ドキュメントで文書化済み nextjs.org
概要
既存のpages構成で稼働しているnextjsサーバーで、app routerを有効にして一部ページをappディレクトリに移行しました。
すると、pagesにある特定のリンクへのページ遷移/router.push()時に、SPAにも関わらず確定で強制リロード/page refresh/hard navigationのような挙動が発生しました。
頑張ってデバッグするとどうやら、app router機能のリリース辺りで以下のような修正が入っていたことに起因するようでした。
This adds a new client router filter for app paths and redirects so that we correctly hard navigate when a transition for one of this paths is encountered.
~~
The specific filter leveraged here is a bloom filter as we can tolerate some false matches (currently targeting an error rate of 2%) as they just trigger a hard navigation which should be acceptable and this also avoids needing to send an entire manifest with the related paths.
https://github.com/vercel/next.js/pull/46283
https://github.com/vercel/next.js/pull/49741
SPAにおいてもページ遷移時に、一定確率で静的もしくは動的なページであるとクライアント側で誤認定されて、ページリロードされるような挙動になるよう。。
対策
当該アプリケーションでは、ページ遷移時にstateが失われると壊れる設計になっており直ぐに対処する必要があったので、
next.config.tsでclientRouterFilter=falseにすることで一旦の暫定対応としました。
const nextConfig = { experimental: { clientRouterFilter: false, // clientRouterFilterがページ遷移時に、hard navigationするかチェックしているみたい ~~省略~~
falseにすることのデメリットを完全に把握したわけではないので、よく動作確認すること推奨です。
また、issueも上がっていたので、恒久的な対応としてはnextjs側の対応を待つしかなさそうです。
safariでnew Date()すると、RangeError: Invalid date value が発生する本当の理由
概要
safariで、
new Date('2023-03-01 12:02:31')
のようなコードを書くと、Invalid Dateが発生することがあります
結論
ISO 8601(基本/拡張形式)に従ったフォーマットを指定しましょう
new Date('2023-03-01T12:02:31') // 拡張形式に準拠して、日付と時刻の間に区切り文字Tを挿入
理由
ググると、safariでは日付の形式にハイフン使ってはいけないのでスラッシュ「/」を使いましょうみたいな解説の記事が多いですが、「ISO 8601に準拠していない形式では、動作が保証されていない」が正確だと思われます。
Date コンストラクターで日付文字列を解釈する際には、常に入力が ISO 8601 形式 (YYYY-MM-DDTHH:mm:ss.sssZ) であることを確認してください。他の形式で解釈した場合には、その挙動は実装によって定義されていて、すべてのブラウザーで動くとは限りません。
ISO 8601に準拠するべきと覚えておくと、同様に以下のようなフォーマットでエラーが発生した場合の理解や対処がスムーズになると思います。
new Date('2023-3-21T12:01:13') // 月がMM形式になっていない new Date('2023-03-21T12.01.13') // 時刻がコロン「:」形式でない
日付の区切り文字がスラッシュ「/」でも多くのブラウザで動くのは、実装依存によるものでJavascriptとしての仕様によるものではないはずです。