From 6e120a205ca6f65989ffd71f19b2ccfe83c42201 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 00:21:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20-=20=ED=8C=90?= =?UTF-8?q?=EB=A7=A4=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20API=20=EB=B0=8F=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=9D=91=EB=8B=B5=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?(#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/settlements/account/detail 신규: 정보 변경 화면 prefill용 상세 조회 - INDIVIDUAL/BUSINESS 공통 필드 + 사업자 추가 필드 + status/isActive - business_number는 utils/masking.maskBusinessNumber로 123-45-****0 형태 마스킹 응답 - POST /register/business 케이스 분기 확장 - 최초 등록: 신규 row 생성 (PENDING, is_active=false) - 개인 → 사업자 전환: 기존 INDIVIDUAL row 삭제 후 BUSINESS PENDING 신규 생성 - 사업자 → 사업자 정보변경: 같은 row 덮어쓰기 + status=PENDING + is_active=false - businessNumber 중복 검증을 본인 row 제외로 수정 - businessLicenseUrl을 사업자→사업자 변경 시에만 optional 처리 (기존 URL 유지) - AlreadyRegistered 에러 제거 (변경 시나리오를 허용하기 위함) - POST /register/individual, /register/business 응답에 status + requiresApproval 추가 - 시나리오별 (APPROVED/false, PENDING/true)로 프론트 모달 분기 지원 - SettlementRepository.updateBusinessAccountForApproval 추가 - Swagger 갱신 (신규 endpoint, 응답 필드, 시나리오 설명) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settlement.account.controller.ts | 33 +++- .../settlement.seller.controller.ts | 4 + src/settlements/dtos/settlement.dto.ts | 25 ++- .../repositories/settlement.repository.ts | 40 +++++ src/settlements/routes/settlement.route.ts | 72 ++++++++- .../services/settlement.account.service.ts | 45 ++++++ .../services/settlement.seller.service.ts | 73 +++++++-- src/settlements/utils/masking.ts | 9 ++ swagger.json | 144 +++++++++++++++++- 9 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 src/settlements/utils/masking.ts diff --git a/src/settlements/controllers/settlement.account.controller.ts b/src/settlements/controllers/settlement.account.controller.ts index caa8312..e3785a1 100644 --- a/src/settlements/controllers/settlement.account.controller.ts +++ b/src/settlements/controllers/settlement.account.controller.ts @@ -1,5 +1,9 @@ import { Request, Response } from 'express'; -import { verifySellerAccount, getAccountInfo } from '../services/settlement.account.service'; +import { + verifySellerAccount, + getAccountInfo, + getSellerAccountDetail, +} from '../services/settlement.account.service'; import { VerifyAccountRequestDto, ViewAccountResponseDto } from '../dtos/settlement.dto'; export const verifyAccount = async (req: Request, res: Response) => { @@ -56,6 +60,33 @@ export const verifyAccount = async (req: Request, res: Response) => { } }; +export const ViewAccountDetail = async (req: Request, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ + error: 'Unauthorized', + message: '로그인이 필요합니다.', + statusCode: 401, + }); + } + const userId = (req.user as { user_id: number }).user_id; + const data = await getSellerAccountDetail(userId); + return res.status(200).json({ + message: '판매자 정보 조회가 완료되었습니다.', + data, + statusCode: 200, + }); + } catch (error: any) { + const status = error.statusCode || 500; + return res.status(status).json({ + error: error.error || 'InternalServerError', + message: error.message || '알 수 없는 오류가 발생했습니다.', + statusCode: status, + }); + } +}; + export const ViewAccount = async (req: Request, res: Response) => { try { const user = req.user; diff --git a/src/settlements/controllers/settlement.seller.controller.ts b/src/settlements/controllers/settlement.seller.controller.ts index d734434..700cd3c 100644 --- a/src/settlements/controllers/settlement.seller.controller.ts +++ b/src/settlements/controllers/settlement.seller.controller.ts @@ -49,6 +49,8 @@ export const registerIndividual = async (req: Request, res: Response) => { return res.status(200).json({ message: result.message, + status: result.status, + requiresApproval: result.requiresApproval, statusCode: 200, }); } catch (error: any) { @@ -157,6 +159,8 @@ export const registerBusiness = async (req: Request, res: Response) => { return res.status(200).json({ message: result.message, + status: result.status, + requiresApproval: result.requiresApproval, statusCode: 200, }); } catch (error: any) { diff --git a/src/settlements/dtos/settlement.dto.ts b/src/settlements/dtos/settlement.dto.ts index 16730ad..bd69623 100644 --- a/src/settlements/dtos/settlement.dto.ts +++ b/src/settlements/dtos/settlement.dto.ts @@ -29,7 +29,8 @@ export interface RegisterIndividualSellerRequestDto { export interface RegisterBusinessSellerRequestDto { registerToken: string; companyName: string; - businessLicenseUrl: string; // S3 업로드 응답으로 받은 URL (Phase 9에서 key로 전환 예정) + // 정보 변경 시 새 파일 업로드 안 하고 기존 등록증 유지하고 싶으면 생략 가능 + businessLicenseUrl?: string; isTermsAgreed: boolean; } @@ -51,3 +52,25 @@ export interface UpdateAccountRequestDto { accountNumber: string; holderName: string; } + +// 정보 변경 화면 prefill용 — 사업자는 추가 필드 포함, businessNumber는 마스킹 +export interface AccountDetailResponseDto { + sellerType: SellerKind; + status: 'APPROVED' | 'PENDING' | 'REJECTED'; + isActive: boolean; + bank: string; + accountNumber: string; + holderName: string; + name: string; + businessType?: BusinessKind; + businessNumber?: string; // 마스킹된 표시값 + representativeName?: string; + companyName?: string; + businessLicenseUrl?: string; +} + +export interface RegisterResultDto { + message: string; + status: 'APPROVED' | 'PENDING'; + requiresApproval: boolean; +} diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index 4194173..632d847 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -19,6 +19,18 @@ export interface CreateBusinessAccountInput { businessLicenseUrl: string; } +export interface UpdateBusinessAccountInput { + representativeName: string; + bank: string; + accountNumber: string; + holderName: string; + businessNumber: string; + businessType: BusinessKind; + companyName: string; + // optional — 빈 값이면 기존 URL 유지 + businessLicenseUrl?: string | null; +} + export const SettlementRepository = { upsertIndividualAccount: async ( userId: number, @@ -83,6 +95,34 @@ export const SettlementRepository = { }); }, + // 사업자 → 사업자 정보변경. + // 같은 row 덮어쓰기 + status=PENDING + is_active=false (관리자 승인 전까지 일시 비활성화). + // businessLicenseUrl이 undefined면 기존 URL 유지. + updateBusinessAccountForApproval: async ( + userId: number, + dto: UpdateBusinessAccountInput, + ) => { + const data: Record = { + 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, + seller_type: 'BUSINESS', + status: 'PENDING', + is_active: false, + }; + if (dto.businessLicenseUrl) { + data.business_license_url = dto.businessLicenseUrl; + } + return await prisma.settlementAccount.update({ + where: { user_id: userId }, + data, + }); + }, + deleteAccountByUserId: async (userId: number) => { return await prisma.settlementAccount.delete({ where: { user_id: userId }, diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index e2f734d..d6a180d 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { verifyAccount, ViewAccount } from "../controllers/settlement.account.controller"; +import { verifyAccount, ViewAccount, ViewAccountDetail } from "../controllers/settlement.account.controller"; import { registerIndividual, registerBusiness } from "../controllers/settlement.seller.controller"; import { uploadLicense } from "../controllers/settlement.seller.controller"; import { getMonthlySales, getYearlySettlements } from "../controllers/settlement.history.controller"; @@ -199,6 +199,53 @@ router.post("/verify-account", authenticateJwt, verifyAccount); */ router.get("/accounts", authenticateJwt, ViewAccount); +/** + * @swagger + * /api/settlements/account/detail: + * get: + * summary: 판매자 정보 변경 화면용 상세 조회 + * description: | + * 정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API. + * 사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함. + * businessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함. + * birthDate는 정책상 미저장이라 응답에 없음. + * tags: [Settlement] + * security: + * - jwt: [] + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 판매자 정보 조회가 완료되었습니다. } + * data: + * type: object + * properties: + * sellerType: { type: string, enum: [INDIVIDUAL, BUSINESS] } + * status: { type: string, enum: [APPROVED, PENDING, REJECTED] } + * isActive: { type: boolean } + * bank: { type: string, example: "088" } + * accountNumber: { type: string, example: "1234567890" } + * holderName: { type: string, example: 홍길동 } + * name: { type: string, description: 실명(INDIVIDUAL) 또는 대표자명(BUSINESS) } + * businessType: { type: string, enum: [PERSONAL, CORPORATE], nullable: true } + * businessNumber: { type: string, nullable: true, description: 마스킹된 사업자번호, example: "123-45-****0" } + * representativeName: { type: string, nullable: true } + * companyName: { type: string, nullable: true } + * businessLicenseUrl: { type: string, nullable: true } + * statusCode: { type: integer, example: 200 } + * 401: + * description: 로그인 필요 + * 404: + * description: 등록된 판매자 정보 없음 + * 500: + * description: 서버 오류 + */ +router.get("/account/detail", authenticateJwt, ViewAccountDetail); + /** * @swagger * /api/settlements/register/individual: @@ -237,6 +284,8 @@ router.get("/accounts", authenticateJwt, ViewAccount); * type: object * properties: * message: { type: string, example: 개인 판매자 등록이 완료되었습니다. } + * status: { type: string, enum: [APPROVED], example: APPROVED } + * requiresApproval: { type: boolean, example: false, description: 개인 판매자는 즉시 승인이므로 항상 false } * statusCode: { type: integer, example: 200 } * 400: * description: 필수값 누락 또는 약관 미동의 @@ -318,10 +367,18 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * @swagger * /api/settlements/register/business: * post: - * summary: 사업자 판매자 등록 신청 + * summary: 사업자 판매자 등록/변경 신청 * description: | * /verify-account에서 발급받은 registerToken과 추가 사업자 정보(상호명, 사업자등록증)를 받아 - * 사업자 판매자로 등록 신청합니다. 상태는 PENDING으로 저장되고 관리자 승인 후 활성화됩니다. + * 사업자 판매자로 등록 또는 변경 신청합니다. 모든 시나리오에서 상태는 PENDING으로 저장되고 관리자 승인 후 활성화됩니다. + * + * 시나리오별 동작: + * - 최초 사업자 등록: 신규 row 생성 (PENDING, is_active=false) + * - 개인 → 사업자 전환: 기존 INDIVIDUAL row 삭제 + 신규 BUSINESS row 생성 (PENDING) + * - 사업자 → 사업자 정보 변경: 같은 row 덮어쓰기 + status=PENDING + is_active=false (승인 전까지 일시 비활성화) + * + * 사업자등록증(businessLicenseUrl)은 사업자 → 사업자 변경 시에만 생략 가능 (기존 URL 유지). + * 최초 등록 / 개인→사업자 전환에는 필수. * tags: [Settlement] * security: * - jwt: [] @@ -334,7 +391,6 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * required: * - registerToken * - companyName - * - businessLicenseUrl * - isTermsAgreed * properties: * registerToken: @@ -345,7 +401,7 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * example: (주)프롬프트팩토리 * businessLicenseUrl: * type: string - * description: /upload/business-license 응답으로 받은 fileUrl 또는 fileKey + * description: /upload/business-license 응답으로 받은 fileUrl. 사업자→사업자 변경 시에만 생략 가능 * isTermsAgreed: * type: boolean * example: true @@ -357,7 +413,9 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * schema: * type: object * properties: - * message: { type: string, example: 사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다. } + * message: { type: string, description: 시나리오별 안내 메시지, example: 사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다. } + * status: { type: string, enum: [PENDING], example: PENDING } + * requiresApproval: { type: boolean, example: true, description: 사업자는 관리자 승인 필요 — 항상 true } * statusCode: { type: integer, example: 200 } * 400: * description: 필수값 누락 또는 약관 미동의 @@ -370,7 +428,7 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * schema: * type: object * properties: - * error: { type: string, description: "RegisterTokenAlreadyUsed | AlreadyRegistered | DuplicateBusinessNumber" } + * error: { type: string, description: "RegisterTokenAlreadyUsed | DuplicateBusinessNumber" } * message: { type: string } * statusCode: { type: integer, example: 409 } * 500: diff --git a/src/settlements/services/settlement.account.service.ts b/src/settlements/services/settlement.account.service.ts index a88fe0c..a53c6d8 100644 --- a/src/settlements/services/settlement.account.service.ts +++ b/src/settlements/services/settlement.account.service.ts @@ -2,9 +2,11 @@ import { AppError } from '../../errors/AppError'; import { VerifyAccountRequestDto, AccountDataDto, + AccountDetailResponseDto, SellerKind, BusinessKind, } from '../dtos/settlement.dto'; +import { maskBusinessNumber } from '../utils/masking'; import { SettlementRepository } from '../repositories/settlement.repository'; import { AccountVerificationError, @@ -141,3 +143,46 @@ export const getAccountInfo = async (userId: number): Promise => holderName: account.account_holder, }; }; + +// 정보 변경 화면 prefill용. +// 사업자는 추가 필드 포함하고 사업자번호는 마스킹된 상태로 응답. +// 실제 계좌 인증/등록 시점에는 사용자가 사업자번호를 다시 입력해야 함 (마스킹 값만 갖고는 인증 불가). +export const getSellerAccountDetail = async ( + userId: number, +): Promise => { + const account = await SettlementRepository.findAccountByUserId(userId); + + if (!account) { + throw new AppError( + '등록된 판매자 정보가 존재하지 않습니다.', + 404, + 'AccountNotFound', + ); + } + + const sellerType = account.seller_type as SellerKind; + + const base: AccountDetailResponseDto = { + sellerType, + status: account.status as 'APPROVED' | 'PENDING' | 'REJECTED', + isActive: account.is_active, + bank: account.bank_code, + accountNumber: account.account_number, + holderName: account.account_holder, + name: account.account_holder, + }; + + if (sellerType === 'BUSINESS') { + return { + ...base, + businessType: (account.business_type ?? undefined) as BusinessKind | undefined, + businessNumber: maskBusinessNumber(account.business_number) ?? undefined, + representativeName: account.representative_name ?? undefined, + companyName: account.company_name ?? undefined, + businessLicenseUrl: account.business_license_url ?? undefined, + name: account.representative_name ?? account.account_holder, + }; + } + + return base; +}; diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index abb6138..5d5c4cc 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -83,6 +83,8 @@ export const registerIndividualSeller = async ( message: isUpdate ? '판매자 정보가 성공적으로 수정되었습니다.' : '개인 판매자 등록이 완료되었습니다.', + status: 'APPROVED' as const, + requiresApproval: false, }; }; @@ -94,7 +96,6 @@ export const registerBusinessSeller = async ( !dto || !dto.registerToken || !dto.companyName || - !dto.businessLicenseUrl || dto.isTermsAgreed !== true ) { const error = new Error('필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다.'); @@ -116,20 +117,67 @@ export const registerBusinessSeller = async ( } const existingAccount = await SettlementRepository.findAccountByUserId(userId); - if (existingAccount) { - const error = new Error('이미 판매자로 등록되었거나 승인 심사 대기 중인 회원입니다.'); - error.name = 'AlreadyRegistered'; - throw error; - } - const existingBusiness = await SettlementRepository.findAccountByBusinessNumber(payload.businessNumber); - if (existingBusiness) { + // 사업자등록번호 중복 검사 — 본인 row는 제외 + const existingBusiness = await SettlementRepository.findAccountByBusinessNumber( + payload.businessNumber, + ); + if (existingBusiness && existingBusiness.user_id !== userId) { const error = new Error('이미 등록되었거나 심사 대기 중인 사업자등록번호입니다.'); error.name = 'DuplicateBusinessNumber'; throw error; } - await SettlementRepository.createBusinessAccount(userId, { + // 최초 사업자 등록 시에는 사업자등록증 URL 필수. + // 사업자 → 사업자 정보변경 시에만 생략 허용 (기존 URL 유지). + const isBusinessUpdate = !!existingAccount && existingAccount.seller_type === 'BUSINESS'; + if (!isBusinessUpdate && !dto.businessLicenseUrl) { + const error = new Error('사업자등록증 파일을 업로드해 주세요.'); + error.name = 'ValidationError'; + throw error; + } + + if (!existingAccount) { + // 최초 사업자 등록 + 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: '사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.', + status: 'PENDING' as const, + requiresApproval: true, + }; + } + + if (existingAccount.seller_type === 'INDIVIDUAL') { + // 개인 → 사업자 전환: 기존 INDIVIDUAL 삭제 후 BUSINESS PENDING 신규 생성 + await SettlementRepository.deleteAccountByUserId(userId); + 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: '사업자 판매자 변경 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.', + status: 'PENDING' as const, + requiresApproval: true, + }; + } + + // BUSINESS → BUSINESS 정보 변경: 같은 row 덮어쓰기 + PENDING + is_active=false + await SettlementRepository.updateBusinessAccountForApproval(userId, { representativeName: payload.name, bank: payload.bank, accountNumber: payload.accountNumber, @@ -139,6 +187,9 @@ export const registerBusinessSeller = async ( companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl, }); - - return { message: '사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.' }; + return { + message: '사업자 정보 변경 신청이 완료되었습니다. 관리자 승인 후 변경 사항이 반영됩니다.', + status: 'PENDING' as const, + requiresApproval: true, + }; }; diff --git a/src/settlements/utils/masking.ts b/src/settlements/utils/masking.ts new file mode 100644 index 0000000..91d065e --- /dev/null +++ b/src/settlements/utils/masking.ts @@ -0,0 +1,9 @@ +// 응답/관리자 화면 노출용 사업자등록번호 마스킹. +// 평문 저장은 유지하고 노출 시점에만 마스킹 (Phase 9 D-2 절충). +// 1234567890 -> 123-45-****0 +export const maskBusinessNumber = (raw: string | null | undefined): string | null => { + if (!raw) return null; + const digits = raw.replace(/\D/g, ''); + if (digits.length !== 10) return raw; + return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-****${digits.slice(9)}`; +}; diff --git a/swagger.json b/swagger.json index 971a25c..ec333a6 100644 --- a/swagger.json +++ b/swagger.json @@ -7113,6 +7113,116 @@ } } }, + "/api/settlements/account/detail": { + "get": { + "summary": "판매자 정보 변경 화면용 상세 조회", + "description": "정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API.\n사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함.\nbusinessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함.\nbirthDate는 정책상 미저장이라 응답에 없음.\n", + "tags": [ + "Settlement" + ], + "security": [ + { + "jwt": [] + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "판매자 정보 조회가 완료되었습니다." + }, + "data": { + "type": "object", + "properties": { + "sellerType": { + "type": "string", + "enum": [ + "INDIVIDUAL", + "BUSINESS" + ] + }, + "status": { + "type": "string", + "enum": [ + "APPROVED", + "PENDING", + "REJECTED" + ] + }, + "isActive": { + "type": "boolean" + }, + "bank": { + "type": "string", + "example": "088" + }, + "accountNumber": { + "type": "string", + "example": "1234567890" + }, + "holderName": { + "type": "string", + "example": "홍길동" + }, + "name": { + "type": "string", + "description": "실명(INDIVIDUAL) 또는 대표자명(BUSINESS)" + }, + "businessType": { + "type": "string", + "enum": [ + "PERSONAL", + "CORPORATE" + ], + "nullable": true + }, + "businessNumber": { + "type": "string", + "nullable": true, + "description": "마스킹된 사업자번호", + "example": "123-45-****0" + }, + "representativeName": { + "type": "string", + "nullable": true + }, + "companyName": { + "type": "string", + "nullable": true + }, + "businessLicenseUrl": { + "type": "string", + "nullable": true + } + } + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "401": { + "description": "로그인 필요" + }, + "404": { + "description": "등록된 판매자 정보 없음" + }, + "500": { + "description": "서버 오류" + } + } + } + }, "/api/settlements/register/individual": { "post": { "summary": "개인 판매자 등록", @@ -7162,6 +7272,18 @@ "type": "string", "example": "개인 판매자 등록이 완료되었습니다." }, + "status": { + "type": "string", + "enum": [ + "APPROVED" + ], + "example": "APPROVED" + }, + "requiresApproval": { + "type": "boolean", + "example": false, + "description": "개인 판매자는 즉시 승인이므로 항상 false" + }, "statusCode": { "type": "integer", "example": 200 @@ -7310,8 +7432,8 @@ }, "/api/settlements/register/business": { "post": { - "summary": "사업자 판매자 등록 신청", - "description": "/verify-account에서 발급받은 registerToken과 추가 사업자 정보(상호명, 사업자등록증)를 받아\n사업자 판매자로 등록 신청합니다. 상태는 PENDING으로 저장되고 관리자 승인 후 활성화됩니다.\n", + "summary": "사업자 판매자 등록/변경 신청", + "description": "/verify-account에서 발급받은 registerToken과 추가 사업자 정보(상호명, 사업자등록증)를 받아\n사업자 판매자로 등록 또는 변경 신청합니다. 모든 시나리오에서 상태는 PENDING으로 저장되고 관리자 승인 후 활성화됩니다.\n\n시나리오별 동작:\n- 최초 사업자 등록: 신규 row 생성 (PENDING, is_active=false)\n- 개인 → 사업자 전환: 기존 INDIVIDUAL row 삭제 + 신규 BUSINESS row 생성 (PENDING)\n- 사업자 → 사업자 정보 변경: 같은 row 덮어쓰기 + status=PENDING + is_active=false (승인 전까지 일시 비활성화)\n\n사업자등록증(businessLicenseUrl)은 사업자 → 사업자 변경 시에만 생략 가능 (기존 URL 유지).\n최초 등록 / 개인→사업자 전환에는 필수.\n", "tags": [ "Settlement" ], @@ -7329,7 +7451,6 @@ "required": [ "registerToken", "companyName", - "businessLicenseUrl", "isTermsAgreed" ], "properties": { @@ -7343,7 +7464,7 @@ }, "businessLicenseUrl": { "type": "string", - "description": "/upload/business-license 응답으로 받은 fileUrl 또는 fileKey" + "description": "/upload/business-license 응답으로 받은 fileUrl. 사업자→사업자 변경 시에만 생략 가능" }, "isTermsAgreed": { "type": "boolean", @@ -7364,8 +7485,21 @@ "properties": { "message": { "type": "string", + "description": "시나리오별 안내 메시지", "example": "사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다." }, + "status": { + "type": "string", + "enum": [ + "PENDING" + ], + "example": "PENDING" + }, + "requiresApproval": { + "type": "boolean", + "example": true, + "description": "사업자는 관리자 승인 필요 — 항상 true" + }, "statusCode": { "type": "integer", "example": 200 @@ -7390,7 +7524,7 @@ "properties": { "error": { "type": "string", - "description": "RegisterTokenAlreadyUsed | AlreadyRegistered | DuplicateBusinessNumber" + "description": "RegisterTokenAlreadyUsed | DuplicateBusinessNumber" }, "message": { "type": "string" From db94469092bac9413cb82729744e426395e59c58 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 00:24:36 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20Phase=2011=20-=20LEGACY=20?= =?UTF-8?q?=EC=9D=80=ED=96=89=EC=BD=94=EB=93=9C=20=EB=A7=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #470에서 호환 보존용으로 남겼던 폐기 13종 은행코드 맵을 운영 DB 점검 후 제거. - LEGACY_PAYPLE_BANKS, ALL_KNOWN_BANKS, isKnownBank 헬퍼 삭제 - 운영/dev DB 모두 폐기 코드(008/054~063/067/076/077) 사용 0건 확인 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/settlements/constants/bank.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/settlements/constants/bank.ts b/src/settlements/constants/bank.ts index f65ac84..46edbde 100644 --- a/src/settlements/constants/bank.ts +++ b/src/settlements/constants/bank.ts @@ -62,28 +62,5 @@ export const PAYPLE_BANKS: Record = { "304": "현대캐피탈", }; -// Payple 명세에서 제거된 코드 — 기존 등록 사용자 호환 보존용. -// 신규 등록/인증에는 사용 금지. Phase 11 운영 마이그레이션 후 제거 예정. -export const LEGACY_PAYPLE_BANKS: Record = { - "008": "수출입은행", - "054": "HSBC은행", - "055": "도이치은행", - "057": "제이피모간체이스은행", - "058": "미즈호은행", - "059": "엠유에프지은행", - "060": "BOA은행", - "061": "비엔피파리바은행", - "062": "중국공상은행", - "063": "중국은행", - "067": "중국건설은행", - "076": "신용보증기금", - "077": "기술보증기금", -}; - -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); From 961d662f719ae80671dad7ec7fff0d3939dff501 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 00:28:39 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20-=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=B3=80=EA=B2=BD=20prefill=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20birth=5Fdate=20=EC=A0=80=EC=9E=A5=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=9E=AC=EA=B0=9C=20(#473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #470에서 PII 최소화로 birth_date 미저장 정책을 택했으나, 정보 변경 화면에서 계좌 재인증을 위해 birthDate prefill이 필요하다는 UX 이슈로 정책 변경. - SettlementAccount.birth_date 컬럼 저장 재개 - upsertIndividualAccount, createBusinessAccount, updateBusinessAccountForApproval에 birthDate 전달 - BUSINESS+CORPORATE는 사용자가 입력 안 하므로 null 그대로 - GET /account/detail 응답에 birthDate 평문 포함 (본인 조회 한정) - registerIndividual: payload.birthDate 누락 시 ValidationError - Swagger 갱신: birthDate 응답 필드 명세 후속: 본 컬럼은 평문 저장이므로 별도 이슈에서 AES-256-GCM + KMS 암호화 검토. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/settlements/dtos/settlement.dto.ts | 3 ++- src/settlements/repositories/settlement.repository.ts | 8 +++++++- src/settlements/routes/settlement.route.ts | 3 ++- src/settlements/services/settlement.account.service.ts | 1 + src/settlements/services/settlement.seller.service.ts | 10 ++++++++++ swagger.json | 8 +++++++- 6 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/settlements/dtos/settlement.dto.ts b/src/settlements/dtos/settlement.dto.ts index bd69623..c18ec80 100644 --- a/src/settlements/dtos/settlement.dto.ts +++ b/src/settlements/dtos/settlement.dto.ts @@ -62,8 +62,9 @@ export interface AccountDetailResponseDto { accountNumber: string; holderName: string; name: string; + birthDate?: string; // YYMMDD. 본인 조회용 평문. INDIVIDUAL / BUSINESS+PERSONAL일 때 값 존재 businessType?: BusinessKind; - businessNumber?: string; // 마스킹된 표시값 + businessNumber?: string; // 마스킹된 표시값 representativeName?: string; companyName?: string; businessLicenseUrl?: string; diff --git a/src/settlements/repositories/settlement.repository.ts b/src/settlements/repositories/settlement.repository.ts index 632d847..73a5368 100644 --- a/src/settlements/repositories/settlement.repository.ts +++ b/src/settlements/repositories/settlement.repository.ts @@ -6,6 +6,7 @@ export interface UpsertIndividualAccountInput { bank: string; accountNumber: string; holderName: string; + birthDate: string; // INDIVIDUAL은 Payple 인증에 birthDate 필수 } export interface CreateBusinessAccountInput { @@ -17,6 +18,7 @@ export interface CreateBusinessAccountInput { businessType: BusinessKind; companyName: string; businessLicenseUrl: string; + birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 (대표자 생년월일). CORPORATE는 undefined } export interface UpdateBusinessAccountInput { @@ -29,6 +31,7 @@ export interface UpdateBusinessAccountInput { companyName: string; // optional — 빈 값이면 기존 URL 유지 businessLicenseUrl?: string | null; + birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 } export const SettlementRepository = { @@ -42,11 +45,11 @@ export const SettlementRepository = { bank_code: dto.bank, account_number: dto.accountNumber, account_holder: dto.holderName, + birth_date: dto.birthDate, seller_type: 'INDIVIDUAL', business_type: null, status: 'APPROVED', is_active: true, - birth_date: null, business_number: null, company_name: null, representative_name: null, @@ -57,6 +60,7 @@ export const SettlementRepository = { bank_code: dto.bank, account_number: dto.accountNumber, account_holder: dto.holderName, + birth_date: dto.birthDate, seller_type: 'INDIVIDUAL', status: 'APPROVED', is_active: true, @@ -88,6 +92,7 @@ export const SettlementRepository = { company_name: dto.companyName, representative_name: dto.representativeName, business_license_url: dto.businessLicenseUrl, + birth_date: dto.birthDate ?? null, seller_type: 'BUSINESS', status: 'PENDING', is_active: false, @@ -110,6 +115,7 @@ export const SettlementRepository = { business_type: dto.businessType, company_name: dto.companyName, representative_name: dto.representativeName, + birth_date: dto.birthDate ?? null, seller_type: 'BUSINESS', status: 'PENDING', is_active: false, diff --git a/src/settlements/routes/settlement.route.ts b/src/settlements/routes/settlement.route.ts index d6a180d..1e3c776 100644 --- a/src/settlements/routes/settlement.route.ts +++ b/src/settlements/routes/settlement.route.ts @@ -208,7 +208,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * 정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API. * 사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함. * businessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함. - * birthDate는 정책상 미저장이라 응답에 없음. + * birthDate는 본인 조회용 평문으로 응답 (정보 변경 시 계좌 재인증을 위해 prefill 필요). * tags: [Settlement] * security: * - jwt: [] @@ -231,6 +231,7 @@ router.get("/accounts", authenticateJwt, ViewAccount); * accountNumber: { type: string, example: "1234567890" } * holderName: { type: string, example: 홍길동 } * name: { type: string, description: 실명(INDIVIDUAL) 또는 대표자명(BUSINESS) } + * birthDate: { type: string, nullable: true, description: 예금주 생년월일 YYMMDD. CORPORATE는 null, example: "880212" } * businessType: { type: string, enum: [PERSONAL, CORPORATE], nullable: true } * businessNumber: { type: string, nullable: true, description: 마스킹된 사업자번호, example: "123-45-****0" } * representativeName: { type: string, nullable: true } diff --git a/src/settlements/services/settlement.account.service.ts b/src/settlements/services/settlement.account.service.ts index a53c6d8..6c5ac12 100644 --- a/src/settlements/services/settlement.account.service.ts +++ b/src/settlements/services/settlement.account.service.ts @@ -170,6 +170,7 @@ export const getSellerAccountDetail = async ( accountNumber: account.account_number, holderName: account.account_holder, name: account.account_holder, + birthDate: account.birth_date ?? undefined, }; if (sellerType === 'BUSINESS') { diff --git a/src/settlements/services/settlement.seller.service.ts b/src/settlements/services/settlement.seller.service.ts index 5d5c4cc..5108eec 100644 --- a/src/settlements/services/settlement.seller.service.ts +++ b/src/settlements/services/settlement.seller.service.ts @@ -72,10 +72,17 @@ export const registerIndividualSeller = async ( await SettlementRepository.deleteAccountByUserId(userId); } + if (!payload.birthDate) { + const error = new Error('등록 토큰에 생년월일 정보가 없습니다. 계좌 인증을 다시 진행해 주세요.'); + error.name = 'ValidationError'; + throw error; + } + await SettlementRepository.upsertIndividualAccount(userId, { bank: payload.bank, accountNumber: payload.accountNumber, holderName: payload.holderName, + birthDate: payload.birthDate, }); const isUpdate = !!existingAccount && existingAccount.seller_type === 'INDIVIDUAL'; @@ -148,6 +155,7 @@ export const registerBusinessSeller = async ( businessType: payload.businessType, companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl!, + birthDate: payload.birthDate, }); return { message: '사업자 판매자 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.', @@ -168,6 +176,7 @@ export const registerBusinessSeller = async ( businessType: payload.businessType, companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl!, + birthDate: payload.birthDate, }); return { message: '사업자 판매자 변경 신청이 완료되었습니다. 관리자 승인 후 최종 등록됩니다.', @@ -186,6 +195,7 @@ export const registerBusinessSeller = async ( businessType: payload.businessType, companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl, + birthDate: payload.birthDate, }); return { message: '사업자 정보 변경 신청이 완료되었습니다. 관리자 승인 후 변경 사항이 반영됩니다.', diff --git a/swagger.json b/swagger.json index ec333a6..b75396c 100644 --- a/swagger.json +++ b/swagger.json @@ -7116,7 +7116,7 @@ "/api/settlements/account/detail": { "get": { "summary": "판매자 정보 변경 화면용 상세 조회", - "description": "정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API.\n사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함.\nbusinessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함.\nbirthDate는 정책상 미저장이라 응답에 없음.\n", + "description": "정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API.\n사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함.\nbusinessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함.\nbirthDate는 본인 조회용 평문으로 응답 (정보 변경 시 계좌 재인증을 위해 prefill 필요).\n", "tags": [ "Settlement" ], @@ -7174,6 +7174,12 @@ "type": "string", "description": "실명(INDIVIDUAL) 또는 대표자명(BUSINESS)" }, + "birthDate": { + "type": "string", + "nullable": true, + "description": "예금주 생년월일 YYMMDD. CORPORATE는 null", + "example": "880212" + }, "businessType": { "type": "string", "enum": [