diff --git a/prisma/migrations/20260203073133_add_method_column/migration.sql b/prisma/migrations/20260203073133_add_method_column/migration.sql new file mode 100644 index 0000000..1f63600 --- /dev/null +++ b/prisma/migrations/20260203073133_add_method_column/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - The values [kakaopay,tosspay] on the enum `Payment_provider` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterTable +ALTER TABLE `Payment` ADD COLUMN `cash_receipt_type` VARCHAR(191) NULL, + ADD COLUMN `cash_receipt_url` VARCHAR(191) NULL, + ADD COLUMN `method` ENUM('CARD', 'VIRTUAL_ACCOUNT', 'TRANSFER', 'MOBILE', 'EASY_PAY') NOT NULL DEFAULT 'CARD', + MODIFY `provider` ENUM('TOSSPAYMENTS', 'KAKAOPAY', 'NAVERPAY', 'TOSSPAY', 'SAMSUNGPAY', 'APPLEPAY', 'LPAY', 'PAYCO', 'SSG', 'PINPAY') NOT NULL; diff --git a/prisma/migrations/20260203073423_remove_default_value/migration.sql b/prisma/migrations/20260203073423_remove_default_value/migration.sql new file mode 100644 index 0000000..280e113 --- /dev/null +++ b/prisma/migrations/20260203073423_remove_default_value/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Payment` ALTER COLUMN `method` DROP DEFAULT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53f84a3..7f31d2d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -392,17 +392,44 @@ model Purchase { @@index([user_id], map: "Purchase_user_id_fkey") } +// 결제 수단 +enum PaymentMethod { + CARD // 카드 + VIRTUAL_ACCOUNT // 가상계좌 + TRANSFER // 계좌이체 + MOBILE // 휴대폰 + EASY_PAY // 간편결제 +} + +// 구체적인 PG사 또는 간편결제사 +enum PaymentProvider { + TOSSPAYMENTS // 일반 토스 PG (카드, 가상계좌 등) + KAKAOPAY // 카카오페이 + NAVERPAY // 네이버페이 + TOSSPAY // 토스페이 + SAMSUNGPAY // 삼성페이 + APPLEPAY // 애플페이 + LPAY // 엘페이 + PAYCO // 페이코 + SSG // SSG페이 + PINPAY // 핀페이 +} + model Payment { payment_id Int @id @default(autoincrement()) purchase_id Int @unique status Status - provider Payment_provider + method PaymentMethod + provider PaymentProvider merchant_uid String @unique created_at DateTime @default(now()) updated_at DateTime @updatedAt imp_uid String @unique purchase Purchase @relation(fields: [purchase_id], references: [purchase_id], onDelete: Cascade) settlement Settlement? + // 현금영수증 정보 (가상계좌/계좌이체 시) + cash_receipt_url String? // 영수증 조회 URL + cash_receipt_type String? // 소득공제(DEDUCTION), 지출증빙(PROOF) 등 } model Settlement { @@ -588,11 +615,6 @@ enum NotificationType { ADMIN_MESSAGE } -enum Payment_provider { - kakaopay - tosspay -} - enum userStatus { active banned diff --git a/src/purchases/dtos/purchase.dto.ts b/src/purchases/dtos/purchase.dto.ts index 69d35be..baf5e79 100644 --- a/src/purchases/dtos/purchase.dto.ts +++ b/src/purchases/dtos/purchase.dto.ts @@ -1,9 +1,11 @@ +import { PaymentProvider } from "@prisma/client"; + export interface PurchaseHistoryItemDTO { prompt_id: number; title: string; price: number; seller_nickname: string; - pg: 'kakaopay' | 'tosspay'; + pg: PaymentProvider | null; } export interface PurchaseHistoryResponseDTO { diff --git a/src/purchases/dtos/purchase.request.dto.ts b/src/purchases/dtos/purchase.request.dto.ts index 3fadb02..7e2c84b 100644 --- a/src/purchases/dtos/purchase.request.dto.ts +++ b/src/purchases/dtos/purchase.request.dto.ts @@ -1,12 +1,3 @@ export interface PromptPurchaseRequestDTO { prompt_id: number; - pg: 'kakaopay' | 'tosspay'; // 결제 수단 - merchant_uid: string; // 고유 주문번호 - amount: number; - buyer_name: string; - redirect_url: string; - custom_data: { - prompt_id: number; - user_id: number; - }; } \ No newline at end of file diff --git a/src/purchases/repositories/purchase.complete.repository.ts b/src/purchases/repositories/purchase.complete.repository.ts index c1d129d..9023650 100644 --- a/src/purchases/repositories/purchase.complete.repository.ts +++ b/src/purchases/repositories/purchase.complete.repository.ts @@ -1,9 +1,9 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, PaymentMethod, PaymentProvider, Status } from '@prisma/client'; type Tx = Prisma.TransactionClient; export const PurchaseCompleteRepository = { - createPurchaseTx(tx: Tx, data: { + createPurchaseTx(tx: Tx, data: { user_id: number; prompt_id: number; seller_id?: number; @@ -16,17 +16,23 @@ export const PurchaseCompleteRepository = { createPaymentTx(tx: Tx, data: { purchase_id: number; merchant_uid: string; - pg: 'kakaopay' | 'tosspay'; - status: 'Succeed' | 'Failed' | 'Pending'; + method: PaymentMethod; + provider: PaymentProvider; + status: Status; paymentId: string; + cash_receipt_url?: string | null; + cash_receipt_type?: string | null; }) { return tx.payment.create({ data: { purchase: { connect: { purchase_id: data.purchase_id } }, merchant_uid: data.merchant_uid, - provider: data.pg, - status: data.status, imp_uid: data.paymentId, + method: data.method, + provider: data.provider, + status: data.status, + cash_receipt_url: data.cash_receipt_url, + cash_receipt_type: data.cash_receipt_type, }, }); }, @@ -36,7 +42,7 @@ export const PurchaseCompleteRepository = { paymentId: number; amount: number; fee: number; - status: 'Succeed' | 'Failed' | 'Pending'; + status: Status; }) { return tx.settlement.upsert({ where: { payment_id: input.paymentId }, diff --git a/src/purchases/routes/purchase.request.route.ts b/src/purchases/routes/purchase.request.route.ts index 4456cdf..f5e519f 100644 --- a/src/purchases/routes/purchase.request.route.ts +++ b/src/purchases/routes/purchase.request.route.ts @@ -17,8 +17,8 @@ const router = Router(); * @swagger * /api/prompts/purchases/requests: * post: - * summary: 결제 요청 생성 - * description: 결제 시작을 위한 요청을 생성합니다. + * summary: 결제 요청 생성 (사전 검증) + * description: 프론트엔드에서 결제창을 띄우기 전, 주문 번호 생성 및 사전 검증을 수행합니다. * tags: [Purchase] * security: * - jwt: [] @@ -28,23 +28,28 @@ const router = Router(); * application/json: * schema: * type: object + * required: + * - prompt_id + * - merchant_uid + * - amount + * - buyer_name + * - redirect_url * properties: * prompt_id: * type: integer - * pg: - * type: string - * enum: [kakaopay, tosspayments] * merchant_uid: * type: string + * description: 가맹점 주문 번호 * amount: * type: integer + * description: 결제 예정 금액 * buyer_name: * type: string * redirect_url: * type: string * responses: * 200: - * description: 결제 요청 생성 성공 + * description: 요청 성공 * content: * application/json: * schema: @@ -52,22 +57,10 @@ const router = Router(); * properties: * message: * type: string - * payment_gateway: - * type: string * merchant_uid: * type: string - * redirect_url: - * type: string * statusCode: * type: integer - * 400: - * description: 잘못된 요청 - * 401: - * description: 인증 실패 - * 404: - * description: 리소스 없음 - * 409: - * description: 중복/상태 충돌 */ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurchase); @@ -75,8 +68,8 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * @swagger * /api/prompts/purchases/complete: * post: - * summary: 결제 완료 처리(Webhook/리다이렉트 후 서버 검증) - * description: 포트원 imp_uid 기반으로 서버에서 결제 검증 후 구매/결제/정산을 기록합니다. + * summary: 결제 완료 처리 (검증 및 저장) + * description: 포트원 결제 완료 후, paymentId를 서버로 보내 검증하고 구매를 확정합니다. * tags: [Purchase] * security: * - jwt: [] @@ -86,14 +79,18 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * application/json: * schema: * type: object + * required: + * - paymentId * properties: - * imp_uid: + * paymentId: * type: string + * description: 포트원 V2 결제 ID * merchant_uid: * type: string + * description: 가맹점 주문 번호 * responses: * 200: - * description: 결제 완료 처리 성공 + * description: 결제 성공 및 저장 완료 * content: * application/json: * schema: @@ -106,19 +103,8 @@ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurch * enum: [Succeed, Failed, Pending] * purchase_id: * type: integer - * nullable: true * statusCode: * type: integer - * 400: - * description: 검증 실패/유효하지 않은 요청 - * 401: - * description: 인증 실패 - * 404: - * description: 리소스 없음 - * 409: - * description: 충돌 - * 500: - * description: 서버 오류 */ router.post('/complete', authenticateJwt, PurchaseCompleteController.completePurchase); @@ -126,34 +112,14 @@ router.post('/complete', authenticateJwt, PurchaseCompleteController.completePur * @swagger * /api/prompts/purchases: * get: - * summary: 내 결제 내역 조회 - * description: 인증된 사용자의 결제(구매) 내역을 조회합니다. + * summary: 결제 내역 조회 + * description: 인증된 사용자의 결제 내역을 최신순으로 조회합니다. * tags: [Purchase] * security: * - jwt: [] - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * required: false - * description: 페이지 번호 (옵션) - * - in: query - * name: pageSize - * schema: - * type: integer - * required: false - * description: 페이지 크기 (옵션) - * - in: query - * name: status - * schema: - * type: string - * enum: [Succeed, Failed, Pending] - * required: false - * description: 결제 상태 필터 (옵션) * responses: * 200: - * description: 결제 내역 조회 성공 + * description: 조회 성공 * content: * application/json: * schema: @@ -172,21 +138,18 @@ router.post('/complete', authenticateJwt, PurchaseCompleteController.completePur * type: string * price: * type: integer + * seller_nickname: + * type: string * purchased_at: * type: string * format: date-time - * seller_nickname: - * type: string - * nullable: true * pg: * type: string - * enum: [kakaopay, tosspay, null] + * description: 결제 제공자 (DB Enum) + * enum: [TOSSPAYMENTS, KAKAOPAY, TOSSPAY, NAVERPAY, SAMSUNGPAY, APPLEPAY, LPAY, PAYCO, SSG, PINPAY] + * nullable: true * statusCode: * type: integer - * 401: - * description: 인증 실패 - * 500: - * description: 서버 오류 */ router.get('/', authenticateJwt, PurchaseHistoryController.list); diff --git a/src/purchases/services/purchase.complete.service.ts b/src/purchases/services/purchase.complete.service.ts index 608c95b..258eaee 100644 --- a/src/purchases/services/purchase.complete.service.ts +++ b/src/purchases/services/purchase.complete.service.ts @@ -4,7 +4,6 @@ import { PurchaseCompleteRepository } from '../repositories/purchase.complete.re import { AppError } from '../../errors/AppError'; import prisma from '../../config/prisma'; import { fetchAndVerifyPortonePayment } from '../utils/portone'; -import { mapPgProvider } from '../utils/payment.util'; export const PurchaseCompleteService = { async completePurchase(userId: number, dto: PromptPurchaseCompleteRequestDTO): Promise { @@ -32,9 +31,6 @@ export const PurchaseCompleteService = { throw new AppError('이미 구매한 프롬프트입니다.', 409, 'AlreadyPurchased'); } - // 5. 트랜잭션 처리 - const pgProvider = mapPgProvider(verifiedPayment.method_provider); - const { purchase_id } = await prisma.$transaction(async (tx) => { // 구매 기록 생성 const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { @@ -49,9 +45,12 @@ export const PurchaseCompleteService = { const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { purchase_id: purchase.purchase_id, merchant_uid: paymentId, - pg: pgProvider, + paymentId: paymentId, status: 'Succeed', - paymentId: paymentId + method: verifiedPayment.method, + provider: verifiedPayment.provider, + cash_receipt_url: verifiedPayment.cashReceipt?.url, + cash_receipt_type: verifiedPayment.cashReceipt?.type, }); // 정산 데이터 생성 diff --git a/src/purchases/services/purchase.request.service.ts b/src/purchases/services/purchase.request.service.ts index 2ca3d2b..9b0e7e4 100644 --- a/src/purchases/services/purchase.request.service.ts +++ b/src/purchases/services/purchase.request.service.ts @@ -1,3 +1,4 @@ +import { v4 as uuidv4 } from 'uuid'; import { PromptPurchaseRequestDTO } from '../dtos/purchase.request.dto'; import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; import { AppError } from '../../errors/AppError'; @@ -12,16 +13,18 @@ export const PurchaseRequestService = { const existing = await PurchaseRequestRepository.findExistingPurchase(userId, dto.prompt_id); if (existing) throw new AppError('이미 구매한 프롬프트입니다.', 409, 'AlreadyPurchased'); + const paymentId = `payment-${uuidv4()}`; + return { - message: '결제 요청이 정상 처리되었습니다.', - payment_gateway: dto.pg, - merchant_uid: dto.merchant_uid, - redirect_url: dto.redirect_url, // 클라이언트가 넘긴 값 사용 - custom_data: { + message: '주문서가 생성되었습니다.', + statusCode: 200, + merchant_uid: paymentId, + amount: prompt.price, + prompt_title: prompt.title, + custom_data: { prompt_id: dto.prompt_id, user_id: userId, }, - statusCode: 200, }; }, }; \ No newline at end of file diff --git a/src/purchases/services/purchase.service.ts b/src/purchases/services/purchase.service.ts index 11aef0e..ad0884b 100644 --- a/src/purchases/services/purchase.service.ts +++ b/src/purchases/services/purchase.service.ts @@ -1,6 +1,5 @@ import { PurchaseRepository } from "../repositories/purchase.repository"; import { PurchaseHistoryItemDTO, PurchaseHistoryResponseDTO } from "../dtos/purchase.dto"; -import { mapPgProvider } from "../utils/payment.util"; export const PurchaseHistoryService = { async list(userId: number): Promise { @@ -12,7 +11,7 @@ export const PurchaseHistoryService = { price: r.amount, purchased_at: r.created_at.toISOString(), seller_nickname: r.prompt.user.nickname, - pg: mapPgProvider(r.payment?.provider), + pg: r.payment?.provider ?? null, })); return { diff --git a/src/purchases/services/purchase.webhook.service.ts b/src/purchases/services/purchase.webhook.service.ts index bec02fa..8ab782d 100644 --- a/src/purchases/services/purchase.webhook.service.ts +++ b/src/purchases/services/purchase.webhook.service.ts @@ -2,7 +2,6 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repo import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import prisma from '../../config/prisma'; import { fetchAndVerifyPortonePayment } from '../utils/portone'; -import { mapPgProvider } from '../utils/payment.util'; export const WebhookService = { async handleTransactionPaid(paymentId: string, storeId: string) { @@ -47,8 +46,6 @@ export const WebhookService = { return; } - const pgProvider = mapPgProvider(verifiedPayment.method_provider); - await prisma.$transaction(async (tx) => { // 구매 생성 const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { @@ -63,7 +60,10 @@ export const WebhookService = { const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { purchase_id: purchase.purchase_id, merchant_uid: paymentId, - pg: pgProvider, + method: verifiedPayment.method, + provider: verifiedPayment.provider, + cash_receipt_url: verifiedPayment.cashReceipt?.url, + cash_receipt_type: verifiedPayment.cashReceipt?.type, status: 'Succeed', paymentId: paymentId }); diff --git a/src/purchases/utils/payment.util.ts b/src/purchases/utils/payment.util.ts index c227fbd..9bb65d6 100644 --- a/src/purchases/utils/payment.util.ts +++ b/src/purchases/utils/payment.util.ts @@ -1,9 +1,42 @@ -import { AppError } from "../../errors/AppError"; +import { PaymentMethod, PaymentProvider } from '@prisma/client'; +import { AppError } from '../../errors/AppError'; -export function mapPgProvider(provider: string | undefined): 'kakaopay' | 'tosspay' { - const src = (provider || '').toLowerCase(); - if (src.includes('kakao')) return 'kakaopay'; - if (src.includes('toss')) return 'tosspay'; +// 1. 결제 수단 매핑 +export function normalizePaymentMethod(input: string): PaymentMethod { + const code = input.toUpperCase().replace(/\s+/g, ''); - throw new AppError(`지원하지 않는 결제 수단입니다. (Provider: ${provider})`, 400, 'InvalidPaymentMethod'); + if (code === 'CARD' || code.includes('카드')) return 'CARD'; + if (code === 'VIRTUAL_ACCOUNT' || code.includes('가상계좌')) return 'VIRTUAL_ACCOUNT'; + if (code === 'TRANSFER' || code.includes('계좌이체')) return 'TRANSFER'; + if (code === 'MOBILE' || code.includes('MOBILE_PHONE') || code.includes('휴대폰')) return 'MOBILE'; + if (code === 'EASY_PAY' || code.includes('간편결제')) return 'EASY_PAY'; + + throw new AppError(`지원하지 않는 결제 수단입니다: ${input}`, 400, 'UnsupportedPaymentMethod'); +} + +// 2. 제공자 (Provider) 매핑 +export function normalizePaymentProvider(input: string, method: PaymentMethod): PaymentProvider { + const code = (input || '').toUpperCase().replace(/\s+/g, ''); + + if (code.includes('KAKAO') || code.includes('카카오')) { + return 'KAKAOPAY'; + } + if (code.includes('TOSSPAY') || code.includes('토스')) { + return 'TOSSPAY'; + } + if ( + code.includes('NAVER') || code.includes('네이버') || + code.includes('SAMSUNG') || code.includes('삼성') || + code.includes('APPLE') || code.includes('애플') || + code.includes('PAYCO') || code.includes('페이코') || + code.includes('LPAY') || code.includes('엘페이') || + code.includes('SSG') || code.includes('에스에스지') || + code.includes('PINPAY') || code.includes('핀페이') + ) { + throw new AppError(`현재 지원하지 않는 간편결제사입니다: ${input}`, 400, 'ProviderNotSupported'); + } + if (method !== 'EASY_PAY') { + return 'TOSSPAYMENTS'; + } + throw new AppError(`식별할 수 없는 결제 제공자입니다: ${input}`, 400, 'UnknownPaymentProvider'); } \ No newline at end of file diff --git a/src/purchases/utils/portone.ts b/src/purchases/utils/portone.ts index 5f948ed..e6d5672 100644 --- a/src/purchases/utils/portone.ts +++ b/src/purchases/utils/portone.ts @@ -1,5 +1,7 @@ import axios from 'axios'; import { AppError } from '../../errors/AppError'; +import { PaymentMethod, PaymentProvider } from '@prisma/client' +import { normalizePaymentMethod, normalizePaymentProvider } from "./payment.util" interface PortOnePaymentResponse { id: string; // paymentId @@ -13,13 +15,23 @@ interface PortOnePaymentResponse { }; orderName: string; method?: { - type: "CARD" | "VIRTUAL_ACCOUNT" | "EASY_PAY" | "TRANSFER" | "MOBILE"; + type: "PaymentMethodCard" | "PaymentMethodVirtualAccount" | "PaymentMethodEasyPay" | "PaymentMethodTransfer" | "PaymentMethodMobile" easyPay?: { provider: string; }; card?: { publisher: string; }; + transfer?: { bank: string; }; + virtualAccount?: { bank: string; }; + mobile?: { carrier?: string; }; + }; + cashReceipt?: { + type: "DEDUCTION" | "PROOF" | "NONE"; + url: string; + issueNumber: string; + currency: string; + amount: number; }; customData?: string; requestedAt: string; @@ -30,9 +42,14 @@ export type PortonePaymentVerified = { paymentId: string; amount: number; status: string; - method_provider?: string; - paidAt?: Date; + method: PaymentMethod; + provider: PaymentProvider; + paidAt: Date; customData: any; + cashReceipt?: { + type: string; + url: string; + } | null; }; export async function fetchAndVerifyPortonePayment( @@ -85,20 +102,42 @@ export async function fetchAndVerifyPortonePayment( } // 5. PG Provider 추출 - let provider = ''; - if (payment.method?.type === 'EASY_PAY') { - provider = payment.method.easyPay?.provider || ''; - } else if (payment.method?.type === 'CARD') { - provider = payment.method.card?.publisher || 'CARD'; + const rawMethodType = payment.method?.type || ''; + const method = normalizePaymentMethod(rawMethodType); + let rawProvider = ''; + + if (method === 'EASY_PAY') { + rawProvider = payment.method?.easyPay?.provider || ''; + } else if (method === 'CARD') { + rawProvider = payment.method?.card?.publisher || ''; + } else if (method === 'TRANSFER') { + rawProvider = payment.method?.transfer?.bank || ''; + } else if (method === 'VIRTUAL_ACCOUNT') { + rawProvider = payment.method?.virtualAccount?.bank || ''; + } else if (method === 'MOBILE') { + rawProvider = payment.method?.mobile?.carrier || 'MOBILE'; + } + + const provider = normalizePaymentProvider(rawProvider, method); + + // 6. 현금영수증 데이터 추출 + let cashReceiptInfo = null; + if (payment.cashReceipt) { + cashReceiptInfo = { + type: payment.cashReceipt.type, + url: payment.cashReceipt.url + }; } return { paymentId: payment.id, amount: payment.amount.total, status: payment.status, - method_provider: provider, + method: method, + provider: provider, paidAt: payment.paidAt ? new Date(payment.paidAt) : new Date(), customData: parsedCustomData, + cashReceipt: cashReceiptInfo }; } catch (err: any) {