From c062c78d762f7c52b4dd7e3583a857bbbb4f4407 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 22:30:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4(status/refunded=5Fat)=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #485에서 Refunded 상태가 도입됐으나 결제내역(GET /api/prompts/purchases) 응답에 status 정보가 없어 환불된 거래를 식별할 수 없었음. 환불 표시 추가. - PurchaseRepository.findSucceededByUser → findPurchaseHistory로 rename - status IN (Succeed, Refunded) 양쪽 모두 조회 - payment.status / refund.refunded_at include - PurchaseHistoryItemDTO 확장: purchased_at(stale dto 정정) + status + refunded_at - PurchaseHistoryService.list: status, refunded_at 매핑 - Swagger 응답 schema에 status enum + refunded_at 필드 추가 프론트는 status='Refunded' 항목에 환불 뱃지/메시지 표시 가능. --- src/purchases/dtos/purchase.dto.ts | 20 +++++----- .../repositories/purchase.repository.ts | 18 +++++---- src/purchases/routes/purchase.route.ts | 11 ++++- src/purchases/services/purchase.service.ts | 40 ++++++++++--------- 4 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/purchases/dtos/purchase.dto.ts b/src/purchases/dtos/purchase.dto.ts index 40168c5..8afc5b0 100644 --- a/src/purchases/dtos/purchase.dto.ts +++ b/src/purchases/dtos/purchase.dto.ts @@ -1,13 +1,15 @@ - export interface PurchaseHistoryItemDTO { - prompt_id: number; - title: string; - price: number; - seller_nickname: string; + prompt_id: number; + title: string; + price: number; + seller_nickname: string; + purchased_at: string; + status: 'Succeed' | 'Refunded'; + refunded_at: string | null; } export interface PurchaseHistoryResponseDTO { - message: string; - purchases: PurchaseHistoryItemDTO[]; - statusCode: number; -} \ No newline at end of file + message: string; + purchases: PurchaseHistoryItemDTO[]; + statusCode: number; +} diff --git a/src/purchases/repositories/purchase.repository.ts b/src/purchases/repositories/purchase.repository.ts index 35361be..1c72089 100644 --- a/src/purchases/repositories/purchase.repository.ts +++ b/src/purchases/repositories/purchase.repository.ts @@ -1,22 +1,26 @@ import prisma from '../../config/prisma'; export const PurchaseRepository = { - findSucceededByUser(userId: number) { + // 결제내역: 결제 성공(Succeed) + 환불(Refunded) 거래 포함. + // 환불된 거래도 사용자가 결제내역에서 식별할 수 있어야 함 (#497). + findPurchaseHistory(userId: number) { return prisma.purchase.findMany({ where: { user_id: userId, - payment: { is: { status: 'Succeed'}}, + payment: { is: { status: { in: ['Succeed', 'Refunded'] } } }, }, include: { prompt: { select: { prompt_id: true, title: true, - user: { select: { nickname: true}}, + user: { select: { nickname: true } }, }, }, + payment: { select: { status: true } }, + refund: { select: { refunded_at: true } }, }, - orderBy: { created_at: 'desc'}, - }) - } -}; \ No newline at end of file + orderBy: { created_at: 'desc' }, + }); + }, +}; diff --git a/src/purchases/routes/purchase.route.ts b/src/purchases/routes/purchase.route.ts index c1c5827..5d51f4c 100644 --- a/src/purchases/routes/purchase.route.ts +++ b/src/purchases/routes/purchase.route.ts @@ -98,9 +98,18 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * type: integer * seller_nickname: * type: string - * purchased_at: + * * purchased_at: * type: string * format: date-time + * status: + * type: string + * enum: [Succeed, Refunded] + * description: 결제 상태. 환불 시 'Refunded' + * refunded_at: + * type: string + * format: date-time + * nullable: true + * description: 환불 시각 (status='Refunded'일 때만 존재) * statusCode: * type: integer */ diff --git a/src/purchases/services/purchase.service.ts b/src/purchases/services/purchase.service.ts index e676e83..28eb299 100644 --- a/src/purchases/services/purchase.service.ts +++ b/src/purchases/services/purchase.service.ts @@ -1,22 +1,24 @@ -import { PurchaseRepository } from "../repositories/purchase.repository"; -import { PurchaseHistoryItemDTO, PurchaseHistoryResponseDTO } from "../dtos/purchase.dto"; +import { PurchaseRepository } from '../repositories/purchase.repository'; +import { PurchaseHistoryItemDTO, PurchaseHistoryResponseDTO } from '../dtos/purchase.dto'; export const PurchaseHistoryService = { - async list(userId: number): Promise { - const rows = await PurchaseRepository.findSucceededByUser(userId); - - const purchases: PurchaseHistoryItemDTO[] = rows.map((r) => ({ - prompt_id: r.prompt.prompt_id, - title: r.prompt.title, - price: r.amount, - purchased_at: r.created_at.toISOString(), - seller_nickname: r.prompt.user.nickname, - })); + async list(userId: number): Promise { + const rows = await PurchaseRepository.findPurchaseHistory(userId); - return { - message: "결제 내역 조회 성공", - purchases, - statusCode: 200, - } - } -} \ No newline at end of file + const purchases: PurchaseHistoryItemDTO[] = rows.map((r) => ({ + prompt_id: r.prompt.prompt_id, + title: r.prompt.title, + price: r.amount, + purchased_at: r.created_at.toISOString(), + seller_nickname: r.prompt.user.nickname, + status: (r.payment?.status ?? 'Succeed') as 'Succeed' | 'Refunded', + refunded_at: r.refund?.refunded_at?.toISOString() ?? null, + })); + + return { + message: '결제 내역 조회 성공', + purchases, + statusCode: 200, + }; + }, +}; From d3665078c6365fab1573c24428493938859b896a Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 22:37:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(swagger):=20purchase.route.ts=20purchas?= =?UTF-8?q?ed=5Fat=20=EB=9D=BC=EC=9D=B8=20=EC=9D=B4=EC=A4=91=20=EB=B3=84?= =?UTF-8?q?=ED=91=9C=20=EC=A0=95=EC=A0=95=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직전 commit에서 jsdoc 인덴트가 ' * * ' 형태로 별표 두 개가 들어가 YAML 파싱 실패. swagger 빌드 깨짐. ' * '로 정정. --- src/purchases/routes/purchase.route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/purchases/routes/purchase.route.ts b/src/purchases/routes/purchase.route.ts index 5d51f4c..f5650fb 100644 --- a/src/purchases/routes/purchase.route.ts +++ b/src/purchases/routes/purchase.route.ts @@ -98,18 +98,18 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * type: integer * seller_nickname: * type: string - * * purchased_at: + * purchased_at: * type: string * format: date-time * status: * type: string * enum: [Succeed, Refunded] - * description: 결제 상태. 환불 시 'Refunded' + * description: 결제 상태. 환불 시 Refunded * refunded_at: * type: string * format: date-time * nullable: true - * description: 환불 시각 (status='Refunded'일 때만 존재) + * description: 환불 시각. status가 Refunded일 때만 존재 * statusCode: * type: integer */ From 0411cfcfdaf4b2c424f99e98d5557971420a7cbe Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 22:37:01 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20/sales/monthly=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20+=20summary=EB=A5=BC=20?= =?UTF-8?q?=EC=9B=94=20=EC=A0=84=EC=B2=B4=20=ED=95=A9=EA=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=ED=99=94=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쿼리 파라미터 page(기본 1), limit(기본 10, 최대 100) 추가 - repository: - findSalesByMonth에 skip/take 적용 (페이지 데이터만 반환) - countSalesByMonth 신규 (페이지네이션 메타) - aggregateSalesByMonth 신규 (raw SQL로 월 전체 합계 — Refunded 분리 포함) - service: 세 호출 병렬 + items/summary/pagination 매핑 - summary의 의미를 페이지 단위 → 월 전체 합계로 정합화 (페이지네이션 도입과 함께 통계 정확성 우선). 응답 형식 동일 (count/total_sales/total_settled/ total_fee/refunded_count/refunded_amount). - DTO에 pagination 필드 추가 - Swagger 갱신: query params + pagination 응답 + summary description 보강 - swagger.json 재생성 --- .../settlement.history.controller.ts | 4 +- .../dtos/settlement.history.dto.ts | 14 ++- .../settlement.history.repository.ts | 68 ++++++++++++- src/settlements/routes/settlement.route.ts | 36 +++++-- .../services/settlement.history.service.ts | 95 +++++++++++-------- swagger.json | 74 +++++++++++++-- 6 files changed, 235 insertions(+), 56 deletions(-) diff --git a/src/settlements/controllers/settlement.history.controller.ts b/src/settlements/controllers/settlement.history.controller.ts index d01d02b..e359658 100644 --- a/src/settlements/controllers/settlement.history.controller.ts +++ b/src/settlements/controllers/settlement.history.controller.ts @@ -16,8 +16,10 @@ export const getMonthlySales = async (req: Request, res: Response) => { const now = new Date(); const year = req.query.year ? Number(req.query.year) : now.getUTCFullYear(); const month = req.query.month ? Number(req.query.month) : now.getUTCMonth() + 1; + const page = req.query.page ? Number(req.query.page) : undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; - const result = await SettlementHistoryService.getMonthlySales(userId, year, month); + const result = await SettlementHistoryService.getMonthlySales(userId, year, month, page, limit); return res.status(200).json(result); } catch (error: any) { const status = error.status || 500; diff --git a/src/settlements/dtos/settlement.history.dto.ts b/src/settlements/dtos/settlement.history.dto.ts index a9eb880..4725108 100644 --- a/src/settlements/dtos/settlement.history.dto.ts +++ b/src/settlements/dtos/settlement.history.dto.ts @@ -13,20 +13,30 @@ export interface MonthlySalesItemDto { status: 'Pending' | 'Succeed' | 'Failed' | 'Refunded'; } +// 월 전체 합계 (페이지 무관 — #497 페이지네이션 도입과 함께 의미 정합화) export interface MonthlySalesSummaryDto { - count: number; + count: number; // 월 전체 거래 건수 total_sales: number; - total_settled: number; // net — Succeed만, 환불 제외 + total_settled: number; // net — 환불 제외 total_fee: number; refunded_count: number; refunded_amount: number; } +export interface PaginationDto { + page: number; + limit: number; + total: number; + total_pages: number; + has_next: boolean; +} + export interface MonthlySalesResponseDto { message: string; year: number; month: number; summary: MonthlySalesSummaryDto; + pagination: PaginationDto; items: MonthlySalesItemDto[]; statusCode: number; } diff --git a/src/settlements/repositories/settlement.history.repository.ts b/src/settlements/repositories/settlement.history.repository.ts index d9d4a36..632b550 100644 --- a/src/settlements/repositories/settlement.history.repository.ts +++ b/src/settlements/repositories/settlement.history.repository.ts @@ -17,7 +17,15 @@ export const SettlementHistoryRepository = { }; }, - async findSalesByMonth(userId: number, year: number, month: number) { + // 월별 판매 내역 (페이지네이션 적용). + // 페이지 데이터만 반환. 합계/카운트는 aggregateSalesByMonth/countSalesByMonth로 별도 조회. + async findSalesByMonth( + userId: number, + year: number, + month: number, + skip: number, + take: number, + ) { const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 1)); @@ -27,6 +35,8 @@ export const SettlementHistoryRepository = { created_at: { gte: start, lt: end }, }, orderBy: { created_at: 'desc' }, + skip, + take, select: { settlement_id: true, amount: true, @@ -51,6 +61,62 @@ export const SettlementHistoryRepository = { }); }, + // 월별 판매 전체 건수 (페이지네이션 메타). + async countSalesByMonth(userId: number, year: number, month: number): Promise { + const start = new Date(Date.UTC(year, month - 1, 1)); + const end = new Date(Date.UTC(year, month, 1)); + return prisma.settlement.count({ + where: { user_id: userId, created_at: { gte: start, lt: end } }, + }); + }, + + // 월 전체 합계 (페이지 무관). + // summary 의미를 "현재 페이지 합계"가 아닌 "월 전체 합계"로 정합화 (#497). + async aggregateSalesByMonth( + userId: number, + year: number, + month: number, + ): Promise<{ + total_sales: number; + total_settled: number; + total_fee: number; + refunded_amount: number; + refunded_count: number; + }> { + const start = new Date(Date.UTC(year, month - 1, 1)); + const end = new Date(Date.UTC(year, month, 1)); + const rows = await prisma.$queryRaw< + Array<{ + total_sales: bigint | null; + total_settled: bigint | null; + total_fee: bigint | null; + refunded_amount: bigint | null; + refunded_count: bigint | null; + }> + >` + SELECT + SUM(p.amount) AS total_sales, + SUM(CASE WHEN s.status = ${Status.Refunded} THEN 0 ELSE s.amount END) AS total_settled, + SUM(s.fee) AS total_fee, + SUM(CASE WHEN s.status = ${Status.Refunded} THEN s.amount ELSE 0 END) AS refunded_amount, + SUM(CASE WHEN s.status = ${Status.Refunded} THEN 1 ELSE 0 END) AS refunded_count + FROM Settlement s + JOIN Payment pm ON pm.payment_id = s.payment_id + JOIN Purchase p ON p.purchase_id = pm.purchase_id + WHERE s.user_id = ${userId} + AND s.created_at >= ${start} + AND s.created_at < ${end} + `; + const r = rows[0] ?? {}; + return { + total_sales: Number(r.total_sales ?? 0), + total_settled: Number(r.total_settled ?? 0), + total_fee: Number(r.total_fee ?? 0), + refunded_amount: Number(r.refunded_amount ?? 0), + refunded_count: Number(r.refunded_count ?? 0), + }; + }, + async aggregateYearlyTotals(userId: number): Promise< Array<{ year: number; diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index b7d6fc6..ede687d 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -455,6 +455,21 @@ router.post("/register/business", authenticateJwt, registerBusiness); * - in: query * name: month * schema: { type: integer, minimum: 1, maximum: 12, example: 5 } + * - in: query + * name: page + * description: 페이지 번호 1부터 시작 + * schema: + * type: integer + * minimum: 1 + * default: 1 + * - in: query + * name: limit + * description: 페이지당 항목 수 최대 100 + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 10 * responses: * 200: * description: 조회 성공 @@ -468,13 +483,22 @@ router.post("/register/business", authenticateJwt, registerBusiness); * month: { type: integer, example: 5 } * summary: * type: object + * description: 월 전체 합계 (페이지 무관) + * properties: + * count: { type: integer, description: 월 전체 거래 건수, example: 25 } + * total_sales: { type: integer, description: 원래 판매가 합계 (환불 포함), example: 250000 } + * total_settled: { type: integer, description: net 정산 금액 (환불 제외), example: 200000 } + * total_fee: { type: integer, example: 25000 } + * refunded_count: { type: integer, example: 2 } + * refunded_amount: { type: integer, example: 18000 } + * pagination: + * type: object * properties: - * count: { type: integer, example: 3 } - * total_sales: { type: integer, description: 원래 판매가 합계 (환불 포함), example: 30000 } - * total_settled: { type: integer, description: net 정산 금액 (환불 제외), example: 18000 } - * total_fee: { type: integer, example: 3000 } - * refunded_count: { type: integer, example: 1 } - * refunded_amount: { type: integer, example: 9000 } + * page: { type: integer, example: 1 } + * limit: { type: integer, example: 10 } + * total: { type: integer, example: 25 } + * total_pages: { type: integer, example: 3 } + * has_next: { type: boolean, example: true } * items: * type: array * items: diff --git a/src/settlements/services/settlement.history.service.ts b/src/settlements/services/settlement.history.service.ts index f31b92e..df2578c 100644 --- a/src/settlements/services/settlement.history.service.ts +++ b/src/settlements/services/settlement.history.service.ts @@ -5,8 +5,27 @@ import { PendingAmountResponseDto, } from '../dtos/settlement.history.dto'; +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 100; + +const sanitizePagination = (page?: number, limit?: number): { page: number; limit: number } => { + const safePage = Number.isInteger(page) && (page as number) > 0 ? (page as number) : DEFAULT_PAGE; + const safeLimit = + Number.isInteger(limit) && (limit as number) > 0 + ? Math.min(limit as number, MAX_LIMIT) + : DEFAULT_LIMIT; + return { page: safePage, limit: safeLimit }; +}; + export const SettlementHistoryService = { - async getMonthlySales(userId: number, year: number, month: number): Promise { + async getMonthlySales( + userId: number, + year: number, + month: number, + pageParam?: number, + limitParam?: number, + ): Promise { if (!Number.isInteger(year) || year < 2000 || year > 2100) { throw { status: 400, type: 'ValidationError', message: 'year 값이 올바르지 않습니다.' }; } @@ -14,54 +33,50 @@ export const SettlementHistoryService = { throw { status: 400, type: 'ValidationError', message: 'month 값이 올바르지 않습니다 (1-12).' }; } - const rows = await SettlementHistoryRepository.findSalesByMonth(userId, year, month); + const { page, limit } = sanitizePagination(pageParam, limitParam); + const skip = (page - 1) * limit; - let total_sales = 0; - let total_settled = 0; - let total_fee = 0; - let refunded_count = 0; - let refunded_amount = 0; + const [rows, total, totals] = await Promise.all([ + SettlementHistoryRepository.findSalesByMonth(userId, year, month, skip, limit), + SettlementHistoryRepository.countSalesByMonth(userId, year, month), + SettlementHistoryRepository.aggregateSalesByMonth(userId, year, month), + ]); - const items = rows.map((r) => { - const sale_price = r.payment?.purchase?.amount ?? 0; - total_sales += sale_price; - total_fee += r.fee; + const items = rows.map((r) => ({ + settlement_id: r.settlement_id, + sold_at: r.created_at.toISOString(), + prompt_id: r.payment?.purchase?.prompt?.prompt_id ?? 0, + prompt_title: r.payment?.purchase?.prompt?.title ?? '', + buyer_id: r.payment?.purchase?.user_id ?? 0, + buyer_nickname: r.payment?.purchase?.user?.nickname ?? null, + pay_type: r.payment?.pay_type ?? null, + card_name: r.payment?.card_name ?? null, + sale_price: r.payment?.purchase?.amount ?? 0, + settled_amount: r.amount, + fee: r.fee, + status: r.status as 'Pending' | 'Succeed' | 'Failed' | 'Refunded', + })); - // net 정산금: 환불은 제외 (회계상 판매자 net 수익) - if (r.status === 'Refunded') { - refunded_count += 1; - refunded_amount += r.amount; - } else { - total_settled += r.amount; - } - - return { - settlement_id: r.settlement_id, - sold_at: r.created_at.toISOString(), - prompt_id: r.payment?.purchase?.prompt?.prompt_id ?? 0, - prompt_title: r.payment?.purchase?.prompt?.title ?? '', - buyer_id: r.payment?.purchase?.user_id ?? 0, - buyer_nickname: r.payment?.purchase?.user?.nickname ?? null, - pay_type: r.payment?.pay_type ?? null, - card_name: r.payment?.card_name ?? null, - sale_price, - settled_amount: r.amount, - fee: r.fee, - status: r.status as 'Pending' | 'Succeed' | 'Failed' | 'Refunded', - }; - }); + const totalPages = total === 0 ? 0 : Math.ceil(total / limit); return { message: '월별 판매 내역 조회 성공', year, month, summary: { - count: items.length, - total_sales, - total_settled, - total_fee, - refunded_count, - refunded_amount, + count: total, + total_sales: totals.total_sales, + total_settled: totals.total_settled, + total_fee: totals.total_fee, + refunded_count: totals.refunded_count, + refunded_amount: totals.refunded_amount, + }, + pagination: { + page, + limit, + total, + total_pages: totalPages, + has_next: page < totalPages, }, items, statusCode: 200, diff --git a/swagger.json b/swagger.json index 64287a8..1241957 100644 --- a/swagger.json +++ b/swagger.json @@ -4970,6 +4970,20 @@ "purchased_at": { "type": "string", "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "Succeed", + "Refunded" + ], + "description": "결제 상태. 환불 시 Refunded" + }, + "refunded_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "환불 시각. status가 Refunded일 때만 존재" } } } @@ -7875,6 +7889,27 @@ "maximum": 12, "example": 5 } + }, + { + "in": "query", + "name": "page", + "description": "페이지 번호 1부터 시작", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "description": "페이지당 항목 수 최대 100", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10 + } } ], "responses": { @@ -7899,32 +7934,59 @@ }, "summary": { "type": "object", + "description": "월 전체 합계 (페이지 무관)", "properties": { "count": { "type": "integer", - "example": 3 + "description": "월 전체 거래 건수", + "example": 25 }, "total_sales": { "type": "integer", "description": "원래 판매가 합계 (환불 포함)", - "example": 30000 + "example": 250000 }, "total_settled": { "type": "integer", "description": "net 정산 금액 (환불 제외)", - "example": 18000 + "example": 200000 }, "total_fee": { "type": "integer", - "example": 3000 + "example": 25000 }, "refunded_count": { "type": "integer", - "example": 1 + "example": 2 }, "refunded_amount": { "type": "integer", - "example": 9000 + "example": 18000 + } + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 10 + }, + "total": { + "type": "integer", + "example": 25 + }, + "total_pages": { + "type": "integer", + "example": 3 + }, + "has_next": { + "type": "boolean", + "example": true } } },