No description
Find a file
NillPoe 25e2950e6e
feat: 현금영수증 문자 전송 제거
PaySync API에서 POST /v2/cash-receipts/{id}/send-sms가 없어져
라우트, 컨트롤러, 서비스, 요청 검증, 테스트, README에서 문자 전송을 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:01:18 +09:00
app feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:18 +09:00
bootstrap feat: PaySync v2 API로 이전 2026-10-04 06:08:46 +09:00
config feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
public feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
routes feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:18 +09:00
storage feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
tests feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:18 +09:00
.editorconfig feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.env.example feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.gitattributes feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
.gitignore feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
artisan feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
composer.json feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
composer.lock feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
phpunit.xml feat: PaySync PHP + Laravel 샘플 백엔드 추가 2026-09-29 13:34:13 +09:00
README.md feat: 현금영수증 문자 전송 제거 2026-10-04 19:01:18 +09:00

PaySync 연동 예제 (PHP + Laravel)

PaySync REST API(v2)를 호출하고 PaySync 웹훅(v2)을 받는 가맹점 백엔드 예제입니다. 주문, 현금영수증, 입금 내역 API를 그대로 중계하고, 웹훅은 Standard Webhooks 서명을 검증한 뒤 처리합니다. API 명세는 PaySync API 문서를 참고하세요.

데이터베이스, 세션, 프런트엔드 없이 API 라우트만 쓰는 Laravel 13 앱입니다.

요구사항

  • PHP 8.3 이상 (8.5 권장)
  • Composer 2
  • PaySync API 키와 웹훅 서명 키 (PaySync 대시보드에서 발급)

PHP를 설치하지 않았다면 Docker의 composer 이미지(PHP 포함)로 아래 명령을 실행할 수 있습니다.

docker run --rm -it -v "$PWD:/app" -w /app -p 8080:8080 composer:latest <명령>
# 예: ... composer:latest php artisan serve --host=0.0.0.0

설정

composer setup   # composer install, .env.example → .env 복사, APP_KEY 생성

그다음 .env의 값을 채웁니다. .env는 Git에 올리지 않습니다.

환경 변수 필수 기본값 설명
PAYSYNC_BASE_URL https://api.paysync.kr PaySync API 주소
PAYSYNC_API_KEY O API 키 (sk_live_...). Authorization: Bearer <키>로 전송합니다.
PAYSYNC_WEBHOOK_SIGNING_SECRET O 웹훅 서명 키 (whsec_...)
SERVER_PORT 8080 (.env.example) php artisan serve 포트. 다른 언어 예제의 PORT에 해당합니다.

PaySync 설정은 config/services.php의 paysync 항목에서 읽습니다. 설정을 캐시(php artisan config:cache)했다면 .env를 바꾼 뒤 다시 캐시하세요.

실행

php artisan serve            # http://localhost:8080 (.env의 SERVER_PORT)
php artisan serve --port=8080  # 포트를 직접 지정

로그는 콘솔(stderr)에 찍힙니다. 파일로 남기려면 .env에서 LOG_CHANNEL=single로 바꾸면 storage/logs/laravel.log에 기록됩니다.

php artisan test   # 테스트 (PaySync API는 Http::fake()로 대체)
vendor/bin/pint    # 코드 스타일 정리

엔드포인트

메서드 경로 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 / 검증 실패 시 400

PaySync는 주문 생성을 포함한 모든 성공 응답을 HTTP 200과 code: "10001"로 돌려줍니다. 이 예제는 생성과 재발급에 201, 이메일 전송에 204를 돌려줍니다. 응답의 시각(createdAt, paidAt 등)은 Unix 초 정수입니다.

목록 조회 쿼리 파라미터 (offset 기본값 0, limit 기본값 20, 나머지는 선택이며 값이 없으면 PaySync에 전달하지 않습니다):

  • /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

dateFrom, dateTo는 YYYY-MM-DD 형식의 한국 시간 날짜이고 두 날짜 모두 포함합니다. status, source, bankAccountId는 쉼표로 여러 값을 보낼 수 있습니다. (예: status=PENDING,EXPIRED) 값은 그대로 PaySync에 전달하고, 값 검사는 PaySync가 합니다.

요청 본문

  • 주문 생성: customer.name(필수), customer.email, customer.phoneNumber, amount(필수), bankAccountIds, expiresAt(Unix 초) 또는 expireAfterDays(1~365), cashReceipt(type, identifier), metadata, sendGuideEmail
  • 주문 수정: customer.name, customer.email, customer.phoneNumber, amount, expiresAt, extendDays, bankAccountIds, cashReceipt, resendGuideEmail. 보낸 필드만 바뀌고, null은 값을 지웁니다. (예: "expiresAt": null은 입금 기한을 없앱니다) 그래서 받은 JSON 객체를 그대로 전달합니다.
  • 주문 취소: reason(선택). 본문 없이 보내도 됩니다.
  • 현금영수증 발급: type(필수, PERSONAL/CORPORATE), identifier(selfIssued가 true가 아니면 필수), selfIssued, amount(필수), customerName, invoiceId
  • 현금영수증 취소: reason(필수), amount(선택, 빼면 남은 금액 전체)
  • 현금영수증 재발급: identifier, type, selfIssued
  • 현금영수증 이메일 전송: email(필수)

이 예제는 필수 필드와 JSON 타입만 확인합니다. 길이와 형식은 PaySync가 검사하고 오류 코드를 돌려줍니다.

오류 응답

오류 응답은 { "code": "...", "message": "...", "data": ... } 형식입니다. data는 PaySync가 오류에 data를 담았을 때만 붙습니다.

  • 요청 값이 올바르지 않으면 400 BAD_REQUEST (항목별 내용은 errors에 담깁니다. Laravel 기본값인 422 대신 400을 씁니다.)
  • PaySync가 4xx와 code를 돌려주면 같은 상태와 code, data (예: 404 20014, 409 220010)
  • PaySync 5xx, JSON이 아닌 응답, 연결 실패, 10001이 아닌 성공 응답은 502 (code는 PaySync가 준 값 또는 UPSTREAM_ERROR)

같은 입금자명과 금액의 결제 대기 주문이 이미 있으면 기존 주문 ID가 data에 담겨 옵니다.

{
  "code": "220010",
  "message": "PaySync API 요청이 거절되었습니다: 220010",
  "data": { "invoiceId": "ivc_n0o1p2q3r4s5t6u7v8w9x0y1", "number": 1042 }
}

코드 목록은 에러 코드를 참고하세요.

요청 예시

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

# 결제 대기 주문 목록
curl 'http://localhost:8080/api/invoices?status=PENDING&limit=10'

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

# 주문 취소
curl -X POST http://localhost:8080/api/invoices/ivc_xxx/cancel \
  -H 'Content-Type: application/json' \
  -d '{"reason":"고객 요청으로 주문 취소"}'

웹훅

등록

PaySync 대시보드의 웹훅 페이지에서 웹훅 추가를 누르고, 받을 URL(예: https://example.com/api/webhooks)을 입력합니다. API 버전은 v2를 고릅니다. 이 예제는 v2 페이로드를 처리합니다. 웹훅을 만들면 서명 키(whsec_...)가 생깁니다. 웹훅 목록의 보기에서 확인해 PAYSYNC_WEBHOOK_SIGNING_SECRET에 넣고 서버를 다시 시작합니다.

페이로드와 처리

v2 웹훅은 모두 같은 봉투에 담겨 옵니다. 페이로드의 시각은 ISO 8601 문자열입니다.

{
  "id": "evt_f1g2h3i4j5k6l7m8n9o0p1q2",
  "type": "invoice.paid",
  "timestamp": "2026-04-28T03:20:12Z",
  "data": { "id": "ivc_...", "status": "PAID", "paidTrigger": "AUTOMATIC_MATCHING", "...": "..." }
}
이벤트 전송 시점
invoice.created 주문이 만들어졌습니다.
invoice.updated 결제 대기 주문의 내용이 바뀌었습니다.
invoice.paid 주문이 결제 완료되었습니다. 경로는 data.paidTrigger(AUTOMATIC_MATCHING, MANUAL_MATCHING, MANUAL_APPROVE, API_CALL)로 알 수 있습니다.
invoice.expired 입금 기한이 지나 주문이 만료되었습니다.
invoice.canceled 주문이 취소되었습니다.
invoice.reopened 매칭이 해제되어 결제 완료 주문이 다시 열렸습니다.
transaction.created 새 입금이 들어왔습니다.

이벤트별 처리는 app/Services/WebhookService.php의 switch 문에 추가합니다. 모르는 type은 무시하고 200을 돌려줍니다.

  • 서명은 파싱하기 전의 원본 본문으로 검증하고, webhook-timestamp가 5분 넘게 차이 나면 거부합니다.
  • 재시도와 재전송 때문에 같은 이벤트가 여러 번 올 수 있습니다. 페이로드의 id(evt_...)로 중복을 걸러 주세요.
  • 이벤트가 오는 순서는 보장되지 않습니다.
  • PaySync는 HTTP 200~299를 성공으로 기록하고, 그 밖의 응답이나 10초 안에 응답이 없으면 다시 보냅니다. 이 예제는 200을 돌려줍니다.

로컬 테스트

PaySync는 localhost로 웹훅을 보낼 수 없으므로 ngrok 같은 터널로 로컬 서버를 외부에 엽니다.

php artisan serve
ngrok http 8080

출력된 주소 뒤에 경로를 붙여(예: https://abcd-1234.ngrok-free.app/api/webhooks) 위와 같이 웹훅으로 등록합니다. 웹훅 목록의 행 메뉴에서 테스트 이벤트 보내기를 누르거나 주문을 만들면 웹훅이 오고, 서버 로그에 정상 웹훅이 수신되었습니다: evt_... (invoice.created)가 찍힙니다.

터널 없이 직접 서명한 요청을 보내 볼 수도 있습니다. .env의 서명 키로 서명해서 전송합니다. (bash 기준)

BODY='{"id":"evt_local","type":"invoice.paid","timestamp":"2026-04-28T03:20:12Z","data":{"id":"ivc_test","paidTrigger":"API_CALL"}}'
TIMESTAMP=$(date +%s)
SIGNATURE=$(php -r '
require "vendor/autoload.php";
Dotenv\Dotenv::createImmutable(".")->load();
echo (new StandardWebhooks\Webhook($_ENV["PAYSYNC_WEBHOOK_SIGNING_SECRET"]))->sign("whm_local", $argv[1], $argv[2]);
' "$TIMESTAMP" "$BODY")

curl -i -X POST http://localhost:8080/api/webhooks \
  -H "webhook-id: whm_local" \
  -H "webhook-timestamp: $TIMESTAMP" \
  -H "webhook-signature: $SIGNATURE" \
  --data-raw "$BODY"

HTTP/1.1 200 OK가 오고 로그에 주문이 결제 완료되었습니다: ivc_test (API_CALL)가 찍히면 성공입니다. 서명이 틀리면 400을 돌려주고 웹훅 검증에 실패했습니다 경고를 남깁니다. 검증되지 않은 요청의 본문은 로그에 남기지 않습니다.

구조

bootstrap/app.php           라우트 등록, 오류 → HTTP 응답 변환 (PaySyncApiException, 검증 오류)
config/services.php         PaySync 설정 (환경 변수)
routes/api.php              엔드포인트 (/api 접두사)
app/
├── PaySync/                PaySync API 클라이언트 (기본 URL, 인증, 응답 해석, PaySyncApiException)
├── Providers/              클라이언트와 웹훅 검증기를 컨테이너에 등록
├── Services/               도메인별 API 호출 (주문, 현금영수증, 입금 내역) + 웹훅 처리
├── Http/Controllers/       도메인별 컨트롤러
├── Http/Requests/          요청 검증 (FormRequest)
├── Data/                   응답 모델 (Invoice, CashReceipt, Transaction, BankAccount, Actor, Page 등)
└── Enums/                  CashReceiptType (요청 검증용)
tests/Feature/              PHPUnit 기능 테스트