No description
Find a file
NillPoe c28a1425df
feat: 현금영수증 문자 전송 제거
- PaySync API에서 POST /v2/cash-receipts/{id}/send-sms가 없어져 문자 전송 엔드포인트, 서비스, 요청 모델, 테스트를 지웠습니다.
- 팝빌 오류(240009) 502 테스트는 이메일 전송으로 옮겼습니다.
- README 엔드포인트 표에서 send-sms를 지웠습니다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:00:56 +09:00
app feat: 현금영수증 문자 전송 제거 2026-10-04 19:00:56 +09:00
tests feat: 현금영수증 문자 전송 제거 2026-10-04 19:00:56 +09:00
.env.example feat: PaySync Python + FastAPI 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.gitattributes feat: PaySync Python + FastAPI 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.gitignore feat: PaySync Python + FastAPI 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
pyproject.toml feat: PaySync Python + FastAPI 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
README.md feat: 현금영수증 문자 전송 제거 2026-10-04 19:00:56 +09:00

PaySync 샘플 백엔드 (Python + FastAPI)

PaySync REST API v2(https://api.paysync.kr/v2/...)를 호출하고 PaySync v2 웹훅을 받는 가맹점 서버 예제입니다. 주문(invoice), 현금영수증, 입금 내역 API를 그대로 감싸서 제공하고, 웹훅은 Standard Webhooks 서명을 검증한 뒤 처리합니다. API 명세는 PaySync API 문서를 참고하세요.

요구사항

  • Python 3.12 이상 (3.14에서 테스트했습니다)
  • PaySync API 키와 웹훅 서명 키(whsec_...)
  • uv (선택. 없으면 venv + pip로 실행할 수 있습니다)

주요 라이브러리: FastAPI 0.141, Uvicorn 0.54, httpx 0.28, pydantic 2.13, pydantic-settings 2.15, standardwebhooks 1.1

설정

.env.example을 .env로 복사한 뒤 값을 채웁니다. 같은 이름의 환경 변수가 있으면 .env보다 우선합니다.

환경 변수 필수 기본값 설명
PAYSYNC_BASE_URL https://api.paysync.kr PaySync API 주소
PAYSYNC_API_KEY ✅ API 키. Authorization: Bearer <키>로 전송합니다.
PAYSYNC_WEBHOOK_SIGNING_SECRET ✅ 웹훅 서명 키 (whsec_...)
PORT 8080 python -m app으로 실행할 때의 포트

실행

uv를 쓰는 경우

uv run fastapi dev app/main.py --port 8080   # 개발 모드 (코드 변경 시 자동 재시작)
uv run pytest                                # 테스트

venv + pip를 쓰는 경우

python -m venv .venv
source .venv/bin/activate            # Windows (PowerShell): .venv\Scripts\Activate.ps1
pip install -e . --group dev         # --group 옵션은 pip 25.1 이상에서 지원합니다

fastapi dev app/main.py --port 8080  # 개발 모드
pytest                               # 테스트

다른 실행 방법:

uvicorn app.main:app --reload --port 8080
python -m app                        # PORT 환경 변수(기본 8080)를 사용합니다

서버가 뜨면 http://localhost:8080/docs에서 Swagger UI로 API를 호출해 볼 수 있습니다.

엔드포인트

메서드 경로 PaySync API 성공 응답
POST /api/invoices POST /v2/invoices 201 + 주문
GET /api/invoices GET /v2/invoices 200 + { items, totalItems }
GET /api/invoices/{id} GET /v2/invoices/{id} 200 + 주문 (처리 이력 history 포함)
PATCH /api/invoices/{id} PATCH /v2/invoices/{id} 200 + 주문
POST /api/invoices/{id}/mark-paid POST /v2/invoices/{id}/mark-paid 200 + 주문
POST /api/invoices/{id}/cancel POST /v2/invoices/{id}/cancel 200 + 주문
POST /api/cash-receipts POST /v2/cash-receipts 201 + 현금영수증
GET /api/cash-receipts GET /v2/cash-receipts 200 + { items, totalItems }
GET /api/cash-receipts/{id} GET /v2/cash-receipts/{id} 200 + 현금영수증
POST /api/cash-receipts/{id}/cancel POST /v2/cash-receipts/{id}/cancel 200 + 취소 영수증
POST /api/cash-receipts/{id}/reissue POST /v2/cash-receipts/{id}/reissue 201 + 새 현금영수증
GET /api/cash-receipts/{id}/urls GET /v2/cash-receipts/{id}/urls 200 + { view, print, pdf }
POST /api/cash-receipts/{id}/send-email POST /v2/cash-receipts/{id}/send-email 204
GET /api/transactions GET /v2/transactions 200 + { items, totalItems }
GET /api/transactions/{id} GET /v2/transactions/{id} 200 + 입금 내역
POST /api/webhooks (PaySync가 호출) 200

PaySync v2는 성공하면 항상 HTTP 200과 code: "10001"을 돌려줍니다. 이 샘플은 생성 요청에 201, 데이터가 없는 요청에 204로 바꿔 응답합니다. 응답의 시각(createdAt, expiresAt 등)은 Unix 초 단위 정수입니다.

목록 조회 쿼리 파라미터(offset 기본 0, limit 기본 20, 나머지는 선택)는 값이 있는 것만 PaySync에 그대로 전달합니다. 날짜 필터 dateFrom, dateTo는 YYYY-MM-DD(한국 시간, 그날 포함)이고, status처럼 여러 값을 받는 필터는 status=PENDING,EXPIRED처럼 쉼표로 이어 보냅니다.

  • /api/invoices: offset, limit, search, status, due, source, createdBy, dateFrom, dateTo
  • /api/transactions: offset, limit, search, bankAccountId, status, matchMethod, dateFrom, dateTo
  • /api/cash-receipts: offset, limit, search, status, type, method, kind, invoiceId, dateFrom, dateTo

주문 생성 예시:

curl -X POST http://localhost:8080/api/invoices \
  -H "Content-Type: application/json" \
  -d '{"customer":{"name":"홍길동"},"amount":50000,"expireAfterDays":1,"metadata":{"orderId":"ORDER-0001"}}'

주문 수정은 받은 JSON 객체를 그대로 PaySync에 전달합니다. 보내지 않은 필드는 그대로 두고, null을 보낸 필드는 지웁니다.

# 입금 기한을 3일 늘리고, 구매자 이메일을 지웁니다.
curl -X PATCH http://localhost:8080/api/invoices/ivc_... \
  -H "Content-Type: application/json" \
  -d '{"extendDays":3,"customer":{"email":null}}'

주문 취소는 본문 없이 보내거나 {"reason":"고객 요청"}처럼 사유를 넣어 보냅니다.

에러 응답

에러는 { "code": ..., "message": ..., "data": ... } 형식으로 응답합니다. data는 PaySync가 에러와 함께 보낸 값이 있을 때만 붙습니다.

{
  "code": "220010",
  "message": "주문 생성에 실패했습니다.",
  "data": { "invoiceId": "ivc_n0o1p2q3r4s5t6u7v8w9x0y1", "number": 1042 }
}
상황 상태 코드 code
요청 값이 올바르지 않음 (필수 필드 누락, JSON 타입 오류) 400 INVALID_REQUEST (+ errors에 상세 내용)
PaySync가 4xx와 에러 코드를 응답 PaySync 상태 코드 그대로 (예: 409) PaySync 코드 그대로 (예: 220010)
그 밖의 PaySync 실패 (5xx, 빈 본문이나 JSON이 아닌 응답, 연결 실패, 10001이 아닌 성공 응답) 502 PaySync 코드 또는 UPSTREAM_ERROR

길이나 형식 같은 세부 검증은 PaySync가 하고, 그 에러 코드를 그대로 전달합니다. 코드 목록은 에러 코드를 참고하세요.

웹훅 로컬 테스트

POST /api/webhooks는 webhook-id, webhook-timestamp, webhook-signature 헤더와 파싱 전 원본 본문으로 서명을 검증합니다.

  • 검증 성공: 정상 웹훅이 수신되었습니다: <이벤트 ID> (<type>) 로그를 남기고, 이벤트 type별로 처리한 뒤(app/webhooks/service.py) 200으로 응답합니다. PaySync는 HTTP 200~299를 성공으로 기록하고, 응답을 10초까지 기다립니다. 모르는 type도 무시하고 200으로 응답합니다.
  • 검증 실패: 경고 로그만 남기고(본문은 남기지 않습니다) 400으로 응답합니다.

v2 웹훅 본문은 { "id": "evt_...", "type": "...", "timestamp": "<ISO 8601>", "data": {...} } 형식입니다. invoice.* 이벤트(invoice.created, invoice.updated, invoice.paid, invoice.expired, invoice.canceled, invoice.reopened)의 data는 주문이고, transaction.created의 data는 입금입니다. 웹훅 페이로드의 시각은 API 응답과 달리 ISO 8601 문자열입니다. 결제 완료 경로는 invoice.paid의 data.paidTrigger(AUTOMATIC_MATCHING, MANUAL_MATCHING, MANUAL_APPROVE, API_CALL)로 알 수 있습니다.

재시도나 재전송 때문에 같은 이벤트가 여러 번 올 수 있으니 페이로드의 id(evt_...)로 중복을 걸러야 합니다. 이벤트가 오는 순서도 보장되지 않습니다.

1. 서명한 요청을 직접 보내기

서버를 띄운 상태에서 아래 코드를 send_webhook.py로 저장하고 python send_webhook.py로 실행합니다. .env와 같은 서명 키를 사용합니다.

import json
from datetime import UTC, datetime

import httpx
from standardwebhooks import Webhook

from app.config import get_settings

body = json.dumps(
    {
        "id": "evt_local",
        "type": "invoice.paid",
        "timestamp": "2026-04-28T03:20:12Z",
        "data": {"id": "ivc_test", "paidTrigger": "API_CALL"},
    }
)
msg_id, now = "whm_local_test", datetime.now(UTC)
signature = Webhook(get_settings().paysync_webhook_signing_secret.get_secret_value()).sign(msg_id, now, body)

res = httpx.post(
    "http://localhost:8080/api/webhooks",
    content=body,
    headers={"webhook-id": msg_id, "webhook-timestamp": str(int(now.timestamp())), "webhook-signature": signature},
)
print(res.status_code)  # 200

서명 없이 보내면 400이 돌아옵니다.

curl -i -X POST http://localhost:8080/api/webhooks -d '{"type":"invoice.paid"}'

2. PaySync에서 실제 웹훅 받기

PaySync에는 localhost나 사설 IP를 등록할 수 없으므로 터널로 공개 주소를 만듭니다.

cloudflared tunnel --url http://localhost:8080   # 또는: ngrok http 8080

대시보드 웹훅에서 웹훅 추가를 누르고, 받을 URL에 https://<터널 주소>/api/webhooks를 입력합니다. API 버전은 v2를 고릅니다. 웹훅 목록의 보기에서 서명 키(whsec_...)를 확인해 PAYSYNC_WEBHOOK_SIGNING_SECRET에 넣은 뒤 서버를 다시 시작합니다. 받을 이벤트에 invoice.created를 골랐다면, 주문을 만들 때 웹훅이 옵니다. 행 메뉴의 테스트 이벤트 보내기로 서명 검증까지 확인할 수도 있습니다.

프로젝트 구조

app/
├── main.py              # FastAPI 앱, lifespan(httpx 클라이언트 생성), 에러 핸들러
├── config.py            # 환경 변수 설정 (pydantic-settings)
├── paysync/
│   ├── client.py        # PaySync API 클라이언트, PaySyncApiError
│   └── models.py        # v2 응답 봉투, 목록 응답(Page), 주체(Actor), camelCase 기본 모델
├── invoices/            # 주문: models.py, service.py, router.py
├── cash_receipts/       # 현금영수증
├── transactions/        # 입금 내역 (입금 계좌 모델 포함)
└── webhooks/
    ├── router.py        # 서명 검증
    └── service.py       # 이벤트 type별 처리 (비즈니스 로직 자리)
tests/                   # respx로 PaySync API를 흉내 낸 테스트