From 391c3bfd13bf0f57a4ea3c052f495384dace9cdf Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 22:57:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EC=A0=95=EC=82=B0=204=EA=B0=9C=20Pa?= =?UTF-8?q?yple=20=EC=9C=A0=ED=8B=B8=EC=97=90=20Referer=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=B6=94=EA=B0=80=20(#503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payple 안내사항 2번에 따라 AWS 등 클라우드에서 도메인 정보가 누락될 경우 대비해 Referer 헤더 전송. 결제 유틸(purchases/utils/payple.ts)에는 이미 적용돼 있었으나 정산 4개 유틸에 모두 누락된 상태였음. - utils/payple.ts에 buildPaypleHeaders() 헬퍼 추가 - Content-Type / Cache-Control / Referer(PAYPLE_REFERER가 있을 때만) - extra 인자로 Authorization 등 호출별 헤더 병합 가능 - 4개 파일의 모든 axios.post 호출이 buildPaypleHeaders 사용: - utils/payple.ts (실명인증 OAuth + /inquiry/real_name) - utils/payple-settlement.ts (정산내역 조회) - utils/payple-refund.ts (결제 취소) - utils/payple-billing.ts (빌링키 조회/해지) 환경변수 PAYPLE_REFERER는 .env에 이미 설정됨 (운영: https://www.promptplace.kr, dev: http://localhost:5173). --- src/settlements/utils/payple-billing.ts | 8 ++++---- src/settlements/utils/payple-refund.ts | 6 +++--- src/settlements/utils/payple-settlement.ts | 6 +++--- src/settlements/utils/payple.ts | 17 +++++++++++------ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/settlements/utils/payple-billing.ts b/src/settlements/utils/payple-billing.ts index a567c3e..e17d286 100644 --- a/src/settlements/utils/payple-billing.ts +++ b/src/settlements/utils/payple-billing.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import redisClient from '../../config/redis'; import { AppError } from '../../errors/AppError'; -import { redactPaypleLog } from './payple'; +import { redactPaypleLog, buildPaypleHeaders } from './payple'; // Payple 빌링키 라이프사이클 (#491 후속 작업 대비). // 본 파일은 빌링키 조회(PUSERINFO) / 해지(PUSERDEL) 인프라만 정의. @@ -67,7 +67,7 @@ const fetchBillingAuth = async (work: BillingWork): Promise => { const res = await axios.post( url, { cst_id: cstId, custKey, PCD_PAY_WORK: work }, - { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + { headers: buildPaypleHeaders() }, ); if (res.data?.result !== 'success') { @@ -113,7 +113,7 @@ export const fetchBillingKeyInfo = async (payerId: string): Promise => { const res = await axios.post( url, { cst_id: cstId, custKey, PCD_PAYCANCEL_FLAG: 'Y' }, - { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + { headers: buildPaypleHeaders() }, ); if (res.data?.result !== 'success') { @@ -123,7 +123,7 @@ export const requestPaypleRefund = async ( let res; try { res = await axios.post(url, body, { - headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + headers: buildPaypleHeaders(), }); } catch (err: any) { console.error('[payple-refund] request network error', { diff --git a/src/settlements/utils/payple-settlement.ts b/src/settlements/utils/payple-settlement.ts index 9fa4c48..431af0f 100644 --- a/src/settlements/utils/payple-settlement.ts +++ b/src/settlements/utils/payple-settlement.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import redisClient from '../../config/redis'; import { AppError } from '../../errors/AppError'; -import { redactPaypleLog } from './payple'; +import { redactPaypleLog, buildPaypleHeaders } from './payple'; // Payple 정산내역 조회용 파트너 인증 + 조회 유틸. // 정산내역 조회는 PCD_SETTLEMENT_FLAG=Y로 인증 받고, 응답의 PCD_PAY_HOST + PCD_PAY_URL로 호출. @@ -68,7 +68,7 @@ export const fetchPaypleSettlementAuth = async (): Promise const res = await axios.post( url, { cst_id: cstId, custKey, PCD_SETTLEMENT_FLAG: 'Y' }, - { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + { headers: buildPaypleHeaders() }, ); if (res.data?.result !== 'success') { @@ -146,7 +146,7 @@ export const fetchPaypleSettlements = async ( const url = `${auth.payHost}${auth.payUrl}`; const res = await axios.post(url, body, { - headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + headers: buildPaypleHeaders(), }); if (res.data?.PCD_PAY_RST !== 'success') { diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index cc59ca6..0135edd 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -21,6 +21,15 @@ export class AccountVerificationError extends AppError { } } +// Payple 호출 공통 헤더. +// AWS 등 클라우드 환경에서 도메인 정보가 누락되는 경우를 대비해 Referer 헤더를 함께 전송 (Payple 안내사항). +export const buildPaypleHeaders = (extra?: Record): Record => ({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + ...(process.env.PAYPLE_REFERER ? { Referer: process.env.PAYPLE_REFERER } : {}), + ...extra, +}); + // 민감 정보 redactor — 로그에 노출되면 안 되는 필드 const REDACTED_FIELDS = new Set([ // 실명인증/은행/토큰 @@ -106,7 +115,7 @@ const fetchPaypleAccessToken = async (): Promise => { const res = await axios.post( `${PAYPLE_HUB_URL}/oauth/token`, { cst_id, custKey, code }, - { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + { headers: buildPaypleHeaders() }, ); if (res.data.result !== 'T0000') { @@ -192,11 +201,7 @@ export const verifyRealNameWithPayple = async ( account_holder_info: holderInfo, }, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache', - }, + headers: buildPaypleHeaders({ Authorization: `Bearer ${accessToken}` }), }, ); } catch (err: any) { From c62de52b724b53c693343c2aec97bfa6956d63fe Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 23:10:44 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20verify-account=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=EC=97=90=20Payple=20=EB=B9=8C=EB=A7=81=ED=82=A4(billing=5Ftran?= =?UTF-8?q?=5Fid)=20=EB=B0=9C=EA=B8=89/=EC=A0=80=EC=9E=A5=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(#491=20PR=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #491 작업 분할 PR B. 현재 `verifyRealNameWithPayple`가 Payple 응답에서 account_holder_name만 추출하고 billing_tran_id는 무시 중이었음. 정산 지급대행(`/transfer/execute` 등)에 필수인 빌링키를 발급받아 저장. ### 변경 - utils/payple.ts: - PaypleVerifyResult 타입 신규 (accountHolderName + billingTranId) - verifyRealNameWithPayple 반환에 billing_tran_id 포함 (없으면 null) - utils/register-token.ts: - RegisterTokenPayload에 billingTranId 추가 (verify→register 흐름에서 전달) - services/settlement.account.service.ts: - verify 결과의 billingTranId를 register-token payload에 포함 - services/settlement.seller.service.ts: - registerIndividual / registerBusiness(신규/개인→사업자/사업자→사업자) 모두 payload.billingTranId를 repository에 전달 - repositories/settlement.repository.ts: - Upsert/Create/UpdateBusinessAccountInput에 billingTranId 추가 - SettlementAccount.billing_tran_id 컬럼에 저장 ### 확인 사항 - Payple 정산지급대행 endpoint 명세 확인 결과 /inquiry/real_name이 동일 endpoint - 응답의 billing_tran_id가 향후 /transfer/request, /transfer/execute, /account/remain 등 모든 지급이체 API에서 필요 후속 (PR C/D): - /transfer/request (이체 대기 요청 — endpoint 추정 필요) - /transfer/execute (이체 실행 — host: demohub.payple.kr, /transfer/execute) - /transfer/result, /transfer/result/date, /transfer/result/group_key - /request/result/group_key (이체대기 조회) - /account/remain (잔액 조회) - Webhook 수신 - 정산 사이클 cron (매월 15일 KST, 최소 1만원, 음수 이월) --- .../repositories/settlement.repository.ts | 7 +++++++ .../services/settlement.account.service.ts | 3 ++- .../services/settlement.seller.service.ts | 4 ++++ src/settlements/utils/payple.ts | 13 +++++++++++-- src/settlements/utils/register-token.ts | 1 + 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index 73a5368..da0c90e 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -7,6 +7,7 @@ export interface UpsertIndividualAccountInput { accountNumber: string; holderName: string; birthDate: string; // INDIVIDUAL은 Payple 인증에 birthDate 필수 + billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491) } export interface CreateBusinessAccountInput { @@ -19,6 +20,7 @@ export interface CreateBusinessAccountInput { companyName: string; businessLicenseUrl: string; birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 (대표자 생년월일). CORPORATE는 undefined + billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491) } export interface UpdateBusinessAccountInput { @@ -32,6 +34,7 @@ export interface UpdateBusinessAccountInput { // optional — 빈 값이면 기존 URL 유지 businessLicenseUrl?: string | null; birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 + billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491) } export const SettlementRepository = { @@ -46,6 +49,7 @@ export const SettlementRepository = { account_number: dto.accountNumber, account_holder: dto.holderName, birth_date: dto.birthDate, + billing_tran_id: dto.billingTranId ?? null, seller_type: 'INDIVIDUAL', business_type: null, status: 'APPROVED', @@ -61,6 +65,7 @@ export const SettlementRepository = { account_number: dto.accountNumber, account_holder: dto.holderName, birth_date: dto.birthDate, + billing_tran_id: dto.billingTranId ?? null, seller_type: 'INDIVIDUAL', status: 'APPROVED', is_active: true, @@ -93,6 +98,7 @@ export const SettlementRepository = { representative_name: dto.representativeName, business_license_url: dto.businessLicenseUrl, birth_date: dto.birthDate ?? null, + billing_tran_id: dto.billingTranId ?? null, seller_type: 'BUSINESS', status: 'PENDING', is_active: false, @@ -116,6 +122,7 @@ export const SettlementRepository = { company_name: dto.companyName, representative_name: dto.representativeName, birth_date: dto.birthDate ?? null, + billing_tran_id: dto.billingTranId ?? null, seller_type: 'BUSINESS', status: 'PENDING', is_active: false, diff --git a/src/settlements/services/settlement.account.service.ts b/src/settlements/services/settlement.account.service.ts index 7f0494a..da7b84d 100644 --- a/src/settlements/services/settlement.account.service.ts +++ b/src/settlements/services/settlement.account.service.ts @@ -94,7 +94,7 @@ export const verifySellerAccount = async (userId: number, dto: VerifyAccountRequ await consumePaypleRateLimit(userId); - await verifyRealNameWithPayple({ + const { billingTranId } = await verifyRealNameWithPayple({ userId, sellerType: dto.sellerType, businessType: dto.businessType, @@ -118,6 +118,7 @@ export const verifySellerAccount = async (userId: number, dto: VerifyAccountRequ bank: dto.bank, accountNumber: dto.accountNumber, holderName: dto.holderName, + billingTranId, }); return { diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index 5108eec..f73ce50 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -83,6 +83,7 @@ export const registerIndividualSeller = async ( accountNumber: payload.accountNumber, holderName: payload.holderName, birthDate: payload.birthDate, + billingTranId: payload.billingTranId, }); const isUpdate = !!existingAccount && existingAccount.seller_type === 'INDIVIDUAL'; @@ -156,6 +157,7 @@ export const registerBusinessSeller = async ( companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl!, birthDate: payload.birthDate, + billingTranId: payload.billingTranId, }); return { message: '사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.', @@ -177,6 +179,7 @@ export const registerBusinessSeller = async ( companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl!, birthDate: payload.birthDate, + billingTranId: payload.billingTranId, }); return { message: '사업자 판매자 변경 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.', @@ -196,6 +199,7 @@ export const registerBusinessSeller = async ( companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl, birthDate: payload.birthDate, + billingTranId: payload.billingTranId, }); return { message: '사업자 정보 변경 신청이 완료되었습니다. 관리자 승인 후 변경 사항이 반영됩니다.', diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index 0135edd..0dab45e 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -147,9 +147,14 @@ const toSellerHint = (sellerType: 'INDIVIDUAL' | 'BUSINESS', businessType?: 'PER // Payple 실명-계좌 일치 인증. // 개인 / 개인사업자 → account_holder_info_type='0', account_holder_info=birthDate(YYMMDD) // 법인사업자 → account_holder_info_type='6', account_holder_info=businessNumber(10자리) +export interface PaypleVerifyResult { + accountHolderName: string; + billingTranId: string | null; +} + export const verifyRealNameWithPayple = async ( params: PaypleVerifyParams, -): Promise<{ accountHolderName: string }> => { +): Promise => { const { userId, sellerType, businessType, bank, accountNumber, holderName, birthDate, businessNumber } = params; if (!isValidPaypleBank(bank)) { @@ -230,7 +235,11 @@ export const verifyRealNameWithPayple = async ( ); } - return { accountHolderName: res.data.account_holder_name as string }; + // 정산지급대행 빌링키(billing_tran_id) — 향후 지급이체 호출에 필요. SettlementAccount에 저장 (#491). + return { + accountHolderName: res.data.account_holder_name as string, + billingTranId: (res.data.billing_tran_id as string | undefined) ?? null, + }; }; // Payple 실명인증 응답을 사용자 모달 8종 + 신규 3종(INVALID_BIRTHDATE / INVALID_BUSINESS_NUMBER / SYSTEM_ERROR)으로 매핑. diff --git a/src/settlements/utils/register-token.ts b/src/settlements/utils/register-token.ts index 9af0511..3098ef5 100644 --- a/src/settlements/utils/register-token.ts +++ b/src/settlements/utils/register-token.ts @@ -24,6 +24,7 @@ export interface RegisterTokenPayload { bank: string; accountNumber: string; holderName: string; + billingTranId?: string | null; // Payple 정산지급대행 빌링키 (#491). 발급 안 됐을 수 있음 jti: string; } From 799608f02155998eac4b39a94822db8a7e6b48f8 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 23:18:19 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20Payple=20=EC=A0=95=EC=82=B0?= =?UTF-8?q?=EC=A7=80=EA=B8=89=EB=8C=80=ED=96=89=20=EC=9C=A0=ED=8B=B8=20(?= =?UTF-8?q?=EC=9D=B4=EC=B2=B4=20=EB=8C=80=EA=B8=B0/=EC=8B=A4=ED=96=89/?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EC=9E=94=EC=95=A1)=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?#491=20PR=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #491 작업 분할 PR C. 공식 통합 가이드(docs.payple.kr/integration/hub/payout)에서 확보한 endpoint를 utils/payple-payout.ts에 8개 함수로 묶음. 본 PR은 내부 유틸만 추가, 외부 endpoint 노출 / cron은 PR D에서. ### 신규 utils/payple-payout.ts 공통: postWithToken 헬퍼 — OAuth + buildPaypleHeaders + redactPaypleLog 일관 적용, result !== 'A0000'이면 PaypleHubFailed 502. 8개 export 함수: - requestPayoutStandby({ billingTranId, tranAmt, ... }) → group_key 발급 POST /transfer/request - executePayout({ groupKey, billingTranId, executeType: 'NOW'|'CANCEL', webhookUrl }) POST /transfer/execute - inquireStandbyByGroup(groupKey) — POST /request/result/group_key - inquireResultByCase(apiTranId) — POST /transfer/result - inquireResultByDay(bankTranDate yyyyMMdd) — POST /transfer/result/date - inquireResultByGroup(groupKey) — POST /transfer/result/group_key - fetchRemainingBalance() — POST /account/remain ### utils/payple.ts - fetchPaypleAccessToken export (정산지급대행 호출에서 재사용) ### 환경변수 기존 PAYPLE_HUB_URL (demohub.payple.kr → live에선 hub.payple.kr로 변경) / PAYPLE_CST_ID / PAYPLE_CUST_KEY 그대로 사용. 추가 env 없음. 다음 (PR D): - 정산 사이클 cron — 매월 15일 KST 09:00 - 최소 1만원 충족 시 빌링키별 requestPayoutStandby → executePayout 자동화 - 환수 차감 (Refunded 합계 - 다음 사이클 차감) - 음수 잔액 이월 처리 - Webhook 수신 (POST /api/payouts/webhook, 멱등성 + 인증) --- src/settlements/utils/payple-payout.ts | 197 +++++++++++++++++++++++++ src/settlements/utils/payple.ts | 2 +- 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/settlements/utils/payple-payout.ts diff --git a/src/settlements/utils/payple-payout.ts b/src/settlements/utils/payple-payout.ts new file mode 100644 index 0000000..12ea418 --- /dev/null +++ b/src/settlements/utils/payple-payout.ts @@ -0,0 +1,197 @@ +import axios from 'axios'; +import { AppError } from '../../errors/AppError'; +import { + fetchPaypleAccessToken, + buildPaypleHeaders, + redactPaypleLog, +} from './payple'; + +// Payple 정산지급대행(Hub Payout) API 클라이언트 (#491 PR C). +// +// Endpoint (https://docs.payple.kr/integration/hub/payout): +// POST {HUB}/oauth/token — 파트너 인증 (60초 토큰) +// POST {HUB}/inquiry/real_name — 계좌인증 (billing_tran_id 발급, PR B에서 사용) +// POST {HUB}/transfer/request — 이체 대기 요청 (group_key 발급) +// POST {HUB}/transfer/execute — 이체 실행 (NOW) / 대기 취소 (CANCEL) +// POST {HUB}/request/result/group_key — 이체대기 조회 +// POST {HUB}/transfer/result — 건별 결과 조회 +// POST {HUB}/transfer/result/date — 일별 결과 조회 +// POST {HUB}/transfer/result/group_key— 그룹별 결과 조회 +// POST {HUB}/account/remain — 잔액 조회 +// +// HUB = demohub.payple.kr (test) / hub.payple.kr (live) — PAYPLE_HUB_URL env로 분리 +// +// 본 PR은 유틸만 추가. 외부 endpoint 노출/cron은 PR D에서. + +const getHubBaseUrl = (): string => { + const url = process.env.PAYPLE_HUB_URL; + if (!url) throw new AppError('PAYPLE_HUB_URL 환경변수가 설정되지 않았습니다.', 500, 'ConfigError'); + return url; +}; + +const loadCredentials = (): { cstId: string; custKey: string } => { + const cstId = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + if (!cstId || !custKey) { + throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError'); + } + return { cstId, custKey }; +}; + +const postWithToken = async ( + path: string, + body: Record, + context: string, +): Promise => { + const accessToken = await fetchPaypleAccessToken(); + const url = `${getHubBaseUrl()}${path}`; + try { + const res = await axios.post(url, body, { + headers: buildPaypleHeaders({ Authorization: `Bearer ${accessToken}` }), + }); + if (res.data?.result && res.data.result !== 'A0000') { + console.error(`[payple-payout] ${context} rejected`, { + code: res.data.result, + response: redactPaypleLog(res.data), + }); + throw new AppError( + `Payple ${context} 호출에 실패했습니다. (${res.data.result})`, + 502, + 'PaypleHubFailed', + ); + } + return res.data as T; + } catch (err: any) { + if (err instanceof AppError) throw err; + console.error(`[payple-payout] ${context} network error`, { + code: err?.response?.data?.result, + response: redactPaypleLog(err?.response?.data), + }); + throw new AppError( + `Payple ${context} 호출 중 통신 오류가 발생했습니다.`, + 502, + 'PaypleHubFailed', + ); + } +}; + +// =============== 이체 대기 요청 (POST /transfer/request) =============== +export interface PayoutStandbyParams { + billingTranId: string; + tranAmt: number; // sandbox는 1000 고정 + subId?: string; + distinctKey?: string; // 중복 방지 키 (미입력 시 Payple 자동 발급) + printContent?: string; // 입금 인자 (최대 6자) +} + +export interface PayoutStandbyResult { + cstId: string; + subId?: string; + distinctKey: string; + groupKey: string; + billingTranId: string; + tranAmt: string; + remainAmt: string; + printContent?: string; + apiTranDtm: string; +} + +export const requestPayoutStandby = async (params: PayoutStandbyParams): Promise => { + const { cstId, custKey } = loadCredentials(); + const body: Record = { + cst_id: cstId, + custKey, + billing_tran_id: params.billingTranId, + tran_amt: String(params.tranAmt), + }; + if (params.subId) body.sub_id = params.subId; + if (params.distinctKey) body.distinct_key = params.distinctKey; + if (params.printContent) body.print_content = params.printContent; + + const data = await postWithToken>('/transfer/request', body, '이체대기요청'); + return { + cstId: data.cst_id, + subId: data.sub_id, + distinctKey: data.distinct_key, + groupKey: data.group_key, + billingTranId: data.billing_tran_id, + tranAmt: data.tran_amt, + remainAmt: data.remain_amt, + printContent: data.print_content, + apiTranDtm: data.api_tran_dtm, + }; +}; + +// =============== 이체 실행 (POST /transfer/execute) — NOW / CANCEL =============== +export type ExecuteType = 'NOW' | 'CANCEL'; + +export interface PayoutExecuteParams { + groupKey: string; + billingTranId: string; // 'ALL' 또는 특정 빌링키 + executeType: ExecuteType; + webhookUrl?: string; // 테스트 환경에서만 필수 +} + +export interface PayoutExecuteResult { + cstId: string; + groupKey: string; + billingTranId: string; + totTranAmt: string; + remainAmt: string; + executeType: ExecuteType; + apiTranDtm: string; +} + +export const executePayout = async (params: PayoutExecuteParams): Promise => { + const { cstId, custKey } = loadCredentials(); + const body: Record = { + cst_id: cstId, + custKey, + group_key: params.groupKey, + billing_tran_id: params.billingTranId, + execute_type: params.executeType, + }; + if (params.webhookUrl) body.webhook_url = params.webhookUrl; + + const data = await postWithToken>('/transfer/execute', body, '이체실행'); + return { + cstId: data.cst_id, + groupKey: data.group_key, + billingTranId: data.billing_tran_id, + totTranAmt: data.tot_tran_amt, + remainAmt: data.remain_amt, + executeType: data.execute_type as ExecuteType, + apiTranDtm: data.api_tran_dtm, + }; +}; + +// =============== 이체대기 조회 (POST /request/result/group_key) =============== +export const inquireStandbyByGroup = async (groupKey: string): Promise> => { + const { cstId, custKey } = loadCredentials(); + return postWithToken('/request/result/group_key', { cst_id: cstId, custKey, group_key: groupKey }, '이체대기조회'); +}; + +// =============== 건별 결과 조회 (POST /transfer/result) =============== +export const inquireResultByCase = async (apiTranId: string): Promise> => { + const { cstId, custKey } = loadCredentials(); + return postWithToken('/transfer/result', { cst_id: cstId, custKey, api_tran_id: apiTranId }, '건별결과조회'); +}; + +// =============== 일별 결과 조회 (POST /transfer/result/date) =============== +// bankTranDate: YYYYMMDD +export const inquireResultByDay = async (bankTranDate: string): Promise> => { + const { cstId, custKey } = loadCredentials(); + return postWithToken('/transfer/result/date', { cst_id: cstId, custKey, bank_tran_date: bankTranDate }, '일별결과조회'); +}; + +// =============== 그룹별 결과 조회 (POST /transfer/result/group_key) =============== +export const inquireResultByGroup = async (groupKey: string): Promise> => { + const { cstId, custKey } = loadCredentials(); + return postWithToken('/transfer/result/group_key', { cst_id: cstId, custKey, group_key: groupKey }, '그룹별결과조회'); +}; + +// =============== 잔액 조회 (POST /account/remain) =============== +export const fetchRemainingBalance = async (): Promise> => { + const { cstId, custKey } = loadCredentials(); + return postWithToken('/account/remain', { cst_id: cstId, custKey }, '잔액조회'); +}; diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index 0dab45e..ea25313 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -100,7 +100,7 @@ export const consumePaypleRateLimit = async (userId: number): Promise => { }; // Payple OAuth 토큰 발급 -const fetchPaypleAccessToken = async (): Promise => { +export const fetchPaypleAccessToken = async (): Promise => { const PAYPLE_HUB_URL = process.env.PAYPLE_HUB_URL; const cst_id = process.env.PAYPLE_CST_ID; const custKey = process.env.PAYPLE_CUST_KEY; From 050d7db64e861e0314909d5d8c57ebea2ea5aff4 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 23:27:44 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(db):=20SettlementPayout=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20+=20PayoutStatus=20enum=20=EC=B6=94=EA=B0=80=20(#49?= =?UTF-8?q?1=20PR=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정산 사이클 cron이 생성할 이체 이력 모델. 매월 15일 KST 사이클별로 판매자별 1 row 생성. 마이그레이션 dev DB 적용 완료. ### 모델 SettlementPayout: - user_id + cycle_start 복합 unique (한 사이클에 한 row만) - cycle_start/end: 이번 사이클 범위 (예: 직전 15일 0시 ~ 이번 15일 0시 직전) - amount_gross: Succeed Settlement 합계 - amount_refund: Refunded Settlement 합계 - carry_over_prev: 이전 사이클 음수 이월액 (절댓값) - amount_net: 최종 지급액 = gross - refund - carry_over_prev (음수 가능) - billing_tran_id: 시점 스냅샷 (SettlementAccount에서 복사) - group_key, api_tran_id: Payple 응답 추적용 - status (PayoutStatus): Pending/Succeed/Failed/Skipped - reason: Skipped/Failed 사유 ### enum 신규 PayoutStatus { Pending, Succeed, Failed, Skipped } - 기존 Status enum과 분리 (Skipped는 정산 전용 의미) ### User 관계 - payouts SettlementPayout[] --- .../migration.sql | 27 ++++++++++++++ prisma/schema.prisma | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 prisma/migrations/20260524100000_add_settlement_payout/migration.sql diff --git a/prisma/migrations/20260524100000_add_settlement_payout/migration.sql b/prisma/migrations/20260524100000_add_settlement_payout/migration.sql new file mode 100644 index 0000000..bdd96f9 --- /dev/null +++ b/prisma/migrations/20260524100000_add_settlement_payout/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable: SettlementPayout (#491 PR D) +CREATE TABLE `SettlementPayout` ( + `payout_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `cycle_start` DATETIME(3) NOT NULL, + `cycle_end` DATETIME(3) NOT NULL, + `amount_gross` INTEGER NOT NULL DEFAULT 0, + `amount_refund` INTEGER NOT NULL DEFAULT 0, + `carry_over_prev` INTEGER NOT NULL DEFAULT 0, + `amount_net` INTEGER NOT NULL, + `billing_tran_id` VARCHAR(64) NULL, + `group_key` VARCHAR(64) NULL, + `api_tran_id` VARCHAR(64) NULL, + `status` ENUM('Pending','Succeed','Failed','Skipped') NOT NULL DEFAULT 'Pending', + `reason` VARCHAR(200) NULL, + `requested_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `completed_at` DATETIME(3) NULL, + + UNIQUE INDEX `SettlementPayout_user_id_cycle_start_key`(`user_id`, `cycle_start`), + INDEX `SettlementPayout_status_idx`(`status`), + INDEX `SettlementPayout_cycle_start_idx`(`cycle_start`), + PRIMARY KEY (`payout_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `SettlementPayout` ADD CONSTRAINT `SettlementPayout_user_id_fkey` + FOREIGN KEY (`user_id`) REFERENCES `User`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d5cea7..8816afe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,7 @@ model User { settlementAccount SettlementAccount? consents UserConsent[] refunds Refund[] + payouts SettlementPayout[] chatRoomAs1 ChatRoom[] @relation("ChatUser1") chatRoomAs2 ChatRoom[] @relation("ChatUser2") sentChatMessages ChatMessage[] @relation("UserSentMessages") @@ -519,6 +520,40 @@ model SettlementAccount { @@index([status]) } +// 정산 사이클별 지급이체 이력 (#491 PR D). +// 매월 15일 cron이 판매자별 net 금액(Succeed 합계 - Refunded 차감 - 이월)을 계산해 row 생성. +// 최소 금액 미달 시 Skipped, Payple 이체 성공 시 Succeed, 실패 시 Failed. +enum PayoutStatus { + Pending + Succeed + Failed + Skipped // 최소 금액 미달 또는 빌링키 미보유 등으로 자동 skip +} + +model SettlementPayout { + payout_id Int @id @default(autoincrement()) + user_id Int + cycle_start DateTime + cycle_end DateTime + amount_gross Int @default(0) // Succeed Settlement 합계 (사이클 범위 내) + amount_refund Int @default(0) // Refunded Settlement 합계 + carry_over_prev Int @default(0) // 이전 사이클에서 이월된 음수 잔액 (절댓값) + amount_net Int // gross - refund - carry_over_prev (최종 지급액, 음수 가능) + billing_tran_id String? @db.VarChar(64) // 시점 빌링키 (SettlementAccount에서 스냅샷) + group_key String? @db.VarChar(64) // Payple 이체대기 요청 응답 + api_tran_id String? @db.VarChar(64) // Payple webhook의 이체 완료 키 + status PayoutStatus @default(Pending) + reason String? @db.VarChar(200) // Skipped/Failed 사유 + requested_at DateTime @default(now()) + completed_at DateTime? + + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + + @@unique([user_id, cycle_start]) // 한 사이클에 한 row만 + @@index([status]) + @@index([cycle_start]) +} + model Review { review_id Int @id @default(autoincrement()) rating Float From 83cbbd6413a1585f195f96ccab8f3544d13d3f5d Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 23:29:55 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=81=B4=20cron=20+=20=EC=9E=90=EB=8F=99=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=EC=9D=B4=EC=B2=B4=20=ED=9D=90=EB=A6=84=20(#491=20PR?= =?UTF-8?q?=20D=20-=202/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매월 15일 KST 09:00 cron으로 활성 판매자들의 net 금액을 산출해 Payple 빌링키로 자동 이체. Webhook 수신은 다음 커밋. ### settlement-payout.repository.ts (신규) - findEligibleSellers — 승인 + is_active + 빌링키 보유 판매자 - getCarryOverFromLastPayout — 직전 사이클 net 음수 이월액(절댓값) - aggregateUnsettledForCycle — raw SQL로 Succeed/Refunded 합계 - createPendingPayout — user_id+cycle_start 멱등 upsert - updatePaypleGroupKey / markFailed / markSucceeded - findPayoutByGroupKey — webhook 매칭용 ### settlement-payout.service.ts (신규) runPayoutCycle(): - Redis 분산 락 (CYCLE_LOCK_TTL 30분) - 사이클 범위 계산 (직전 15일 KST 00:00 ~ 이번 15일 KST 00:00) - 각 판매자별 processOneSeller: - net = gross - refund - carry_over_prev - 빌링키 미보유 → Skipped + 사유 - net < 10,000원 → Skipped + 이월 (음수면 다음 사이클 차감) - 정상 → requestPayoutStandby → group_key 저장 → executePayout(NOW) - Payple 호출 실패 시 markFailed - 결과 카운트 로그 (paid/skipped_min/skipped_no_key/failed) ### settlement-payout.job.ts (신규) - cron '0 9 15 * *', Asia/Seoul - src/index.ts에 startSettlementPayoutJob() 부트스트랩 ### 환경변수 (신규) - PAYPLE_PAYOUT_CYCLE_ENABLED ('false'로 비활성화) - PAYPLE_PAYOUT_WEBHOOK_URL (executePayout 요청에 첨부) --- src/index.ts | 2 + src/settlements/jobs/settlement-payout.job.ts | 26 +++ .../settlement-payout.repository.ts | 113 +++++++++++ .../services/settlement-payout.service.ts | 182 ++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 src/settlements/jobs/settlement-payout.job.ts create mode 100644 src/settlements/repositories/settlement-payout.repository.ts create mode 100644 src/settlements/services/settlement-payout.service.ts diff --git a/src/index.ts b/src/index.ts index 8db0119..ae713c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import chatRouter from "./chat/routes/chat.route"; import { initSocket } from "./socket/server"; import { startPromptStatSnapshotJob } from "./stats/jobs/prompt-stat-snapshot.job"; import { startSettlementSyncJob } from "./settlements/jobs/settlement-sync.job"; +import { startSettlementPayoutJob } from "./settlements/jobs/settlement-payout.job"; import morgan = require('morgan'); const PORT = 3000; const app = express(); @@ -50,6 +51,7 @@ const server = http.createServer(app); initSocket(server) startPromptStatSnapshotJob(); startSettlementSyncJob(); +startSettlementPayoutJob(); // 1. 응답 핸들러(json 파서보다 위에) app.use(responseHandler); app.use((req, res, next) => { diff --git a/src/settlements/jobs/settlement-payout.job.ts b/src/settlements/jobs/settlement-payout.job.ts new file mode 100644 index 0000000..840dc7e --- /dev/null +++ b/src/settlements/jobs/settlement-payout.job.ts @@ -0,0 +1,26 @@ +import cron from 'node-cron'; +import { runPayoutCycle } from '../services/settlement-payout.service'; + +// 매월 15일 KST 09:00 — 정산 사이클 (#491 PR D). +// 안내 사항: "정산일은 매월 15일이며, 이전 한 달 동안의 수익이 정산됩니다." +const CRON_EXPRESSION = '0 9 15 * *'; +const TIMEZONE = 'Asia/Seoul'; + +export const startSettlementPayoutJob = (): void => { + cron.schedule( + CRON_EXPRESSION, + async () => { + try { + await runPayoutCycle(); + } catch (error) { + console.error('[payout-cron] scheduled run failed', error); + } + }, + { timezone: TIMEZONE }, + ); + + console.log('[payout-cron] scheduled', { + cron: CRON_EXPRESSION, + timezone: TIMEZONE, + }); +}; diff --git a/src/settlements/repositories/settlement-payout.repository.ts b/src/settlements/repositories/settlement-payout.repository.ts new file mode 100644 index 0000000..5dc8e94 --- /dev/null +++ b/src/settlements/repositories/settlement-payout.repository.ts @@ -0,0 +1,113 @@ +import prisma from '../../config/prisma'; +import { PayoutStatus, Status } from '@prisma/client'; + +export const SettlementPayoutRepository = { + // 활성 판매자(승인 + is_active + 빌링키 보유) 목록. + async findEligibleSellers() { + return prisma.settlementAccount.findMany({ + where: { + status: 'APPROVED', + is_active: true, + billing_tran_id: { not: null }, + }, + select: { + user_id: true, + billing_tran_id: true, + }, + }); + }, + + // 직전 사이클 payout의 net 음수 이월액(절댓값). 없으면 0. + async getCarryOverFromLastPayout(userId: number): Promise { + const last = await prisma.settlementPayout.findFirst({ + where: { user_id: userId }, + orderBy: { cycle_start: 'desc' }, + select: { amount_net: true }, + }); + if (!last) return 0; + return last.amount_net < 0 ? Math.abs(last.amount_net) : 0; + }, + + // 사이클 범위 내 gross(Succeed) / refund(Refunded) 집계. + async aggregateUnsettledForCycle( + userId: number, + cycleStart: Date, + cycleEnd: Date, + ): Promise<{ gross: number; refund: number }> { + const rows = await prisma.$queryRaw< + Array<{ gross: bigint | null; refund: bigint | null }> + >` + SELECT + SUM(CASE WHEN status = ${Status.Succeed} THEN amount ELSE 0 END) AS gross, + SUM(CASE WHEN status = ${Status.Refunded} THEN amount ELSE 0 END) AS refund + FROM Settlement + WHERE user_id = ${userId} + AND updated_at >= ${cycleStart} + AND updated_at < ${cycleEnd} + `; + const r = rows[0] ?? {}; + return { + gross: Number(r.gross ?? 0), + refund: Number(r.refund ?? 0), + }; + }, + + // 멱등성: 같은 user_id + cycle_start로 한 번만 생성. + async createPendingPayout(input: { + userId: number; + cycleStart: Date; + cycleEnd: Date; + amountGross: number; + amountRefund: number; + carryOverPrev: number; + amountNet: number; + billingTranId: string | null; + status: PayoutStatus; + reason?: string | null; + }) { + return prisma.settlementPayout.upsert({ + where: { user_id_cycle_start: { user_id: input.userId, cycle_start: input.cycleStart } }, + update: {}, // 이미 있으면 그대로 — 멱등성 + create: { + user_id: input.userId, + cycle_start: input.cycleStart, + cycle_end: input.cycleEnd, + amount_gross: input.amountGross, + amount_refund: input.amountRefund, + carry_over_prev: input.carryOverPrev, + amount_net: input.amountNet, + billing_tran_id: input.billingTranId, + status: input.status, + reason: input.reason ?? null, + }, + }); + }, + + async updatePaypleGroupKey(payoutId: number, groupKey: string) { + return prisma.settlementPayout.update({ + where: { payout_id: payoutId }, + data: { group_key: groupKey }, + }); + }, + + async markFailed(payoutId: number, reason: string) { + return prisma.settlementPayout.update({ + where: { payout_id: payoutId }, + data: { status: 'Failed', reason, completed_at: new Date() }, + }); + }, + + // Webhook 수신용 — group_key 또는 billing_tran_id로 매칭. + async findPayoutByGroupKey(groupKey: string) { + return prisma.settlementPayout.findFirst({ + where: { group_key: groupKey, status: 'Pending' }, + }); + }, + + async markSucceeded(payoutId: number, apiTranId: string) { + return prisma.settlementPayout.update({ + where: { payout_id: payoutId }, + data: { status: 'Succeed', api_tran_id: apiTranId, completed_at: new Date() }, + }); + }, +}; diff --git a/src/settlements/services/settlement-payout.service.ts b/src/settlements/services/settlement-payout.service.ts new file mode 100644 index 0000000..fbafceb --- /dev/null +++ b/src/settlements/services/settlement-payout.service.ts @@ -0,0 +1,182 @@ +import redisClient from '../../config/redis'; +import { SettlementPayoutRepository } from '../repositories/settlement-payout.repository'; +import { requestPayoutStandby, executePayout } from '../utils/payple-payout'; + +// 정산 사이클 cron — 매월 15일 KST 09:00. +// 정책 (#491): +// - 사이클 범위: 직전 15일 KST 00:00 ~ 이번 15일 KST 00:00 (직전 한 달) +// - 최소 지급액: 10,000원 미만이면 Skipped + 이월 +// - 잔액 음수 시: 다음 사이클로 이월 (carry_over_prev로 차감) +// - 빌링키 미보유: Skipped + 이월 (재인증 안내는 별도) +// - 분산 락: Redis SET NX EX 1800 +// +// Webhook 수신 시 결과를 Succeed/Failed로 마감 (별도 controller). + +const CYCLE_LOCK_KEY = 'payple:payout:cycle:lock'; +const CYCLE_LOCK_TTL_SECONDS = 30 * 60; +const MIN_PAYOUT_AMOUNT = 10_000; + +// 매월 15일 KST 00:00 계산 +const getCycleBoundaries = (now: Date = new Date()): { cycleStart: Date; cycleEnd: Date } => { + const kstOffsetMs = 9 * 60 * 60 * 1000; + const nowKst = new Date(now.getTime() + kstOffsetMs); + const year = nowKst.getUTCFullYear(); + const month = nowKst.getUTCMonth(); // 0-indexed + // 이번 15일 KST 00:00 (UTC로는 14일 15:00) + const cycleEndUtc = new Date(Date.UTC(year, month, 15) - kstOffsetMs); + // 직전 15일 KST 00:00 + const cycleStartUtc = new Date(Date.UTC(year, month - 1, 15) - kstOffsetMs); + return { cycleStart: cycleStartUtc, cycleEnd: cycleEndUtc }; +}; + +interface CycleCounters { + total_sellers: number; + paid: number; + skipped_min: number; + skipped_no_key: number; + failed: number; +} + +const processOneSeller = async ( + seller: { user_id: number; billing_tran_id: string | null }, + cycle: { cycleStart: Date; cycleEnd: Date }, + counters: CycleCounters, +): Promise => { + const { gross, refund } = await SettlementPayoutRepository.aggregateUnsettledForCycle( + seller.user_id, + cycle.cycleStart, + cycle.cycleEnd, + ); + const carryOverPrev = await SettlementPayoutRepository.getCarryOverFromLastPayout(seller.user_id); + const amountNet = gross - refund - carryOverPrev; + + // 빌링키 미보유 + if (!seller.billing_tran_id) { + await SettlementPayoutRepository.createPendingPayout({ + userId: seller.user_id, + cycleStart: cycle.cycleStart, + cycleEnd: cycle.cycleEnd, + amountGross: gross, + amountRefund: refund, + carryOverPrev, + amountNet, + billingTranId: null, + status: 'Skipped', + reason: '빌링키 미보유 — 계좌 재인증 필요', + }); + counters.skipped_no_key += 1; + return; + } + + // 최소 금액 미달 (음수도 포함) + if (amountNet < MIN_PAYOUT_AMOUNT) { + await SettlementPayoutRepository.createPendingPayout({ + userId: seller.user_id, + cycleStart: cycle.cycleStart, + cycleEnd: cycle.cycleEnd, + amountGross: gross, + amountRefund: refund, + carryOverPrev, + amountNet, + billingTranId: seller.billing_tran_id, + status: 'Skipped', + reason: amountNet < 0 + ? `음수 잔액 이월 (${amountNet}원)` + : `최소 지급액(${MIN_PAYOUT_AMOUNT}원) 미달`, + }); + counters.skipped_min += 1; + return; + } + + // Payple 이체 대기 요청 + 즉시 실행 + const payout = await SettlementPayoutRepository.createPendingPayout({ + userId: seller.user_id, + cycleStart: cycle.cycleStart, + cycleEnd: cycle.cycleEnd, + amountGross: gross, + amountRefund: refund, + carryOverPrev, + amountNet, + billingTranId: seller.billing_tran_id, + status: 'Pending', + }); + + try { + const standby = await requestPayoutStandby({ + billingTranId: seller.billing_tran_id, + tranAmt: amountNet, + subId: `user_${seller.user_id}`, + distinctKey: `payout-${payout.payout_id}`, + }); + await SettlementPayoutRepository.updatePaypleGroupKey(payout.payout_id, standby.groupKey); + + await executePayout({ + groupKey: standby.groupKey, + billingTranId: seller.billing_tran_id, + executeType: 'NOW', + webhookUrl: process.env.PAYPLE_PAYOUT_WEBHOOK_URL, + }); + // 결과는 Webhook이 수신해서 Succeed/Failed로 마감 + counters.paid += 1; + } catch (err: any) { + console.error('[payout-cycle] payple call failed', { + userId: seller.user_id, + payoutId: payout.payout_id, + error: err?.message, + }); + await SettlementPayoutRepository.markFailed(payout.payout_id, err?.message ?? 'Payple 호출 실패'); + counters.failed += 1; + } +}; + +export const runPayoutCycle = async (): Promise => { + if (process.env.PAYPLE_PAYOUT_CYCLE_ENABLED === 'false') { + console.log('[payout-cycle] disabled by env'); + return; + } + + const lockAcquired = await redisClient.set(CYCLE_LOCK_KEY, '1', { + NX: true, + EX: CYCLE_LOCK_TTL_SECONDS, + }); + if (lockAcquired !== 'OK') { + console.warn('[payout-cycle] lock held by another instance — skip this run'); + return; + } + + const startedAt = Date.now(); + try { + const cycle = getCycleBoundaries(); + const sellers = await SettlementPayoutRepository.findEligibleSellers(); + const counters: CycleCounters = { + total_sellers: sellers.length, + paid: 0, + skipped_min: 0, + skipped_no_key: 0, + failed: 0, + }; + + for (const seller of sellers) { + try { + await processOneSeller(seller, cycle, counters); + } catch (err: any) { + counters.failed += 1; + console.error('[payout-cycle] seller processing failed', { + userId: seller.user_id, + error: err?.message, + }); + } + } + + console.log('[payout-cycle] completed', { + cycle_start: cycle.cycleStart.toISOString(), + cycle_end: cycle.cycleEnd.toISOString(), + ...counters, + elapsedMs: Date.now() - startedAt, + }); + } catch (err: any) { + console.error('[payout-cycle] cycle failed', { error: err?.message }); + } finally { + await redisClient.del(CYCLE_LOCK_KEY); + } +}; From e6457c7cc3943127935b6e2ef8db22a2de3688ee Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 23:30:47 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20Payple=20=EC=A0=95=EC=82=B0?= =?UTF-8?q?=EC=A7=80=EA=B8=89=EB=8C=80=ED=96=89=20webhook=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=20(#491=20PR=20D=20-=203/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executePayout(NOW) 요청 시 첨부한 webhook_url로 Payple이 이체 결과를 전송. group_key 매칭으로 SettlementPayout을 Succeed/Failed로 마감. ### 신규 - POST /api/payouts/webhook - handlePayoutWebhook 컨트롤러: - group_key로 findPayoutByGroupKey 매칭 - result='A0000' → markSucceeded(api_tran_id, completed_at=now) - result≠A0000 → markFailed(reason) - 멱등: 이미 Pending이 아닌 row는 200 OK만 (Payple 재전송 차단) - 매칭 없는 group_key는 200 OK (false-positive 알림 방지) - redactor로 로그 마스킹 ### Swagger 명세 - /api/payouts/webhook 요청/응답 schema - 운영 전 IP allowlist/shared-secret 인증 추가 권고 명시 ### 운영 안전성 - Payple 명세에 인증 방식 미명시 → sandbox 검증 후 별도 보안 강화 권장 --- src/index.ts | 2 + .../controllers/payout-webhook.controller.ts | 43 ++++++++++++++++ .../routes/payout-webhook.route.ts | 41 +++++++++++++++ swagger.json | 51 +++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/settlements/controllers/payout-webhook.controller.ts create mode 100644 src/settlements/routes/payout-webhook.route.ts diff --git a/src/index.ts b/src/index.ts index ae713c8..6b8ba55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import promptRoutes from "./prompts/routes/prompt.route"; // 프롬프트 관련 import ReviewRouter from "./reviews/routes/review.route"; import purchaseRouter from "./purchases/routes/purchase.route"; import refundRouter from "./refunds/routes/refund.route"; +import payoutWebhookRouter from "./settlements/routes/payout-webhook.route"; import purchaseWebhookRouter from "./purchases/routes/purchase.webhook.route"; import settlementRouter from "./settlements/routes/settlement.route"; import withdrawalRouter from "./withdrawals/routes/withdrawal.route"; @@ -179,6 +180,7 @@ app.use("/api/tips", tipRouter); // 정산 라우터 app.use("/api/settlements", settlementRouter); app.use("/api/settlements", withdrawalRouter); +app.use("/api/payouts", payoutWebhookRouter); //공지사항 라우터 app.use("/api/announcements", announcementRouter); diff --git a/src/settlements/controllers/payout-webhook.controller.ts b/src/settlements/controllers/payout-webhook.controller.ts new file mode 100644 index 0000000..0f8a461 --- /dev/null +++ b/src/settlements/controllers/payout-webhook.controller.ts @@ -0,0 +1,43 @@ +import { Request, Response } from 'express'; +import { SettlementPayoutRepository } from '../repositories/settlement-payout.repository'; +import { redactPaypleLog } from '../utils/payple'; + +// Payple 정산지급대행 이체 결과 webhook 수신 (#491 PR D). +// 명세상 인증 방식 미명시 — 운영 전 IP allowlist 또는 shared-secret 별도 적용 권장. +// 본 컨트롤러는 group_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감. +// 멱등성: 이미 Pending이 아니면 200으로 응답하고 변경 안 함. + +export const handlePayoutWebhook = async (req: Request, res: Response) => { + const body = req.body ?? {}; + const result = body.result as string | undefined; + const groupKey = body.group_key as string | undefined; + const apiTranId = body.api_tran_id as string | undefined; + const message = body.message as string | undefined; + + if (!groupKey) { + console.error('[payout-webhook] missing group_key', { body: redactPaypleLog(body) }); + return res.status(400).json({ error: 'BadRequest', message: 'group_key 누락', statusCode: 400 }); + } + + const payout = await SettlementPayoutRepository.findPayoutByGroupKey(groupKey); + if (!payout) { + // 멱등 — 이미 마감됐거나 매칭 없음. 200으로 응답해 Payple 재전송 방지. + console.warn('[payout-webhook] no Pending payout for group_key', { groupKey }); + return res.status(200).json({ ok: true }); + } + + try { + if (result === 'A0000') { + await SettlementPayoutRepository.markSucceeded(payout.payout_id, apiTranId ?? ''); + console.log('[payout-webhook] payout succeeded', { payoutId: payout.payout_id, apiTranId }); + } else { + const reason = `Payple webhook ${result ?? 'UNKNOWN'} - ${message ?? ''}`; + await SettlementPayoutRepository.markFailed(payout.payout_id, reason); + console.error('[payout-webhook] payout failed', { payoutId: payout.payout_id, reason }); + } + return res.status(200).json({ ok: true }); + } catch (err: any) { + console.error('[payout-webhook] update failed', { error: err?.message }); + return res.status(500).json({ error: 'InternalServerError', statusCode: 500 }); + } +}; diff --git a/src/settlements/routes/payout-webhook.route.ts b/src/settlements/routes/payout-webhook.route.ts new file mode 100644 index 0000000..b36b6ab --- /dev/null +++ b/src/settlements/routes/payout-webhook.route.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { handlePayoutWebhook } from '../controllers/payout-webhook.controller'; + +const router = Router(); + +/** + * @swagger + * /api/payouts/webhook: + * post: + * summary: Payple 정산지급대행 이체 결과 webhook 수신 + * description: | + * executePayout(NOW) 요청 시 첨부한 webhook_url로 Payple이 이체 결과를 전송. + * group_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감. + * 멱등: 이미 Pending이 아니면 200 OK 응답만 (재전송 방지). + * + * 운영 전 IP allowlist 또는 shared-secret 인증 별도 적용 권장 (Payple 명세 미명시). + * tags: [Settlement] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * result: { type: string, description: A0000 성공 / 그 외 실패 } + * message: { type: string } + * group_key: { type: string } + * billing_tran_id: { type: string } + * api_tran_id: { type: string } + * tran_amt: { type: string } + * responses: + * 200: + * description: 멱등 처리 완료 + * 400: + * description: group_key 누락 + * 500: + * description: 서버 오류 + */ +router.post('/webhook', handlePayoutWebhook); + +export default router; diff --git a/swagger.json b/swagger.json index 1241957..17b1776 100644 --- a/swagger.json +++ b/swagger.json @@ -7101,6 +7101,57 @@ } } }, + "/api/payouts/webhook": { + "post": { + "summary": "Payple 정산지급대행 이체 결과 webhook 수신", + "description": "executePayout(NOW) 요청 시 첨부한 webhook_url로 Payple이 이체 결과를 전송.\ngroup_key로 SettlementPayout을 찾아 status를 Succeed/Failed로 마감.\n멱등: 이미 Pending이 아니면 200 OK 응답만 (재전송 방지).\n\n운영 전 IP allowlist 또는 shared-secret 인증 별도 적용 권장 (Payple 명세 미명시).\n", + "tags": [ + "Settlement" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "string", + "description": "A0000 성공 / 그 외 실패" + }, + "message": { + "type": "string" + }, + "group_key": { + "type": "string" + }, + "billing_tran_id": { + "type": "string" + }, + "api_tran_id": { + "type": "string" + }, + "tran_amt": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "멱등 처리 완료" + }, + "400": { + "description": "group_key 누락" + }, + "500": { + "description": "서버 오류" + } + } + } + }, "/api/settlements/verify-account": { "post": { "summary": "판매자 계좌 인증 및 register-token 발급",