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 diff --git a/src/index.ts b/src/index.ts index 8db0119..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"; @@ -40,6 +41,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 +52,7 @@ const server = http.createServer(app); initSocket(server) startPromptStatSnapshotJob(); startSettlementSyncJob(); +startSettlementPayoutJob(); // 1. 응답 핸들러(json 파서보다 위에) app.use(responseHandler); app.use((req, res, next) => { @@ -177,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/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/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/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/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); + } +}; 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-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 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-refund.ts b/src/settlements/utils/payple-refund.ts index 62d49c6..a06028e 100644 --- a/src/settlements/utils/payple-refund.ts +++ b/src/settlements/utils/payple-refund.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_PAYCANCEL_FLAG=Y로 파트너 인증 → 응답의 PCD_PAY_HOST + PCD_PAY_URL로 취소 요청. @@ -69,7 +69,7 @@ const fetchRefundAuth = async (): 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..ea25313 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([ // 실명인증/은행/토큰 @@ -91,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; @@ -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') { @@ -138,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)) { @@ -192,11 +206,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) { @@ -225,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; } 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 발급",