토스 웹훅 — Payments::WebhooksController + HMAC 서명 검증 + PAYMENT_STATUS_CHANGED 처리 + 테스트

ID: 42a75287-6f2e-41b1-b774-576ed54b9a6f

높음 리뷰

## 목표
PRD Section 11.4 기반 토스 웹훅 처리. CSRF skip, HMAC SHA-256 서명 검증, PAYMENT_STATUS_CHANGED 이벤트 처리.

## 현재 상태
- Payments::WebhooksController 스텁 존재 (receive 액션, allow_unauthenticated_access + skip_forgery_protection 설정됨)
- 라우트: `post "/payments/webhook", to: "payments/webhooks#receive"`
- Payment 모델: toss_payment_key, status enum {pending:0, done:1, canceled:2, failed:3}, paid_at, canceled_at

## PRD 코드 (Section 11.4)
```ruby
class Payments::WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
allow_unauthenticated_access

def receive
unless valid_signature?
return render json: { error: "Invalid signature" }, status: :unauthorized
end
payload = JSON.parse(request.body.read)
case payload["eventType"]
when "PAYMENT_STATUS_CHANGED"
handle_payment_status_changed(payload["data"])
end
render json: { received: true }
end

private

def valid_signature?
secret = ENV["TOSS_WEBHOOK_SECRET"]
signature = request.headers["TossPayments-Signature"]
body = request.body.read
request.body.rewind
expected = OpenSSL::HMAC.hexdigest("sha256", secret, body)
ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
end

def handle_payment_status_changed(data)
payment = Payment.find_by(toss_payment_key: data["paymentKey"])
return unless payment
case data["status"]
when "DONE" then payment.update!(status: :done, paid_at: Time.current)
when "CANCELED" then payment.update!(status: :canceled, canceled_at: Time.current)
when "ABORTED", "EXPIRED" then payment.update!(status: :failed)
end
end
end
```

## 구현 사항
1. **Payments::WebhooksController** — PRD 코드 기반
2. **⚠️ request.body.read 주의**: body를 두 번 읽으므로 rewind 필요
3. **HMAC SHA-256 서명 검증**: TOSS_WEBHOOK_SECRET + secure_compare
4. **PAYMENT_STATUS_CHANGED만 처리** (v2.1: BILLING_SKIPPED 삭제)
5. **allow_unauthenticated_access** 이미 설정되어 있을 수 있음 — 확인 후 유지

### 테스트
- 유효한 서명 → 200 + Payment 상태 변경
- 무효한 서명 → 401
- DONE → payment.done, CANCELED → payment.canceled, ABORTED → payment.failed
- 없는 paymentKey → 무시 (200 반환)
- 알 수 없는 eventType → 무시

### ⚠️ payments/webhooks 범위만 (developer-2는 payments/checkout, completions 작업 중)

## 대시보드 기록 (MCP)
- 시작: `AddActivityLogTool` (ticket_id: "c547c6c5-b25a-4c42-a7fe-850261c83e5a", message: "토스 웹훅 시작")
- 완료: `AddActivityLogTool` (message: "토스 웹훅 완료", details 필수)

## 완료 절차
1. `UpdateTicketStatusTool`로 서브 티켓 → `review`
2. `AddActivityLogTool`로 완료 기록
3. `SendMessage`로 팀리드에게 보고 (summary: "토스 웹훅 구현 완료")

⚠️ SendMessage 필수!

첨부 이미지

이미지 추가 (Ctrl+V로 붙여넣기 또는 클릭)

JPEG, PNG, GIF, WebP / 최대 10MB

담당자: developer-1
생성일: 2026년 03월 26일 11:35

활동 로그

  • D
    developer-1 상태 변경: 할 일 → 리뷰

    2026년 03월 26일 11:38:04