diff --git a/prisma/migrations/20260522020000_add_business_type_enum_and_column/migration.sql b/prisma/migrations/20260522020000_add_business_type_enum_and_column/migration.sql new file mode 100644 index 0000000..cf23452 --- /dev/null +++ b/prisma/migrations/20260522020000_add_business_type_enum_and_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `SettlementAccount` ADD COLUMN `business_type` ENUM('PERSONAL', 'CORPORATE') NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 551cf9a..b9cd259 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -460,6 +460,11 @@ enum SellerType { BUSINESS } +enum BusinessType { + PERSONAL // 개인사업자 + CORPORATE // 법인사업자 +} + enum ApprovalStatus { PENDING // 심사 대기중 APPROVED // 승인 완료 @@ -473,7 +478,8 @@ 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) + business_type BusinessType? // BUSINESS일 때만 PERSONAL/CORPORATE 지정 + birth_date String? @db.VarChar(10) // 정책상 미저장 (PII 폐기). Phase 11에서 컬럼 제거 검토 status ApprovalStatus @default(APPROVED) // 개인은 즉시 승인, 사업자는 PENDING으로 생성 is_active Boolean @default(true) representative_name String? @db.VarChar(100) diff --git a/src/settlements/constants/bank.ts b/src/settlements/constants/bank.ts index d4254ad..f65ac84 100644 --- a/src/settlements/constants/bank.ts +++ b/src/settlements/constants/bank.ts @@ -1,24 +1,71 @@ -// 페이플 bank_code_std (KFTC 표준 3자리 은행 코드) +// 페이플 bank_code_std (KFTC 표준 3자리 금융기관 코드) +// 출처: Payple 정산지급대행 공식 명세 export const PAYPLE_BANKS: Record = { - "002": "산업은행", - "003": "기업은행", - "004": "국민은행", + // 은행 + "002": "KDB산업은행", + "003": "IBK기업은행", + "004": "KB국민은행", "007": "수협은행", - "008": "수출입은행", - "011": "농협은행", - "012": "지역농축협", + "011": "NH농협은행", "020": "우리은행", "023": "SC제일은행", "027": "한국씨티은행", - "031": "아이엠뱅크(대구)", + "031": "아이엠뱅크 (구 대구은행)", "032": "부산은행", "034": "광주은행", "035": "제주은행", "037": "전북은행", "039": "경남은행", - "045": "새마을금고", - "048": "신협", - "050": "저축은행", + "081": "하나은행", + "088": "신한은행", + "089": "케이뱅크", + "090": "카카오뱅크", + "092": "토스뱅크", + + // 상호금융기관 + "012": "농협중앙회(단위농축협)", + "030": "수산업협동조합중앙회", + "045": "새마을금고중앙회", + "048": "신협중앙회", + "050": "저축은행중앙회", + "064": "산림조합중앙회", + "071": "우정사업본부(우체국)", + + // 금융투자회사 (증권사) + "209": "유안타증권", + "218": "KB증권", + "227": "다올투자증권", + "238": "미래에셋증권", + "240": "삼성증권", + "243": "한국투자증권", + "247": "NH투자증권", + "261": "교보증권", + "262": "아이엠증권", + "263": "현대차증권", + "264": "키움증권", + "265": "LS증권", + "266": "SK증권", + "267": "대신증권", + "269": "한화투자증권", + "270": "하나증권", + "271": "토스증권", + "278": "신한금융투자", + "279": "DB증권", + "280": "유진투자증권", + "287": "메리츠증권", + + // 보험사 + "436": "교보생명", + "443": "DB손해보험", + + // 캐피탈사 + "304": "현대캐피탈", +}; + +// Payple 명세에서 제거된 코드 — 기존 등록 사용자 호환 보존용. +// 신규 등록/인증에는 사용 금지. Phase 11 운영 마이그레이션 후 제거 예정. +export const LEGACY_PAYPLE_BANKS: Record = { + "008": "수출입은행", "054": "HSBC은행", "055": "도이치은행", "057": "제이피모간체이스은행", @@ -28,14 +75,15 @@ export const PAYPLE_BANKS: Record = { "061": "비엔피파리바은행", "062": "중국공상은행", "063": "중국은행", - "064": "산림조합", "067": "중국건설은행", - "071": "우체국", "076": "신용보증기금", "077": "기술보증기금", - "081": "하나은행", - "088": "신한은행", - "089": "케이뱅크", - "090": "카카오뱅크", - "092": "토스뱅크", }; + +export const ALL_KNOWN_BANKS: Record = { ...PAYPLE_BANKS, ...LEGACY_PAYPLE_BANKS }; + +export const isValidPaypleBank = (code: string): boolean => + Object.prototype.hasOwnProperty.call(PAYPLE_BANKS, code); + +export const isKnownBank = (code: string): boolean => + Object.prototype.hasOwnProperty.call(ALL_KNOWN_BANKS, code); diff --git a/src/settlements/controllers/settlement.account.controller.ts b/src/settlements/controllers/settlement.account.controller.ts index 8391a2a..caa8312 100644 --- a/src/settlements/controllers/settlement.account.controller.ts +++ b/src/settlements/controllers/settlement.account.controller.ts @@ -1,37 +1,57 @@ import { Request, Response } from 'express'; -import { verifyAndSaveAccount, getAccountInfo } from '../services/settlement.account.service'; -import { VerifyAccountRequestDto, ViewAccountResponseDto} from '../dtos/settlement.dto'; +import { verifySellerAccount, getAccountInfo } from '../services/settlement.account.service'; +import { VerifyAccountRequestDto, ViewAccountResponseDto } from '../dtos/settlement.dto'; export const verifyAccount = async (req: Request, res: Response) => { try { - const user = req.user; - + const user = req.user; + if (!user) { return res.status(401).json({ - error: "Unauthorized", - message: "로그인이 필요합니다.", - statusCode: 401 + error: 'Unauthorized', + message: '로그인이 필요합니다.', + statusCode: 401, }); } const dto: VerifyAccountRequestDto = req.body; const userId = (req.user as { user_id: number }).user_id; - const result = await verifyAndSaveAccount(userId, dto); + const result = await verifySellerAccount(userId, dto); return res.status(200).json({ message: result.message, - statusCode: 200 + registerToken: result.registerToken, + expiresIn: result.expiresIn, + statusCode: 200, }); - } catch (error: any) { - const status = error.status || 500; - const errorType = error.type || "InternalServerError"; - const message = error.message || "알 수 없는 오류가 발생했습니다."; - + if (error.name === 'AccountVerificationError' || error?.name === 'AccountVerificationError') { + return res.status(400).json({ + error: 'AccountVerificationError', + subCode: error.subCode, + message: error.message, + statusCode: 400, + }); + } + if (error.error === 'ValidationError' || error?.name === 'ValidationError') { + return res.status(400).json({ + error: 'ValidationError', + message: error.message, + statusCode: 400, + }); + } + if (error.error === 'InvalidAccountInfo') { + return res.status(400).json({ + error: 'InvalidAccountInfo', + message: error.message, + statusCode: 400, + }); + } + const status = error.statusCode || 500; return res.status(status).json({ - error: errorType, - message: message, - statusCode: status + error: error.error || 'InternalServerError', + message: error.message || '알 수 없는 오류가 발생했습니다.', + statusCode: status, }); } }; @@ -42,9 +62,9 @@ export const ViewAccount = async (req: Request, res: Response) => { if (!user) { return res.status(401).json({ - error: "Unauthorized", - message: "로그인이 필요합니다.", - statusCode: 401 + error: 'Unauthorized', + message: '로그인이 필요합니다.', + statusCode: 401, }); } @@ -53,21 +73,21 @@ export const ViewAccount = async (req: Request, res: Response) => { const accountData = await getAccountInfo(userId); const response: ViewAccountResponseDto = { - message: "계좌 정보 조회가 완료되었습니다.", + message: '계좌 정보 조회가 완료되었습니다.', data: accountData, - statusCode: 200 + statusCode: 200, }; return res.status(200).json(response); } catch (error: any) { const status = error.statusCode || 500; - const errorType = error.code || "InternalServerError"; - const message = error.message || "알 수 없는 오류가 발생했습니다."; + const errorType = error.code || 'InternalServerError'; + const message = error.message || '알 수 없는 오류가 발생했습니다.'; return res.status(status).json({ error: errorType, - message: message, - statusCode: status + message, + statusCode: status, }); } -} \ No newline at end of file +}; diff --git a/src/settlements/controllers/settlement.seller.controller.ts b/src/settlements/controllers/settlement.seller.controller.ts index c3edeb7..d734434 100644 --- a/src/settlements/controllers/settlement.seller.controller.ts +++ b/src/settlements/controllers/settlement.seller.controller.ts @@ -1,14 +1,41 @@ import { Request, Response } from 'express'; -import { registerIndividualSeller, registerBusinessSeller } from '../services/settlement.seller.service'; import multer from 'multer'; +import { + registerIndividualSeller, + registerBusinessSeller, + uploadBusinessLicenseFile, +} from '../services/settlement.seller.service'; import { uploadBusinessLicense } from '../../middlewares/upload'; -import { uploadBusinessLicenseFile } from '../services/settlement.seller.service'; import { AppError } from '../../errors/AppError'; +const mapTokenError = (error: any) => { + if (error?.error === 'InvalidRegisterToken') { + return { + status: 401, + body: { + error: 'InvalidRegisterToken', + message: error.message, + statusCode: 401, + }, + }; + } + if (error?.error === 'RegisterTokenAlreadyUsed') { + return { + status: 409, + body: { + error: 'RegisterTokenAlreadyUsed', + message: error.message, + statusCode: 409, + }, + }; + } + return null; +}; + export const registerIndividual = async (req: Request, res: Response) => { try { const user = req.user; - + if (!user) { return res.status(401).json({ error: 'Unauthorized', @@ -24,8 +51,11 @@ export const registerIndividual = async (req: Request, res: Response) => { message: result.message, statusCode: 200, }); - } catch (error: any) { + const tokenError = mapTokenError(error); + if (tokenError) { + return res.status(tokenError.status).json(tokenError.body); + } if (error.name === 'ValidationError') { return res.status(400).json({ error: 'ValidationError', @@ -33,11 +63,14 @@ export const registerIndividual = async (req: Request, res: Response) => { statusCode: 400, }); } - if (error.name === 'AccountVerificationError') { - return res.status(400).json({ error: 'AccountVerificationError', subCode: error.subCode, message: error.message, statusCode: 400 }); + return res.status(400).json({ + error: 'AccountVerificationError', + subCode: error.subCode, + message: error.message, + statusCode: 400, + }); } - return res.status(500).json({ error: 'InternalServerError', message: '서버 오류가 발생했습니다.', @@ -59,16 +92,16 @@ export const uploadLicense = async (req: Request, res: Response) => { statusCode: 413, }); } - } - + } + if (err instanceof AppError && err.statusCode === 415) { return res.status(415).json({ - error: err.name, + error: err.name, message: err.message, statusCode: 415, }); } else if (err) { - throw err; + throw err; } const user = req.user as { user_id: number } | undefined; @@ -88,16 +121,16 @@ export const uploadLicense = async (req: Request, res: Response) => { }); } - const result = await uploadBusinessLicenseFile(user.user_id, req.file); + const result = await uploadBusinessLicenseFile(req.file); return res.status(200).json({ message: result.message, + fileKey: result.fileKey, fileUrl: result.fileUrl, statusCode: 200, }); - } catch (error: any) { - console.error('사업자등록증 업로드 중 에러 발생:', error); + console.error('사업자등록증 업로드 중 에러 발생'); return res.status(500).json({ error: 'InternalServerError', message: '알 수 없는 오류가 발생했습니다.', @@ -110,7 +143,7 @@ export const uploadLicense = async (req: Request, res: Response) => { export const registerBusiness = async (req: Request, res: Response) => { try { const user = req.user as { user_id: number } | undefined; - + if (!user) { return res.status(401).json({ error: 'Unauthorized', @@ -126,8 +159,11 @@ export const registerBusiness = async (req: Request, res: Response) => { message: result.message, statusCode: 200, }); - } catch (error: any) { + const tokenError = mapTokenError(error); + if (tokenError) { + return res.status(tokenError.status).json(tokenError.body); + } if (error.name === 'ValidationError') { return res.status(400).json({ error: 'ValidationError', @@ -135,7 +171,6 @@ export const registerBusiness = async (req: Request, res: Response) => { statusCode: 400, }); } - if (error.name === 'AlreadyRegistered') { return res.status(409).json({ error: 'AlreadyRegistered', @@ -143,7 +178,6 @@ export const registerBusiness = async (req: Request, res: Response) => { statusCode: 409, }); } - if (error.name === 'DuplicateBusinessNumber') { return res.status(409).json({ error: 'DuplicateBusinessNumber', @@ -152,11 +186,11 @@ export const registerBusiness = async (req: Request, res: Response) => { }); } - console.error('사업자 판매자 등록 중 에러 발생:', error); + console.error('사업자 판매자 등록 중 에러 발생'); return res.status(500).json({ error: 'InternalServerError', message: '서버 오류가 발생했습니다.', statusCode: 500, }); } -}; \ No newline at end of file +}; diff --git a/src/settlements/dtos/settlement.dto.ts b/src/settlements/dtos/settlement.dto.ts index 2729c15..16730ad 100644 --- a/src/settlements/dtos/settlement.dto.ts +++ b/src/settlements/dtos/settlement.dto.ts @@ -1,15 +1,42 @@ +export type SellerKind = 'INDIVIDUAL' | 'BUSINESS'; +export type BusinessKind = 'PERSONAL' | 'CORPORATE'; + +// 계좌 인증 요청 — 개인 / 개인사업자 / 법인사업자를 sellerType + businessType으로 구분 export interface VerifyAccountRequestDto { - name: string; - birthDate: string; - bank: string; + sellerType: SellerKind; + businessType?: BusinessKind; // sellerType === 'BUSINESS'일 때 필수 + name: string; // 실명(INDIVIDUAL) / 대표자명(BUSINESS) + birthDate?: string; // YYMMDD 6자리. INDIVIDUAL / BUSINESS+PERSONAL 필수 + businessNumber?: string; // 10자리 숫자. BUSINESS+CORPORATE 필수 + bank: string; // Payple bank_code_std 3자리 accountNumber: string; - holderName: string; + holderName: string; // 법인은 법인명, 그 외엔 실명/대표자명과 일치 +} + +export interface VerifyAccountResponseDto { + message: string; + registerToken: string; + expiresIn: number; + statusCode: number; +} + +// 등록 요청 — 인증 결과는 registerToken(JWT)에 인코딩됨 +export interface RegisterIndividualSellerRequestDto { + registerToken: string; + isTermsAgreed: boolean; +} + +export interface RegisterBusinessSellerRequestDto { + registerToken: string; + companyName: string; + businessLicenseUrl: string; // S3 업로드 응답으로 받은 URL (Phase 9에서 key로 전환 예정) + isTermsAgreed: boolean; } export interface AccountDataDto { - bank: string; + bank: string; accountNumber: string; - holderName: string; + holderName: string; } export interface ViewAccountResponseDto { @@ -19,28 +46,8 @@ export interface ViewAccountResponseDto { } export interface UpdateAccountRequestDto { - name: string; - bank: string; - accountNumber: string; - holderName: string; -} - -export interface RegisterIndividualSellerRequestDto { name: string; - birthDate: string; bank: string; accountNumber: string; holderName: string; - isTermsAgreed: boolean; -} - -export interface RegisterBusinessSellerRequestDto { - representativeName: string; - bank: string; - accountNumber: string; - holderName: string; - businessNumber: string; - companyName: string; - businessLicenseUrl: string; - isTermsAgreed: boolean; } diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index 0641c96..4194173 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -1,58 +1,86 @@ import prisma from '../../config/prisma'; import { SettlementAccount } from '@prisma/client'; -import { VerifyAccountRequestDto, RegisterBusinessSellerRequestDto} from '../dtos/settlement.dto'; +import { BusinessKind } from '../dtos/settlement.dto'; + +export interface UpsertIndividualAccountInput { + bank: string; + accountNumber: string; + holderName: string; +} + +export interface CreateBusinessAccountInput { + representativeName: string; + bank: string; + accountNumber: string; + holderName: string; + businessNumber: string; + businessType: BusinessKind; + companyName: string; + businessLicenseUrl: string; +} export const SettlementRepository = { - upsertSettlementAccount: async ( + upsertIndividualAccount: async ( userId: number, - dto: VerifyAccountRequestDto + dto: UpsertIndividualAccountInput, ): Promise => { return await prisma.settlementAccount.upsert({ where: { user_id: userId }, update: { - birth_date: dto.birthDate, - bank_code: dto.bank, + bank_code: dto.bank, account_number: dto.accountNumber, account_holder: dto.holderName, + seller_type: 'INDIVIDUAL', + business_type: null, + status: 'APPROVED', is_active: true, + birth_date: null, + business_number: null, + company_name: null, + representative_name: null, + business_license_url: null, }, create: { user_id: userId, - birth_date: dto.birthDate, bank_code: dto.bank, account_number: dto.accountNumber, account_holder: dto.holderName, + seller_type: 'INDIVIDUAL', + status: 'APPROVED', + is_active: true, }, }); }, - findAccountByUserId: async(userId: number): Promise => { + findAccountByUserId: async (userId: number): Promise => { return await prisma.settlementAccount.findUnique({ - where: { user_id: userId}, + where: { user_id: userId }, }); }, findAccountByBusinessNumber: async (businessNumber: string) => { - return await prisma.settlementAccount.findFirst({ - where: { business_number: businessNumber }, - }); -}, + return await prisma.settlementAccount.findFirst({ + where: { business_number: businessNumber }, + }); + }, - createBusinessAccount: async (userId: number, dto: RegisterBusinessSellerRequestDto) => { - return await prisma.settlementAccount.create({ - data: { - user_id: userId, - bank_code: dto.bank, - account_number: dto.accountNumber, - account_holder: dto.holderName, - business_number: dto.businessNumber, - company_name: dto.companyName, - representative_name: dto.representativeName, - business_license_url: dto.businessLicenseUrl, - seller_type: 'BUSINESS', - status: 'PENDING', - is_active: false, - }}) + createBusinessAccount: async (userId: number, dto: CreateBusinessAccountInput) => { + return await prisma.settlementAccount.create({ + data: { + user_id: userId, + bank_code: dto.bank, + account_number: dto.accountNumber, + account_holder: dto.holderName, + business_number: dto.businessNumber, + business_type: dto.businessType, + company_name: dto.companyName, + representative_name: dto.representativeName, + business_license_url: dto.businessLicenseUrl, + seller_type: 'BUSINESS', + status: 'PENDING', + is_active: false, + }, + }); }, deleteAccountByUserId: async (userId: number) => { @@ -60,4 +88,4 @@ export const SettlementRepository = { 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 a8dae21..e2f734d 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -18,8 +18,17 @@ const router = Router(); * @swagger * /api/settlements/verify-account: * post: - * summary: 판매자 계좌 인증 및 등록 - * description: 사용자가 입력한 은행 계좌번호의 예금주명이 포트원 API를 통해 조회한 예금주명과 일치하는지 확인 후, 인증 성공 시 유저의 정산 계좌로 등록 또는 수정(Upsert)합니다. + * summary: 판매자 계좌 인증 및 register-token 발급 + * description: | + * 페이플 실명-계좌 인증을 호출하고 성공 시 register-token(JWT, 5분 TTL, 1회용)을 발급합니다. + * 프론트엔드는 인증 성공 후 받은 registerToken을 register API 호출 시 그대로 전달해야 합니다. + * + * sellerType별 요청 필드: + * - INDIVIDUAL: name + birthDate (YYMMDD) + bank/accountNumber/holderName + * - BUSINESS + PERSONAL(개인사업자): name(대표자명) + birthDate(YYMMDD) + bank/accountNumber/holderName + * - BUSINESS + CORPORATE(법인사업자): name(대표자명) + businessNumber(10자리) + bank/accountNumber/holderName(법인명) + * + * 페이플 호출 한도(일일 5회)는 우리 서비스 측 Redis로 관리합니다. * tags: [Settlement] * security: * - jwt: [] @@ -30,30 +39,48 @@ const router = Router(); * schema: * type: object * required: + * - sellerType * - name * - bank * - accountNumber * - holderName * properties: + * sellerType: + * type: string + * enum: [INDIVIDUAL, BUSINESS] + * example: BUSINESS + * businessType: + * type: string + * enum: [PERSONAL, CORPORATE] + * description: sellerType이 BUSINESS일 때 필수. 개인사업자(PERSONAL) / 법인사업자(CORPORATE) + * example: CORPORATE * name: * type: string - * description: 일반 개인 판매자 실명 / 개인·법인 사업자 대표자명 + * description: 실명(INDIVIDUAL) / 대표자명(BUSINESS) * example: 홍길동 + * birthDate: + * type: string + * description: 예금주 생년월일 6자리(YYMMDD). INDIVIDUAL / BUSINESS+PERSONAL 필수 + * example: "880212" + * businessNumber: + * type: string + * description: 사업자등록번호 10자리(숫자만). BUSINESS+CORPORATE 필수 + * example: "1234567890" * bank: * type: string - * description: 포트원 표준 은행 코드 - * example: KOOKMIN + * description: 페이플 표준 은행 코드 3자리 + * example: "088" * accountNumber: * type: string - * description: '-'를 제외한 계좌 번호 + * description: 하이픈을 제외한 계좌 번호 * example: "1234567890" * holderName: * type: string - * description: 계좌 예금주명 + * description: 계좌 예금주명. 법인은 법인명, 그 외엔 실명/대표자명 * example: 홍길동 * responses: * 200: - * description: 계좌 인증 및 등록 성공 + * description: 계좌 인증 성공. registerToken 발급 * content: * application/json: * schema: @@ -61,78 +88,19 @@ const router = Router(); * properties: * message: * type: string - * example: 계좌 인증이 완되었습니다. - * statusCode: - * type: integer - * example: 200 - * 401: - * description: 인증 실패 - 로그인하지 않은 사용자 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unauthorized - * message: + * example: 계좌 인증이 완료되었습니다. + * registerToken: * type: string - * example: 로그인이 필요합니다. + * description: 등록 API 호출용 1회용 JWT(5분 TTL) + * expiresIn: + * type: integer + * description: 토큰 만료 시간(초) + * example: 300 * statusCode: * type: integer - * example: 401 + * example: 200 * 400: - * description: 검증 실패 (ValidationError, NameMismatch, AccountHolderMismatch, InvalidAccountInfo) - * content: - * application/json: - * schema: - * oneOf: - * - type: object - * properties: - * error: - * type: string - * example: ValidationError - * message: - * type: string - * example: 필수 입력값(생년월일, 은행, 계좌번호, 실명/대표자명, 예금주명)이 모두 입력되지 않았습니다. - * statusCode: - * type: integer - * example: 400 - * - type: object - * properties: - * error: - * type: string - * example: NameMismatch - * message: - * type: string - * example: 입력하신 실명/대표자명과 예금주명이 일치하지 않습니다. - * statusCode: - * type: integer - * example: 400 - * - type: object - * properties: - * error: - * type: string - * example: AccountHolderMismatch - * message: - * type: string - * example: 인증 실패: 실제 계좌의 예금주명과 다릅니다. - * statusCode: - * type: integer - * example: 400 - * - type: object - * properties: - * error: - * type: string - * example: InvalidAccountInfo - * message: - * type: string - * example: 유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다. - * statusCode: - * type: integer - * example: 400 - * 500: - * description: 서버 오류 - 알 수 없는 예외 발생 + * description: 검증 실패 또는 계좌 인증 실패 * content: * application/json: * schema: @@ -140,13 +108,60 @@ const router = Router(); * properties: * error: * type: string - * example: InternalServerError + * description: ValidationError | AccountVerificationError | InvalidAccountInfo + * subCode: + * type: string + * description: | + * 계좌 인증 실패 상세 코드 (프론트 모달 분기용) + * NAME_MISMATCH / BANK_MISMATCH / ACCOUNT_NOT_FOUND / ACCOUNT_RESTRICTED / + * UNSUPPORTED_TYPE / BANK_TIMEOUT / BANK_MAINTENANCE / LIMIT_EXCEEDED / + * INVALID_BIRTHDATE / INVALID_BUSINESS_NUMBER / SYSTEM_ERROR * message: * type: string - * example: 알 수 없는 오류가 발생했습니다. * statusCode: * type: integer - * example: 500 + * example: 400 + * examples: + * 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: 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 - 일일 인증 횟수 초과(자체 정책 5회) + * value: { error: AccountVerificationError, subCode: LIMIT_EXCEEDED, message: "일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요.", statusCode: 400 } + * invalidBirthDate: + * summary: 신규 모달 A - 생년월일 형식 오류 + * value: { error: AccountVerificationError, subCode: INVALID_BIRTHDATE, message: "입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.", statusCode: 400 } + * invalidBusinessNumber: + * summary: 신규 모달 B - 사업자등록번호 형식 오류 + * value: { error: AccountVerificationError, subCode: INVALID_BUSINESS_NUMBER, message: "입력하신 사업자등록번호가 올바르지 않습니다. 10자리 숫자로 다시 입력해 주세요.", statusCode: 400 } + * systemError: + * summary: 신규 모달 C - 시스템/인프라 오류 + * value: { error: AccountVerificationError, subCode: SYSTEM_ERROR, message: "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", statusCode: 400 } + * invalidAccountInfo: + * summary: 지원하지 않는 은행 코드 + * value: { error: InvalidAccountInfo, message: "유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.", statusCode: 400 } + * 401: + * description: 로그인 필요 + * 500: + * description: 서버 오류 */ router.post("/verify-account", authenticateJwt, verifyAccount); @@ -155,9 +170,8 @@ router.post("/verify-account", authenticateJwt, verifyAccount); * /api/settlements/accounts: * get: * summary: 등록된 정산 계좌 정보 조회 - * description: 현재 로그인한 사용자의 정산용 계좌 정보(은행, 계좌번호, 예금주명)를 조회합니다. - * tags: - * - Settlement + * description: 현재 로그인한 사용자의 정산 계좌(은행, 계좌번호, 예금주명) 정보를 조회합니다. + * tags: [Settlement] * security: * - jwt: [] * responses: @@ -168,72 +182,20 @@ router.post("/verify-account", authenticateJwt, verifyAccount); * schema: * type: object * properties: - * message: - * type: string - * example: 계좌 정보 조회가 완료되었습니다. + * message: { type: string, example: 계좌 정보 조회가 완료되었습니다. } * data: * type: object * properties: - * bank: - * type: string - * example: KOOKMIN - * accountNumber: - * type: string - * example: "1234567890" - * holderName: - * type: string - * example: 홍길동 - * statusCode: - * type: integer - * example: 200 + * bank: { type: string, example: "088" } + * accountNumber: { type: string, example: "1234567890" } + * holderName: { type: string, example: 홍길동 } + * statusCode: { type: integer, example: 200 } * 401: - * description: 인증 실패 - 로그인하지 않은 사용자 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unauthorized - * message: - * type: string - * example: 로그인이 필요합니다. - * statusCode: - * type: integer - * example: 401 + * description: 로그인 필요 * 404: - * description: 계좌 정보 없음 - 등록된 계좌가 없는 경우 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: AccountNotFound - * message: - * type: string - * example: 등록된 계좌 정보가 존재하지 않습니다. - * statusCode: - * type: integer - * example: 404 + * description: 등록된 계좌 없음 * 500: * description: 서버 오류 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: InternalServerError - * message: - * type: string - * example: 알 수 없는 오류가 발생했습니다. - * statusCode: - * type: integer - * example: 500 */ router.get("/accounts", authenticateJwt, ViewAccount); @@ -241,10 +203,12 @@ router.get("/accounts", authenticateJwt, ViewAccount); * @swagger * /api/settlements/register/individual: * post: - * summary: 개인 판매자 등록 및 정보 수정 - * description: 개인정보 수집 이용 동의 및 계좌 정보를 입력받아 일반 개인 판매자로 등록하거나 판매자 정보를 수정합니다. (일일 계좌 인증 5회 제한) - * tags: - * - Settlement + * summary: 개인 판매자 등록 + * description: | + * /verify-account에서 발급받은 registerToken과 약관 동의를 받아 개인 판매자로 등록합니다. + * registerToken은 1회용이며 만료/재사용 시 401/409 반환. + * 기존 사업자 등록이 있으면 삭제하고 개인으로 재등록됩니다. + * tags: [Settlement] * security: * - jwt: [] * requestBody: @@ -254,175 +218,50 @@ router.get("/accounts", authenticateJwt, ViewAccount); * schema: * type: object * required: - * - name - * - birthDate - * - bank - * - accountNumber - * - holderName + * - registerToken * - isTermsAgreed * properties: - * name: + * registerToken: * type: string - * description: 실명 - * example: 홍길동 - * birthDate: - * type: string - * description: 예금주 생년월일 6자리 (YYMMDD) - * example: "880212" - * bank: - * type: string - * description: 페이플 금융기관 3자리 숫자 코드 - * example: "004" - * accountNumber: - * type: string - * description: '-'를 제외한 계좌 번호 - * example: "1234567890" - * holderName: - * type: string - * description: 계좌 예금주명 - * example: 홍길동 + * description: /verify-account 응답으로 받은 1회용 JWT * isTermsAgreed: * type: boolean - * description: 개인정보 수집 이용 동의 여부 (반드시 true) + * description: 개인정보 수집 이용 동의 (반드시 true) * example: true * responses: * 200: - * description: 판매자 등록 및 수정 성공 + * description: 등록 성공 (신규 또는 수정) * content: * application/json: * schema: * type: object * properties: - * message: - * type: string - * statusCode: - * type: integer - * example: 200 - * examples: - * CreateSuccess: - * summary: 신규 등록 성공 - * value: - * message: 개인 판매자 등록이 완료되었습니다. - * statusCode: 200 - * UpdateSuccess: - * summary: 기존 정보 수정 성공 - * value: - * message: 판매자 정보가 성공적으로 수정되었습니다. - * statusCode: 200 + * message: { type: string, example: 개인 판매자 등록이 완료되었습니다. } + * statusCode: { type: integer, example: 200 } * 400: - * description: 검증 실패 또는 계좌 인증 실패 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * description: 에러 코드 (ValidationError 또는 AccountVerificationError) - * subCode: - * type: string - * description: 계좌 인증 실패 상세 코드 (프론트엔드 모달 분기용) - * message: - * type: string - * description: 에러 상세 메시지 - * statusCode: - * type: integer - * example: 400 - * examples: - * validationError: - * summary: 필수 입력값 누락 - * value: - * error: ValidationError - * message: 필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다. - * statusCode: 400 - * 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: 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 + * description: 필수값 누락 또는 약관 미동의 * 401: - * description: 인증 실패 - 로그인하지 않은 사용자 + * description: registerToken 만료/무효 또는 로그인 필요 * content: * application/json: * schema: * type: object * properties: - * error: - * type: string - * example: Unauthorized - * message: - * type: string - * example: 로그인이 필요합니다. - * statusCode: - * type: integer - * example: 401 - * 500: - * description: 서버 오류 - 알 수 없는 예외 발생 + * error: { type: string, example: InvalidRegisterToken } + * message: { type: string, example: 등록 토큰이 만료되었거나 유효하지 않습니다. 계좌 인증을 다시 진행해 주세요. } + * statusCode: { type: integer, example: 401 } + * 409: + * description: registerToken 재사용 * content: * application/json: * schema: * type: object * properties: - * error: - * type: string - * example: InternalServerError - * message: - * type: string - * example: 서버 오류가 발생했습니다. - * statusCode: - * type: integer - * example: 500 + * error: { type: string, example: RegisterTokenAlreadyUsed } + * message: { type: string, example: 이미 사용된 등록 토큰입니다. 계좌 인증을 다시 진행해 주세요. } + * statusCode: { type: integer, example: 409 } + * 500: + * description: 서버 오류 */ router.post("/register/individual", authenticateJwt, registerIndividual); @@ -431,9 +270,11 @@ router.post("/register/individual", authenticateJwt, registerIndividual); * /api/settlements/upload/business-license: * post: * summary: 사업자등록증 업로드 (개인/법인 사업자) - * description: 개인 또는 법인 사업자의 사업자등록증 파일(이미지 또는 PDF, 최대 20MB)을 업로드하고 S3 URL을 반환받습니다. - * tags: - * - Settlement + * description: | + * 사업자등록증 파일(jpg/jpeg/png/pdf, 최대 20MB)을 업로드합니다. + * 업로드 시 magic-byte로 실제 파일 형식을 검증하므로 확장자 위장 파일은 415로 거부됩니다. + * S3 객체 키는 예측 불가한 UUID로 생성됩니다. + * tags: [Settlement] * security: * - jwt: [] * requestBody: @@ -442,110 +283,34 @@ router.post("/register/individual", authenticateJwt, registerIndividual); * multipart/form-data: * schema: * type: object - * required: - * - file + * required: [file] * properties: * file: * type: string * format: binary - * description: 업로드할 사업자등록증 파일 (jpg, jpeg, png, pdf) / 최대 20MB + * description: jpg / jpeg / png / pdf (최대 20MB) * responses: * 200: - * description: 파일 업로드 성공 + * description: 업로드 성공 * content: * application/json: * schema: * type: object * properties: - * message: - * type: string - * example: 사업자등록증 업로드가 완료되었습니다. - * fileUrl: - * type: string - * example: https://promptplace-storage.s3.ap-northeast-2.amazonaws.com/business-licenses/123-1709865432123.jpg - * statusCode: - * type: integer - * example: 200 + * message: { type: string, example: 사업자등록증 업로드가 완료되었습니다. } + * fileKey: { type: string, description: S3 객체 키 (DB/Register API에 전달용), example: "business-licenses/0e5b9d7a-...-.pdf" } + * fileUrl: { type: string, description: 현재 형식의 S3 URL (버킷 private 전환 시 presigned로 교체 예정) } + * statusCode: { type: integer, example: 200 } * 400: - * description: 업로드할 파일 누락 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: ValidationError - * message: - * type: string - * example: 업로드할 파일이 첨부되지 않았습니다. - * statusCode: - * type: integer - * example: 400 + * description: 파일 누락 * 401: - * description: 인증 실패 - 로그인하지 않은 사용자 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unauthorized - * message: - * type: string - * example: 로그인이 필요합니다. - * statusCode: - * type: integer - * example: 401 + * description: 로그인 필요 * 413: - * description: 파일 용량 제한 초과 (20MB) - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: FileTooLarge - * message: - * type: string - * example: 파일 크기는 최대 20MB까지만 허용됩니다. - * statusCode: - * type: integer - * example: 413 + * description: 파일 용량 초과 (20MB) * 415: - * description: 지원하지 않는 파일 형식 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: InvalidFileType - * message: - * type: string - * example: 지원하지 않는 파일 형식입니다. (jpg, jpeg, png, pdf만 가능) - * statusCode: - * type: integer - * example: 415 + * description: 지원하지 않는 파일 형식 또는 확장자 위장 (magic-byte 불일치) * 500: - * description: 서버 오류 - 알 수 없는 예외 발생 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: InternalServerError - * message: - * type: string - * example: 서버 오류가 발생했습니다. - * statusCode: - * type: integer - * example: 500 + * description: 서버 오류 */ router.post("/upload/business-license", authenticateJwt, uploadLicense); @@ -553,10 +318,11 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * @swagger * /api/settlements/register/business: * post: - * summary: 사업자 판매자 등록 - * description: 개인/법인 사업자의 정보와 사업자등록증 URL을 입력받아 판매자로 등록 신청합니다. (관리자 승인 대기 상태로 저장됨) - * tags: - * - Settlement + * summary: 사업자 판매자 등록 신청 + * description: | + * /verify-account에서 발급받은 registerToken과 추가 사업자 정보(상호명, 사업자등록증)를 받아 + * 사업자 판매자로 등록 신청합니다. 상태는 PENDING으로 저장되고 관리자 승인 후 활성화됩니다. + * tags: [Settlement] * security: * - jwt: [] * requestBody: @@ -566,137 +332,49 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * schema: * type: object * required: - * - representativeName - * - bank - * - accountNumber - * - holderName - * - businessNumber + * - registerToken * - companyName * - businessLicenseUrl * - isTermsAgreed * properties: - * representativeName: - * type: string - * description: 대표자명 - * example: 김대표 - * bank: - * type: string - * description: 포트원 표준 은행 코드 - * example: SHINHAN - * accountNumber: - * type: string - * description: '-'를 제외한 계좌 번호 - * example: "0987654321" - * holderName: - * type: string - * description: 계좌 예금주명 - * example: 김대표 - * businessNumber: + * registerToken: * type: string - * description: 사업자등록번호 ('-' 제외 숫자만) - * example: "1234567890" + * description: /verify-account 응답으로 받은 1회용 JWT * companyName: * type: string - * description: 상호명 * example: (주)프롬프트팩토리 * businessLicenseUrl: * type: string - * description: 업로드 API로 발급받은 사업자등록증 이미지 URL - * example: https://promptplace-storage.s3.ap-northeast-2.amazonaws.com/business-licenses/123-1709865432123.jpg + * description: /upload/business-license 응답으로 받은 fileUrl 또는 fileKey * isTermsAgreed: * type: boolean - * description: 개인정보 수집 이용 동의 여부 (반드시 true) * example: true * responses: * 200: - * description: 판매자 신청 성공 (승인 대기 상태) + * description: 신청 성공 (PENDING 상태) * content: * application/json: * schema: * type: object * properties: - * message: - * type: string - * example: 사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다. - * statusCode: - * type: integer - * example: 200 + * message: { type: string, example: 사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다. } + * statusCode: { type: integer, example: 200 } * 400: - * description: 검증 실패 - 필수 입력값 누락 또는 약관 미동의 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: ValidationError - * message: - * type: string - * example: 필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다. - * statusCode: - * type: integer - * example: 400 + * description: 필수값 누락 또는 약관 미동의 * 401: - * description: 인증 실패 - 로그인하지 않은 사용자 - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unauthorized - * message: - * type: string - * example: 로그인이 필요합니다. - * statusCode: - * type: integer - * example: 401 + * description: registerToken 만료/무효 또는 로그인 필요 * 409: - * description: 충돌 - 중복된 사업자등록번호 또는 이미 등록된 유저 - * content: - * application/json: - * schema: - * oneOf: - * - type: object - * properties: - * error: - * type: string - * example: DuplicateBusinessNumber - * message: - * type: string - * example: 이미 등록되었거나 심사 대기 중인 사업자등록번호입니다. - * statusCode: - * type: integer - * example: 409 - * - type: object - * properties: - * error: - * type: string - * example: AlreadyRegistered - * message: - * type: string - * example: 이미 판매자로 등록되었거나 승인 심사 대기 중인 회원입니다. - * statusCode: - * type: integer - * example: 409 - * 500: - * description: 서버 오류 - 알 수 없는 예외 발생 + * description: registerToken 재사용 / 이미 등록 / 사업자번호 중복 * content: * application/json: * schema: * type: object * properties: - * error: - * type: string - * example: InternalServerError - * message: - * type: string - * example: 서버 오류가 발생했습니다. - * statusCode: - * type: integer - * example: 500 + * error: { type: string, description: "RegisterTokenAlreadyUsed | AlreadyRegistered | DuplicateBusinessNumber" } + * message: { type: string } + * statusCode: { type: integer, example: 409 } + * 500: + * description: 서버 오류 */ router.post("/register/business", authenticateJwt, registerBusiness); @@ -705,7 +383,7 @@ router.post("/register/business", authenticateJwt, registerBusiness); * /api/settlements/sales/monthly: * get: * summary: 월별 판매 내역 조회 - * description: 로그인한 판매자의 특정 연-월에 발생한 판매(Settlement) 내역을 조회합니다. year/month 미지정 시 현재 UTC 기준 년/월을 사용합니다. 결제수단(pay_type)과 카드사명(card_name)은 페이플 일반결제 검증 결과를 그대로 노출합니다. + * description: 로그인한 판매자의 특정 연-월에 발생한 판매(Settlement) 내역을 조회합니다. year/month 미지정 시 현재 UTC 기준 년/월을 사용합니다. * tags: * - Settlement * security: @@ -714,11 +392,9 @@ router.post("/register/business", authenticateJwt, registerBusiness); * - in: query * name: year * schema: { type: integer, example: 2026 } - * description: 조회할 연도 (기본값 현재 UTC 연도) * - in: query * name: month * schema: { type: integer, minimum: 1, maximum: 12, example: 5 } - * description: 조회할 월 (1-12, 기본값 현재 UTC 월) * responses: * 200: * description: 조회 성공 @@ -734,8 +410,8 @@ router.post("/register/business", authenticateJwt, registerBusiness); * type: object * properties: * count: { type: integer, example: 3 } - * total_sales: { type: integer, description: 원래 판매가 합계, example: 30000 } - * total_settled: { type: integer, description: 정산 금액(수수료 차감 후) 합계, example: 27000 } + * total_sales: { type: integer, example: 30000 } + * total_settled: { type: integer, example: 27000 } * total_fee: { type: integer, example: 3000 } * items: * type: array @@ -748,15 +424,15 @@ router.post("/register/business", authenticateJwt, registerBusiness); * prompt_title: { type: string } * buyer_id: { type: integer } * buyer_nickname: { type: string, nullable: true } - * pay_type: { type: string, nullable: true, description: '페이플 결제 타입 (card/transfer)' } - * card_name: { type: string, nullable: true, description: '카드사명 (페이플 PCD_PAY_CARDNAME)' } + * pay_type: { type: string, nullable: true } + * card_name: { type: string, nullable: true } * sale_price: { type: integer } * settled_amount: { type: integer } * fee: { type: integer } * status: { type: string, enum: [Pending, Succeed, Failed] } * statusCode: { type: integer, example: 200 } * 400: - * description: 잘못된 year/month 값 + * description: 잘못된 year/month * 401: * description: 로그인 필요 */ @@ -767,7 +443,7 @@ router.get("/sales/monthly", authenticateJwt, getMonthlySales); * /api/settlements/yearly: * get: * summary: 연도별 누적 정산 내역 조회 - * description: 로그인한 판매자의 연도별 누적 정산 합계를 조회합니다. 각 연도 row는 해당 연도의 판매 건수, 원래 판매가 합계(total_sales), 정산 금액 합계(total_settled), 수수료 합계(total_fee), 상태별 정산금(succeeded/pending) 누적치를 포함합니다. + * description: 로그인한 판매자의 연도별 누적 정산 합계를 조회합니다. * tags: * - Settlement * security: @@ -799,4 +475,4 @@ router.get("/sales/monthly", authenticateJwt, getMonthlySales); */ router.get("/yearly", authenticateJwt, getYearlySettlements); -export default router; \ No newline at end of file +export default router; diff --git a/src/settlements/services/seller-consent.service.ts b/src/settlements/services/seller-consent.service.ts new file mode 100644 index 0000000..49d6182 --- /dev/null +++ b/src/settlements/services/seller-consent.service.ts @@ -0,0 +1,16 @@ +import prisma from '../../config/prisma'; + +export const SELLER_REGISTRATION_CONSENT_TYPE = 'SELLER_REGISTRATION_PERSONAL_INFO'; + +// 판매자 등록 개인정보 수집 동의를 verify-account 호출 시점에 선기록. +// 등록(register/*)이 최종 실패하더라도 동의 이력은 보존되어야 PIPA 증빙 가능. +export const recordSellerRegistrationConsent = async (params: { + userId: number; +}): Promise => { + const { userId } = params; + await prisma.userConsent.upsert({ + where: { user_id_consent_type: { user_id: userId, consent_type: SELLER_REGISTRATION_CONSENT_TYPE } }, + update: { is_agreed: true }, + create: { user_id: userId, consent_type: SELLER_REGISTRATION_CONSENT_TYPE, is_agreed: true }, + }); +}; diff --git a/src/settlements/services/settlement.account.service.ts b/src/settlements/services/settlement.account.service.ts index 280ae22..a88fe0c 100644 --- a/src/settlements/services/settlement.account.service.ts +++ b/src/settlements/services/settlement.account.service.ts @@ -1,114 +1,128 @@ -import axios from 'axios'; -import { PAYPLE_BANKS } from '../constants/bank'; -import { VerifyAccountRequestDto, AccountDataDto } from '../dtos/settlement.dto'; +import { AppError } from '../../errors/AppError'; +import { + VerifyAccountRequestDto, + AccountDataDto, + SellerKind, + BusinessKind, +} from '../dtos/settlement.dto'; import { SettlementRepository } from '../repositories/settlement.repository'; -import { AccountVerificationError, parseAccountVerificationError } from '../utils/payple'; - -export const verifyAndSaveAccount = async (userId: number, dto: VerifyAccountRequestDto) => { - const { name, birthDate, bank, accountNumber, holderName } = dto; - - if (!name || !birthDate || !bank || !accountNumber || !holderName) { - throw { - status: 400, - type: 'ValidationError', - message: '필수 입력값(이름, 생년월일, 은행, 계좌번호, 예금주명)이 모두 입력되지 않았습니다.', - }; +import { + AccountVerificationError, + consumePaypleRateLimit, + verifyRealNameWithPayple, +} from '../utils/payple'; +import { issueRegisterToken } from '../utils/register-token'; +import { recordSellerRegistrationConsent } from './seller-consent.service'; +import { isValidPaypleBank } from '../constants/bank'; + +const ALLOWED_SELLER_TYPES: readonly SellerKind[] = ['INDIVIDUAL', 'BUSINESS']; +const ALLOWED_BUSINESS_TYPES: readonly BusinessKind[] = ['PERSONAL', 'CORPORATE']; + +const validateDto = (dto: VerifyAccountRequestDto): void => { + if (!dto || typeof dto !== 'object') { + throw new AppError('요청 본문이 올바르지 않습니다.', 400, 'ValidationError'); } - - if (!PAYPLE_BANKS[bank]) { - throw { - status: 400, - type: 'InvalidAccountInfo', - message: '유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.', - }; + if (!ALLOWED_SELLER_TYPES.includes(dto.sellerType)) { + throw new AppError('판매자 유형(sellerType)이 올바르지 않습니다.', 400, 'ValidationError'); } - - 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 { - 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 (!dto.name || !dto.bank || !dto.accountNumber || !dto.holderName) { + throw new AppError( + '필수 입력값(이름, 은행, 계좌번호, 예금주명)이 모두 입력되지 않았습니다.', + 400, + 'ValidationError', ); - - if (authResponse.data.result !== 'T0000') { - throw new AccountVerificationError( - `페이플 인증 실패: ${authResponse.data.message}`, - 'AUTH_FAILED' + } + if (dto.sellerType === 'BUSINESS') { + if (!dto.businessType || !ALLOWED_BUSINESS_TYPES.includes(dto.businessType)) { + throw new AppError( + '사업자 유형(businessType)이 올바르지 않습니다. PERSONAL 또는 CORPORATE만 가능합니다.', + 400, + 'ValidationError', ); } - - const accessToken = authResponse.data.access_token; - - 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', - }, + if (dto.businessType === 'CORPORATE') { + if (!dto.businessNumber || !/^\d{10}$/.test(dto.businessNumber)) { + throw new AppError( + '입력하신 사업자등록번호가 올바르지 않습니다. 10자리 숫자로 다시 입력해 주세요.', + 400, + 'ValidationError', + ); + } + } else { + // PERSONAL — 개인사업자는 생년월일 필수 (Payple type=0) + if (!dto.birthDate || !/^\d{6}$/.test(dto.birthDate)) { + throw new AppError( + '입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.', + 400, + 'ValidationError', + ); } - ); - - if (verifyResponse.data.result !== 'A0000') { - throw parseAccountVerificationError(verifyResponse.data); } - - if (verifyResponse.data.account_holder_name !== holderName) { - throw new AccountVerificationError( - '인증 실패: 실제 계좌의 예금주명과 다릅니다.', - 'NAME_MISMATCH' + } else { + // INDIVIDUAL + if (!dto.birthDate || !/^\d{6}$/.test(dto.birthDate)) { + throw new AppError( + '입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.', + 400, + 'ValidationError', ); } - } catch (error: any) { - 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: '유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.', - }; - } + } + if (!isValidPaypleBank(dto.bank)) { + throw new AppError( + '유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.', + 400, + 'InvalidAccountInfo', + ); + } - throw { - status: 500, - type: 'InternalServerError', - message: '알 수 없는 오류가 발생했습니다.', - }; + // 법인사업자가 아닌 경우(INDIVIDUAL, BUSINESS+PERSONAL): name === holderName + // 법인사업자는 holderName이 법인명이므로 사전 비교 안 함 (Payple 응답으로 검증) + const isCorporate = dto.sellerType === 'BUSINESS' && dto.businessType === 'CORPORATE'; + if (!isCorporate && dto.name !== dto.holderName) { + throw new AccountVerificationError( + '실명/대표자명과 예금주명이 일치하지 않습니다. 다시 확인해주세요.', + 'NAME_MISMATCH', + ); } +}; - await SettlementRepository.upsertSettlementAccount(userId, dto); +export const verifySellerAccount = async (userId: number, dto: VerifyAccountRequestDto) => { + validateDto(dto); + + await consumePaypleRateLimit(userId); + + await verifyRealNameWithPayple({ + userId, + sellerType: dto.sellerType, + businessType: dto.businessType, + bank: dto.bank, + accountNumber: dto.accountNumber, + holderName: dto.holderName, + birthDate: dto.birthDate, + businessNumber: dto.businessNumber, + }); + + // PIPA 증빙: 등록 실패해도 동의 이력은 보존 + await recordSellerRegistrationConsent({ userId }); + + const { token, expiresIn } = issueRegisterToken({ + userId, + sellerType: dto.sellerType, + businessType: dto.businessType, + name: dto.name, + birthDate: dto.birthDate, + businessNumber: dto.businessNumber, + bank: dto.bank, + accountNumber: dto.accountNumber, + holderName: dto.holderName, + }); - return { message: '계좌 인증이 완료되었습니다.' }; + return { + message: '계좌 인증이 완료되었습니다.', + registerToken: token, + expiresIn, + }; }; export const getAccountInfo = async (userId: number): Promise => { diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index 23ba0c3..abb6138 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -1,168 +1,120 @@ -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'; +import { v4 as uuidv4 } from 'uuid'; +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"; +import { consumeRegisterToken } from '../utils/register-token'; +import { uploadFileToS3 } from '../utils/s3-client'; +import { detectBusinessLicenseFileType, isClaimedMimeMatch } from '../utils/file-signature'; + +export const uploadBusinessLicenseFile = async ( + file: Express.Multer.File, +) => { + const detected = detectBusinessLicenseFileType(file.buffer); + if (!detected) { + throw new AppError( + '지원하지 않는 파일 형식입니다. (jpg, jpeg, png, pdf만 가능)', + 415, + 'InvalidFileType', + ); + } + if (!isClaimedMimeMatch(file.mimetype, detected)) { + throw new AppError( + '업로드된 파일의 실제 형식과 확장자가 일치하지 않습니다.', + 415, + 'InvalidFileType', + ); + } + + try { + // userId 노출/예측 가능성 차단을 위해 uuid 사용 + const fileKey = `business-licenses/${uuidv4()}${detected.ext}`; + const fileUrl = await uploadFileToS3(fileKey, file.buffer, detected.mime); + + return { + message: '사업자등록증 업로드가 완료되었습니다.', + fileKey, + fileUrl, + }; + } catch (error) { + console.error('S3 업로드 에러'); + throw new AppError('파일 업로드 중 서버 오류가 발생했습니다.', 500, 'InternalServerError'); + } +}; -export const registerIndividualSeller = async (userId: number, dto: RegisterIndividualSellerRequestDto) => { - if (!dto.name || !dto.birthDate || !dto.bank || !dto.accountNumber || !dto.holderName || dto.isTermsAgreed !== true) { +export const registerIndividualSeller = async ( + userId: number, + dto: RegisterIndividualSellerRequestDto, +) => { + if (!dto || !dto.registerToken || dto.isTermsAgreed !== true) { const error = new Error('필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다.'); error.name = 'ValidationError'; 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 payload = await consumeRegisterToken(dto.registerToken); - 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'; + if (payload.userId !== userId) { + const error = new Error('등록 토큰의 사용자 정보가 요청과 일치하지 않습니다.'); + error.name = 'ValidationError'; 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, - 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') { - throw parseAccountVerificationError(verifyResponse.data); - } - - if (verifyResponse.data.account_holder_name !== dto.holderName) { - throw new AccountVerificationError('실명과 예금주명이 일치하는지 다시 확인해주세요.', 'NAME_MISMATCH'); - } - - } catch (err: any) { - if (err.name === 'AccountVerificationError') throw err; - const error = new Error(err.response?.data?.message || '계좌 인증 처리 중 서버 오류가 발생했습니다.'); - error.name = 'AccountVerificationError'; + if (payload.sellerType !== 'INDIVIDUAL') { + const error = new Error('등록 토큰의 판매자 유형이 일치하지 않습니다.'); + error.name = 'ValidationError'; 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, - bank: dto.bank, - accountNumber: dto.accountNumber, - holderName: dto.holderName, + await SettlementRepository.upsertIndividualAccount(userId, { + bank: payload.bank, + accountNumber: payload.accountNumber, + holderName: payload.holderName, }); - const isUpdate = existingAccount && existingAccount.seller_type === 'INDIVIDUAL'; - - return { - message: isUpdate - ? '판매자 정보가 성공적으로 수정되었습니다.' - : '개인 판매자 등록이 완료되었습니다.' + const isUpdate = !!existingAccount && existingAccount.seller_type === 'INDIVIDUAL'; + return { + message: isUpdate + ? '판매자 정보가 성공적으로 수정되었습니다.' + : '개인 판매자 등록이 완료되었습니다.', }; }; -export const s3Client = new S3Client({ - region: process.env.S3_REGION, - credentials: { - accessKeyId: process.env.S3_ACCESS_KEY_ID!, - secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, - }, -}); - -export const uploadFileToS3 = async (key: string, buffer: Buffer, contentType: string) => { - const command = new PutObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: key, - Body: buffer, - ContentType: contentType, - }); - - await s3Client.send(command); - - // 업로드된 파일의 S3 URL 반환 - return `https://${process.env.S3_BUCKET!}.s3.${process.env.S3_REGION}.amazonaws.com/${key}`; -}; - -export const uploadBusinessLicenseFile = async (userId: number, file: Express.Multer.File) => { - try { - const ext = path.extname(file.originalname); - - const uniqueKey = `business-licenses/${userId}-${Date.now()}${ext}`; - - const fileUrl = await uploadFileToS3(uniqueKey, file.buffer, file.mimetype); - - return { - message: '사업자등록증 업로드가 완료되었습니다.', - fileUrl: fileUrl - }; - } catch (error) { - console.error("S3 업로드 에러:", error); - throw new AppError("파일 업로드 중 서버 오류가 발생했습니다.", 500, "InternalServerError"); - } -}; - -export const registerBusinessSeller = async (userId: number, dto: RegisterBusinessSellerRequestDto) => { - // 1. 필수값 누락 및 약관 동의 여부 검증 (400) +export const registerBusinessSeller = async ( + userId: number, + dto: RegisterBusinessSellerRequestDto, +) => { if ( - !dto.representativeName || !dto.bank || !dto.accountNumber || - !dto.holderName || !dto.businessNumber || !dto.companyName || - !dto.businessLicenseUrl || dto.isTermsAgreed !== true + !dto || + !dto.registerToken || + !dto.companyName || + !dto.businessLicenseUrl || + dto.isTermsAgreed !== true ) { const error = new Error('필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다.'); error.name = 'ValidationError'; throw error; } - // 2. 이미 등록되었거나 심사 대기 중인 유저인지 검증 (409) + const payload = await consumeRegisterToken(dto.registerToken); + + if (payload.userId !== userId) { + const error = new Error('등록 토큰의 사용자 정보가 요청과 일치하지 않습니다.'); + error.name = 'ValidationError'; + throw error; + } + if (payload.sellerType !== 'BUSINESS' || !payload.businessType || !payload.businessNumber) { + const error = new Error('등록 토큰의 판매자 유형이 일치하지 않습니다.'); + error.name = 'ValidationError'; + throw error; + } + const existingAccount = await SettlementRepository.findAccountByUserId(userId); if (existingAccount) { const error = new Error('이미 판매자로 등록되었거나 승인 심사 대기 중인 회원입니다.'); @@ -170,16 +122,23 @@ export const registerBusinessSeller = async (userId: number, dto: RegisterBusine throw error; } - // 3. 중복된 사업자등록번호인지 검증 (409) - const existingBusiness = await SettlementRepository.findAccountByBusinessNumber(dto.businessNumber); + const existingBusiness = await SettlementRepository.findAccountByBusinessNumber(payload.businessNumber); if (existingBusiness) { const error = new Error('이미 등록되었거나 심사 대기 중인 사업자등록번호입니다.'); error.name = 'DuplicateBusinessNumber'; throw error; } - // 4. 검증 통과 시 DB 저장 (상태: PENDING) - await SettlementRepository.createBusinessAccount(userId, dto); + await SettlementRepository.createBusinessAccount(userId, { + representativeName: payload.name, + bank: payload.bank, + accountNumber: payload.accountNumber, + holderName: payload.holderName, + businessNumber: payload.businessNumber, + businessType: payload.businessType, + companyName: dto.companyName, + businessLicenseUrl: dto.businessLicenseUrl, + }); return { message: '사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.' }; }; diff --git a/src/settlements/utils/file-signature.ts b/src/settlements/utils/file-signature.ts new file mode 100644 index 0000000..d65eb01 --- /dev/null +++ b/src/settlements/utils/file-signature.ts @@ -0,0 +1,43 @@ +// 사업자등록증 업로드 magic-byte 검증. +// multer fileFilter는 클라이언트가 위조 가능한 MIME/확장자만 보므로 신뢰 불가. +// 업로드 직전에 실제 파일 시그니처를 한 번 더 확인한다. + +interface FileSignature { + ext: string; + mime: string; + bytes: number[]; +} + +const SIGNATURES: ReadonlyArray = [ + { ext: '.jpg', mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] }, + { ext: '.png', mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, + { ext: '.pdf', mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, +]; + +export interface DetectedFileType { + ext: string; + mime: string; +} + +export const detectBusinessLicenseFileType = (buffer: Buffer): DetectedFileType | null => { + if (!buffer || buffer.length < 4) return null; + for (const sig of SIGNATURES) { + const match = sig.bytes.every((byte, idx) => buffer[idx] === byte); + if (match) return { ext: sig.ext, mime: sig.mime }; + } + return null; +}; + +// 클라이언트가 제출한 MIME과 실제 시그니처가 일치하는지 추가 검증. +// 확장자만 위장한 파일을 거부. +const ALIAS: Record = { + 'image/jpg': 'image/jpeg', + 'image/jpeg': 'image/jpeg', + 'image/png': 'image/png', + 'application/pdf': 'application/pdf', +}; + +export const isClaimedMimeMatch = (claimedMime: string, detected: DetectedFileType): boolean => { + const normalized = ALIAS[claimedMime]; + return normalized === detected.mime; +}; diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index 24fdcdc..c4d000e 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -1,4 +1,10 @@ -import { AppError } from "../../errors/AppError"; +import axios from 'axios'; +import crypto from 'crypto'; +import { AppError } from '../../errors/AppError'; +import redisClient from '../../config/redis'; +import { isValidPaypleBank } from '../constants/bank'; + +export type SellerTypeHint = 'INDIVIDUAL' | 'BUSINESS_PERSONAL' | 'BUSINESS_CORPORATE'; interface PaypleErrorResponse { result: string; @@ -11,15 +17,213 @@ export class AccountVerificationError extends AppError { constructor(message: string, subCode: string) { super(message, 400, 'AccountVerificationError'); this.subCode = subCode; - this.name = this.constructor.name; + this.name = this.constructor.name; + } +} + +// 민감 정보 redactor — 로그에 노출되면 안 되는 필드 +const REDACTED_FIELDS = new Set([ + 'account_num', + 'account_holder_info', + 'custKey', + 'cst_id', + 'billing_tran_id', + 'bank_tran_id', + 'access_token', +]); + +const redactValue = (v: unknown): string => { + if (typeof v !== 'string') return '***'; + return v.length > 4 ? `${v.slice(0, 2)}***${v.slice(-2)}` : '***'; +}; + +export const redactPaypleLog = (payload: unknown): unknown => { + if (!payload || typeof payload !== 'object') return payload; + if (Array.isArray(payload)) return payload.map(redactPaypleLog); + const out: Record = {}; + for (const [k, v] of Object.entries(payload as Record)) { + if (REDACTED_FIELDS.has(k)) { + out[k] = redactValue(v); + } else if (v && typeof v === 'object') { + out[k] = redactPaypleLog(v); + } else { + out[k] = v; + } + } + return out; +}; + +// Redis 일일 5회 인증 제한 — SET NX EX로 race condition 제거 +const RATE_LIMIT_PER_DAY = 5; + +const getSecondsUntilMidnight = (): number => { + const now = new Date(); + const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0); + return Math.floor((midnight.getTime() - now.getTime()) / 1000); +}; + +export const consumePaypleRateLimit = async (userId: number): Promise => { + const key = `payple_limit:${userId}`; + const ttl = getSecondsUntilMidnight(); + // NX + EX를 한 번의 atomic 호출로. 키가 없으면 0으로 초기화 + TTL. + await redisClient.set(key, '0', { NX: true, EX: ttl }); + const next = await redisClient.incr(key); + if (next > RATE_LIMIT_PER_DAY) { + throw new AccountVerificationError( + '일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요.', + 'LIMIT_EXCEEDED', + ); + } +}; + +// Payple OAuth 토큰 발급 +const fetchPaypleAccessToken = async (): Promise => { + const PAYPLE_HUB_URL = process.env.PAYPLE_HUB_URL; + const cst_id = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + if (!PAYPLE_HUB_URL || !cst_id || !custKey) { + throw new AccountVerificationError( + '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + 'SYSTEM_ERROR', + ); + } + + const code = crypto.randomBytes(5).toString('hex'); + const res = await axios.post( + `${PAYPLE_HUB_URL}/oauth/token`, + { cst_id, custKey, code }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + ); + + if (res.data.result !== 'T0000') { + console.error('[payple] oauth failed', { code: res.data.result }); + throw new AccountVerificationError( + '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + 'SYSTEM_ERROR', + ); } + return res.data.access_token as string; +}; + +export interface PaypleVerifyParams { + userId: number; + sellerType: 'INDIVIDUAL' | 'BUSINESS'; + businessType?: 'PERSONAL' | 'CORPORATE'; + bank: string; + accountNumber: string; + holderName: string; + birthDate?: string; + businessNumber?: string; } -export const parseAccountVerificationError = (paypleResponse: PaypleErrorResponse) => { +const toSellerHint = (sellerType: 'INDIVIDUAL' | 'BUSINESS', businessType?: 'PERSONAL' | 'CORPORATE'): SellerTypeHint => { + if (sellerType === 'INDIVIDUAL') return 'INDIVIDUAL'; + return businessType === 'CORPORATE' ? 'BUSINESS_CORPORATE' : 'BUSINESS_PERSONAL'; +}; + +// Payple 실명-계좌 일치 인증. +// 개인 / 개인사업자 → account_holder_info_type='0', account_holder_info=birthDate(YYMMDD) +// 법인사업자 → account_holder_info_type='6', account_holder_info=businessNumber(10자리) +export const verifyRealNameWithPayple = async ( + params: PaypleVerifyParams, +): Promise<{ accountHolderName: string }> => { + const { userId, sellerType, businessType, bank, accountNumber, holderName, birthDate, businessNumber } = params; + + if (!isValidPaypleBank(bank)) { + throw new AccountVerificationError( + '유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.', + 'BANK_MISMATCH', + ); + } + + let holderInfoType: '0' | '6'; + let holderInfo: string; + if (sellerType === 'BUSINESS' && businessType === 'CORPORATE') { + if (!businessNumber || !/^\d{10}$/.test(businessNumber)) { + throw new AccountVerificationError( + '입력하신 사업자등록번호가 올바르지 않습니다. 10자리 숫자로 다시 입력해 주세요.', + 'INVALID_BUSINESS_NUMBER', + ); + } + holderInfoType = '6'; + holderInfo = businessNumber; + } else { + if (!birthDate || !/^\d{6}$/.test(birthDate)) { + throw new AccountVerificationError( + '입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.', + 'INVALID_BIRTHDATE', + ); + } + holderInfoType = '0'; + holderInfo = birthDate; + } + + 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 accessToken = await fetchPaypleAccessToken(); + const sellerHint = toSellerHint(sellerType, businessType); + + let res; + try { + res = 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: holderInfoType, + account_holder_info: holderInfo, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }, + ); + } catch (err: any) { + if (err?.response?.data) { + const parsed = parseAccountVerificationError(err.response.data, sellerHint); + console.error('[payple] verify http error', { code: err.response.data?.result }); + throw new AccountVerificationError(parsed.message, parsed.subCode); + } + console.error('[payple] verify network error'); + throw new AccountVerificationError( + '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + 'SYSTEM_ERROR', + ); + } + + if (res.data.result !== 'A0000') { + const parsed = parseAccountVerificationError(res.data, sellerHint); + console.error('[payple] verify rejected', { code: res.data.result }); + throw new AccountVerificationError(parsed.message, parsed.subCode); + } + + if (res.data.account_holder_name !== holderName) { + throw new AccountVerificationError( + '실명/대표자명과 예금주명이 일치하지 않습니다. 다시 확인해주세요.', + 'NAME_MISMATCH', + ); + } + + return { accountHolderName: res.data.account_holder_name as string }; +}; + +// Payple 실명인증 응답을 사용자 모달 8종 + 신규 3종(INVALID_BIRTHDATE / INVALID_BUSINESS_NUMBER / SYSTEM_ERROR)으로 매핑. +// sellerHint는 N0112(holder_info 값 오류)에서 생년월일 vs 사업자번호 모달로 분기하기 위한 힌트. +export const parseAccountVerificationError = ( + paypleResponse: PaypleErrorResponse, + sellerHint?: SellerTypeHint, +) => { const code = paypleResponse.result; const msg = paypleResponse.message || ''; - // 1. 점검 시간 (7번 모달) + // 모달 7: 점검 시간 if (code === 'T0996' || code === 'T0997') { return { subCode: 'BANK_MAINTENANCE', @@ -27,23 +231,26 @@ export const parseAccountVerificationError = (paypleResponse: PaypleErrorRespons }; } - // 2. 은행 오류/불일치 (2번 모달) - if (code === 'N0101' || msg.includes('기관코드')) { + // 모달 2: 은행 코드 오류 / 계좌번호 형식 오류 + if (['N0101', 'N0102', 'N0103'].includes(code) || msg.includes('기관코드')) { return { subCode: 'BANK_MISMATCH', message: '선택하신 은행과 계좌번호가 일치하지 않습니다. 은행명을 다시 확인해 주세요.', }; } - // 3. 통신/지연 오류 (6번 모달) - if (code === 'A0007' || code === 'A0999' || code === 'A0001' || msg.includes('타임아웃')) { + // 모달 6: 통신/지연/내부 처리 오류 + if ( + ['A0001', 'A0002', 'A0003', 'A0007', 'A0099', 'A0999'].includes(code) || + msg.includes('타임아웃') + ) { return { subCode: 'BANK_TIMEOUT', message: '해당 은행과의 통신이 원활하지 않습니다. 잠시 후 다시 시도해 주세요.', }; } - // 4. 없는 계좌 (3번 모달) + // 모달 3: 해당 계좌 없음 if (code === 'N0198' || msg.includes('해당계좌 없음(412)')) { return { subCode: 'ACCOUNT_NOT_FOUND', @@ -51,15 +258,49 @@ export const parseAccountVerificationError = (paypleResponse: PaypleErrorRespons }; } - // A0009 내부 세부 분기 (4번, 5번 모달) + // 신규 모달 A: 생년월일 형식 오류 + if (code === 'N0120') { + return { + subCode: 'INVALID_BIRTHDATE', + message: '입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.', + }; + } + + // 신규 모달 B: 사업자등록번호 형식 오류 + if (code === 'N0130') { + return { + subCode: 'INVALID_BUSINESS_NUMBER', + message: '입력하신 사업자등록번호가 올바르지 않습니다. 10자리 숫자로 다시 입력해 주세요.', + }; + } + + // N0112: account_holder_info 값 오류 — sellerHint로 분기 + if (code === 'N0112') { + if (sellerHint === 'BUSINESS_CORPORATE') { + return { + subCode: 'INVALID_BUSINESS_NUMBER', + message: '입력하신 사업자등록번호가 올바르지 않습니다. 10자리 숫자로 다시 입력해 주세요.', + }; + } + return { + subCode: 'INVALID_BIRTHDATE', + message: '입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.', + }; + } + + // A0009 세부 분기 — 모달 1 / 4 / 5 if (code === 'A0009') { if (msg.includes('예금주명 불일치(815)')) { return { subCode: 'NAME_MISMATCH', - message: '실명과 예금주명이 일치하지 않는 계좌입니다. 다시 확인해주세요.', + message: '실명/대표자명과 예금주명이 일치하지 않습니다. 다시 확인해주세요.', }; } - if (msg.includes('해약 계좌(415)') || msg.includes('사고 신고계좌(419)') || msg.includes('거래중지 계좌(420)')) { + if ( + msg.includes('해약 계좌(415)') || + msg.includes('사고 신고계좌(419)') || + msg.includes('거래중지 계좌(420)') + ) { return { subCode: 'ACCOUNT_RESTRICTED', message: '입력하신 계좌는 현재 정상적인 거래가 불가능한 상태입니다. 은행 확인 후 다시 시도해 주세요.', @@ -71,17 +312,26 @@ export const parseAccountVerificationError = (paypleResponse: PaypleErrorRespons message: '해당 계좌는 정산용으로 등록할 수 없는 유형입니다. 원화 입출금이 가능한 보통예금 계좌로 다시 시도해 주세요.', }; } + if (msg.includes('해당계좌 없음(412)')) { + return { + subCode: 'ACCOUNT_NOT_FOUND', + message: '해당 계좌는 존재하지 않는 계좌입니다. 다시 확인해주세요.', + }; + } } - // 5. 거래 한도/횟수 초과 (8번 모달) - if (msg.includes('초과') || msg.includes('횟수')) { + // 신규 모달 C: 시스템 오류 (개발자/인프라/Payple 내부) + if ( + ['N0111', 'N0199', 'N0499', 'T1999'].includes(code) || + /^T0\d{3}$/.test(code) + ) { return { - subCode: 'LIMIT_EXCEEDED', - message: '일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요.', + subCode: 'SYSTEM_ERROR', + message: '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', }; } - // 기본 에러 처리 (입력값 오류 등) + // fallback return { subCode: 'UNKNOWN_VERIFICATION_ERROR', message: msg || '계좌 인증 처리 중 오류가 발생했습니다. 다시 시도해 주세요.', diff --git a/src/settlements/utils/register-token.ts b/src/settlements/utils/register-token.ts new file mode 100644 index 0000000..9af0511 --- /dev/null +++ b/src/settlements/utils/register-token.ts @@ -0,0 +1,79 @@ +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { AppError } from '../../errors/AppError'; +import redisClient from '../../config/redis'; +import { SellerKind, BusinessKind } from '../dtos/settlement.dto'; + +const TOKEN_TTL_SECONDS = 5 * 60; +const ISSUER = 'promptplace'; +const AUDIENCE = 'seller-register'; + +const getSecret = (): string => { + const secret = process.env.REGISTER_TOKEN_SECRET || process.env.JWT_SECRET; + if (!secret) throw new AppError('서버 설정 오류입니다.', 500, 'InternalServerError'); + return secret; +}; + +export interface RegisterTokenPayload { + userId: number; + sellerType: SellerKind; + businessType?: BusinessKind; + name: string; + birthDate?: string; + businessNumber?: string; + bank: string; + accountNumber: string; + holderName: string; + jti: string; +} + +export const issueRegisterToken = ( + payload: Omit, +): { token: string; expiresIn: number } => { + const jti = crypto.randomUUID(); + const fullPayload: RegisterTokenPayload = { ...payload, jti }; + const token = jwt.sign(fullPayload, getSecret(), { + expiresIn: TOKEN_TTL_SECONDS, + issuer: ISSUER, + audience: AUDIENCE, + }); + return { token, expiresIn: TOKEN_TTL_SECONDS }; +}; + +export class InvalidRegisterTokenError extends AppError { + constructor(message = '등록 토큰이 만료되었거나 유효하지 않습니다. 계좌 인증을 다시 진행해 주세요.') { + super(message, 401, 'InvalidRegisterToken'); + this.name = 'InvalidRegisterTokenError'; + } +} + +export class RegisterTokenAlreadyUsedError extends AppError { + constructor(message = '이미 사용된 등록 토큰입니다. 계좌 인증을 다시 진행해 주세요.') { + super(message, 409, 'RegisterTokenAlreadyUsed'); + this.name = 'RegisterTokenAlreadyUsedError'; + } +} + +// 토큰 검증 + 1회용 소비 (Redis blacklist). +// 동일 jti가 두 번째로 들어오면 RegisterTokenAlreadyUsedError. +export const consumeRegisterToken = async (token: string): Promise => { + let payload: RegisterTokenPayload; + try { + payload = jwt.verify(token, getSecret(), { + issuer: ISSUER, + audience: AUDIENCE, + }) as RegisterTokenPayload; + } catch { + throw new InvalidRegisterTokenError(); + } + + const blacklistKey = `register_token_used:${payload.jti}`; + const setRes = await redisClient.set(blacklistKey, '1', { + NX: true, + EX: TOKEN_TTL_SECONDS + 60, + }); + if (setRes !== 'OK') { + throw new RegisterTokenAlreadyUsedError(); + } + return payload; +}; diff --git a/src/settlements/utils/s3-client.ts b/src/settlements/utils/s3-client.ts new file mode 100644 index 0000000..43861c1 --- /dev/null +++ b/src/settlements/utils/s3-client.ts @@ -0,0 +1,26 @@ +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +export const s3Client = new S3Client({ + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, + }, +}); + +export const uploadFileToS3 = async ( + key: string, + buffer: Buffer, + contentType: string, +): Promise => { + const command = new PutObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + Body: buffer, + ContentType: contentType, + }); + + await s3Client.send(command); + + return `https://${process.env.S3_BUCKET!}.s3.${process.env.S3_REGION}.amazonaws.com/${key}`; +}; diff --git a/src/settlements/utils/s3-presign.ts b/src/settlements/utils/s3-presign.ts new file mode 100644 index 0000000..a47cdab --- /dev/null +++ b/src/settlements/utils/s3-presign.ts @@ -0,0 +1,18 @@ +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { s3Client } from './s3-client'; + +const DEFAULT_TTL_SECONDS = 5 * 60; + +// 버킷 private 전환 후 관리자 화면 등에서 사업자등록증을 임시 조회할 때 사용. +// DB에 저장된 S3 객체 키를 받아 짧은 TTL의 presigned GET URL 발급. +export const getPresignedDownloadUrl = async ( + objectKey: string, + expiresInSeconds: number = DEFAULT_TTL_SECONDS, +): Promise => { + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: objectKey, + }); + return await getSignedUrl(s3Client, command, { expiresIn: expiresInSeconds }); +}; diff --git a/swagger.json b/swagger.json index c831dbb..971a25c 100644 --- a/swagger.json +++ b/swagger.json @@ -6798,10 +6798,10 @@ } } }, - "/api/settlements/accounts": { - "get": { - "summary": "등록된 정산 계좌 정보 조회", - "description": "현재 로그인한 사용자의 정산용 계좌 정보(은행, 계좌번호, 예금주명)를 조회합니다.", + "/api/settlements/verify-account": { + "post": { + "summary": "판매자 계좌 인증 및 register-token 발급", + "description": "페이플 실명-계좌 인증을 호출하고 성공 시 register-token(JWT, 5분 TTL, 1회용)을 발급합니다.\n프론트엔드는 인증 성공 후 받은 registerToken을 register API 호출 시 그대로 전달해야 합니다.\n\nsellerType별 요청 필드:\n- INDIVIDUAL: name + birthDate (YYMMDD) + bank/accountNumber/holderName\n- BUSINESS + PERSONAL(개인사업자): name(대표자명) + birthDate(YYMMDD) + bank/accountNumber/holderName\n- BUSINESS + CORPORATE(법인사업자): name(대표자명) + businessNumber(10자리) + bank/accountNumber/holderName(법인명)\n\n페이플 호출 한도(일일 5회)는 우리 서비스 측 Redis로 관리합니다.\n", "tags": [ "Settlement" ], @@ -6810,9 +6810,75 @@ "jwt": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "sellerType", + "name", + "bank", + "accountNumber", + "holderName" + ], + "properties": { + "sellerType": { + "type": "string", + "enum": [ + "INDIVIDUAL", + "BUSINESS" + ], + "example": "BUSINESS" + }, + "businessType": { + "type": "string", + "enum": [ + "PERSONAL", + "CORPORATE" + ], + "description": "sellerType이 BUSINESS일 때 필수. 개인사업자(PERSONAL) / 법인사업자(CORPORATE)", + "example": "CORPORATE" + }, + "name": { + "type": "string", + "description": "실명(INDIVIDUAL) / 대표자명(BUSINESS)", + "example": "홍길동" + }, + "birthDate": { + "type": "string", + "description": "예금주 생년월일 6자리(YYMMDD). INDIVIDUAL / BUSINESS+PERSONAL 필수", + "example": "880212" + }, + "businessNumber": { + "type": "string", + "description": "사업자등록번호 10자리(숫자만). BUSINESS+CORPORATE 필수", + "example": "1234567890" + }, + "bank": { + "type": "string", + "description": "페이플 표준 은행 코드 3자리", + "example": "088" + }, + "accountNumber": { + "type": "string", + "description": "하이픈을 제외한 계좌 번호", + "example": "1234567890" + }, + "holderName": { + "type": "string", + "description": "계좌 예금주명. 법인은 법인명, 그 외엔 실명/대표자명", + "example": "홍길동" + } + } + } + } + } + }, "responses": { "200": { - "description": "계좌 정보 조회 성공", + "description": "계좌 인증 성공. registerToken 발급", "content": { "application/json": { "schema": { @@ -6820,24 +6886,16 @@ "properties": { "message": { "type": "string", - "example": "계좌 정보 조회가 완료되었습니다." + "example": "계좌 인증이 완료되었습니다." }, - "data": { - "type": "object", - "properties": { - "bank": { - "type": "string", - "example": "KOOKMIN" - }, - "accountNumber": { - "type": "string", - "example": "1234567890" - }, - "holderName": { - "type": "string", - "example": "홍길동" - } - } + "registerToken": { + "type": "string", + "description": "등록 API 호출용 1회용 JWT(5분 TTL)" + }, + "expiresIn": { + "type": "integer", + "description": "토큰 만료 시간(초)", + "example": 300 }, "statusCode": { "type": "integer", @@ -6848,8 +6906,8 @@ } } }, - "401": { - "description": "인증 실패 - 로그인하지 않은 사용자", + "400": { + "description": "검증 실패 또는 계좌 인증 실패", "content": { "application/json": { "schema": { @@ -6857,76 +6915,208 @@ "properties": { "error": { "type": "string", - "example": "Unauthorized" + "description": "ValidationError | AccountVerificationError | InvalidAccountInfo" }, - "message": { + "subCode": { "type": "string", - "example": "로그인이 필요합니다." - }, - "statusCode": { - "type": "integer", - "example": 401 - } - } - } - } - } - }, - "404": { - "description": "계좌 정보 없음 - 등록된 계좌가 없는 경우", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "AccountNotFound" + "description": "계좌 인증 실패 상세 코드 (프론트 모달 분기용)\nNAME_MISMATCH / BANK_MISMATCH / ACCOUNT_NOT_FOUND / ACCOUNT_RESTRICTED /\nUNSUPPORTED_TYPE / BANK_TIMEOUT / BANK_MAINTENANCE / LIMIT_EXCEEDED /\nINVALID_BIRTHDATE / INVALID_BUSINESS_NUMBER / SYSTEM_ERROR\n" }, "message": { - "type": "string", - "example": "등록된 계좌 정보가 존재하지 않습니다." + "type": "string" }, "statusCode": { "type": "integer", - "example": 404 + "example": 400 + } + } + }, + "examples": { + "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": "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 - 일일 인증 횟수 초과(자체 정책 5회)", + "value": { + "error": "AccountVerificationError", + "subCode": "LIMIT_EXCEEDED", + "message": "일일 계좌 인증 횟수를 초과했습니다. 보안을 위해 내일 다시 시도해 주세요.", + "statusCode": 400 + } + }, + "invalidBirthDate": { + "summary": "신규 모달 A - 생년월일 형식 오류", + "value": { + "error": "AccountVerificationError", + "subCode": "INVALID_BIRTHDATE", + "message": "입력하신 생년월일이 올바르지 않습니다. YYMMDD 형식으로 다시 입력해 주세요.", + "statusCode": 400 + } + }, + "invalidBusinessNumber": { + "summary": "신규 모달 B - 사업자등록번호 형식 오류", + "value": { + "error": "AccountVerificationError", + "subCode": "INVALID_BUSINESS_NUMBER", + "message": "입력하신 사업자등록번호가 올바르지 않습니다. 10자리 숫자로 다시 입력해 주세요.", + "statusCode": 400 + } + }, + "systemError": { + "summary": "신규 모달 C - 시스템/인프라 오류", + "value": { + "error": "AccountVerificationError", + "subCode": "SYSTEM_ERROR", + "message": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + "statusCode": 400 + } + }, + "invalidAccountInfo": { + "summary": "지원하지 않는 은행 코드", + "value": { + "error": "InvalidAccountInfo", + "message": "유효하지 않은 계좌번호이거나 지원하지 않는 은행입니다.", + "statusCode": 400 } } } } } }, + "401": { + "description": "로그인 필요" + }, "500": { - "description": "서버 오류", + "description": "서버 오류" + } + } + } + }, + "/api/settlements/accounts": { + "get": { + "summary": "등록된 정산 계좌 정보 조회", + "description": "현재 로그인한 사용자의 정산 계좌(은행, 계좌번호, 예금주명) 정보를 조회합니다.", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "계좌 정보 조회 성공", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { - "type": "string", - "example": "InternalServerError" - }, "message": { "type": "string", - "example": "알 수 없는 오류가 발생했습니다." + "example": "계좌 정보 조회가 완료되었습니다." + }, + "data": { + "type": "object", + "properties": { + "bank": { + "type": "string", + "example": "088" + }, + "accountNumber": { + "type": "string", + "example": "1234567890" + }, + "holderName": { + "type": "string", + "example": "홍길동" + } + } }, "statusCode": { "type": "integer", - "example": 500 + "example": 200 } } } } } + }, + "401": { + "description": "로그인 필요" + }, + "404": { + "description": "등록된 계좌 없음" + }, + "500": { + "description": "서버 오류" } } } }, - "/api/settlements/upload/business-license": { + "/api/settlements/register/individual": { "post": { - "summary": "사업자등록증 업로드 (개인/법인 사업자)", - "description": "개인 또는 법인 사업자의 사업자등록증 파일(이미지 또는 PDF, 최대 20MB)을 업로드하고 S3 URL을 반환받습니다.", + "summary": "개인 판매자 등록", + "description": "/verify-account에서 발급받은 registerToken과 약관 동의를 받아 개인 판매자로 등록합니다.\nregisterToken은 1회용이며 만료/재사용 시 401/409 반환.\n기존 사업자 등록이 있으면 삭제하고 개인으로 재등록됩니다.\n", "tags": [ "Settlement" ], @@ -6938,17 +7128,22 @@ "requestBody": { "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { "type": "object", "required": [ - "file" + "registerToken", + "isTermsAgreed" ], "properties": { - "file": { + "registerToken": { "type": "string", - "format": "binary", - "description": "업로드할 사업자등록증 파일 (jpg, jpeg, png, pdf) / 최대 20MB" + "description": "/verify-account 응답으로 받은 1회용 JWT" + }, + "isTermsAgreed": { + "type": "boolean", + "description": "개인정보 수집 이용 동의 (반드시 true)", + "example": true } } } @@ -6957,7 +7152,7 @@ }, "responses": { "200": { - "description": "파일 업로드 성공", + "description": "등록 성공 (신규 또는 수정)", "content": { "application/json": { "schema": { @@ -6965,11 +7160,7 @@ "properties": { "message": { "type": "string", - "example": "사업자등록증 업로드가 완료되었습니다." - }, - "fileUrl": { - "type": "string", - "example": "https://promptplace-storage.s3.ap-northeast-2.amazonaws.com/business-licenses/123-1709865432123.jpg" + "example": "개인 판매자 등록이 완료되었습니다." }, "statusCode": { "type": "integer", @@ -6981,7 +7172,10 @@ } }, "400": { - "description": "업로드할 파일 누락", + "description": "필수값 누락 또는 약관 미동의" + }, + "401": { + "description": "registerToken 만료/무효 또는 로그인 필요", "content": { "application/json": { "schema": { @@ -6989,23 +7183,23 @@ "properties": { "error": { "type": "string", - "example": "ValidationError" + "example": "InvalidRegisterToken" }, "message": { "type": "string", - "example": "업로드할 파일이 첨부되지 않았습니다." + "example": "등록 토큰이 만료되었거나 유효하지 않습니다. 계좌 인증을 다시 진행해 주세요." }, "statusCode": { "type": "integer", - "example": 400 + "example": 401 } } } } } }, - "401": { - "description": "인증 실패 - 로그인하지 않은 사용자", + "409": { + "description": "registerToken 재사용", "content": { "application/json": { "schema": { @@ -7013,71 +7207,182 @@ "properties": { "error": { "type": "string", - "example": "Unauthorized" + "example": "RegisterTokenAlreadyUsed" }, "message": { "type": "string", - "example": "로그인이 필요합니다." + "example": "이미 사용된 등록 토큰입니다. 계좌 인증을 다시 진행해 주세요." }, "statusCode": { "type": "integer", - "example": 401 + "example": 409 } } } } } }, - "413": { - "description": "파일 용량 제한 초과 (20MB)", + "500": { + "description": "서버 오류" + } + } + } + }, + "/api/settlements/upload/business-license": { + "post": { + "summary": "사업자등록증 업로드 (개인/법인 사업자)", + "description": "사업자등록증 파일(jpg/jpeg/png/pdf, 최대 20MB)을 업로드합니다.\n업로드 시 magic-byte로 실제 파일 형식을 검증하므로 확장자 위장 파일은 415로 거부됩니다.\nS3 객체 키는 예측 불가한 UUID로 생성됩니다.\n", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "jpg / jpeg / png / pdf (최대 20MB)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "업로드 성공", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { + "message": { "type": "string", - "example": "FileTooLarge" + "example": "사업자등록증 업로드가 완료되었습니다." }, - "message": { + "fileKey": { + "type": "string", + "description": "S3 객체 키 (DB/Register API에 전달용)", + "example": "business-licenses/0e5b9d7a-...-.pdf" + }, + "fileUrl": { "type": "string", - "example": "파일 크기는 최대 20MB까지만 허용됩니다." + "description": "현재 형식의 S3 URL (버킷 private 전환 시 presigned로 교체 예정)" }, "statusCode": { "type": "integer", - "example": 413 + "example": 200 } } } } } }, + "400": { + "description": "파일 누락" + }, + "401": { + "description": "로그인 필요" + }, + "413": { + "description": "파일 용량 초과 (20MB)" + }, "415": { - "description": "지원하지 않는 파일 형식", + "description": "지원하지 않는 파일 형식 또는 확장자 위장 (magic-byte 불일치)" + }, + "500": { + "description": "서버 오류" + } + } + } + }, + "/api/settlements/register/business": { + "post": { + "summary": "사업자 판매자 등록 신청", + "description": "/verify-account에서 발급받은 registerToken과 추가 사업자 정보(상호명, 사업자등록증)를 받아\n사업자 판매자로 등록 신청합니다. 상태는 PENDING으로 저장되고 관리자 승인 후 활성화됩니다.\n", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "registerToken", + "companyName", + "businessLicenseUrl", + "isTermsAgreed" + ], + "properties": { + "registerToken": { + "type": "string", + "description": "/verify-account 응답으로 받은 1회용 JWT" + }, + "companyName": { + "type": "string", + "example": "(주)프롬프트팩토리" + }, + "businessLicenseUrl": { + "type": "string", + "description": "/upload/business-license 응답으로 받은 fileUrl 또는 fileKey" + }, + "isTermsAgreed": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "신청 성공 (PENDING 상태)", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { - "type": "string", - "example": "InvalidFileType" - }, "message": { "type": "string", - "example": "지원하지 않는 파일 형식입니다. (jpg, jpeg, png, pdf만 가능)" + "example": "사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다." }, "statusCode": { "type": "integer", - "example": 415 + "example": 200 } } } } } }, - "500": { - "description": "서버 오류 - 알 수 없는 예외 발생", + "400": { + "description": "필수값 누락 또는 약관 미동의" + }, + "401": { + "description": "registerToken 만료/무효 또는 로그인 필요" + }, + "409": { + "description": "registerToken 재사용 / 이미 등록 / 사업자번호 중복", "content": { "application/json": { "schema": { @@ -7085,20 +7390,22 @@ "properties": { "error": { "type": "string", - "example": "InternalServerError" + "description": "RegisterTokenAlreadyUsed | AlreadyRegistered | DuplicateBusinessNumber" }, "message": { - "type": "string", - "example": "서버 오류가 발생했습니다." + "type": "string" }, "statusCode": { "type": "integer", - "example": 500 + "example": 409 } } } } } + }, + "500": { + "description": "서버 오류" } } } @@ -7106,7 +7413,7 @@ "/api/settlements/sales/monthly": { "get": { "summary": "월별 판매 내역 조회", - "description": "로그인한 판매자의 특정 연-월에 발생한 판매(Settlement) 내역을 조회합니다. year/month 미지정 시 현재 UTC 기준 년/월을 사용합니다. 결제수단(pay_type)과 카드사명(card_name)은 페이플 일반결제 검증 결과를 그대로 노출합니다.", + "description": "로그인한 판매자의 특정 연-월에 발생한 판매(Settlement) 내역을 조회합니다. year/month 미지정 시 현재 UTC 기준 년/월을 사용합니다.", "tags": [ "Settlement" ], @@ -7122,8 +7429,7 @@ "schema": { "type": "integer", "example": 2026 - }, - "description": "조회할 연도 (기본값 현재 UTC 연도)" + } }, { "in": "query", @@ -7133,8 +7439,7 @@ "minimum": 1, "maximum": 12, "example": 5 - }, - "description": "조회할 월 (1-12, 기본값 현재 UTC 월)" + } } ], "responses": { @@ -7166,12 +7471,10 @@ }, "total_sales": { "type": "integer", - "description": "원래 판매가 합계", "example": 30000 }, "total_settled": { "type": "integer", - "description": "정산 금액(수수료 차감 후) 합계", "example": 27000 }, "total_fee": { @@ -7207,13 +7510,11 @@ }, "pay_type": { "type": "string", - "nullable": true, - "description": "페이플 결제 타입 (card/transfer)" + "nullable": true }, "card_name": { "type": "string", - "nullable": true, - "description": "카드사명 (페이플 PCD_PAY_CARDNAME)" + "nullable": true }, "sale_price": { "type": "integer" @@ -7245,7 +7546,7 @@ } }, "400": { - "description": "잘못된 year/month 값" + "description": "잘못된 year/month" }, "401": { "description": "로그인 필요" @@ -7256,7 +7557,7 @@ "/api/settlements/yearly": { "get": { "summary": "연도별 누적 정산 내역 조회", - "description": "로그인한 판매자의 연도별 누적 정산 합계를 조회합니다. 각 연도 row는 해당 연도의 판매 건수, 원래 판매가 합계(total_sales), 정산 금액 합계(total_settled), 수수료 합계(total_fee), 상태별 정산금(succeeded/pending) 누적치를 포함합니다.", + "description": "로그인한 판매자의 연도별 누적 정산 합계를 조회합니다.", "tags": [ "Settlement" ],