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); 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..c18ec80 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,26 @@ 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; + birthDate?: string; // YYMMDD. 본인 조회용 평문. INDIVIDUAL / BUSINESS+PERSONAL일 때 값 존재 + 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..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,20 @@ export interface CreateBusinessAccountInput { businessType: BusinessKind; companyName: string; businessLicenseUrl: string; + birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 (대표자 생년월일). CORPORATE는 undefined +} + +export interface UpdateBusinessAccountInput { + representativeName: string; + bank: string; + accountNumber: string; + holderName: string; + businessNumber: string; + businessType: BusinessKind; + companyName: string; + // optional — 빈 값이면 기존 URL 유지 + businessLicenseUrl?: string | null; + birthDate?: string; // BUSINESS+PERSONAL일 때만 존재 } export const SettlementRepository = { @@ -30,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, @@ -45,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, @@ -76,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, @@ -83,6 +100,35 @@ 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, + birth_date: dto.birthDate ?? null, + 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..1e3c776 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,54 @@ 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는 본인 조회용 평문으로 응답 (정보 변경 시 계좌 재인증을 위해 prefill 필요). + * 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) } + * 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 } + * 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 +285,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 +368,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 +392,6 @@ router.post("/upload/business-license", authenticateJwt, uploadLicense); * required: * - registerToken * - companyName - * - businessLicenseUrl * - isTermsAgreed * properties: * registerToken: @@ -345,7 +402,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 +414,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 +429,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..6c5ac12 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,47 @@ 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, + birthDate: account.birth_date ?? undefined, + }; + + 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..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'; @@ -83,6 +90,8 @@ export const registerIndividualSeller = async ( message: isUpdate ? '판매자 정보가 성공적으로 수정되었습니다.' : '개인 판매자 등록이 완료되었습니다.', + status: 'APPROVED' as const, + requiresApproval: false, }; }; @@ -94,7 +103,6 @@ export const registerBusinessSeller = async ( !dto || !dto.registerToken || !dto.companyName || - !dto.businessLicenseUrl || dto.isTermsAgreed !== true ) { const error = new Error('필수 입력값이 누락되었거나 이용 약관에 동의하지 않았습니다.'); @@ -116,20 +124,69 @@ 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!, + birthDate: payload.birthDate, + }); + 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!, + birthDate: payload.birthDate, + }); + 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, @@ -138,7 +195,11 @@ export const registerBusinessSeller = async ( businessType: payload.businessType, companyName: dto.companyName, businessLicenseUrl: dto.businessLicenseUrl, + birthDate: payload.birthDate, }); - - 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..b75396c 100644 --- a/swagger.json +++ b/swagger.json @@ -7113,6 +7113,122 @@ } } }, + "/api/settlements/account/detail": { + "get": { + "summary": "판매자 정보 변경 화면용 상세 조회", + "description": "정보 변경 화면에서 기존 등록 데이터를 prefill 하기 위한 상세 조회 API.\n사업자는 추가 필드(businessType/businessNumber/companyName/representativeName/businessLicenseUrl/status) 포함.\nbusinessNumber는 마스킹(`123-45-****0`)되어 응답되며, 변경 시 사용자가 실제 값을 다시 입력해야 함.\nbirthDate는 본인 조회용 평문으로 응답 (정보 변경 시 계좌 재인증을 위해 prefill 필요).\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)" + }, + "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 + }, + "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 +7278,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 +7438,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 +7457,6 @@ "required": [ "registerToken", "companyName", - "businessLicenseUrl", "isTermsAgreed" ], "properties": { @@ -7343,7 +7470,7 @@ }, "businessLicenseUrl": { "type": "string", - "description": "/upload/business-license 응답으로 받은 fileUrl 또는 fileKey" + "description": "/upload/business-license 응답으로 받은 fileUrl. 사업자→사업자 변경 시에만 생략 가능" }, "isTermsAgreed": { "type": "boolean", @@ -7364,8 +7491,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 +7530,7 @@ "properties": { "error": { "type": "string", - "description": "RegisterTokenAlreadyUsed | AlreadyRegistered | DuplicateBusinessNumber" + "description": "RegisterTokenAlreadyUsed | DuplicateBusinessNumber" }, "message": { "type": "string"