diff --git a/src/settlements/controllers/settlement.history.controller.ts b/src/settlements/controllers/settlement.history.controller.ts index bc23856..d01d02b 100644 --- a/src/settlements/controllers/settlement.history.controller.ts +++ b/src/settlements/controllers/settlement.history.controller.ts @@ -29,6 +29,29 @@ export const getMonthlySales = async (req: Request, res: Response) => { } }; +export const getPendingAmount = async (req: Request, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ + error: 'Unauthorized', + message: '로그인이 필요합니다.', + statusCode: 401, + }); + } + const userId = (user as { user_id: number }).user_id; + const result = await SettlementHistoryService.getPendingAmount(userId); + return res.status(200).json(result); + } catch (error: any) { + const status = error.status || 500; + return res.status(status).json({ + error: error.type || 'InternalServerError', + message: error.message || '알 수 없는 오류가 발생했습니다.', + statusCode: status, + }); + } +}; + export const getYearlySettlements = async (req: Request, res: Response) => { try { const user = req.user; diff --git a/src/settlements/dtos/settlement.history.dto.ts b/src/settlements/dtos/settlement.history.dto.ts index f974934..3041cf0 100644 --- a/src/settlements/dtos/settlement.history.dto.ts +++ b/src/settlements/dtos/settlement.history.dto.ts @@ -44,3 +44,10 @@ export interface YearlySettlementResponseDto { items: YearlySettlementItemDto[]; statusCode: number; } + +export interface PendingAmountResponseDto { + message: string; + pending_amount: number; + pending_count: number; + statusCode: number; +} diff --git a/src/settlements/repositories/settlement.history.repository.ts b/src/settlements/repositories/settlement.history.repository.ts index cffbd5b..6b06e02 100644 --- a/src/settlements/repositories/settlement.history.repository.ts +++ b/src/settlements/repositories/settlement.history.repository.ts @@ -2,6 +2,21 @@ import prisma from '../../config/prisma'; import { Status } from '@prisma/client'; export const SettlementHistoryRepository = { + // 정산 예정 금액 — Settlement.status='Pending' 인 행의 amount 합계. + // 정산 완료 처리 (Pending → Succeed) 흐름이 별도 이슈에서 구현되기 전까지는 + // 이 값이 모든 미정산 거래의 누계가 됨. + async sumPendingAmount(userId: number): Promise<{ pending_amount: number; pending_count: number }> { + const result = await prisma.settlement.aggregate({ + where: { user_id: userId, status: Status.Pending }, + _sum: { amount: true }, + _count: { _all: true }, + }); + return { + pending_amount: result._sum.amount ?? 0, + pending_count: result._count._all ?? 0, + }; + }, + async findSalesByMonth(userId: number, year: number, month: number) { const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 1)); diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index c863515..cb0c751 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { verifyAccount, ViewAccount, ViewAccountDetail } from "../controllers/settlement.account.controller"; import { registerIndividual, registerBusiness } from "../controllers/settlement.seller.controller"; import { uploadLicense } from "../controllers/settlement.seller.controller"; -import { getMonthlySales, getYearlySettlements } from "../controllers/settlement.history.controller"; +import { getMonthlySales, getYearlySettlements, getPendingAmount } from "../controllers/settlement.history.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -496,6 +496,38 @@ router.post("/register/business", authenticateJwt, registerBusiness); * 401: * description: 로그인 필요 */ +/** + * @swagger + * /api/settlements/pending-amount: + * get: + * summary: 정산 예정 금액 조회 (대시보드) + * description: | + * 로그인한 판매자의 정산 예정 금액(Settlement.status='Pending'인 행의 amount 합계)을 조회합니다. + * 정산관리 화면 상단 대시보드에 노출. + * + * ⚠️ 현재 코드에는 Settlement.status를 'Succeed'로 업데이트하는 정산 완료 처리 흐름이 없어서, + * 모든 미정산 거래의 누계가 반환됩니다. 별도 이슈에서 Payple 정산내역 조회와 연동한 동기화 흐름이 구현된 뒤에는 + * 실제 정산 예정분만 반환됩니다. + * tags: [Settlement] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 정산 예정 금액 조회 성공 } + * pending_amount: { type: integer, example: 125000 } + * pending_count: { type: integer, example: 3 } + * statusCode: { type: integer, example: 200 } + * 401: + * description: 로그인 필요 + */ +router.get("/pending-amount", authenticateJwt, getPendingAmount); + router.get("/sales/monthly", authenticateJwt, getMonthlySales); /** diff --git a/src/settlements/services/settlement.history.service.ts b/src/settlements/services/settlement.history.service.ts index 9f251c4..695f80f 100644 --- a/src/settlements/services/settlement.history.service.ts +++ b/src/settlements/services/settlement.history.service.ts @@ -2,6 +2,7 @@ import { SettlementHistoryRepository } from '../repositories/settlement.history. import { MonthlySalesResponseDto, YearlySettlementResponseDto, + PendingAmountResponseDto, } from '../dtos/settlement.history.dto'; export const SettlementHistoryService = { @@ -51,6 +52,16 @@ export const SettlementHistoryService = { }; }, + async getPendingAmount(userId: number): Promise { + const { pending_amount, pending_count } = await SettlementHistoryRepository.sumPendingAmount(userId); + return { + message: '정산 예정 금액 조회 성공', + pending_amount, + pending_count, + statusCode: 200, + }; + }, + async getYearlySettlements(userId: number): Promise { const items = await SettlementHistoryRepository.aggregateYearlyTotals(userId); return { diff --git a/src/settlements/utils/payple-settlement.ts b/src/settlements/utils/payple-settlement.ts new file mode 100644 index 0000000..0c4a104 --- /dev/null +++ b/src/settlements/utils/payple-settlement.ts @@ -0,0 +1,176 @@ +import axios from 'axios'; +import redisClient from '../../config/redis'; +import { AppError } from '../../errors/AppError'; + +// Payple 정산내역 조회용 파트너 인증 + 조회 유틸. +// 정산내역 조회는 PCD_SETTLEMENT_FLAG=Y로 인증 받고, 응답의 PCD_PAY_HOST + PCD_PAY_URL로 호출. +// 호출 제한: 1초 1회 / 2분 20회 (호출자가 throttling 책임). +// +// 본 이슈에서는 인프라만 추가. 외부 endpoint 노출 없음. 향후 정산 완료 동기화/검증/리포트에 재사용. + +const AUTH_CACHE_KEY = 'payple:settlement:auth'; +// Payple 토큰 만료 정책이 명세상 불분명 — 안전하게 25분 캐시 (보통 30분 만료 가정) +const AUTH_CACHE_TTL_SECONDS = 25 * 60; + +interface PaypleSettlementAuth { + cstId: string; + custKey: string; + authKey: string; + payHost: string; + payUrl: string; +} + +const getCpayBaseUrl = (): string => { + const url = process.env.PAYPLE_CPAY_URL; + if (!url) { + throw new AppError('PAYPLE_CPAY_URL 환경변수가 설정되지 않았습니다.', 500, 'ConfigError'); + } + return url; +}; + +// 정산 인증 endpoint path는 Payple 환경/계약에 따라 다를 수 있어 env로 분리. +// 명세상 명시되지 않아 sandbox 테스트로 확정 필요. +const getSettlementAuthPath = (): string => + process.env.PAYPLE_SETTLEMENT_AUTH_PATH || '/php/auth.php'; + +export const fetchPaypleSettlementAuth = async (): Promise => { + const cached = await redisClient.get(AUTH_CACHE_KEY); + if (cached) { + try { + return JSON.parse(cached); + } catch { + // 캐시 손상 — 재발급 + } + } + + const cst_id = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + if (!cst_id || !custKey) { + throw new AppError('Payple 인증 설정이 누락되었습니다.', 500, 'ConfigError'); + } + + const url = `${getCpayBaseUrl()}${getSettlementAuthPath()}`; + const res = await axios.post( + url, + { cst_id, custKey, PCD_SETTLEMENT_FLAG: 'Y' }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + ); + + if (res.data?.result !== 'success') { + console.error('[payple-settlement] auth failed', { code: res.data?.result }); + throw new AppError('Payple 정산내역 조회 인증에 실패했습니다.', 502, 'PaypleAuthFailed'); + } + + const auth: PaypleSettlementAuth = { + cstId: res.data.cst_id, + custKey: res.data.custKey, + authKey: res.data.AuthKey, + payHost: res.data.PCD_PAY_HOST, + payUrl: res.data.PCD_PAY_URL, + }; + await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(auth), { EX: AUTH_CACHE_TTL_SECONDS }); + return auth; +}; + +export type PaypleMethod = 'CARD' | 'EASYPAY' | 'TRANSFER'; +export type PaypleTxnType = 'APPROVAL' | 'CANCEL'; +export type PaypleResponsePayType = 'card' | 'easypay' | 'transfer'; + +export interface FetchSettlementsParams { + startDate: string; // yyyy-MM-dd 또는 yyyyMMdd + endDate: string; // 최대 31일 범위 + method?: PaypleMethod; + limit?: number; // 기본 100, 최소 1, 최대 3000 + lastKey?: string; // 페이지네이션 커서 +} + +export interface PaypleSettlementItem { + txnType: PaypleTxnType; + payOid: string; + payTime: string; + payCancelTime: string | null; + settleDate: string; + payType: PaypleResponsePayType; + payerName: string; + payGoods: string; + payAmount: number; + feeSupply: number; + feeVat: number; + feeTotal: number; + settleAmount: number; +} + +export interface PaypleSettlementsPage { + items: PaypleSettlementItem[]; + hasMore: boolean; + lastKey: string | null; + totals: { + count: number; + approvalAmount: number; + cancelAmount: number; + feeAmount: number; + settleAmount: number; + }; +} + +export const fetchPaypleSettlements = async ( + params: FetchSettlementsParams, +): Promise => { + const auth = await fetchPaypleSettlementAuth(); + + const body: Record = { + PCD_CST_ID: auth.cstId, + PCD_CUST_KEY: auth.custKey, + PCD_AUTH_KEY: auth.authKey, + PCD_SETTLEMENT_FLAG: 'Y', + PCD_START_DATE: params.startDate, + PCD_END_DATE: params.endDate, + }; + if (params.method) body.PCD_METHOD = params.method; + if (params.limit) body.PCD_LIMIT = params.limit; + if (params.lastKey) body.PCD_LASTKEY = params.lastKey; + + const url = `${auth.payHost}${auth.payUrl}`; + const res = await axios.post(url, body, { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + }); + + if (res.data?.PCD_PAY_RST !== 'success') { + console.error('[payple-settlement] query failed', { code: res.data?.PCD_PAY_CODE }); + throw new AppError( + `Payple 정산내역 조회에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'})`, + 502, + 'PaypleSettlementQueryFailed', + ); + } + + const content: any[] = res.data?.PCD_DATA?.PCD_CONTENT ?? []; + const totals = res.data?.PCD_DATA?.PCD_PAGE_TOTALS ?? {}; + + return { + items: content.map((item) => ({ + txnType: item.PCD_TXN_TYPE, + payOid: item.PCD_PAY_OID, + payTime: item.PCD_PAY_TIME, + payCancelTime: item.PCD_PAY_CANCEL_TIME ?? null, + settleDate: item.PCD_SETTLE_DATE, + payType: item.PCD_PAY_TYPE, + payerName: item.PCD_PAYER_NAME, + payGoods: item.PCD_PAY_GOODS, + payAmount: item.PCD_PAY_AMOUNT, + feeSupply: item.PCD_FEE_SUPPLY, + feeVat: item.PCD_FEE_VAT, + feeTotal: item.PCD_FEE_TOTAL, + settleAmount: item.PCD_SETTLE_AMOUNT, + })), + hasMore: !!res.data?.PCD_DATA?.PCD_HAS_MORE, + lastKey: res.data?.PCD_DATA?.PCD_LASTKEY ?? null, + totals: { + count: totals.PCD_TOTAL_COUNT ?? 0, + approvalAmount: totals.PCD_TOTAL_APPROVAL_AMOUNT ?? 0, + cancelAmount: totals.PCD_TOTAL_CANCEL_AMOUNT ?? 0, + feeAmount: totals.PCD_TOTAL_FEE_AMOUNT ?? 0, + settleAmount: totals.PCD_TOTAL_SETTLE_AMOUNT ?? 0, + }, + }; +}; diff --git a/swagger.json b/swagger.json index d9bff95..df36c62 100644 --- a/swagger.json +++ b/swagger.json @@ -7700,6 +7700,53 @@ } } }, + "/api/settlements/pending-amount": { + "get": { + "summary": "정산 예정 금액 조회 (대시보드)", + "description": "로그인한 판매자의 정산 예정 금액(Settlement.status='Pending'인 행의 amount 합계)을 조회합니다.\n정산관리 화면 상단 대시보드에 노출.\n\n⚠️ 현재 코드에는 Settlement.status를 'Succeed'로 업데이트하는 정산 완료 처리 흐름이 없어서,\n모든 미정산 거래의 누계가 반환됩니다. 별도 이슈에서 Payple 정산내역 조회와 연동한 동기화 흐름이 구현된 뒤에는\n실제 정산 예정분만 반환됩니다.\n", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "정산 예정 금액 조회 성공" + }, + "pending_amount": { + "type": "integer", + "example": 125000 + }, + "pending_count": { + "type": "integer", + "example": 3 + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "401": { + "description": "로그인 필요" + } + } + } + }, "/api/settlements/yearly": { "get": { "summary": "연도별 누적 정산 내역 조회",