From 95c9845d55ca82da5b106a80f7a8efe6d25d2cb2 Mon Sep 17 00:00:00 2001 From: minij02 Date: Fri, 10 Apr 2026 17:24:38 +0900 Subject: [PATCH 1/3] feat: implement individual seller registration and update API with Payple verification --- .../migration.sql | 2 + prisma/schema.prisma | 1 + .../settlement.seller.controller.ts | 4 ++ src/settlements/dtos/settlement.dto.ts | 2 + .../repositories/settlement.repository.ts | 4 +- src/settlements/routes/settlement.route.ts | 51 ++++++++++++--- .../services/settlement.seller.service.ts | 64 ++++++++++++++++++- 7 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260410071809_add_birth_date_to_settlement/migration.sql diff --git a/prisma/migrations/20260410071809_add_birth_date_to_settlement/migration.sql b/prisma/migrations/20260410071809_add_birth_date_to_settlement/migration.sql new file mode 100644 index 0000000..bcc3ee5 --- /dev/null +++ b/prisma/migrations/20260410071809_add_birth_date_to_settlement/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `SettlementAccount` ADD COLUMN `birth_date` VARCHAR(10) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f2872e6..318898b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -460,6 +460,7 @@ model SettlementAccount { account_number String @db.VarChar(30) account_holder String @db.VarChar(100) seller_type SellerType @default(INDIVIDUAL) + birth_date String? @db.VarChar(10) status ApprovalStatus @default(APPROVED) // 개인은 즉시 승인, 사업자는 PENDING으로 생성 is_active Boolean @default(true) representative_name String? @db.VarChar(100) diff --git a/src/settlements/controllers/settlement.seller.controller.ts b/src/settlements/controllers/settlement.seller.controller.ts index c79f700..6682634 100644 --- a/src/settlements/controllers/settlement.seller.controller.ts +++ b/src/settlements/controllers/settlement.seller.controller.ts @@ -33,6 +33,10 @@ export const registerIndividual = async (req: Request, res: Response) => { statusCode: 400, }); } + + if (error.name === 'AccountVerificationError') { + return res.status(400).json({ error: 'AccountVerificationError', message: error.message, statusCode: 400 }); + } if (error.name === 'AlreadyRegistered') { return res.status(409).json({ diff --git a/src/settlements/dtos/settlement.dto.ts b/src/settlements/dtos/settlement.dto.ts index 1bfeac9..2729c15 100644 --- a/src/settlements/dtos/settlement.dto.ts +++ b/src/settlements/dtos/settlement.dto.ts @@ -1,5 +1,6 @@ export interface VerifyAccountRequestDto { name: string; + birthDate: string; bank: string; accountNumber: string; holderName: string; @@ -26,6 +27,7 @@ export interface UpdateAccountRequestDto { export interface RegisterIndividualSellerRequestDto { name: string; + birthDate: string; bank: string; accountNumber: string; holderName: string; diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index f78d1ad..cb9cf6d 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -10,13 +10,15 @@ export const SettlementRepository = { return await prisma.settlementAccount.upsert({ where: { user_id: userId }, update: { - bank_code: dto.bank, + birth_date: dto.birthDate, + bank_code: dto.bank, account_number: dto.accountNumber, account_holder: dto.holderName, is_active: true, }, create: { user_id: userId, + birth_date: dto.birthDate, bank_code: dto.bank, account_number: dto.accountNumber, account_holder: dto.holderName, diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index 02b73c0..262fc69 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -93,7 +93,7 @@ const router = Router(); * example: ValidationError * message: * type: string - * example: 필수 입력값(은행, 계좌번호, 실명/대표자명, 예금주명)이 모두 입력되지 않았습니다. + * example: 필수 입력값(생년월일, 은행, 계좌번호, 실명/대표자명, 예금주명)이 모두 입력되지 않았습니다. * statusCode: * type: integer * example: 400 @@ -240,8 +240,8 @@ router.get("/accounts", authenticateJwt, ViewAccount); * @swagger * /api/settlements/register/individual: * post: - * summary: 개인 판매자 등록 - * description: 개인정보 수집 이용 동의 및 계좌 정보를 입력받아 일반 개인 판매자로 등록합니다. + * summary: 개인 판매자 등록 및 정보 수정 + * description: 개인정보 수집 이용 동의 및 계좌 정보를 입력받아 일반 개인 판매자로 등록하거나 판매자 정보를 수정합니다. * tags: * - Settlement * security: @@ -254,6 +254,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * type: object * required: * - name + * - birthDate * - bank * - accountNumber * - holderName @@ -263,10 +264,14 @@ router.get("/accounts", authenticateJwt, ViewAccount); * type: string * description: 실명 * example: 홍길동 + * birthDate: + * type: string + * description: 예금주 생년월일 6자리 (YYMMDD) + * example: "880212" * bank: * type: string - * description: 포트원 표준 은행 코드 - * example: KOOKMIN + * description: 페이플 금융기관 3자리 숫자 코드 + * example: "004" * accountNumber: * type: string * description: '-'를 제외한 계좌 번호 @@ -281,7 +286,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * example: true * responses: * 200: - * description: 판매자 등록 성공 + * description: 판매자 등록 및 수정 성공 * content: * application/json: * schema: @@ -289,12 +294,22 @@ router.get("/accounts", authenticateJwt, ViewAccount); * properties: * message: * type: string - * example: 개인 판매자 등록이 완료되었습니다. * statusCode: * type: integer * example: 200 + * examples: + * CreateSuccess: + * summary: 신규 등록 성공 + * value: + * message: 개인 판매자 등록이 완료되었습니다. + * statusCode: 200 + * UpdateSuccess: + * summary: 기존 정보 수정 성공 + * value: + * message: 판매자 정보가 성공적으로 수정되었습니다. + * statusCode: 200 * 400: - * description: 검증 실패 - 필수 입력값 누락 또는 약관 미동의 + * description: 검증 실패 * content: * application/json: * schema: @@ -302,13 +317,29 @@ router.get("/accounts", authenticateJwt, ViewAccount); * properties: * error: * type: string - * example: ValidationError + * description: 에러 코드 + * enum: + * - ValidationError + * - AccountAuthFailed * message: * type: string - * example: 필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다. + * description: 에러 메시지 * statusCode: * type: integer * example: 400 + * examples: + * validationError: + * summary: 필수 입력값 누락 또는 약관 미동의 + * value: + * error: ValidationError + * message: 필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다. + * statusCode: 400 + * accountAuthFailed: + * summary: 페이플 계좌 인증 실패 + * value: + * error: AccountAuthFailed + * message: 계좌 인증에 실패했습니다. + * statusCode: 400 * 401: * description: 인증 실패 - 로그인하지 않은 사용자 * content: diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index de7a52f..fbd79c6 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -1,4 +1,6 @@ import path from "path"; +import axios from 'axios'; +import crypto from 'crypto'; import { S3Client, PutObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3"; import { AppError } from "../../errors/AppError"; import { RegisterIndividualSellerRequestDto, RegisterBusinessSellerRequestDto} from '../dtos/settlement.dto'; @@ -6,7 +8,7 @@ import { SettlementRepository } from '../repositories/settlement.repository'; export const registerIndividualSeller = async (userId: number, dto: RegisterIndividualSellerRequestDto) => { // 1. 필수값 누락 및 약관 동의 여부 검증 (400) - if (!dto.name || !dto.bank || !dto.accountNumber || !dto.holderName || dto.isTermsAgreed !== true) { + if (!dto.name || !dto.birthDate || !dto.bank || !dto.accountNumber || !dto.holderName || dto.isTermsAgreed !== true) { const error = new Error('필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다.'); error.name = 'ValidationError'; throw error; @@ -20,14 +22,72 @@ export const registerIndividualSeller = async (userId: number, dto: RegisterIndi throw error; } + const PAYPLE_HUB_URL = process.env.PAYPLE_HUB_URL; + const cst_id = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + const randomCode = crypto.randomBytes(5).toString('hex'); + + try { + const authResponse = await axios.post(`${PAYPLE_HUB_URL}/oauth/token`, { + cst_id: cst_id, + custKey: custKey, + code: randomCode + }, { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } + }); + + if (authResponse.data.result !== 'T0000') { + throw new Error(`페이플 인증 실패: ${authResponse.data.message}`); + } + + const accessToken = authResponse.data.access_token; + + const verifyResponse = await axios.post(`${PAYPLE_HUB_URL}/inquiry/real_name`, { + cst_id: cst_id, + custKey: custKey, + sub_id: `user_${userId}`, + bank_code_std: dto.bank, + account_num: dto.accountNumber, + account_holder_info_type: "0", + account_holder_info: dto.birthDate + }, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + }); + + if (verifyResponse.data.result !== 'A0000') { + const error = new Error(verifyResponse.data.message || '유효하지 않은 계좌 정보입니다.'); + error.name = 'AccountVerificationError'; + throw error; + } + + if (verifyResponse.data.account_holder_name !== dto.holderName) { + const error = new Error('입력하신 예금주명과 실제 계좌의 예금주명이 일치하지 않습니다.'); + error.name = 'AccountVerificationError'; + throw error; + } + + } catch (err: any) { + if (err.name === 'AccountVerificationError') throw err; + const error = new Error(err.response?.data?.message || '계좌 인증 처리 중 서버 오류가 발생했습니다.'); + error.name = 'AccountVerificationError'; + throw error; + } + await SettlementRepository.upsertSettlementAccount(userId, { name: dto.name, + birthDate: dto.birthDate, bank: dto.bank, accountNumber: dto.accountNumber, holderName: dto.holderName, }); - return { message: '개인 판매자 등록이 완료되었습니다.' }; + return { message: existingAccount + ? '판매자 정보가 성공적으로 수정되었습니다.' + : '개인 판매자 등록이 완료되었습니다.' }; }; export const s3Client = new S3Client({ From f9f4891427dfb9d9ae60564062982706e1681e44 Mon Sep 17 00:00:00 2001 From: minij02 Date: Mon, 13 Apr 2026 18:24:36 +0900 Subject: [PATCH 2/3] feat: map Payple verification errors to specific subCodes for UI modals --- package.json | 1 + pnpm-lock.yaml | 76 ++++++++++++++++ src/config/redis.ts | 25 ++++++ .../settlement.seller.controller.ts | 10 +-- .../repositories/settlement.repository.ts | 8 +- src/settlements/routes/settlement.route.ts | 90 +++++++++++++------ .../services/settlement.seller.service.ts | 58 ++++++++---- src/settlements/utils/payple.ts | 89 ++++++++++++++++++ 8 files changed, 300 insertions(+), 57 deletions(-) create mode 100644 src/config/redis.ts create mode 100644 src/settlements/utils/payple.ts diff --git a/package.json b/package.json index e72da9c..2d6c6e0 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "passport-jwt": "^4.0.1", "passport-kakao": "^1.0.1", "passport-naver-v2": "^2.0.8", + "redis": "^5.11.0", "reflect-metadata": "^0.2.2", "save-dev": "0.0.1-security", "socket.io": "^4.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6864609..fdebb66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: passport-naver-v2: specifier: ^2.0.8 version: 2.0.8 + redis: + specifier: ^5.11.0 + version: 5.11.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -689,6 +692,39 @@ packages: '@prisma/get-platform@6.19.0': resolution: {integrity: sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==} + '@redis/bloom@5.11.0': + resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + + '@redis/json@5.11.0': + resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/search@5.11.0': + resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/time-series@5.11.0': + resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -1408,6 +1444,10 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + code-point-at@1.1.0: resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} engines: {node: '>=0.10.0'} @@ -3128,6 +3168,10 @@ packages: readline2@1.0.1: resolution: {integrity: sha512-8/td4MmwUB6PkZUbV25uKz7dfrmjYWxsW8DVfibWdlHRk/l/DfHKn4pU+dfcoGLFgWOdyGCzINRQD7jn+Bv+/g==} + redis@5.11.0: + resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==} + engines: {node: '>= 18'} + reduce-component@1.0.1: resolution: {integrity: sha512-y0wyCcdQul3hI3xHfIs0vg/jSbboQc/YTOAqaxjFG7At+XSexduuOqBVL9SmOLSwa/ldkbzVzdwuk9s2EKTAZg==} @@ -4763,6 +4807,26 @@ snapshots: dependencies: '@prisma/debug': 6.19.0 + '@redis/bloom@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/client@5.11.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/search@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/time-series@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + '@scarf/scarf@1.4.0': {} '@smithy/abort-controller@4.2.5': @@ -5734,6 +5798,8 @@ snapshots: clone@2.1.2: {} + cluster-key-slot@1.1.2: {} + code-point-at@1.1.0: {} collection-visit@1.0.0: @@ -7716,6 +7782,16 @@ snapshots: is-fullwidth-code-point: 1.0.0 mute-stream: 0.0.5 + redis@5.11.0: + dependencies: + '@redis/bloom': 5.11.0(@redis/client@5.11.0) + '@redis/client': 5.11.0 + '@redis/json': 5.11.0(@redis/client@5.11.0) + '@redis/search': 5.11.0(@redis/client@5.11.0) + '@redis/time-series': 5.11.0(@redis/client@5.11.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + reduce-component@1.0.1: {} reflect-metadata@0.2.2: {} diff --git a/src/config/redis.ts b/src/config/redis.ts new file mode 100644 index 0000000..6ce447d --- /dev/null +++ b/src/config/redis.ts @@ -0,0 +1,25 @@ +import { createClient } from 'redis'; + +const redisClient = createClient({ + url: process.env.REDIS_URL, +}); + +redisClient.on('connect', () => { + console.log('Redis connected successfully!'); +}); + +redisClient.on('error', (err) => { + console.error('Redis connection error:', err); +}); + +const connectRedis = async () => { + try { + await redisClient.connect(); + } catch (error) { + console.error('Redis 연결 실패, 서버를 다시 확인해주세요.', error); + } +}; + +connectRedis(); + +export default redisClient; diff --git a/src/settlements/controllers/settlement.seller.controller.ts b/src/settlements/controllers/settlement.seller.controller.ts index 6682634..c3edeb7 100644 --- a/src/settlements/controllers/settlement.seller.controller.ts +++ b/src/settlements/controllers/settlement.seller.controller.ts @@ -35,17 +35,9 @@ export const registerIndividual = async (req: Request, res: Response) => { } if (error.name === 'AccountVerificationError') { - return res.status(400).json({ error: 'AccountVerificationError', message: error.message, statusCode: 400 }); + return res.status(400).json({ error: 'AccountVerificationError', subCode: error.subCode, message: error.message, statusCode: 400 }); } - if (error.name === 'AlreadyRegistered') { - return res.status(409).json({ - error: 'AlreadyRegistered', - message: error.message, - statusCode: 409, - }); - } - return res.status(500).json({ error: 'InternalServerError', message: '서버 오류가 발생했습니다.', diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index cb9cf6d..0641c96 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -53,5 +53,11 @@ export const SettlementRepository = { status: 'PENDING', is_active: false, }}) - } + }, + + deleteAccountByUserId: async (userId: number) => { + return await prisma.settlementAccount.delete({ + where: { user_id: userId }, + }); + }, }; \ No newline at end of file diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index 262fc69..0afee1e 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -241,7 +241,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * /api/settlements/register/individual: * post: * summary: 개인 판매자 등록 및 정보 수정 - * description: 개인정보 수집 이용 동의 및 계좌 정보를 입력받아 일반 개인 판매자로 등록하거나 판매자 정보를 수정합니다. + * description: 개인정보 수집 이용 동의 및 계좌 정보를 입력받아 일반 개인 판매자로 등록하거나 판매자 정보를 수정합니다. (일일 계좌 인증 5회 제한) * tags: * - Settlement * security: @@ -309,7 +309,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * message: 판매자 정보가 성공적으로 수정되었습니다. * statusCode: 200 * 400: - * description: 검증 실패 + * description: 검증 실패 또는 계좌 인증 실패 * content: * application/json: * schema: @@ -317,28 +317,78 @@ router.get("/accounts", authenticateJwt, ViewAccount); * properties: * error: * type: string - * description: 에러 코드 - * enum: - * - ValidationError - * - AccountAuthFailed + * description: 에러 코드 (ValidationError 또는 AccountVerificationError) + * subCode: + * type: string + * description: 계좌 인증 실패 상세 코드 (프론트엔드 모달 분기용) * message: * type: string - * description: 에러 메시지 + * description: 에러 상세 메시지 * statusCode: * type: integer * example: 400 * examples: * validationError: - * summary: 필수 입력값 누락 또는 약관 미동의 + * summary: 필수 입력값 누락 * value: * error: ValidationError * message: 필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다. * statusCode: 400 - * accountAuthFailed: - * summary: 페이플 계좌 인증 실패 + * nameMismatch: + * summary: [모달 1] 예금주명 불일치 + * value: + * error: AccountVerificationError + * subCode: NAME_MISMATCH + * message: 실명과 예금주명이 일치하는지 다시 확인해주세요. + * statusCode: 400 + * bankMismatch: + * summary: [모달 2] 은행 불일치 + * value: + * error: AccountVerificationError + * subCode: BANK_MISMATCH + * message: 선택하신 은행과 계좌번호가 일치하지 않습니다. 은행명을 다시 확인해 주세요. + * statusCode: 400 + * accountNotFound: + * summary: [모달 3] 없는 계좌 + * value: + * error: AccountVerificationError + * subCode: ACCOUNT_NOT_FOUND + * message: 해당 계좌는 존재하지 않는 계좌입니다. 다시 확인해주세요. + * statusCode: 400 + * accountRestricted: + * summary: [모달 4] 거래 불가 계좌 (정지/해약 등) * value: - * error: AccountAuthFailed - * message: 계좌 인증에 실패했습니다. + * error: AccountVerificationError + * subCode: ACCOUNT_RESTRICTED + * message: 입력하신 계좌는 현재 정상적인 거래가 불가능한 상태(해약/사고/정지)입니다. 은행 확인 후 다시 시도해 주세요. + * statusCode: 400 + * unsupportedType: + * summary: [모달 5] 지원하지 않는 계좌 (가상계좌 등) + * value: + * error: AccountVerificationError + * subCode: UNSUPPORTED_TYPE + * message: 해당 계좌는 정산용으로 등록할 수 없는 유형입니다. 원화 입출금이 가능한 보통예금 계좌로 다시 시도해 주세요. + * statusCode: 400 + * bankTimeout: + * summary: [모달 6] 타행 통신 오류/지연 + * value: + * error: AccountVerificationError + * subCode: BANK_TIMEOUT + * message: 해당 은행과의 통신이 원활하지 않습니다. 잠시 후 다시 시도해 주세요. + * statusCode: 400 + * bankMaintenance: + * summary: [모달 7] 은행 점검 시간 + * value: + * error: AccountVerificationError + * subCode: BANK_MAINTENANCE + * message: 현재 은행 정기 점검 시간(가능시간 : 01시 ~ 23시)입니다. 점검 종료 후 다시 시도해 주세요. + * statusCode: 400 + * limitExceeded: + * summary: [모달 8] 일일 인증 횟수 초과 + * value: + * error: AccountVerificationError + * subCode: LIMIT_EXCEEDED + * message: 일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요. * statusCode: 400 * 401: * description: 인증 실패 - 로그인하지 않은 사용자 @@ -356,22 +406,6 @@ router.get("/accounts", authenticateJwt, ViewAccount); * statusCode: * type: integer * example: 401 - * 409: - * description: 충돌 - 이미 등록된 판매자 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: AlreadyRegistered - * message: - * type: string - * example: 이미 판매자로 등록된 회원입니다. - * statusCode: - * type: integer - * example: 409 * 500: * description: 서버 오류 - 알 수 없는 예외 발생 * content: diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index fbd79c6..23ba0c3 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -5,28 +5,42 @@ import { S3Client, PutObjectCommand, DeleteObjectsCommand } from "@aws-sdk/clien import { AppError } from "../../errors/AppError"; import { RegisterIndividualSellerRequestDto, RegisterBusinessSellerRequestDto} from '../dtos/settlement.dto'; import { SettlementRepository } from '../repositories/settlement.repository'; +import redisClient from "../../config/redis"; +import { AccountVerificationError, parseAccountVerificationError } from "../utils/payple"; export const registerIndividualSeller = async (userId: number, dto: RegisterIndividualSellerRequestDto) => { - // 1. 필수값 누락 및 약관 동의 여부 검증 (400) if (!dto.name || !dto.birthDate || !dto.bank || !dto.accountNumber || !dto.holderName || dto.isTermsAgreed !== true) { const error = new Error('필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다.'); error.name = 'ValidationError'; throw error; } - // 2. 이미 등록된 판매자인지(계좌가 있는지) 검증 (409) - const existingAccount = await SettlementRepository.findAccountByUserId(userId); - if (existingAccount) { - const error = new Error('이미 판매자로 등록된 회원입니다.'); - error.name = 'AlreadyRegistered'; - throw error; - } - const PAYPLE_HUB_URL = process.env.PAYPLE_HUB_URL; const cst_id = process.env.PAYPLE_CST_ID; const custKey = process.env.PAYPLE_CUST_KEY; const randomCode = crypto.randomBytes(5).toString('hex'); + const redisKey = `payple_limit:${userId}`; + const currentAttemptsStr = await redisClient.get(redisKey); + const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0; + + if (currentAttempts >= 5) { + const error: any = new Error('일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요.'); + error.name = 'AccountVerificationError'; + error.subCode = 'LIMIT_EXCEEDED'; + throw error; + } + + const newAttempts = await redisClient.incr(redisKey); + + if (newAttempts === 1) { + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0); + const ttlSeconds = Math.floor((midnight.getTime() - now.getTime()) / 1000); + + await redisClient.expire(redisKey, ttlSeconds); + } + try { const authResponse = await axios.post(`${PAYPLE_HUB_URL}/oauth/token`, { cst_id: cst_id, @@ -59,15 +73,11 @@ export const registerIndividualSeller = async (userId: number, dto: RegisterIndi }); if (verifyResponse.data.result !== 'A0000') { - const error = new Error(verifyResponse.data.message || '유효하지 않은 계좌 정보입니다.'); - error.name = 'AccountVerificationError'; - throw error; + throw parseAccountVerificationError(verifyResponse.data); } if (verifyResponse.data.account_holder_name !== dto.holderName) { - const error = new Error('입력하신 예금주명과 실제 계좌의 예금주명이 일치하지 않습니다.'); - error.name = 'AccountVerificationError'; - throw error; + throw new AccountVerificationError('실명과 예금주명이 일치하는지 다시 확인해주세요.', 'NAME_MISMATCH'); } } catch (err: any) { @@ -77,6 +87,12 @@ export const registerIndividualSeller = async (userId: number, dto: RegisterIndi throw error; } + const existingAccount = await SettlementRepository.findAccountByUserId(userId); + + if (existingAccount && existingAccount.seller_type === 'BUSINESS') { + await SettlementRepository.deleteAccountByUserId(userId); + } + await SettlementRepository.upsertSettlementAccount(userId, { name: dto.name, birthDate: dto.birthDate, @@ -85,9 +101,13 @@ export const registerIndividualSeller = async (userId: number, dto: RegisterIndi holderName: dto.holderName, }); - return { message: existingAccount - ? '판매자 정보가 성공적으로 수정되었습니다.' - : '개인 판매자 등록이 완료되었습니다.' }; + const isUpdate = existingAccount && existingAccount.seller_type === 'INDIVIDUAL'; + + return { + message: isUpdate + ? '판매자 정보가 성공적으로 수정되었습니다.' + : '개인 판매자 등록이 완료되었습니다.' + }; }; export const s3Client = new S3Client({ @@ -162,4 +182,4 @@ export const registerBusinessSeller = async (userId: number, dto: RegisterBusine await SettlementRepository.createBusinessAccount(userId, dto); return { message: '사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.' }; -}; \ No newline at end of file +}; diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts new file mode 100644 index 0000000..24fdcdc --- /dev/null +++ b/src/settlements/utils/payple.ts @@ -0,0 +1,89 @@ +import { AppError } from "../../errors/AppError"; + +interface PaypleErrorResponse { + result: string; + message: string; +} + +export class AccountVerificationError extends AppError { + public subCode: string; + + constructor(message: string, subCode: string) { + super(message, 400, 'AccountVerificationError'); + this.subCode = subCode; + this.name = this.constructor.name; + } +} + +export const parseAccountVerificationError = (paypleResponse: PaypleErrorResponse) => { + const code = paypleResponse.result; + const msg = paypleResponse.message || ''; + + // 1. 점검 시간 (7번 모달) + if (code === 'T0996' || code === 'T0997') { + return { + subCode: 'BANK_MAINTENANCE', + message: '현재 은행 정기 점검 시간(가능시간 : 01시 ~ 23시)입니다. 점검 종료 후 다시 시도해 주세요.', + }; + } + + // 2. 은행 오류/불일치 (2번 모달) + if (code === 'N0101' || msg.includes('기관코드')) { + return { + subCode: 'BANK_MISMATCH', + message: '선택하신 은행과 계좌번호가 일치하지 않습니다. 은행명을 다시 확인해 주세요.', + }; + } + + // 3. 통신/지연 오류 (6번 모달) + if (code === 'A0007' || code === 'A0999' || code === 'A0001' || msg.includes('타임아웃')) { + return { + subCode: 'BANK_TIMEOUT', + message: '해당 은행과의 통신이 원활하지 않습니다. 잠시 후 다시 시도해 주세요.', + }; + } + + // 4. 없는 계좌 (3번 모달) + if (code === 'N0198' || msg.includes('해당계좌 없음(412)')) { + return { + subCode: 'ACCOUNT_NOT_FOUND', + message: '해당 계좌는 존재하지 않는 계좌입니다. 다시 확인해주세요.', + }; + } + + // A0009 내부 세부 분기 (4번, 5번 모달) + if (code === 'A0009') { + if (msg.includes('예금주명 불일치(815)')) { + return { + subCode: 'NAME_MISMATCH', + message: '실명과 예금주명이 일치하지 않는 계좌입니다. 다시 확인해주세요.', + }; + } + if (msg.includes('해약 계좌(415)') || msg.includes('사고 신고계좌(419)') || msg.includes('거래중지 계좌(420)')) { + return { + subCode: 'ACCOUNT_RESTRICTED', + message: '입력하신 계좌는 현재 정상적인 거래가 불가능한 상태입니다. 은행 확인 후 다시 시도해 주세요.', + }; + } + if (msg.includes('잡좌(416)') || msg.includes('기타 처리불가(499)')) { + return { + subCode: 'UNSUPPORTED_TYPE', + message: '해당 계좌는 정산용으로 등록할 수 없는 유형입니다. 원화 입출금이 가능한 보통예금 계좌로 다시 시도해 주세요.', + }; + } + } + + // 5. 거래 한도/횟수 초과 (8번 모달) + if (msg.includes('초과') || msg.includes('횟수')) { + return { + subCode: 'LIMIT_EXCEEDED', + message: '일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요.', + }; + } + + // 기본 에러 처리 (입력값 오류 등) + return { + subCode: 'UNKNOWN_VERIFICATION_ERROR', + message: msg || '계좌 인증 처리 중 오류가 발생했습니다. 다시 시도해 주세요.', + }; +}; From 9c55a6bca5cf0ca41ff215f3907d69c6320d4c8f Mon Sep 17 00:00:00 2001 From: minij02 Date: Fri, 1 May 2026 21:25:36 +0900 Subject: [PATCH 3/3] feat: migrate payment and account verification from PortOne to Payple --- package.json | 1 - pnpm-lock.yaml | 8 - .../migration.sql | 20 ++ prisma/schema.prisma | 24 +- src/index.ts | 4 + .../purchase.complete.controller.ts | 11 +- .../controller/purchase.webhook.controller.ts | 36 +-- src/purchases/dtos/purchase.complete.dto.ts | 8 +- src/purchases/dtos/purchase.request.dto.ts | 25 +- .../purchase.complete.repository.ts | 20 +- src/purchases/routes/purchase.route.ts | 98 ++++---- .../routes/purchase.webhook.route.ts | 9 +- .../services/purchase.complete.service.ts | 78 +++--- .../services/purchase.request.service.ts | 32 ++- .../services/purchase.webhook.service.ts | 76 +++--- src/purchases/utils/payple.ts | 230 ++++++++++++++++++ src/purchases/utils/portone.ts | 122 ---------- src/settlements/constants/bank.ts | 88 ++++--- .../services/settlement.account.service.ts | 132 +++++++--- 19 files changed, 588 insertions(+), 434 deletions(-) create mode 100644 prisma/migrations/20260501121255_migrate_payment_to_payple/migration.sql create mode 100644 src/purchases/utils/payple.ts delete mode 100644 src/purchases/utils/portone.ts diff --git a/package.json b/package.json index 2d6c6e0..2dbbb5d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "dependencies": { "@aws-sdk/client-s3": "^3.848.0", "@aws-sdk/s3-request-presigner": "^3.848.0", - "@portone/server-sdk": "^0.19.0", "@prisma/client": "^6.14.0", "@types/express-session": "^1.18.2", "@types/passport-google-oauth20": "^2.0.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdebb66..b7ee95f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.848.0 version: 3.928.0 - '@portone/server-sdk': - specifier: ^0.19.0 - version: 0.19.0 '@prisma/client': specifier: ^6.14.0 version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3) @@ -659,9 +656,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@portone/server-sdk@0.19.0': - resolution: {integrity: sha512-JIbD9HEEqaYuuW0zKU/sapqH1HQBf/Len+8ZPnMRb7E7tHnqRGrKPLS+fom34xUTiF3ZVKTrDjHeQHQ2YZc2og==} - '@prisma/client@6.19.0': resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==} engines: {node: '>=18.18'} @@ -4770,8 +4764,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@portone/server-sdk@0.19.0': {} - '@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.0(typescript@5.9.3) diff --git a/prisma/migrations/20260501121255_migrate_payment_to_payple/migration.sql b/prisma/migrations/20260501121255_migrate_payment_to_payple/migration.sql new file mode 100644 index 0000000..916499a --- /dev/null +++ b/prisma/migrations/20260501121255_migrate_payment_to_payple/migration.sql @@ -0,0 +1,20 @@ +-- Migrate Payment model from PortOne (merchant_uid/imp_uid) to Payple (pcd_pay_oid/pcd_pay_reqkey) +-- Note: existing Payment rows are PortOne test data only (no real transactions yet). + +-- DropIndex +DROP INDEX `Payment_merchant_uid_key` ON `Payment`; +DROP INDEX `Payment_imp_uid_key` ON `Payment`; + +-- AlterTable +ALTER TABLE `Payment` + DROP COLUMN `merchant_uid`, + DROP COLUMN `imp_uid`, + DROP COLUMN `cash_receipt_type`, + ADD COLUMN `pcd_pay_oid` VARCHAR(191) NOT NULL, + ADD COLUMN `pcd_pay_reqkey` VARCHAR(191) NOT NULL, + ADD COLUMN `pay_type` VARCHAR(191) NULL, + ADD COLUMN `card_name` VARCHAR(191) NULL; + +-- CreateIndex +CREATE UNIQUE INDEX `Payment_pcd_pay_oid_key` ON `Payment`(`pcd_pay_oid`); +CREATE UNIQUE INDEX `Payment_pcd_pay_reqkey_key` ON `Payment`(`pcd_pay_reqkey`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 318898b..62791ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -413,18 +413,18 @@ enum PaymentProvider { } model Payment { - payment_id Int @id @default(autoincrement()) - purchase_id Int @unique - status Status - 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) 등 + payment_id Int @id @default(autoincrement()) + purchase_id Int @unique + status Status + pcd_pay_oid String @unique + pcd_pay_reqkey String @unique + pay_type String? + card_name String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + purchase Purchase @relation(fields: [purchase_id], references: [purchase_id], onDelete: Cascade) + settlement Settlement? + cash_receipt_url String? } model Settlement { diff --git a/src/index.ts b/src/index.ts index ab9820c..877461d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import membersRouter from "./members/routes/member.route"; // members 라우터 import promptRoutes from "./prompts/routes/prompt.route"; // 프롬프트 관련 라우터 import ReviewRouter from "./reviews/routes/review.route"; import purchaseRouter from "./purchases/routes/purchase.route"; +import purchaseWebhookRouter from "./purchases/routes/purchase.webhook.route"; import settlementRouter from "./settlements/routes/settlement.route"; import withdrawalRouter from "./withdrawals/routes/withdrawal.route"; import promptDownloadRouter from "./prompts/routes/prompt.download.route"; @@ -123,6 +124,9 @@ app.use("/api/reviews", ReviewRouter); // 프롬프트 검색 API app.use("/api/prompts", promptRoutes); +// 페이플 PCD_RST_URL 서버 콜백 (urlencoded/json 자체 파싱) +app.use("/api/prompts/purchases", purchaseWebhookRouter); + // 프롬프트 결제 라우터 app.use( "/api/prompts/purchases", diff --git a/src/purchases/controller/purchase.complete.controller.ts b/src/purchases/controller/purchase.complete.controller.ts index 53ad500..c321124 100644 --- a/src/purchases/controller/purchase.complete.controller.ts +++ b/src/purchases/controller/purchase.complete.controller.ts @@ -8,10 +8,15 @@ export const PurchaseCompleteController = { const userId = (req.user as any).user_id; const dto = req.body as Partial; - if (!dto || typeof dto.paymentId !== 'string') { + if ( + !dto || + typeof dto.PCD_PAY_OID !== 'string' || + typeof dto.PCD_PAY_REQKEY !== 'string' || + typeof dto.PCD_AUTH_KEY !== 'string' + ) { return res.status(400).json({ error: 'BadRequest', - message: 'paymentId는 필수입니다.', + message: 'PCD_PAY_OID, PCD_PAY_REQKEY, PCD_AUTH_KEY는 필수입니다.', statusCode: 400, }); } @@ -22,4 +27,4 @@ export const PurchaseCompleteController = { next(err); } }, -}; \ No newline at end of file +}; diff --git a/src/purchases/controller/purchase.webhook.controller.ts b/src/purchases/controller/purchase.webhook.controller.ts index 0dbddf8..b617679 100644 --- a/src/purchases/controller/purchase.webhook.controller.ts +++ b/src/purchases/controller/purchase.webhook.controller.ts @@ -1,38 +1,26 @@ import { Request, Response, NextFunction } from 'express'; -import * as PortOne from '@portone/server-sdk'; import { WebhookService } from '../services/purchase.webhook.service'; +import { PayplePaymentResult } from '../utils/payple'; export const WebhookController = { async handleWebhook(req: Request, res: Response, next: NextFunction) { try { - const webhookSecret = process.env.PORTONE_WEBHOOK_SECRET; - if (!webhookSecret) { - console.error('PORTONE_WEBHOOK_SECRET is not set'); - return res.status(500).send('Server Config Error'); - } + const result = req.body as Partial; - // 1. 웹훅 서명 검증 - const webhook = await PortOne.Webhook.verify( - webhookSecret, - req.body, - req.headers as Record - ); + if (!result || typeof result.PCD_PAY_RST !== 'string') { + return res.status(400).send('Invalid payload'); + } - // 2. 이벤트 타입별 처리 -> 현재는 결제 완료(Paid)만 처리 - if (webhook.type === 'Transaction.Paid') { - const { paymentId, storeId } = webhook.data; - await WebhookService.handleTransactionPaid(paymentId, storeId); - } else if (webhook.type === 'Transaction.Cancelled') { - console.log('[Webhook] Transaction Cancelled:', webhook.data.paymentId); + if (result.PCD_PAY_RST !== 'success') { + console.log('[Webhook] Non-success result:', result.PCD_PAY_CODE, result.PCD_PAY_MSG); + return res.status(200).send('OK'); } + + await WebhookService.handlePaypleResult(result as PayplePaymentResult); res.status(200).send('OK'); } catch (err) { - if (err instanceof PortOne.Webhook.WebhookVerificationError) { - console.error('[Webhook] Signature Verification Failed'); - return res.status(400).send('Verification Failed'); - } console.error('[Webhook] Error:', err); res.status(500).send('Internal Server Error'); } - } -}; \ No newline at end of file + }, +}; diff --git a/src/purchases/dtos/purchase.complete.dto.ts b/src/purchases/dtos/purchase.complete.dto.ts index 8169d76..ba75513 100644 --- a/src/purchases/dtos/purchase.complete.dto.ts +++ b/src/purchases/dtos/purchase.complete.dto.ts @@ -1,10 +1,10 @@ -export interface PurchaseCompleteRequestDTO { - paymentId: string; -} +import { PayplePaymentResult } from '../utils/payple'; + +export type PurchaseCompleteRequestDTO = PayplePaymentResult; export interface PurchaseCompleteResponseDTO { message: string; status: 'Succeed' | 'Failed' | 'Pending'; purchase_id?: number; statusCode: number; -} \ No newline at end of file +} diff --git a/src/purchases/dtos/purchase.request.dto.ts b/src/purchases/dtos/purchase.request.dto.ts index 262ec65..e13c34b 100644 --- a/src/purchases/dtos/purchase.request.dto.ts +++ b/src/purchases/dtos/purchase.request.dto.ts @@ -1,17 +1,22 @@ +export type PaypleClientPayType = 'card' | 'transfer'; + export interface PurchaseRequestDTO { prompt_id: number; + pay_type?: PaypleClientPayType; } export interface PurchaseRequestResponseDTO { message: string; statusCode: number; - storeId: string; - paymentId: string; - orderName: string; - totalAmount: number; - channelKey: string; - customData: { - prompt_id: number; - user_id: number; - }; -} \ No newline at end of file + PCD_CST_ID: string; + PCD_CUST_KEY: string; + PCD_AUTH_KEY: string; + PCD_PAY_TYPE: PaypleClientPayType; + PCD_PAY_WORK: 'PAY'; + PCD_PAY_HOST: string; + PCD_PAY_URL: string; + PCD_PAY_OID: string; + PCD_PAY_GOODS: string; + PCD_PAY_TOTAL: number; + PCD_USER_DEFINE1: string; +} diff --git a/src/purchases/repositories/purchase.complete.repository.ts b/src/purchases/repositories/purchase.complete.repository.ts index b5f4052..e1888dc 100644 --- a/src/purchases/repositories/purchase.complete.repository.ts +++ b/src/purchases/repositories/purchase.complete.repository.ts @@ -14,20 +14,22 @@ export const PurchaseCompleteRepository = { createPaymentTx(tx: Tx, data: { purchase_id: number; - merchant_uid: string; - status: Status; - paymentId: string; + pcd_pay_oid: string; + pcd_pay_reqkey: string; + status: Status; + pay_type?: string | null; + card_name?: string | null; 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, - imp_uid: data.paymentId, + pcd_pay_oid: data.pcd_pay_oid, + pcd_pay_reqkey: data.pcd_pay_reqkey, status: data.status, + pay_type: data.pay_type, + card_name: data.card_name, cash_receipt_url: data.cash_receipt_url, - cash_receipt_type: data.cash_receipt_type, }, }); }, @@ -54,5 +56,5 @@ export const PurchaseCompleteRepository = { status: input.status, }, }); - } -}; \ No newline at end of file + }, +}; diff --git a/src/purchases/routes/purchase.route.ts b/src/purchases/routes/purchase.route.ts index 05b29b7..d28f123 100644 --- a/src/purchases/routes/purchase.route.ts +++ b/src/purchases/routes/purchase.route.ts @@ -17,8 +17,8 @@ const router = Router(); * @swagger * /api/prompts/purchases/requests: * post: - * summary: 결제 요청 생성 (주문서 발행) - * description: 프론트엔드에서 포트원 V2 결제창을 띄우기 위해 필요한 주문 번호(paymentId)와 결제 정보를 생성합니다. + * summary: 결제 요청 생성 (페이플 인증 + 주문서 발행) + * description: 페이플 파트너 인증을 수행하고 프론트의 PaypleCpayAuthCheck 호출에 필요한 PCD_* 필드 묶음을 반환합니다. * tags: [Purchase] * security: * - jwt: [] @@ -33,52 +33,36 @@ const router = Router(); * properties: * prompt_id: * type: integer - * description: 구매하려는 프롬프트의 ID * example: 12 + * pay_type: + * type: string + * enum: [card, transfer] + * default: card + * description: 결제 수단 (card=카드, transfer=계좌이체) * responses: * 200: - * description: 주문서 생성 성공 (PortOne V2 SDK 연동 데이터 반환) + * description: 주문서 생성 성공 (페이플 일반결제 연동 데이터 반환) * content: * application/json: * schema: * type: object * properties: - * message: - * type: string - * example: "주문서가 생성되었습니다." - * statusCode: - * type: integer - * example: 200 - * storeId: - * type: string - * description: 포트원 상점 ID (SDK 설정용) - * example: "store-abc12345..." - * paymentId: - * type: string - * description: 서버에서 생성한 고유 주문 번호 (구 merchant_uid) - * example: "payment-550e8400-e29b-41d4-a716-446655440000" - * orderName: - * type: string - * description: 주문명 (프롬프트 제목) - * example: "감성적인 AI 풍경화 프롬프트" - * totalAmount: - * type: number - * description: 결제 금액 (DB 기준) - * example: 5000 - * channelKey: + * message: { type: string, example: "주문서가 생성되었습니다." } + * statusCode: { type: integer, example: 200 } + * PCD_CST_ID: { type: string, description: "페이플 가맹점 ID" } + * PCD_CUST_KEY: { type: string, description: "페이플 가맹점 Key" } + * PCD_AUTH_KEY: { type: string, description: "페이플 인증 토큰" } + * PCD_PAY_TYPE: { type: string, enum: [card, transfer] } + * PCD_PAY_WORK: { type: string, enum: [PAY] } + * PCD_PAY_HOST: { type: string, description: "페이플 결제 호스트 (재검증 시 사용)" } + * PCD_PAY_URL: { type: string, description: "페이플 결제 URL (재검증 시 사용)" } + * PCD_PAY_OID: { type: string, description: "서버 생성 주문 번호" } + * PCD_PAY_GOODS: { type: string, description: "주문명 (프롬프트 제목)" } + * PCD_PAY_TOTAL: { type: number, description: "결제 금액 (서버 검증 기준)" } + * PCD_USER_DEFINE1: * type: string - * description: 포트원 채널 키 (PG사 구분용) - * example: "channel-key-uuid..." - * customData: - * type: object - * description: 결제 검증 및 웹훅 처리를 위한 메타 데이터 - * properties: - * prompt_id: - * type: integer - * example: 12 - * user_id: - * type: integer - * example: 5 + * description: '검증/웹훅용 메타 (JSON 문자열, prompt_id/user_id 포함)' + * example: '{"prompt_id":12,"user_id":5}' */ router.post('/requests', authenticateJwt, PurchaseRequestController.requestPurchase); @@ -131,8 +115,8 @@ router.get('/', authenticateJwt, PurchaseHistoryController.list); * @swagger * /api/prompts/purchases/complete: * post: - * summary: 결제 완료 처리 (검증 및 저장) - * description: 포트원 결제 완료 후, paymentId를 서버로 보내 검증하고 구매를 확정합니다. + * summary: 결제 완료 처리 (페이플 검증 및 저장) + * description: 페이플 결제 완료 후 프론트가 받은 PCD_* 결과 객체를 서버로 그대로 전달하면, PCD_PAY_REQKEY로 페이플에 재검증 후 구매를 확정합니다. * tags: [Purchase] * security: * - jwt: [] @@ -143,11 +127,22 @@ router.get('/', authenticateJwt, PurchaseHistoryController.list); * schema: * type: object * required: - * - paymentId + * - PCD_PAY_RST + * - PCD_PAY_OID + * - PCD_PAY_REQKEY + * - PCD_AUTH_KEY * properties: - * paymentId: - * type: string - * description: 포트원 V2 결제 ID + * PCD_PAY_RST: { type: string, enum: [success, error, close] } + * PCD_PAY_CODE: { type: string } + * PCD_PAY_MSG: { type: string } + * PCD_PAY_OID: { type: string, description: "주문 번호 (요청 시 발급된 값)" } + * PCD_PAY_REQKEY: { type: string, description: "페이플 재검증 키" } + * PCD_AUTH_KEY: { type: string, description: "페이플 인증 토큰" } + * PCD_PAY_HOST: { type: string } + * PCD_PAY_URL: { type: string } + * PCD_PAY_TOTAL: { type: number } + * PCD_PAY_TYPE: { type: string } + * PCD_USER_DEFINE1: { type: string } * responses: * 200: * description: 결제 성공 및 저장 완료 @@ -156,15 +151,10 @@ router.get('/', authenticateJwt, PurchaseHistoryController.list); * schema: * type: object * properties: - * message: - * type: string - * status: - * type: string - * enum: [Succeed, Failed, Pending] - * purchase_id: - * type: integer - * statusCode: - * type: integer + * message: { type: string } + * status: { type: string, enum: [Succeed, Failed, Pending] } + * purchase_id: { type: integer } + * statusCode: { type: integer } */ router.post('/complete', authenticateJwt, PurchaseCompleteController.completePurchase); diff --git a/src/purchases/routes/purchase.webhook.route.ts b/src/purchases/routes/purchase.webhook.route.ts index c3602bf..82d19fc 100644 --- a/src/purchases/routes/purchase.webhook.route.ts +++ b/src/purchases/routes/purchase.webhook.route.ts @@ -1,13 +1,14 @@ import { Router } from 'express'; -import bodyParser from 'body-parser'; +import express from 'express'; import { WebhookController } from '../controller/purchase.webhook.controller'; const router = Router(); router.post( - '/portone-webhook', - bodyParser.text({ type: 'application/json' }), + '/payple-result', + express.urlencoded({ extended: true }), + express.json(), WebhookController.handleWebhook ); -export default router; \ No newline at end of file +export default router; diff --git a/src/purchases/services/purchase.complete.service.ts b/src/purchases/services/purchase.complete.service.ts index 903fc3b..2b4026b 100644 --- a/src/purchases/services/purchase.complete.service.ts +++ b/src/purchases/services/purchase.complete.service.ts @@ -3,73 +3,65 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repo import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import { AppError } from '../../errors/AppError'; import prisma from '../../config/prisma'; -import { fetchAndVerifyPortonePayment } from '../utils/portone'; +import { verifyPayplePayment } from '../utils/payple'; export const PurchaseCompleteService = { async completePurchase(userId: number, dto: PurchaseCompleteRequestDTO): Promise { - const { paymentId } = dto; - - // 1. 포트원 조회 (검증 전 단계) - const verifiedPayment = await fetchAndVerifyPortonePayment(paymentId, { amount: -1 }); + const verifiedPayment = await verifyPayplePayment(dto, { amount: -1 }); const promptId = Number(verifiedPayment.customData?.prompt_id); if (!promptId) throw new AppError('결제 정보에 상품 ID가 없습니다.', 400, 'InvalidPaymentData'); - // 2. DB에서 실제 가격 조회 const prompt = await PurchaseRequestRepository.findPromptWithSeller(promptId); if (!prompt) throw new AppError('프롬프트를 찾을 수 없습니다.', 404, 'NotFound'); - // 3. 서버가 알고 있는 가격과 포트원 결제 가격 비교 (이중 검증) const serverPrice = prompt.price; if (verifiedPayment.amount !== serverPrice) { - throw new AppError('결제 금액 위변조가 감지되었습니다.', 400, 'FraudDetected'); + throw new AppError('결제 금액 위변조가 감지되었습니다.', 400, 'FraudDetected'); } - // 4. 중복 구매 체크 const already = await PurchaseRequestRepository.findExistingPurchase(userId, prompt.prompt_id); if (already) { throw new AppError('이미 구매한 프롬프트입니다.', 409, 'AlreadyPurchased'); } const { purchase_id } = await prisma.$transaction(async (tx) => { - // 구매 기록 생성 - const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { - user_id: userId, - prompt_id: prompt.prompt_id, - amount: serverPrice, - is_free: false - }); + const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { + user_id: userId, + prompt_id: prompt.prompt_id, + amount: serverPrice, + is_free: false, + }); - // 결제 기록 생성 - const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { - purchase_id: purchase.purchase_id, - merchant_uid: paymentId, - paymentId: paymentId, - status: 'Succeed', - cash_receipt_url: verifiedPayment.cashReceipt?.url, - cash_receipt_type: verifiedPayment.cashReceipt?.type, - }); - - // 정산 데이터 생성 - const FEE_RATE = 0.1; - const fee = Math.floor(serverPrice * FEE_RATE); + const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { + purchase_id: purchase.purchase_id, + pcd_pay_oid: verifiedPayment.payOid, + pcd_pay_reqkey: verifiedPayment.reqKey, + status: 'Succeed', + pay_type: verifiedPayment.payType, + card_name: verifiedPayment.cardName, + cash_receipt_url: verifiedPayment.cashReceiptUrl, + }); + + const FEE_RATE = 0.1; + const fee = Math.floor(serverPrice * FEE_RATE); - await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { - sellerId: prompt.user_id, - paymentId: payment.payment_id, - amount: serverPrice - fee, - fee: fee, - status: 'Pending' - }); - - return { purchase_id: purchase.purchase_id }; + await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { + sellerId: prompt.user_id, + paymentId: payment.payment_id, + amount: serverPrice - fee, + fee, + status: 'Pending', + }); + + return { purchase_id: purchase.purchase_id }; }); return { - message: '결제 성공', - status: 'Succeed', - purchase_id, - statusCode: 200, + message: '결제 성공', + status: 'Succeed', + purchase_id, + statusCode: 200, }; }, -}; \ No newline at end of file +}; diff --git a/src/purchases/services/purchase.request.service.ts b/src/purchases/services/purchase.request.service.ts index 2ed9249..9eb508b 100644 --- a/src/purchases/services/purchase.request.service.ts +++ b/src/purchases/services/purchase.request.service.ts @@ -1,8 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; -import { PurchaseRequestDTO } from '../dtos/purchase.request.dto'; -import { PurchaseRequestResponseDTO } from '../dtos/purchase.request.dto'; +import { PurchaseRequestDTO, PurchaseRequestResponseDTO } from '../dtos/purchase.request.dto'; import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; import { AppError } from '../../errors/AppError'; +import { requestPaypleAuth } from '../utils/payple'; export const PurchaseRequestService = { async createPurchaseRequest(userId: number, dto: PurchaseRequestDTO): Promise { @@ -14,20 +14,28 @@ export const PurchaseRequestService = { const existing = await PurchaseRequestRepository.findExistingPurchase(userId, dto.prompt_id); if (existing) throw new AppError('이미 구매한 프롬프트입니다.', 409, 'AlreadyPurchased'); - const paymentId = `payment-${uuidv4()}`; + const payType = dto.pay_type === 'transfer' ? 'transfer' : 'card'; + const payOid = `pay-${uuidv4()}`; + + const auth = await requestPaypleAuth(payType, 'PAY'); return { message: '주문서가 생성되었습니다.', statusCode: 200, - storeId: process.env.PORTONE_STORE_ID || '', - paymentId: paymentId, - orderName: prompt.title, - totalAmount: prompt.price, - channelKey: process.env.PORTONE_CHANNEL_KEY || '', - customData: { - prompt_id: dto.prompt_id, + PCD_CST_ID: process.env.PAYPLE_PAY_CST_ID || '', + PCD_CUST_KEY: process.env.PAYPLE_PAY_CUST_KEY || '', + PCD_AUTH_KEY: auth.authKey, + PCD_PAY_TYPE: payType, + PCD_PAY_WORK: 'PAY', + PCD_PAY_HOST: auth.payHost, + PCD_PAY_URL: auth.payUrl, + PCD_PAY_OID: payOid, + PCD_PAY_GOODS: prompt.title, + PCD_PAY_TOTAL: prompt.price, + PCD_USER_DEFINE1: JSON.stringify({ + prompt_id: dto.prompt_id, user_id: userId, - }, + }), }; }, -}; \ No newline at end of file +}; diff --git a/src/purchases/services/purchase.webhook.service.ts b/src/purchases/services/purchase.webhook.service.ts index 293658a..e8d01ea 100644 --- a/src/purchases/services/purchase.webhook.service.ts +++ b/src/purchases/services/purchase.webhook.service.ts @@ -1,85 +1,75 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import prisma from '../../config/prisma'; -import { fetchAndVerifyPortonePayment } from '../utils/portone'; +import { PayplePaymentResult, verifyPayplePayment } from '../utils/payple'; export const WebhookService = { - async handleTransactionPaid(paymentId: string, storeId: string) { - console.log(`[Webhook] Payment Paid Event Received: ${paymentId}`); + async handlePaypleResult(result: PayplePaymentResult) { + console.log(`[Webhook] Payple Result Received: ${result.PCD_PAY_OID}`); - // 1. 이미 처리된 결제인지 확인 try { - // 2. 포트원 결제 내역 조회 - const verifiedPayment = await fetchAndVerifyPortonePayment(paymentId, { amount: -1 }); + const verified = await verifyPayplePayment(result, { amount: -1 }); - const promptId = Number(verifiedPayment.customData?.prompt_id); + const promptId = Number(verified.customData?.prompt_id); if (!promptId) { - console.error('[Webhook] Prompt ID missing in customData'); - return; // 데이터 오류이므로 종료 (재시도 방지 위해 200 리턴 대상) + console.error('[Webhook] Prompt ID missing in PCD_USER_DEFINE1'); + return; } - const userId = Number(verifiedPayment.customData?.user_id); - - // 3. 중복 구매 체크 - if (userId) { - const existing = await PurchaseRequestRepository.findExistingPurchase(userId, promptId); - if (existing) { - console.log(`[Webhook] Already processed purchase. PaymentId: ${paymentId}`); - return; - } + const userId = Number(verified.customData?.user_id); + if (!userId) { + console.error('[Webhook] User ID missing in PCD_USER_DEFINE1'); + return; + } + + const existing = await PurchaseRequestRepository.findExistingPurchase(userId, promptId); + if (existing) { + console.log(`[Webhook] Already processed purchase. PCD_PAY_OID: ${verified.payOid}`); + return; } - // 4. 가격 검증 const prompt = await PurchaseRequestRepository.findPromptWithSeller(promptId); if (!prompt) throw new Error('Prompt not found'); const serverPrice = prompt.price; - - if (verifiedPayment.amount !== serverPrice) { + if (verified.amount !== serverPrice) { console.error('[Webhook] Fraud detected: Amount mismatch'); - return; - } - - // 5. 트랜잭션 처리 - if (!userId) { - console.error('[Webhook] User ID not found in custom_data. Cannot process.'); - return; + return; } await prisma.$transaction(async (tx) => { - // 구매 생성 const purchase = await PurchaseCompleteRepository.createPurchaseTx(tx, { user_id: userId, prompt_id: prompt.prompt_id, amount: serverPrice, - is_free: false + is_free: false, }); - // 결제 생성 const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { purchase_id: purchase.purchase_id, - merchant_uid: paymentId, - cash_receipt_url: verifiedPayment.cashReceipt?.url, - cash_receipt_type: verifiedPayment.cashReceipt?.type, + pcd_pay_oid: verified.payOid, + pcd_pay_reqkey: verified.reqKey, status: 'Succeed', - paymentId: paymentId + pay_type: verified.payType, + card_name: verified.cardName, + cash_receipt_url: verified.cashReceiptUrl, }); - // 정산 생성 const FEE_RATE = 0.1; const fee = Math.floor(serverPrice * FEE_RATE); await PurchaseCompleteRepository.upsertSettlementForPaymentTx(tx, { sellerId: prompt.user_id, - paymentId: payment.payment_id, + paymentId: payment.payment_id, amount: serverPrice - fee, - fee: fee, - status: 'Pending' + fee, + status: 'Pending', }); }); - console.log(`[Webhook] Successfully processed payment: ${paymentId}`); + + console.log(`[Webhook] Successfully processed payment: ${verified.payOid}`); } catch (error) { console.error('[Webhook] Processing failed:', error); - throw error; + throw error; } - } -}; \ No newline at end of file + }, +}; diff --git a/src/purchases/utils/payple.ts b/src/purchases/utils/payple.ts new file mode 100644 index 0000000..9d4388a --- /dev/null +++ b/src/purchases/utils/payple.ts @@ -0,0 +1,230 @@ +import axios from 'axios'; +import { AppError } from '../../errors/AppError'; + +export type PayplePayType = 'card' | 'transfer'; +export type PayplePayWork = 'PAY' | 'CERT'; + +export interface PaypleAuthResponse { + result: string; + result_msg: string; + cst_id: string; + custKey: string; + AuthKey: string; + PCD_PAY_HOST: string; + PCD_PAY_URL: string; + return_url: string; +} + +export interface PaypleAuthResult { + authKey: string; + payHost: string; + payUrl: string; + returnUrl: string; +} + +export async function requestPaypleAuth( + payType: PayplePayType = 'card', + payWork: PayplePayWork = 'PAY' +): Promise { + const { PAYPLE_CPAY_URL, PAYPLE_PAY_CST_ID, PAYPLE_PAY_CUST_KEY, PAYPLE_REFERER } = process.env; + + if (!PAYPLE_CPAY_URL || !PAYPLE_PAY_CST_ID || !PAYPLE_PAY_CUST_KEY) { + throw new AppError('페이플 결제 환경변수가 설정되지 않았습니다.', 500, 'ServerConfig'); + } + + try { + const { data } = await axios.post( + `${PAYPLE_CPAY_URL}/php/auth.php`, + { + cst_id: PAYPLE_PAY_CST_ID, + custKey: PAYPLE_PAY_CUST_KEY, + PCD_PAY_TYPE: payType, + PCD_PAY_WORK: payWork, + }, + { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + ...(PAYPLE_REFERER ? { Referer: PAYPLE_REFERER } : {}), + }, + timeout: 10_000, + } + ); + + if (data.result !== 'success') { + throw new AppError(`페이플 인증 실패: ${data.result_msg}`, 502, 'BadGateway'); + } + + return { + authKey: data.AuthKey, + payHost: data.PCD_PAY_HOST, + payUrl: data.PCD_PAY_URL, + returnUrl: data.return_url, + }; + } catch (err: any) { + if (err instanceof AppError) throw err; + if (axios.isAxiosError(err)) { + const status = err.response?.status ?? 502; + const msg = err.response?.data?.result_msg || err.message; + console.error('[Payple Auth Error]', { status, msg }); + throw new AppError(`페이플 인증 요청 실패: ${msg}`, 502, 'BadGateway'); + } + throw new AppError('페이플 인증 중 알 수 없는 오류 발생', 500, 'InternalServerError'); + } +} + +export interface PayplePaymentResult { + PCD_PAY_RST: 'success' | 'error' | 'close'; + PCD_PAY_CODE: string; + PCD_PAY_MSG: string; + PCD_PAY_OID: string; + PCD_PAY_TYPE: string; + PCD_PAY_TOTAL: string | number; + PCD_PAY_TIME?: string; + PCD_PAY_GOODS?: string; + PCD_PAYER_NO?: string; + PCD_PAYER_NAME?: string; + PCD_PAYER_EMAIL?: string; + PCD_PAY_CARDNAME?: string; + PCD_PAY_CARDNUM?: string; + PCD_PAY_CARDQUOTA?: string; + PCD_PAY_BANKNAME?: string; + PCD_PAY_BANKNUM?: string; + PCD_PAY_REQKEY?: string; + PCD_AUTH_KEY?: string; + PCD_PAY_HOST?: string; + PCD_PAY_URL?: string; + PCD_PAY_ISTAX?: string; + PCD_PAY_TAXTOTAL?: string | number; + PCD_PAY_CARDRECEIPT?: string; + PCD_USER_DEFINE1?: string; + PCD_USER_DEFINE2?: string; +} + +export type PaypleVerifiedPayment = { + payOid: string; + reqKey: string; + authKey: string; + amount: number; + payType: string; + paidAt: Date; + cardName?: string | null; + cardNum?: string | null; + cardQuota?: string | null; + bankName?: string | null; + bankNum?: string | null; + cashReceiptUrl?: string | null; + customData: { prompt_id?: number; user_id?: number }; +}; + +function parseCustomDefine(define?: string): any { + if (!define) return {}; + try { + return JSON.parse(define); + } catch { + return {}; + } +} + +function parsePaypleTime(t?: string): Date { + if (!t) return new Date(); + const m = t.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); + if (!m) return new Date(t); + const [, y, mo, d, h, mi, s] = m; + return new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}+09:00`); +} + +export async function verifyPayplePayment( + result: PayplePaymentResult, + expected: { amount: number } +): Promise { + const { PAYPLE_PAY_CST_ID, PAYPLE_PAY_CUST_KEY, PAYPLE_REFERER } = process.env; + + if (!PAYPLE_PAY_CST_ID || !PAYPLE_PAY_CUST_KEY) { + throw new AppError('페이플 결제 환경변수가 설정되지 않았습니다.', 500, 'ServerConfig'); + } + + if (result.PCD_PAY_RST !== 'success') { + throw new AppError( + `결제가 완료되지 않았습니다. (${result.PCD_PAY_CODE}: ${result.PCD_PAY_MSG})`, + 400, + 'PaymentNotPaid' + ); + } + + const reqKey = result.PCD_PAY_REQKEY; + const authKey = result.PCD_AUTH_KEY; + const payHost = result.PCD_PAY_HOST; + const payUrl = result.PCD_PAY_URL; + + if (!reqKey || !authKey || !payHost || !payUrl) { + throw new AppError('페이플 결제 검증에 필요한 키가 누락되었습니다.', 400, 'InvalidPaymentData'); + } + + let verified: PayplePaymentResult; + try { + const { data } = await axios.post( + `${payHost}${payUrl}`, + { + PCD_CST_ID: PAYPLE_PAY_CST_ID, + PCD_CUST_KEY: PAYPLE_PAY_CUST_KEY, + PCD_AUTH_KEY: authKey, + PCD_PAY_REQKEY: reqKey, + }, + { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + ...(PAYPLE_REFERER ? { Referer: PAYPLE_REFERER } : {}), + }, + timeout: 10_000, + } + ); + verified = data; + } catch (err: any) { + if (axios.isAxiosError(err)) { + const status = err.response?.status ?? 502; + const msg = err.response?.data?.PCD_PAY_MSG || err.message; + console.error('[Payple Verify Error]', { status, msg }); + throw new AppError(`페이플 검증 실패: ${msg}`, 502, 'BadGateway'); + } + throw new AppError('결제 검증 중 알 수 없는 오류 발생', 500, 'InternalServerError'); + } + + if (verified.PCD_PAY_RST !== 'success') { + throw new AppError( + `페이플 재검증 실패 (${verified.PCD_PAY_CODE}: ${verified.PCD_PAY_MSG})`, + 400, + 'PaymentNotPaid' + ); + } + + const verifiedAmount = Number(verified.PCD_PAY_TOTAL); + if (Number.isNaN(verifiedAmount)) { + throw new AppError('페이플 결제 금액 파싱 실패', 502, 'BadGateway'); + } + if (expected.amount !== -1 && verifiedAmount !== expected.amount) { + throw new AppError('결제 금액 검증 실패 (위변조 의심)', 400, 'PaymentAmountMismatch'); + } + + const customData = { + ...parseCustomDefine(verified.PCD_USER_DEFINE1), + ...parseCustomDefine(verified.PCD_USER_DEFINE2), + }; + + return { + payOid: verified.PCD_PAY_OID, + reqKey, + authKey, + amount: verifiedAmount, + payType: verified.PCD_PAY_TYPE, + paidAt: parsePaypleTime(verified.PCD_PAY_TIME), + cardName: verified.PCD_PAY_CARDNAME ?? null, + cardNum: verified.PCD_PAY_CARDNUM ?? null, + cardQuota: verified.PCD_PAY_CARDQUOTA ?? null, + bankName: verified.PCD_PAY_BANKNAME ?? null, + bankNum: verified.PCD_PAY_BANKNUM ?? null, + cashReceiptUrl: verified.PCD_PAY_CARDRECEIPT ?? null, + customData, + }; +} diff --git a/src/purchases/utils/portone.ts b/src/purchases/utils/portone.ts deleted file mode 100644 index ae87b41..0000000 --- a/src/purchases/utils/portone.ts +++ /dev/null @@ -1,122 +0,0 @@ -import axios from 'axios'; -import { AppError } from '../../errors/AppError'; - -interface PortOnePaymentResponse { - id: string; // paymentId - status: "VIRTUAL_ACCOUNT_ISSUED" | "PAID" | "FAILED" | "CANCELLED" | "PARTIAL_CANCELLED"; - amount: { - total: number; - taxFree: number; - vat: number; - paid: number; - cancelled: number; - }; - orderName: string; - cashReceipt?: { - type: "DEDUCTION" | "PROOF" | "NONE"; - url: string; - issueNumber: string; - currency: string; - amount: number; - }; - customData?: string; - requestedAt: string; - paidAt?: string; -} - -export type PortonePaymentVerified = { - paymentId: string; - amount: number; - status: string; - paidAt: Date; - customData: any; - cashReceipt?: { - type: string; - url: string; - } | null; -}; - -export async function fetchAndVerifyPortonePayment( - paymentId: string, - expected: { amount: number } -): Promise { - const { PORTONE_API_SECRET } = process.env; - - if (!PORTONE_API_SECRET) { - throw new AppError('포트원 API 시크릿이 설정되지 않았습니다.', 500, 'ServerConfig'); - } - - try { - // 1. 포트원 결제 단건 조회 - const { data } = await axios.get( - `https://api.portone.io/payments/${encodeURIComponent(paymentId)}`, - { - headers: { - Authorization: `PortOne ${PORTONE_API_SECRET}`, - 'Content-Type': 'application/json', - }, - timeout: 10_000, - } - ); - - if (!data) { - throw new AppError('포트원 결제 조회 응답이 비어있습니다.', 502, 'BadGateway'); - } - - const payment = data; - - // 2. 상태 검증 (PAID 상태여야 함) - if (payment.status !== 'PAID') { - throw new AppError(`결제가 완료되지 않았습니다. (Status: ${payment.status})`, 400, 'PaymentNotPaid'); - } - - // 3. 금액 검증 - if (expected.amount !== -1 && payment.amount.total !== expected.amount) { - throw new AppError('결제 금액 검증 실패 (위변조 의심)', 400, 'PaymentAmountMismatch'); - } - - // 4. Custom Data 파싱 - let parsedCustomData: any = {}; - if (payment.customData) { - try { - parsedCustomData = JSON.parse(payment.customData); - } catch (e) { - console.warn('Custom Data Parsing Failed', payment.customData); - } - } - - // 5. 현금영수증 데이터 추출 - 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, - paidAt: payment.paidAt ? new Date(payment.paidAt) : new Date(), - customData: parsedCustomData, - cashReceipt: cashReceiptInfo - }; - - } catch (err: any) { - if (axios.isAxiosError(err)) { - const status = err.response?.status ?? 500; - const errorMsg = err.response?.data?.message || err.message; - console.error('[PortOne V2 Verify Error]', { status, errorMsg }); - - if (status === 404) throw new AppError('존재하지 않는 결제 내역입니다.', 404, 'NotFound'); - if (status === 401) throw new AppError('포트원 인증 실패 (API Key Check Required)', 500, 'ServerConfig'); - - throw new AppError(`포트원 검증 실패: ${errorMsg}`, 502, 'BadGateway'); - } - - if (err instanceof AppError) throw err; - - throw new AppError('결제 검증 중 알 수 없는 오류 발생', 500, 'InternalServerError'); - } -} \ No newline at end of file diff --git a/src/settlements/constants/bank.ts b/src/settlements/constants/bank.ts index 2dafbfe..d4254ad 100644 --- a/src/settlements/constants/bank.ts +++ b/src/settlements/constants/bank.ts @@ -1,47 +1,41 @@ -export const PORTONE_BANKS: Record = { - "BANK_OF_KOREA": "한국은행", - "KDB": "산업은행", - "IBK": "기업은행", - "KOOKMIN": "국민은행", - "SUHYUP": "수협은행", - "KEXIM": "수출입은행", - "NONGHYUP": "NH농협은행", - "LOCAL_NONGHYUP": "지역농축협", - "WOORI": "우리은행", - "STANDARD_CHARTERED": "SC제일은행", - "CITI": "한국씨티은행", - "SUHYUP_FEDERATION": "수협중앙회", - "DAEGU": "아이엠뱅크", - "BUSAN": "부산은행", - "KWANGJU": "광주은행", - "JEJU": "제주은행", - "JEONBUK": "전북은행", - "KYONGNAM": "경남은행", - "KFCC": "새마을금고", - "SHINHYUP": "신협", - "SAVINGS_BANK": "저축은행", - "MORGAN_STANLEY": "모간스탠리은행", - "HSBC": "HSBC은행", - "DEUTSCHE": "도이치은행", - "JPMC": "제이피모간체이스은행", - "MIZUHO": "미즈호은행", - "MUFG": "엠유에프지은행", - "BANK_OF_AMERICA": "BOA은행", - "BNP_PARIBAS": "비엔피파리바은행", - "ICBC": "중국공상은행", - "BANK_OF_CHINA": "중국은행", - "NFCF": "산림조합중앙회", - "UOB": "대화은행", - "BOCOM": "교통은행", - "CCB": "중국건설은행", - "POST": "우체국", - "KODIT": "신용보증기금", - "KIBO": "기술보증기금", - "HANA": "하나은행", - "SHINHAN": "신한은행", - "K_BANK": "케이뱅크", - "KAKAO": "카카오뱅크", - "TOSS": "토스뱅크", - "SGI": "서울보증보험", - "KCIS": "한국신용정보원", -}; \ No newline at end of file +// 페이플 bank_code_std (KFTC 표준 3자리 은행 코드) +export const PAYPLE_BANKS: Record = { + "002": "산업은행", + "003": "기업은행", + "004": "국민은행", + "007": "수협은행", + "008": "수출입은행", + "011": "농협은행", + "012": "지역농축협", + "020": "우리은행", + "023": "SC제일은행", + "027": "한국씨티은행", + "031": "아이엠뱅크(대구)", + "032": "부산은행", + "034": "광주은행", + "035": "제주은행", + "037": "전북은행", + "039": "경남은행", + "045": "새마을금고", + "048": "신협", + "050": "저축은행", + "054": "HSBC은행", + "055": "도이치은행", + "057": "제이피모간체이스은행", + "058": "미즈호은행", + "059": "엠유에프지은행", + "060": "BOA은행", + "061": "비엔피파리바은행", + "062": "중국공상은행", + "063": "중국은행", + "064": "산림조합", + "067": "중국건설은행", + "071": "우체국", + "076": "신용보증기금", + "077": "기술보증기금", + "081": "하나은행", + "088": "신한은행", + "089": "케이뱅크", + "090": "카카오뱅크", + "092": "토스뱅크", +}; diff --git a/src/settlements/services/settlement.account.service.ts b/src/settlements/services/settlement.account.service.ts index 141b013..280ae22 100644 --- a/src/settlements/services/settlement.account.service.ts +++ b/src/settlements/services/settlement.account.service.ts @@ -1,73 +1,129 @@ import axios from 'axios'; -import { PORTONE_BANKS } from '../constants/bank'; +import { PAYPLE_BANKS } from '../constants/bank'; import { VerifyAccountRequestDto, AccountDataDto } from '../dtos/settlement.dto'; -import { SettlementRepository} from '../repositories/settlement.repository'; +import { SettlementRepository } from '../repositories/settlement.repository'; +import { AccountVerificationError, parseAccountVerificationError } from '../utils/payple'; export const verifyAndSaveAccount = async (userId: number, dto: VerifyAccountRequestDto) => { - const { name, bank, accountNumber, holderName } = dto; + const { name, birthDate, bank, accountNumber, holderName } = dto; - // 1. 필수 입력값 검증 (400) - if (!name || !bank || !accountNumber || !holderName) { - throw { status: 400, type: "ValidationError", message: "필수 입력값(은행, 계좌번호, 실명/대표자명, 예금주명)이 모두 입력되지 않았습니다." }; + if (!name || !birthDate || !bank || !accountNumber || !holderName) { + throw { + status: 400, + type: 'ValidationError', + message: '필수 입력값(이름, 생년월일, 은행, 계좌번호, 예금주명)이 모두 입력되지 않았습니다.', + }; } - // 2. 지원하는 은행 코드인지 검증 (400) - if (!PORTONE_BANKS[bank]) { - throw { status: 400, type: "InvalidAccountInfo", message: "유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다." }; + if (!PAYPLE_BANKS[bank]) { + throw { + status: 400, + type: 'InvalidAccountInfo', + message: '유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.', + }; } - // 3. 실명과 예금주명 일치 여부 1차 검증 (400) - if (name !== holderName) { - throw { status: 400, type: "NameMismatch", message: "입력하신 실명/대표자명과 예금주명이 일치하지 않습니다." }; + if (name !== holderName) { + throw { + status: 400, + type: 'NameMismatch', + message: '입력하신 실명/대표자명과 예금주명이 일치하지 않습니다.', + }; } + const PAYPLE_HUB_URL = process.env.PAYPLE_HUB_URL; + const cst_id = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + try { - // 4. 포트원 계좌 실명 조회 API 호출 - const portoneUrl = `https://api.portone.io/platform/bank-accounts/${bank}/${accountNumber}/holder`; - const response = await axios.get(portoneUrl, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `PortOne ${process.env.PORTONE_API_SECRET}` - } - }); + const authResponse = await axios.post( + `${PAYPLE_HUB_URL}/oauth/token`, + { cst_id, custKey, code: Math.random().toString(36).slice(2, 12) }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } } + ); + + if (authResponse.data.result !== 'T0000') { + throw new AccountVerificationError( + `페이플 인증 실패: ${authResponse.data.message}`, + 'AUTH_FAILED' + ); + } + + const accessToken = authResponse.data.access_token; - const portoneHolderName = response.data.holderName; + const verifyResponse = await axios.post( + `${PAYPLE_HUB_URL}/inquiry/real_name`, + { + cst_id, + custKey, + sub_id: `user_${userId}`, + bank_code_std: bank, + account_num: accountNumber, + account_holder_info_type: '0', + account_holder_info: birthDate, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + } + ); - // 5. 실제 예금주명 비교 (400) - if (portoneHolderName !== holderName) { - throw { status: 400, type: "AccountHolderMismatch", message: "인증 실패: 실제 계좌의 예금주명과 다릅니다." }; + if (verifyResponse.data.result !== 'A0000') { + throw parseAccountVerificationError(verifyResponse.data); } + if (verifyResponse.data.account_holder_name !== holderName) { + throw new AccountVerificationError( + '인증 실패: 실제 계좌의 예금주명과 다릅니다.', + 'NAME_MISMATCH' + ); + } } catch (error: any) { - if (error.status) throw error; - - // 포트원 API 응답 에러 처리 + if (error.name === 'AccountVerificationError') { + throw { + status: 400, + type: error.subCode || 'AccountVerificationError', + message: error.message, + }; + } + if (error.status) throw error; + if (error.response?.status === 400 || error.response?.status === 404) { - throw { status: 400, type: "InvalidAccountInfo", message: "유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다." }; + throw { + status: 400, + type: 'InvalidAccountInfo', + message: '유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.', + }; } - - throw { status: 500, type: "InternalServerError", message: "알 수 없는 오류가 발생했습니다." }; + + throw { + status: 500, + type: 'InternalServerError', + message: '알 수 없는 오류가 발생했습니다.', + }; } - // 6. 모든 검증을 통과했다면 DB에 저장 await SettlementRepository.upsertSettlementAccount(userId, dto); - return { message: "계좌 인증이 완료되었습니다." }; + return { message: '계좌 인증이 완료되었습니다.' }; }; export const getAccountInfo = async (userId: number): Promise => { const account = await SettlementRepository.findAccountByUserId(userId); if (!account) { - const error: any = new Error("등록된 계좌 정보가 존재하지 않습니다."); + const error: any = new Error('등록된 계좌 정보가 존재하지 않습니다.'); error.statusCode = 404; - error.code = "AccountNotFound"; + error.code = 'AccountNotFound'; throw error; } return { - bank: account.bank_code, - accountNumber: account.account_number, - holderName: account.account_holder, + bank: account.bank_code, + accountNumber: account.account_number, + holderName: account.account_holder, }; -}; \ No newline at end of file +};