From 1a0a795c9ed5fee060fa32fd026cc17139b849ee Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 02:46:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EC=88=98?= =?UTF-8?q?=EC=88=98=EB=A3=8C=EC=97=90=20VAT=201%=20=ED=95=A9=EC=82=B0=20+?= =?UTF-8?q?=20=EC=A0=95=EC=82=B0=EC=A7=80=EA=B8=89=EB=8C=80=ED=96=89=20?= =?UTF-8?q?=EB=B9=8C=EB=A7=81=ED=82=A4=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #491 Phase 1 — 정산 정책 안내(수수료 10% + VAT 1%)에 코드 정렬 + 후속 지급이체 자동화에 사용할 SettlementAccount.billing_tran_id 컬럼 추가. ### 수수료 계산 정정 - src/purchases/utils/fee.ts 신규 — COMMISSION_RATE(0.1), VAT_RATE_ON_COMMISSION(0.1), TOTAL_DEDUCT_RATE(0.11), calculateSettlementFee() - purchase.complete.service.ts / purchase.webhook.service.ts에서 두 곳 동일 패턴 하드코딩(FEE_RATE=0.1) 제거 → calculateSettlementFee 재사용 - 결과: 판매가 10,000원 기준 정산금 9,000 → 8,900으로 정정 (VAT 100원 추가 공제) - 기존 Settlement 행은 정정하지 않음 (불가역 정책) ### Prisma 스키마 - SettlementAccount.billing_tran_id VARCHAR(64) NULL 컬럼 추가 - 마이그레이션: 20260523180000_add_billing_tran_id_and_fee_note (dev DB 적용 완료) - 컬럼은 본 PR에선 미사용. 다음 PR (Payple 정산지급대행 계좌인증 통합)에서 값 저장 다음 단계 (#491 후속 PR): - utils/payple-payout.ts 신규 + verify-account 흐름에 빌링키 발급 통합 - 이체 대기/실행 유틸 + Webhook 수신 - 정산 사이클 cron (매월 15일 KST, 최소 1만원, 음수 이월) --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + .../services/purchase.complete.service.ts | 6 +++--- .../services/purchase.webhook.service.ts | 6 +++--- src/purchases/utils/fee.ts | 19 +++++++++++++++++++ 5 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260523180000_add_billing_tran_id_and_fee_note/migration.sql create mode 100644 src/purchases/utils/fee.ts diff --git a/prisma/migrations/20260523180000_add_billing_tran_id_and_fee_note/migration.sql b/prisma/migrations/20260523180000_add_billing_tran_id_and_fee_note/migration.sql new file mode 100644 index 0000000..450eed2 --- /dev/null +++ b/prisma/migrations/20260523180000_add_billing_tran_id_and_fee_note/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: 정산지급대행 빌링키 컬럼 추가 (#491) +ALTER TABLE `SettlementAccount` ADD COLUMN `billing_tran_id` VARCHAR(64) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7778175..9d5cea7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -508,6 +508,7 @@ model SettlementAccount { company_name String? @db.VarChar(100) business_number String? @unique @db.VarChar(30) business_license_url String? @db.Text + billing_tran_id String? @db.VarChar(64) // Payple 정산지급대행 계좌 빌링키 (#491) created_at DateTime @default(now()) updated_at DateTime @updatedAt diff --git a/src/purchases/services/purchase.complete.service.ts b/src/purchases/services/purchase.complete.service.ts index 2b4026b..7c41ffc 100644 --- a/src/purchases/services/purchase.complete.service.ts +++ b/src/purchases/services/purchase.complete.service.ts @@ -4,6 +4,7 @@ import { PurchaseCompleteRepository } from '../repositories/purchase.complete.re import { AppError } from '../../errors/AppError'; import prisma from '../../config/prisma'; import { verifyPayplePayment } from '../utils/payple'; +import { calculateSettlementFee } from '../utils/fee'; export const PurchaseCompleteService = { async completePurchase(userId: number, dto: PurchaseCompleteRequestDTO): Promise { @@ -43,13 +44,12 @@ export const PurchaseCompleteService = { cash_receipt_url: verifiedPayment.cashReceiptUrl, }); - const FEE_RATE = 0.1; - const fee = Math.floor(serverPrice * FEE_RATE); + const { fee, settledAmount } = calculateSettlementFee(serverPrice); await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { sellerId: prompt.user_id, paymentId: payment.payment_id, - amount: serverPrice - fee, + amount: settledAmount, fee, status: 'Pending', }); diff --git a/src/purchases/services/purchase.webhook.service.ts b/src/purchases/services/purchase.webhook.service.ts index e8d01ea..dffaeb7 100644 --- a/src/purchases/services/purchase.webhook.service.ts +++ b/src/purchases/services/purchase.webhook.service.ts @@ -2,6 +2,7 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repo import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import prisma from '../../config/prisma'; import { PayplePaymentResult, verifyPayplePayment } from '../utils/payple'; +import { calculateSettlementFee } from '../utils/fee'; export const WebhookService = { async handlePaypleResult(result: PayplePaymentResult) { @@ -55,12 +56,11 @@ export const WebhookService = { cash_receipt_url: verified.cashReceiptUrl, }); - const FEE_RATE = 0.1; - const fee = Math.floor(serverPrice * FEE_RATE); + const { fee, settledAmount } = calculateSettlementFee(serverPrice); await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { sellerId: prompt.user_id, paymentId: payment.payment_id, - amount: serverPrice - fee, + amount: settledAmount, fee, status: 'Pending', }); diff --git a/src/purchases/utils/fee.ts b/src/purchases/utils/fee.ts new file mode 100644 index 0000000..b1d8b5d --- /dev/null +++ b/src/purchases/utils/fee.ts @@ -0,0 +1,19 @@ +// 정산 수수료 계산. +// 정책 (#491): 정산금 = 판매가 - (수수료 10% + VAT 1%) = 판매가 × 89%. +// 단일 fee 컬럼에 수수료+VAT 합산 저장 (회계 분리 필요 시 별도 이슈에서 VAT 컬럼 분리). +export const COMMISSION_RATE = 0.1; // 수수료 10% +export const VAT_RATE_ON_COMMISSION = 0.1; // VAT는 수수료의 10% +export const TOTAL_DEDUCT_RATE = COMMISSION_RATE * (1 + VAT_RATE_ON_COMMISSION); // 0.11 + +export interface FeeBreakdown { + fee: number; // 수수료 + VAT 합계 (Settlement.fee 컬럼에 저장) + settledAmount: number; // 판매자 정산금 (Settlement.amount 컬럼) +} + +export const calculateSettlementFee = (salePrice: number): FeeBreakdown => { + const fee = Math.floor(salePrice * TOTAL_DEDUCT_RATE); + return { + fee, + settledAmount: salePrice - fee, + }; +}; From 8c48b45d3b457a9014038c75eec8a875b7e93313 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 02:52:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Payple=20=EB=B9=8C=EB=A7=81?= =?UTF-8?q?=ED=82=A4=20=EC=A1=B0=ED=9A=8C/=ED=95=B4=EC=A7=80=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EC=B6=94=EA=B0=80=20(#491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #491 후속 작업(지급이체 자동화/환불 시 빌링키 해지 등)에서 사용할 Payple 빌링키 라이프사이클 인프라. 본 PR에서는 유틸만 추가, 외부 endpoint 노출 없음. - fetchBillingKeyInfo(payerId): PUSERINFO 인증 → 조회. 카드 마스킹 정보 반환 - deleteBillingKey(payerId): PUSERDEL 인증 → 해지 - Auth Redis 25분 캐시 (PCD_PAY_WORK별 키 분리 — PUSERINFO/PUSERDEL) - 캐시에는 authKey/payHost/payUrl만, cstId/custKey는 매 호출 env 직접 로드 - 요청/응답 로그에 redactPaypleLog 적용 - 환경변수 PAYPLE_BILLING_AUTH_PATH (기본 /php/auth.php, sandbox 검증 후 정정) --- src/settlements/utils/payple-billing.ts | 189 ++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/settlements/utils/payple-billing.ts diff --git a/src/settlements/utils/payple-billing.ts b/src/settlements/utils/payple-billing.ts new file mode 100644 index 0000000..a567c3e --- /dev/null +++ b/src/settlements/utils/payple-billing.ts @@ -0,0 +1,189 @@ +import axios from 'axios'; +import redisClient from '../../config/redis'; +import { AppError } from '../../errors/AppError'; +import { redactPaypleLog } from './payple'; + +// Payple 빌링키 라이프사이클 (#491 후속 작업 대비). +// 본 파일은 빌링키 조회(PUSERINFO) / 해지(PUSERDEL) 인프라만 정의. +// 호출 흐름: 파트너 인증 → AuthKey/PCD_PAY_URL 수신 → 조회/해지 요청. +// +// 보안 정책 (#482/#485과 동일): +// - Auth 캐시 TTL 25분 (Payple 30분 만료 마진) +// - cstId/custKey는 캐시에서 제외하고 매 호출 env 직접 로드 +// - 요청/응답 로그는 redactPaypleLog로 마스킹 + +type BillingWork = 'PUSERINFO' | 'PUSERDEL'; + +const AUTH_CACHE_TTL_SECONDS = 25 * 60; + +interface BillingAuthCache { + authKey: string; + payHost: string; + payUrl: string; +} + +interface BillingAuth extends BillingAuthCache { + cstId: string; + custKey: string; +} + +const loadCredentialsFromEnv = (): { 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 getCpayBaseUrl = (): string => { + const url = process.env.PAYPLE_CPAY_URL; + if (!url) { + throw new AppError('PAYPLE_CPAY_URL 환경변수가 설정되지 않았습니다.', 500, 'ConfigError'); + } + return url; +}; + +const getBillingAuthPath = (): string => + process.env.PAYPLE_BILLING_AUTH_PATH || '/php/auth.php'; + +const fetchBillingAuth = async (work: BillingWork): Promise => { + const { cstId, custKey } = loadCredentialsFromEnv(); + const cacheKey = `payple:billing:auth:${work}`; + + const cached = await redisClient.get(cacheKey); + if (cached) { + try { + const parsed: BillingAuthCache = JSON.parse(cached); + if (parsed.authKey && parsed.payHost && parsed.payUrl) { + return { ...parsed, cstId, custKey }; + } + } catch { + // 캐시 손상 — 재발급 + } + } + + const url = `${getCpayBaseUrl()}${getBillingAuthPath()}`; + const res = await axios.post( + url, + { cst_id: cstId, custKey, PCD_PAY_WORK: work }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + ); + + if (res.data?.result !== 'success') { + console.error('[payple-billing] auth failed', { work, code: res.data?.result }); + throw new AppError(`Payple 빌링키 ${work} 인증에 실패했습니다.`, 502, 'PaypleAuthFailed'); + } + + const cacheable: BillingAuthCache = { + authKey: res.data.AuthKey, + payHost: res.data.PCD_PAY_HOST, + payUrl: res.data.PCD_PAY_URL, + }; + await redisClient.set(cacheKey, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS }); + return { ...cacheable, cstId, custKey }; +}; + +export interface BillingKeyInfo { + payCode: string; + payMsg: string; + payType: string; // 'card' | 'transfer' + payerId: string; // 조회한 빌링키 + payerName?: string; + payerHp?: string; + cardCode?: string; // PCD_PAY_CARD + cardName?: string; // PCD_PAY_CARDNAME + cardNumMasked?: string; // PCD_PAY_CARDNUM +} + +// 빌링키 조회 (PUSERINFO). +// 카드 빌링키의 경우 마스킹된 카드번호/카드사명 반환. +export const fetchBillingKeyInfo = async (payerId: string): Promise => { + if (!payerId) { + throw new AppError('payerId(빌링키)가 누락되었습니다.', 400, 'ValidationError'); + } + const auth = await fetchBillingAuth('PUSERINFO'); + + const url = `${auth.payHost}${auth.payUrl}`; + const res = await axios.post( + url, + { + PCD_CST_ID: auth.cstId, + PCD_CUST_KEY: auth.custKey, + PCD_AUTH_KEY: auth.authKey, + PCD_PAYER_ID: payerId, + }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + ); + + if (res.data?.PCD_PAY_RST !== 'success') { + console.error('[payple-billing] info failed', { + code: res.data?.PCD_PAY_CODE, + response: redactPaypleLog(res.data), + }); + throw new AppError( + `Payple 빌링키 조회에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`, + 502, + 'PaypleBillingInfoFailed', + ); + } + + return { + payCode: res.data.PCD_PAY_CODE, + payMsg: res.data.PCD_PAY_MSG, + payType: res.data.PCD_PAY_TYPE, + payerId: res.data.PCD_PAYER_ID, + payerName: res.data.PCD_PAYER_NAME, + payerHp: res.data.PCD_PAYER_HP, + cardCode: res.data.PCD_PAY_CARD, + cardName: res.data.PCD_PAY_CARDNAME, + cardNumMasked: res.data.PCD_PAY_CARDNUM, + }; +}; + +export interface BillingKeyDeleteResult { + payCode: string; + payMsg: string; + payType: string; + payerId: string; +} + +// 빌링키 해지 (PUSERDEL). +// 카드/계좌 빌링키를 영구 비활성화. 환불/탈퇴 흐름에서 사용. +export const deleteBillingKey = async (payerId: string): Promise => { + if (!payerId) { + throw new AppError('payerId(빌링키)가 누락되었습니다.', 400, 'ValidationError'); + } + const auth = await fetchBillingAuth('PUSERDEL'); + + const url = `${auth.payHost}${auth.payUrl}`; + const res = await axios.post( + url, + { + PCD_CST_ID: auth.cstId, + PCD_CUST_KEY: auth.custKey, + PCD_AUTH_KEY: auth.authKey, + PCD_PAYER_ID: payerId, + }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + ); + + if (res.data?.PCD_PAY_RST !== 'success') { + console.error('[payple-billing] delete failed', { + code: res.data?.PCD_PAY_CODE, + response: redactPaypleLog(res.data), + }); + throw new AppError( + `Payple 빌링키 해지에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`, + 502, + 'PaypleBillingDeleteFailed', + ); + } + + return { + payCode: res.data.PCD_PAY_CODE, + payMsg: res.data.PCD_PAY_MSG, + payType: res.data.PCD_PAY_TYPE, + payerId: res.data.PCD_PAYER_ID, + }; +};