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..f5650fb 100644 --- a/src/purchases/routes/purchase.route.ts +++ b/src/purchases/routes/purchase.route.ts @@ -101,6 +101,15 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * 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, + }; + }, +}; 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 } } },