feat: Re:Link MVP 초기 구현 - 도메인/서비스/프론트엔드 전체

- 모노레포 구조 (Turborepo + pnpm): @relink/domain, @relink/shared, @relink/infrastructure, @relink/database, @relink/web
- 도메인 레이어: 매장(store), 매칭(matching), 업체(vendor), 보조금(subsidy), 계약/에스크로(contract) TDD 완료 (158 단위 테스트)
- 서비스 레이어: 전 도메인 서비스 함수 + 통합 테스트 (58 테스트)
- 프론트엔드: Next.js 15 App Router, 13개 페이지 (사용자 6 + 관리자 7)
- 인프라: PostgreSQL 16 + PostGIS, Prisma ORM, Docker Compose, AuditLog + OutboxEvent 패턴
- .env 파일 포함 (로컬 개발 기본값만 포함, 실제 시크릿 없음)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-07 17:39:56 +09:00
commit 16bd2cb92a
170 changed files with 23628 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Database
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
DATABASE_TEST_URL="postgresql://relink:relink_test@localhost:5433/relink_test"
# Redis
REDIS_URL="redis://localhost:6379"
# Next.js
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_ADMIN_URL="http://localhost:3001"
# Node
NODE_ENV="development"
+13
View File
@@ -0,0 +1,13 @@
# Database
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
DATABASE_TEST_URL="postgresql://relink:relink_test@localhost:5433/relink_test"
# Redis
REDIS_URL="redis://localhost:6379"
# Next.js
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_ADMIN_URL="http://localhost:3001"
# Node
NODE_ENV="development"
+35
View File
@@ -0,0 +1,35 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'import'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
'prettier',
],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': 'error',
'import/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
'import/no-duplicates': 'error',
'no-console': 'warn',
},
ignorePatterns: ['dist/', '.next/', 'node_modules/', 'coverage/'],
settings: {
'import/resolver': {
typescript: true,
},
},
};
+45
View File
@@ -0,0 +1,45 @@
# Dependencies
node_modules/
# Build outputs
dist/
.next/
out/
# Turborepo
.turbo/
# Environment (allow .env for dev defaults, ignore local overrides)
.env.local
.env.*.local
# Test coverage
coverage/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Prisma
packages/database/prisma/*.db
packages/database/prisma/*.db-journal
# Debug
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# OMC plugin state
.omc/
# Misc
*.tsbuildinfo
cursor_billing_chart.html
+1
View File
@@ -0,0 +1 @@
20
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}
+541
View File
@@ -0,0 +1,541 @@
# Re:Link (리링크) 실행형 개발계획서
> 폐업-철거-인테리어 선순환 통합 매칭 플랫폼
> 작성일: 2026-03-07 | 기준 문서: 2026년 예비창업패키지 사업계획서
> 목적: 사업계획서를 실제 제품 개발, 운영, 배포가 가능한 실행 문서로 재구성
---
## 1. 문서 목적
이 문서는 단순 기능 목록이 아니라, Re:Link MVP를 실제로 만들기 위해 먼저 고정해야 할 **정책, 운영, 계측, 아키텍처, 일정**을 정리한 실행 기준 문서다.
### 1-1. 문서 역할
- 사업계획서의 비전과 수익 모델을 제품 요구사항으로 번역한다.
- 개발 전에 애매한 의사결정을 줄이고, 구현 중 재작업을 최소화한다.
- 운영자 개입이 많은 초기 플랫폼의 현실을 반영한다.
- 실제 구현은 별도 `plan.md`에서 TDD 기준으로 관리한다.
### 1-2. 토스식 의사결정 원칙
Re:Link의 MVP 의사결정은 아래 원칙을 기본값으로 삼는다.
| 원칙 | 적용 방식 |
|------|-----------|
| 정책이 코드보다 먼저 | 정부 지원금, 정보 공개, 정산, 분쟁 규칙을 먼저 문서화하고 그 다음 구현한다. |
| 운영 가능한 MVP 우선 | 완전 자동화보다 운영자 승인형 플로우를 먼저 출시한다. |
| 계측 없는 기능은 출시하지 않음 | 모든 핵심 기능은 이벤트 스키마와 KPI 계산식이 먼저 정의되어야 한다. |
| 외부 연동은 격리 | PG, 알림, 지도, 사업자 인증은 모두 어댑터 계층과 재시도 정책 뒤에 둔다. |
| 작게 출시하고 빠르게 보정 | 베타 지역과 업종을 좁혀 feature flag 기반으로 순차 출시한다. |
| 롤백 가능하게 설계 | 결제, 알림, 문서 처리, 상태 전환은 replay 가능한 로그와 감사 기록을 남긴다. |
| DRI 명확화 | 각 도메인마다 최종 의사결정 책임자를 한 명으로 고정한다. |
### 1-3. 문서 계층
| 문서 | 역할 |
|------|------|
| 사업계획서 HTML | 왜 이 사업을 하는지와 시장/수익 전략을 설명 |
| `DEVELOPMENT_PLAN.md` | 무엇을 어떤 원칙으로 만들지 결정 |
| `plan.md` | 현재 구현 중인 테스트와 작업 순서를 관리 |
---
## 2. 제품 정의
### 2-1. 핵심 가치 제안
폐업자가 매장 정보를 **1회 등록**하면, 창업자-철거업체-인테리어업체를 **동시 연결**하여 폐업 비용을 줄이고 재창업/인수 기회를 높이는 선순환 플랫폼을 만든다.
### 2-2. 핵심 사용자
| 코드 | 사용자 | 핵심 니즈 | 초기 수익 연결 |
|------|--------|----------|----------------|
| U1 | 폐업자 | 철거비 절감, 시설 처분, 지원금 신청 도움 | 거래 성사, 부가 서비스 |
| U2 | 창업자 | 시설 인수, 인테리어 비용 절감 | 성공 수수료 |
| U3 | 철거업체 | 안정적 수주, 인증, 정산 안정성 | 수수료, 향후 구독 |
| U4 | 인테리어업체 | 선제 영업 정보, 리드 확보 | 향후 구독, 광고 |
| U5 | 운영자/관리자 | 심사, 검수, 중재, 정산, 계측 | 서비스 신뢰 유지 |
### 2-3. 초기 베타 범위
- 지역: 서울 강남권 `역삼/선릉/논현`, 마포권 `홍대/합정/연남`
- 업종: F&B 우선
- 출시 방식: 운영자 개입형 assisted marketplace
- 목표 사용자: 폐업자 100명, 철거업체 50개, 초기 창업자 리드 확보
### 2-4. MVP 성공 기준
| 항목 | 기준 |
|------|------|
| 공급 확보 | 검수 가능한 폐업 매장 데이터가 지속 유입된다. |
| 거래 신뢰 | 계약, 검수, 정산, 분쟁 처리의 운영 기준이 흔들리지 않는다. |
| 측정 가능성 | 핵심 KPI가 이벤트 로그만으로 재현 가능하다. |
| 운영 가능성 | 운영 콘솔 없이 수작업 엑셀에 의존하지 않는다. |
| 확장성 | Phase 2에서 B2B 구독과 직거래 장터를 붙일 수 있는 데이터 구조를 확보한다. |
---
## 3. MVP 범위 재정의
### 3-1. Phase 1 MVP
| 우선순위 | 기능 묶음 | MVP 포함 범위 | MVP 제외 범위 |
|---------|-----------|----------------|----------------|
| P0 | 사용자/인증 | 역할 선택 가입, 소셜 로그인, 기본 사업자 확인, 운영자 수동 보정 | 고도화된 KYC 자동화 |
| P0 | 매장 등록 | 매장 기본 정보, 임대/시설 정보, 사진 업로드, 검토 요청, 공개/비공개 전환 | 복수 지점 일괄 등록 |
| P0 | 매칭 | 검색, 필터, 매칭 요청, 운영자 수동 추천, 요청 승인/거절 | 실시간 추천 고도화, 자동 배정 |
| P0 | 정부 지원금 가이드형 대행 | 자격 판별, 서류 체크리스트, 업로드, 운영자 검토, 상태 추적 | 법적 자격 없이 완전 대리 제출 자동화 |
| P0.5 | 운영 백오피스 | 등록 검토, 업체 인증, 지원금 검토, 검수 승인, 정산 보류/해제, 감사 로그 | 고급 BI, 복잡한 권한 위임 체계 |
| P1 | 신뢰 인프라 | 업체 인증, 표준 계약서 템플릿, 서명 증적 저장, 에스크로 결제, 사진 검수, 분쟁 접수 | 완전 자동 정산, 고도화된 분쟁 자동 판정 |
| P1 | 시세 추천 | 규칙 기반 범위 추천, 견적 비교 기준 제공 | ML 기반 가격 예측 |
### 3-2. Phase 2
| 우선순위 | 기능 | 범위 |
|---------|------|------|
| P2 | 폐업 물품 직거래 장터 | 물품 등록, 검색, 예약, 거래 완료 |
| P2 | B2B 폐업 예정지 알림 | 지역/업종별 구독, 정기 결제, 알림 상품화 |
| P2 | 정부 지원금 대행 고도화 | 문서 자동 생성, 운영 자동화 |
| P2 | 스마트 시세 추천 고도화 | ML 모델 전환, 정확도 검증 |
| P2 | 모바일 앱 | React Native 기반 모바일 전환 |
### 3-3. MVP에서 의도적으로 늦추는 것
- 실시간 채팅 대신 `문의 스레드 + 운영자 브릿지`를 먼저 사용한다.
- B2B 구독은 데이터 모델과 정책만 먼저 준비하고 유료화는 Phase 2에서 시작한다.
- 직거래 장터는 MVP 핵심 거래 흐름이 안정화된 뒤 붙인다.
---
## 4. 반드시 고정할 제품/운영 정책
### 4-1. 정책 기본값
| 정책 | 기본 결정 |
|------|-----------|
| 정부 지원금 신청 대행 | MVP는 `가이드형 + 운영자 검토형`으로 시작한다. 법적 자격 검토 전 완전 대행 자동화는 하지 않는다. |
| 폐업 정보 공개 | 기본값은 비식별/제한 공개다. 상세 주소, 연락처, 민감 정보는 소유자 동의와 권한 검증 후 노출한다. |
| B2B 데이터 판매/알림 | MVP에서는 익명화된 리드성 데이터 구조만 준비한다. 원본 개인 정보 직접 판매는 하지 않는다. |
| 계약/에스크로 | 계약 생성과 결제는 가능하되 정산 해제는 검수 승인 또는 운영자 승인 이후에만 진행한다. |
| 분쟁 처리 | 분쟁 발생 시 자동 정산 해제는 중단되고 운영자 검토 상태로 전환한다. |
| 권리금/임대차 | MVP에서는 권리금 협상과 임대차 법률 행위의 직접 중개를 하지 않는다. 정보 제공과 파트너 연결만 한다. |
| 업체 인증 | 인증 서류, 영업 이력, 서비스 가능 지역, 제재 이력을 기준으로 운영자 승인형으로 관리한다. |
| 개인정보/문서 보관 | 문서 접근은 서명 URL과 감사 로그 기반으로 제한하고, 보존 기간은 법적 검토 결과를 우선 적용한다. |
### 4-2. 지원금 대행 정책
- 서비스 포지션은 `정부 사업 경쟁자`가 아니라 `신청 보조 파트너`다.
- 사용자에게는 체크리스트, 업로드, 진행 상태, 운영자 피드백을 제공한다.
- 운영자는 서류 누락, 반려 사유, 제출 준비 여부를 판정한다.
- 정책/법무 검토 전에는 시스템이 정부 기관을 대리해 자동 제출하는 구조를 만들지 않는다.
### 4-3. 정보 공개 정책
- 폐업 등록 즉시 모든 정보가 공개되지 않는다.
- 창업자에게는 매칭 가능한 핵심 요약 정보를 먼저 보여준다.
- 철거업체/인테리어업체에는 역할과 권한에 맞는 정보만 공개한다.
- B2B 알림 상품은 Phase 2에서 시작하되, 지금부터 `공개 등급`, `권한`, `동의`, `열람 이력` 데이터를 남긴다.
### 4-4. 계약/정산 정책
- 계약은 표준 템플릿 기반으로 생성한다.
- 서명 행위는 증적을 남겨야 하며, 문서 버전과 타임스탬프를 같이 저장한다.
- 에스크로 상태는 결제 성공과 운영 승인, 검수 승인, 분쟁 여부를 기준으로 전이한다.
- 부분 환불, 수동 정산, 보류 해제는 운영 콘솔에서만 실행한다.
---
## 5. 도메인 모델
### 5-1. 바운디드 컨텍스트
| 컨텍스트 | 책임 |
|----------|------|
| Identity | 사용자, 역할, 인증, 사업자 확인 |
| Store Supply | 매장 등록, 시설/임대 정보, 사진, 공개 상태 |
| Matching | 매칭 후보 계산, 요청, 수락/거절, 운영자 추천 |
| Subsidy | 지원금 체크리스트, 서류, 상태, 운영자 검토 |
| Trust | 업체 인증, 계약, 에스크로, 검수, 분쟁 |
| Notification | 알림톡, 푸시, 이메일, 메시지 템플릿 |
| Backoffice | 운영 큐, 감사 로그, 수동 보정, 통계 |
| Analytics | 이벤트 로그, KPI, 리포트, 데이터 파이프라인 |
### 5-2. 핵심 엔티티
```
User
├── UserProfile
├── UserConsent
├── Store[]
└── Vendor[]
Store
├── StoreLease
├── StoreFacility
├── StoreLifecycle
├── StorePhoto[]
├── StoreAsset[]
├── MatchLead[]
├── MatchRequest[]
└── PriceEstimate[]
Vendor
├── VendorCertification
├── VendorCoverageArea[]
├── VendorQuote[]
└── VendorPenalty[]
MatchRequest
├── MatchReview
└── Contract?
Contract
├── ContractVersion[]
├── SignatureEvidence[]
├── EscrowTransaction
├── InspectionRecord[]
└── DisputeCase?
SubsidyCase
├── SubsidyDocument[]
├── SubsidyChecklistItem[]
└── SubsidyReviewLog[]
AlertSubscription
AuditLog
EventLog
```
### 5-3. 베타에 반드시 필요한 마스터 데이터
| 데이터 | 설명 |
|--------|------|
| `RegionHierarchy` | 시/구/동/상권/클러스터 구조 |
| `IndustryTaxonomy` | 대분류/중분류/세분류/영업형태 |
| `StoreLease` | 보증금, 월세, 관리비, 권리금, 잔여 임대기간 |
| `StoreFacility` | 전용면적, 층수, 좌석 수, 전기, 가스, 배수, 덕트, 주방 설비 |
| `StoreLifecycle` | 폐업 예정일, 인수 가능일, 철거 예정일 |
| `VendorCoverageArea` | 업체 서비스 권역 |
| `VendorCapability` | 철거/인테리어 가능 범위, 장비, 인증 상태 |
### 5-4. 상태머신
| 도메인 | 상태 |
|--------|------|
| Store | `DRAFT -> SUBMITTED -> REVIEWING -> PUBLISHED -> MATCHING -> RESERVED -> CONTRACTED -> CLOSED/CANCELLED` |
| MatchRequest | `PENDING -> REVIEWING -> ACCEPTED/REJECTED -> CONTRACTING -> COMPLETED/EXPIRED` |
| SubsidyCase | `DRAFT -> ELIGIBILITY_CHECKED -> DOCUMENTS_PENDING -> REVIEWING -> READY_TO_SUBMIT -> SUBMITTED -> APPROVED/REJECTED` |
| VendorCertification | `APPLIED -> REVIEWING -> APPROVED/REJECTED -> SUSPENDED` |
| Contract | `DRAFT -> GENERATED -> SIGNING -> SIGNED -> IN_PROGRESS -> COMPLETED/CANCELLED` |
| Escrow | `PENDING -> DEPOSIT_PAID -> HOLDING -> RELEASE_REVIEW -> RELEASED/REFUNDED` 또는 `HOLDING/RELEASE_REVIEW -> DISPUTED` |
| Inspection | `REQUESTED -> UPLOADED -> REVIEWING -> APPROVED/REJECTED` |
| DisputeCase | `OPEN -> INVESTIGATING -> MEDIATING -> RESOLVED/CLOSED` |
모든 상태 전환은 `AuditLog``EventLog`를 동시에 남긴다.
---
## 6. 운영 백오피스 설계
### 6-1. MVP 필수 운영 화면
- 매장 등록 검토 큐
- 공개/비공개 전환 및 반려 사유 관리
- 업체 인증 심사 큐
- 지원금 서류 검토 큐
- 계약 문서/서명 증적 뷰어
- 검수 사진 승인 화면
- 분쟁 접수/메모/상태 변경 화면
- 정산 보류/해제 및 결제 대사 화면
- 이벤트/KPI 대시보드
- 감사 로그 조회
### 6-2. 운영 역할
| 역할 | 책임 |
|------|------|
| `SUPER_ADMIN` | 전체 설정, 권한, 긴급 조치 |
| `OPS_MANAGER` | 등록 검토, 매칭 보정, 운영 품질 |
| `SUBSIDY_OPERATOR` | 지원금 서류 검토, 상태 관리 |
| `TRUST_OPERATOR` | 업체 인증, 계약, 검수, 분쟁 |
| `FINANCE_OPERATOR` | 정산, 결제 대사, 환불 처리 |
### 6-3. 운영 기본 원칙
- 운영자가 개입한 모든 결정에는 사유 코드와 메모를 남긴다.
- 사람이 개입해 상태를 바꾼 경우 원래 상태와 변경자를 감사 로그에 남긴다.
- 베타 단계에서는 자동화보다 운영 안정성이 우선이다.
---
## 7. 아키텍처와 인프라
### 7-1. 아키텍처 방향
Phase 1은 **Next.js 기반 모듈러 모놀리스**로 시작하되, 도메인 로직은 프레임워크 바깥 계층에 두어 Phase 2의 분리를 `재작성`이 아닌 `어댑터 추가`로 만든다.
### 7-2. 추천 프로젝트 구조
```
re-link/
├── apps/
│ ├── web/ # Next.js 사용자 웹
│ ├── admin/ # Next.js 운영 콘솔
│ └── api/ # Phase 2 NestJS 어댑터
├── packages/
│ ├── domain/ # 엔티티, 값 객체, 상태 규칙
│ ├── application/ # 유스케이스, 서비스, 정책
│ ├── infrastructure/ # PG/알림/지도/파일/결제 어댑터
│ ├── database/ # Prisma 스키마 + 마이그레이션
│ ├── analytics/ # 이벤트 스키마, KPI 계산
│ ├── ui/ # 공통 UI
│ └── shared/ # 공통 타입, 상수, 유틸
└── docs/
└── policies/ # 제품/운영 정책 문서
```
### 7-3. 기술 선택
| 레이어 | 선택 | 원칙 |
|--------|------|------|
| 프론트엔드 | Next.js 15 + TypeScript | 웹/운영 콘솔 동시 대응 |
| 상태 관리 | Zustand + TanStack Query v5 | 클라이언트/서버 상태 분리 |
| UI | shadcn/ui + Tailwind CSS v4 | 빠른 조립과 일관성 |
| 백엔드 | Next.js Route Handler + Service Layer | HTTP 어댑터를 얇게 유지 |
| ORM | Prisma | 타입 안정성과 마이그레이션 |
| DB | PostgreSQL 16 + PostGIS | 지역 기반 검색과 관계 모델 |
| 캐시 | Redis | 캐시, 세션, 읽기 성능 개선 |
| 비동기 처리 | Postgres Outbox + Worker | MVP에서 신뢰성 있는 재시도와 idempotency 확보 |
| 파일 | 객체 스토리지 인터페이스 | IDC 환경에서도 MinIO/S3 호환 구조로 추상화 |
| 결제 | 토스페이먼츠 | 국내 PG/에스크로 연동 |
| 인증 | Auth.js 계열 + 소셜 로그인 | 접근성 확보 |
| 지도 | Kakao Maps SDK + 로컬 API | 국내 주소/좌표 정확도 |
| 알림 | 카카오 알림톡 + FCM + SES | 템플릿형 커뮤니케이션 |
| 관측 | Sentry + 구조화 로그 + 대시보드 | 운영 중심 모니터링 |
### 7-4. 외부 연동 규칙
| 연동 | MVP 원칙 |
|------|-----------|
| 토스페이먼츠 | 웹훅은 idempotent 하게 처리하고 replay 기능을 제공한다. |
| 알림 | 템플릿 ID를 관리하고 발송 실패 시 재시도한다. |
| 지도 | 주소 정규화와 좌표 캐시를 둔다. |
| 사업자 확인 | 자동 조회 실패 시 운영자 수동 승인 절차를 둔다. |
| 파일 업로드 | 서명 URL, 접근 로그, 업로드 용량 제한을 적용한다. |
### 7-5. 배포 원칙
- PR 단계에서 lint, type-check, unit test를 통과해야 한다.
- staging에서 integration test와 smoke test를 통과해야 production 배포가 가능하다.
- 결제, 정산, 분쟁 관련 기능은 feature flag 뒤에서 점진 출시한다.
---
## 8. 보안 및 컴플라이언스
| 영역 | 대책 |
|------|------|
| 인증 | 세션/토큰 만료 정책, 소셜 로그인, 운영자 계정 보호 |
| 인가 | RBAC + API 단위 권한 검사 |
| 개인정보 | PII 분리 저장, 민감 필드 암호화, 접근 로그 |
| 결제 | 카드 정보 비저장, PG 위임, 정산 로그 보존 |
| 문서 | 서명 URL 기반 접근, 버전 보존, 다운로드 로그 |
| API | Rate limiting, CORS, CSRF, 입력 검증 |
| 인프라 | VPC, 프라이빗 DB, 보안 그룹, 비밀값 관리 |
| 감사 | 운영자 조작 로그, 상태 변경 이력, 이벤트 추적 |
---
## 9. 데이터 계측과 KPI 정의
### 9-1. KPI 소스 오브 트루스
| KPI | 정의 | 소스 이벤트 |
|-----|------|-------------|
| 등록 폐업자 | `store_submitted`를 1회 이상 발생시킨 고유 폐업자 수 | `store_submitted` |
| 입점 업체 수 | 인증 승인된 업체 수 | `vendor_certification_approved` |
| 월 거래 건수 | 인수 완료 또는 철거 정산 완료된 거래 수 | `transaction_completed` |
| MRR | 월 인식 반복 매출 합계 | `revenue_recognized_monthly` |
| B2B 구독 업체 | 유료 구독이 활성 상태인 업체 수 | `subscription_activated` |
| 시설인수 매칭 성공률 | 인수 대상 매장 중 계약 완료된 매장 비율 | `store_published`, `acquisition_contract_signed` |
| 유지율 | 기준 기간 내 재방문 또는 재거래한 사업자 비율 | `monthly_active_user`, `transaction_completed` |
### 9-2. MVP 필수 이벤트
| 이벤트명 | 필수 속성 |
|----------|-----------|
| `store_draft_created` | `user_id`, `region_cluster`, `industry_code` |
| `store_submitted` | `store_id`, `user_id`, `region_cluster`, `industry_code` |
| `store_reviewed` | `store_id`, `result`, `reason_code`, `operator_id` |
| `store_published` | `store_id`, `region_cluster`, `industry_code`, `publish_channel` |
| `match_requested` | `store_id`, `requester_role`, `requester_id` |
| `match_accepted` | `store_id`, `match_request_id`, `operator_involved` |
| `subsidy_case_started` | `subsidy_case_id`, `store_id`, `eligibility_result` |
| `subsidy_case_reviewed` | `subsidy_case_id`, `result`, `reason_code`, `operator_id` |
| `vendor_certification_applied` | `vendor_id`, `service_type`, `coverage_area` |
| `vendor_certification_approved` | `vendor_id`, `operator_id` |
| `contract_generated` | `contract_id`, `store_id`, `contract_type` |
| `acquisition_contract_signed` | `contract_id`, `store_id`, `industry_code`, `region_cluster` |
| `contract_signed` | `contract_id`, `signed_by_role`, `document_version` |
| `escrow_paid` | `escrow_id`, `amount`, `payment_provider` |
| `inspection_submitted` | `inspection_id`, `contract_id`, `photo_count` |
| `inspection_approved` | `inspection_id`, `operator_id` |
| `dispute_opened` | `dispute_id`, `contract_id`, `reason_code` |
| `escrow_released` | `escrow_id`, `amount`, `release_type` |
| `transaction_completed` | `transaction_type`, `store_id`, `contract_id`, `amount` |
| `revenue_recognized_monthly` | `revenue_type`, `amount`, `recognized_month` |
| `subscription_activated` | `vendor_id`, `plan_code`, `billing_cycle` |
| `monthly_active_user` | `user_id`, `user_role`, `activity_month` |
| `notification_sent` | `channel`, `template_code`, `target_role` |
### 9-3. 계측 원칙
- 이벤트 스키마에는 버전을 둔다.
- PII는 analytics 이벤트에 직접 싣지 않는다.
- 베타 이전에 운영자가 KPI를 직접 확인할 수 있는 대시보드를 연다.
- 계산식이 정의되지 않은 지표는 KPI로 선언하지 않는다.
---
## 10. 테스트 및 품질 기준
### 10-1. 개발 방식
- 모든 기능은 `Red -> Green -> Refactor`의 TDD 사이클로 구현한다.
- 개발 시작 전에 별도 `plan.md`를 만들고 테스트 단위를 먼저 쪼갠다.
- 구조적 변경과 행위적 변경은 같은 커밋에 섞지 않는다.
### 10-2. 테스트 레이어
| 레이어 | 범위 |
|--------|------|
| Unit Test | 정책 함수, 상태머신, 가격 규칙, 권한 규칙 |
| Integration Test | API, DB, PG 웹훅, 알림 어댑터, 파일 업로드 |
| E2E Test | 매장 등록, 매칭 요청, 지원금 서류 제출, 계약/검수/정산 흐름 |
### 10-3. 베타 전 필수 시나리오
- 폐업자가 매장을 등록하고 운영자 검토 후 공개할 수 있다.
- 창업자가 공개 매장을 검색하고 매칭 요청을 보낼 수 있다.
- 운영자가 지원금 서류를 검토하고 상태를 변경할 수 있다.
- 업체 인증 승인 후 계약 생성과 결제가 진행된다.
- 검수 승인 전에는 정산이 해제되지 않는다.
- 분쟁이 열리면 정산이 자동으로 보류된다.
---
## 11. 게이트 기반 일정
### 11-1. 전제 조건
- 26.04 협약 시작
- 대표 1명 + CTO 1명 즉시 투입
- 26.05 백엔드 1명, 디자이너 1명 합류
- 기존 프로토타입 코드와 운영 방식이 존재함
### 11-2. 게이트 정의
| 게이트 | 종료 조건 |
|--------|-----------|
| G0 정책 고정 | 지원금, 정보 공개, 정산/분쟁 정책 문서 확정 |
| G1 공급 유입 준비 | 회원, 매장 등록, 검토 큐, 지역/업종 마스터 완료 |
| G2 Assisted Matching 준비 | 공개/검색/요청/수동 추천/알림까지 동작 |
| G3 Trust 인프라 준비 | 업체 인증, 계약, 결제, 검수, 분쟁 흐름 검증 |
| G4 Beta Ready | 계측, 대시보드, 보안, 관제, 운영 런북 완료 |
### 11-3. 스프린트 계획
| 스프린트 | 기간 | 목표 | 산출물 |
|---------|------|------|--------|
| S1 | 04.01~04.14 | G0 준비 | 프로토타입 리뷰, 정책 초안, 도메인 모델, 이벤트 스키마, 인프라 초안 |
| S2 | 04.15~04.28 | G0 완료 | 정책 문서 확정, 운영 권한 모델, 백오피스 IA, CI/CD |
| S3 | 04.29~05.12 | G1 시작 | 회원/인증, 지역/업종 마스터, 매장 등록, 검토 큐 |
| S4 | 05.13~05.26 | G1 완료 | 매장 공개, 기본 검색, 운영자 검토, 사진 업로드 |
| S5 | 05.27~06.09 | G2 | 매칭 요청/수락, 수동 추천, 문의 스레드, 알림 |
| S6 | 06.10~06.23 | G2 확장 | 지원금 가이드형 대행, 체크리스트, 운영자 리뷰 |
| S7 | 06.24~07.07 | G3 시작 | 업체 등록/인증, 계약서 템플릿, 서명 증적 |
| S8 | 07.08~07.21 | G3 완료 | 에스크로 샌드박스, 검수, 분쟁, 정산 보류/해제 |
| S9 | 07.22~08.04 | G4 시작 | KPI 대시보드, 감사 로그, 장애 알람, 런북 |
| S10 | 08.05~08.18 | G4 완료 | 통합 테스트, E2E, 보안 점검, 운영 온보딩 |
| S11 | 08.19~09.01 | 베타 런칭 | 강남/마포 베타, 모니터링, 운영 데이터 수집 |
| S12~S16 | 09.02~12.31 | Phase 2 준비 | 직거래 장터, B2B 알림, 시세 고도화, 모바일 전환 준비 |
### 11-4. 우선순위 재정렬 원칙
- 정책과 운영이 고정되지 않으면 자동화 기능을 미룬다.
- 정산과 분쟁이 불안정하면 채팅이나 고급 UX보다 먼저 보완한다.
- 베타 이전에는 `운영 도구``계측`이 신규 기능보다 우선이다.
### 11-5. MVP DRI
| 영역 | DRI |
|------|-----|
| 제품 정책/우선순위 | 대표 |
| 아키텍처/배포/품질 | CTO |
| 결제/정산/외부 연동 | 백엔드 개발 |
| 운영 프로세스/백오피스 | 대표 + 운영 담당 |
| UX/플로우 설계 | UI/UX 디자이너 |
---
## 12. 외부 의존성과 리스크
### 12-1. 외부 의존성
| 영역 | 파트너 | 착수 시점 | 실패 시 fallback |
|------|--------|-----------|------------------|
| 결제/에스크로 | 토스페이먼츠 | S1 | 수동 정산 승인형 운영 |
| 계약/법무 | 법률사무소 | S1~S2 | 계약 템플릿은 법무 승인 전 제한 사용 |
| 정부 지원 연계 | 소상공인 지원 기관 | S2~S3 | 가이드형 서비스로 축소 |
| 초기 공급 확보 | 지역 철거업체 협회/파트너 | S3~S4 | 운영자 직접 온보딩 |
| 인프라 | AWS/IDC | S1 | 환경 이중화와 저장소 추상화 |
### 12-2. 핵심 리스크
| 등급 | 리스크 | 대응 |
|------|--------|------|
| CRITICAL | 에스크로 계약/연동 지연 | S1 즉시 착수, 웹훅/정산 구조 선구현, 운영 fallback 준비 |
| CRITICAL | 지원금 대행의 법적 경계 불명확 | 가이드형 MVP 유지, 법무 검토 후 범위 확장 |
| HIGH | 폐업 정보 확보 부족 | 지원금 유입 채널, 협회/기관 제휴, 운영자 소싱 병행 |
| HIGH | 분쟁/정산 운영 과부하 | 백오피스와 사유 코드 체계 선구축 |
| HIGH | 지역/업종 데이터 품질 부족 | 강남/마포, F&B로 범위 축소 후 데이터 정제 |
| MEDIUM | 실시간 기능 복잡도 | 문의 스레드 우선, 실시간 채팅은 뒤로 이동 |
| MEDIUM | 파일 저장 안정성 | 객체 스토리지 추상화, 접근 제어, 백업 정책 적용 |
---
## 13. 개발 착수 전 즉시 해야 할 일
### 13-1. 반드시 이번 주에 끝낼 것
1. 프로토타입 코드와 현재 운영 방식 실사
2. `지원금 대행 정책서` 작성
3. `폐업 정보 공개 정책서` 작성
4. `계약-에스크로-분쟁 정책서` 작성
5. KPI 계산식과 이벤트 스키마 고정
6. 베타 지역/업종 마스터 데이터 정의
### 13-2. 바로 만들어야 할 산출물
- `plan.md`
- `docs/policies/subsidy-policy.md`
- `docs/policies/data-exposure-policy.md`
- `docs/policies/contract-escrow-policy.md`
- `docs/analytics/event-schema.md`
- `docs/ops/runbook.md`
---
## 14. 최종 정리
Re:Link의 MVP는 단순한 매칭 앱이 아니라, **정책과 운영으로 신뢰를 만드는 거래 플랫폼**이다. 따라서 개발 우선순위는 `예쁜 UI``기능 수`가 아니라 아래 순서를 따라야 한다.
1. 정책 고정
2. 운영 도구 구축
3. 계측 삽입
4. 핵심 거래 흐름 구현
5. 베타 검증
6. 이후 자동화와 확장
이 순서를 지키면 사업계획서의 강점인 `정부 연계`, `신뢰 인프라`, `데이터 자산화`가 코드 구조와 운영 구조 안에서 살아난다.
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['next/core-web-vitals'],
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@relink/ui', '@relink/shared'],
};
export default nextConfig;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@relink/admin",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@relink/shared": "workspace:*",
"@relink/ui": "workspace:*",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "^15.1.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Re:Link Admin',
description: 'Re:Link 운영 콘솔',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function AdminHomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-4xl font-bold">Re:Link Admin</h1>
<p className="mt-4 text-lg text-gray-600"> </p>
</main>
);
}
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"verbatimModuleSyntax": false
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['next/core-web-vitals'],
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@relink/ui', '@relink/shared'],
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@relink/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next"
},
"dependencies": {
"@prisma/client": "^6.1.0",
"@relink/database": "workspace:*",
"@relink/domain": "workspace:*",
"@relink/infrastructure": "workspace:*",
"@relink/shared": "workspace:*",
"@relink/ui": "workspace:*",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "^15.1.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
@@ -0,0 +1,721 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
} from '../../services/store-service';
import {
createMatchRequestService,
acceptMatchRequestService,
} from '../../services/match-request-service';
import {
createContractService,
releaseEscrowService,
processEscrowWebhookService,
openDisputeService,
} from '../../services/contract-service';
describe('Contract Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'owner@example.com',
emailNormalized: 'owner@example.com',
name: '매장 소유자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: ownerUserId.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operatorUserId.toString(),
);
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
policyVersion.id.toString(),
operatorUserId.toString(),
);
return createResult.value.publicId;
}
async function createAcceptedMatchRequest(
storePublicId: string,
requesterUserId: bigint,
operatorUserId: bigint,
) {
const createResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requesterUserId.toString(),
message: '매장 인수 희망',
});
if (!createResult.ok) throw new Error('createMatchRequest failed');
const acceptResult = await acceptMatchRequestService(
prisma,
createResult.value.publicId,
operatorUserId.toString(),
);
if (!acceptResult.ok) throw new Error('acceptMatchRequest failed');
return createResult.value.publicId;
}
async function createContractPolicyVersion() {
return prisma.policyVersion.create({
data: {
policyType: 'CONTRACT_TEMPLATE',
versionCode: 'ct-v1.0',
contentHash: 'sha256-contract-template',
effectiveFrom: new Date(),
},
});
}
// ---------------------------------------------------------------------------
// I012: POST /api/v1/contracts - 계약 생성
// ---------------------------------------------------------------------------
describe('I012: createContractService', () => {
it('ACCEPTED 매칭에서 DRAFT 계약이 생성되고 AuditLog/OutboxEvent 기록', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const result = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('DRAFT');
expect(result.value.escrowStatus).toBe('NOT_STARTED');
expect(result.value.publicId).toBeTruthy();
// DB 저장 확인
const contract = await prisma.contract.findUnique({
where: { publicId: result.value.publicId },
});
expect(contract).not.toBeNull();
expect(contract!.contractType).toBe('ACQUISITION');
expect(contract!.templateCode).toBe('ACQ_STANDARD_V1');
expect(contract!.policyVersionId).toBe(contractPolicy.id);
expect(contract!.storeOwnerUserId).toBe(owner.id);
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId, actionType: 'CONTRACT_CREATED' },
});
expect(auditLogs).toHaveLength(1);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId, eventName: 'contract.created' },
});
expect(outboxEvents).toHaveLength(1);
});
it('OPEN 매칭에서는 계약 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
// OPEN 상태 매칭 (수락하지 않음)
const matchResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
if (!matchResult.ok) throw new Error('createMatchRequest failed');
const contractPolicy = await createContractPolicyVersion();
const result = await createContractService(prisma, {
matchRequestPublicId: matchResult.value.publicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
});
it('존재하지 않는 매칭 요청으로는 계약 생성 불가', async () => {
const operator = await createOperator();
const contractPolicy = await createContractPolicyVersion();
const result = await createContractService(prisma, {
matchRequestPublicId: 'non-existent',
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('존재하지 않는 정책 버전으로는 계약 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const result = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: '99999',
createdByUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
// ---------------------------------------------------------------------------
// I013: 에스크로 정산 해제
// ---------------------------------------------------------------------------
describe('I013: releaseEscrowService', () => {
async function createActiveContractWithHoldingEscrow(
owner: { id: bigint },
operator: { id: bigint },
requester: { id: bigint },
) {
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
// 계약 상태를 ACTIVE로, 에스크로를 HOLDING으로 변경
const contract = await prisma.contract.findUnique({
where: { publicId: contractResult.value.publicId },
});
await prisma.contract.update({
where: { id: contract!.id },
data: { status: 'ACTIVE', escrowStatus: 'HOLDING' },
});
return contractResult.value.publicId;
}
it('검수 승인 완료 시 에스크로가 RELEASE_REVIEW로 전환되고 감사 로그 기록', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHoldingEscrow(
owner,
operator,
requester,
);
// 검수 승인 레코드 생성
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
await prisma.inspectionRecord.create({
data: {
contractId: contract!.id,
inspectionType: 'FINAL_COMPLETION',
status: 'APPROVED',
reviewedAt: new Date(),
},
});
const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString());
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.escrowStatus).toBe('RELEASE_REVIEW');
// DB 확인
const updated = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
expect(updated!.escrowStatus).toBe('RELEASE_REVIEW');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: contractPublicId, actionType: 'ESCROW_RELEASE_REQUESTED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.escrowStatus).toBe('HOLDING');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.escrowStatus).toBe(
'RELEASE_REVIEW',
);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: contractPublicId, eventName: 'escrow.release_requested' },
});
expect(outboxEvents).toHaveLength(1);
});
it('검수 미승인 시 정산 해제 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHoldingEscrow(
owner,
operator,
requester,
);
// 검수 레코드 없음 (미승인)
const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString());
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INSPECTION_NOT_APPROVED');
});
it('열린 분쟁이 있으면 정산 해제 차단', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHoldingEscrow(
owner,
operator,
requester,
);
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
// 검수 승인 + 열린 분쟁 생성
await prisma.inspectionRecord.create({
data: {
contractId: contract!.id,
inspectionType: 'FINAL_COMPLETION',
status: 'APPROVED',
reviewedAt: new Date(),
},
});
await prisma.disputeCase.create({
data: {
contractId: contract!.id,
openedByUserId: owner.id,
status: 'OPEN',
reasonCode: 'QUALITY_ISSUE',
},
});
const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString());
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('DISPUTE_OPEN');
});
});
// ---------------------------------------------------------------------------
// I014: 에스크로 웹훅 멱등 처리
// ---------------------------------------------------------------------------
describe('I014: processEscrowWebhookService', () => {
async function createContractForWebhook(
owner: { id: bigint },
operator: { id: bigint },
requester: { id: bigint },
) {
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
return contractResult.value.publicId;
}
it('새로운 웹훅 이벤트를 처리하고 에스크로 상태가 변경된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createContractForWebhook(owner, operator, requester);
const result = await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: 'webhook-evt-001',
transactionType: 'DEPOSIT',
amount: 5000000,
providerCode: 'TOSS',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.isNew).toBe(true);
expect(result.value.escrowStatus).toBe('HOLDING');
// DB 확인
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
expect(contract!.escrowStatus).toBe('HOLDING');
// EscrowTransaction 확인
const transactions = await prisma.escrowTransaction.findMany({
where: { contractId: contract!.id },
});
expect(transactions).toHaveLength(1);
expect(transactions[0]!.idempotencyKey).toBe('webhook-evt-001');
expect(transactions[0]!.transactionType).toBe('DEPOSIT');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: contractPublicId, actionType: 'ESCROW_TRANSACTION_PROCESSED' },
});
expect(auditLogs).toHaveLength(1);
});
it('동일 idempotencyKey로 중복 요청 시 isNew=false 반환 (멱등)', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createContractForWebhook(owner, operator, requester);
// 1차 처리
await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: 'webhook-evt-dup',
transactionType: 'DEPOSIT',
amount: 5000000,
});
// 2차 처리 (중복)
const result = await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: 'webhook-evt-dup',
transactionType: 'DEPOSIT',
amount: 5000000,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.isNew).toBe(false);
// EscrowTransaction은 1건만 존재
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
const transactions = await prisma.escrowTransaction.findMany({
where: { contractId: contract!.id },
});
expect(transactions).toHaveLength(1);
});
it('빈 idempotencyKey는 실패', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createContractForWebhook(owner, operator, requester);
const result = await processEscrowWebhookService(prisma, {
contractPublicId,
idempotencyKey: '',
transactionType: 'DEPOSIT',
amount: 5000000,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
});
// ---------------------------------------------------------------------------
// I015: 분쟁 접수
// ---------------------------------------------------------------------------
describe('I015: openDisputeService', () => {
async function createActiveContractWithHolding(
owner: { id: bigint },
operator: { id: bigint },
requester: { id: bigint },
) {
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
const contract = await prisma.contract.findUnique({
where: { publicId: contractResult.value.publicId },
});
await prisma.contract.update({
where: { id: contract!.id },
data: { status: 'ACTIVE', escrowStatus: 'HOLDING' },
});
return contractResult.value.publicId;
}
it('ACTIVE 계약 + HOLDING 에스크로에서 분쟁 접수 성공', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const contractPublicId = await createActiveContractWithHolding(owner, operator, requester);
const result = await openDisputeService(
prisma,
contractPublicId,
'QUALITY_ISSUE',
owner.id.toString(),
'시공 품질이 계약 내용과 다릅니다.',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.escrowStatus).toBe('DISPUTED');
// DB 확인
const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } });
expect(contract!.escrowStatus).toBe('DISPUTED');
// DisputeCase 확인
const disputes = await prisma.disputeCase.findMany({
where: { contractId: contract!.id },
});
expect(disputes).toHaveLength(1);
expect(disputes[0]!.reasonCode).toBe('QUALITY_ISSUE');
expect(disputes[0]!.description).toBe('시공 품질이 계약 내용과 다릅니다.');
expect(disputes[0]!.status).toBe('OPEN');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: contractPublicId, actionType: 'DISPUTE_OPENED' },
});
expect(auditLogs).toHaveLength(1);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: contractPublicId, eventName: 'dispute.opened' },
});
expect(outboxEvents).toHaveLength(1);
});
it('DRAFT 계약에서는 분쟁 접수 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const matchPublicId = await createAcceptedMatchRequest(
storePublicId,
requester.id,
operator.id,
);
const contractPolicy = await createContractPolicyVersion();
const contractResult = await createContractService(prisma, {
matchRequestPublicId: matchPublicId,
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: contractPolicy.id.toString(),
createdByUserId: operator.id.toString(),
});
if (!contractResult.ok) throw new Error('createContract failed');
// DRAFT 상태 + NOT_STARTED 에스크로 (기본 상태 유지)
const result = await openDisputeService(
prisma,
contractResult.value.publicId,
'QUALITY_ISSUE',
owner.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_CONTRACT_STATUS');
});
it('존재하지 않는 계약에는 분쟁 접수 불가', async () => {
const owner = await createTestUser();
const result = await openDisputeService(
prisma,
'non-existent-contract',
'QUALITY_ISSUE',
owner.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
});
@@ -0,0 +1,419 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
} from '../../services/store-service';
import {
createMatchRequestService,
acceptMatchRequestService,
searchStoresService,
} from '../../services/match-request-service';
describe('Match Request Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'owner@example.com',
emailNormalized: 'owner@example.com',
name: '매장 소유자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: ownerUserId.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(prisma, createResult.value.publicId, 'APPROVED', operatorUserId.toString());
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
policyVersion.id.toString(),
operatorUserId.toString(),
);
return createResult.value.publicId;
}
// ---------------------------------------------------------------------------
// I006: 매칭 요청 생성 통합 테스트
// ---------------------------------------------------------------------------
describe('I006: createMatchRequestService', () => {
it('공개 매장에 매칭 요청을 생성하고 AuditLog/OutboxEvent가 기록된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder@example.com',
emailNormalized: 'founder@example.com',
name: '창업 희망자',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const result = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
message: '매장 인수 희망합니다.',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('OPEN');
expect(result.value.matchType).toBe('ACQUISITION');
expect(result.value.publicId).toBeTruthy();
// DB에 실제 저장 확인
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: result.value.publicId },
});
expect(matchRequest).not.toBeNull();
expect(matchRequest!.message).toBe('매장 인수 희망합니다.');
expect(matchRequest!.requesterUserId).toBe(requester.id);
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId, actionType: 'MATCH_REQUEST_CREATED' },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actorUserId).toBe(requester.id);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('match_request.created');
});
it('비공개 매장에는 매칭 요청을 생성할 수 없다', async () => {
const owner = await createTestUser();
const requester = await createTestUser({
email: 'founder2@example.com',
emailNormalized: 'founder2@example.com',
name: '창업자2',
primaryRole: 'FOUNDER',
});
// DRAFT 상태 매장 (비공개)
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
const result = await createMatchRequestService(prisma, {
storePublicId: createResult.value.publicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('STORE_NOT_PUBLISHED');
});
it('동일 사용자가 동일 매장에 열린 매칭 요청이 있으면 중복 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder3@example.com',
emailNormalized: 'founder3@example.com',
name: '창업자3',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
// 1차 매칭 요청 성공
const first = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(first.ok).toBe(true);
// 2차 매칭 요청 실패 (중복)
const second = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error.code).toBe('DUPLICATE_OPEN_REQUEST');
});
it('존재하지 않는 매장에는 매칭 요청 불가', async () => {
const requester = await createTestUser();
const result = await createMatchRequestService(prisma, {
storePublicId: 'non-existent-store',
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('운영자 추천 시 추천 사유 없으면 실패', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
const result = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'OPERATOR_RECOMMENDATION',
requesterUserId: operator.id.toString(),
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
});
// ---------------------------------------------------------------------------
// I007: 매칭 요청 수락 + 매장 검색 통합 테스트
// ---------------------------------------------------------------------------
describe('I007: acceptMatchRequestService', () => {
it('OPEN 상태의 매칭 요청을 ACCEPTED로 전환하고 감사 로그가 기록된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder4@example.com',
emailNormalized: 'founder4@example.com',
name: '창업자4',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const createResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
message: '인수 희망',
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const acceptResult = await acceptMatchRequestService(
prisma,
createResult.value.publicId,
operator.id.toString(),
);
expect(acceptResult.ok).toBe(true);
if (!acceptResult.ok) return;
expect(acceptResult.value.status).toBe('ACCEPTED');
// DB 확인
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: createResult.value.publicId },
});
expect(matchRequest!.status).toBe('ACCEPTED');
expect(matchRequest!.acceptedAt).not.toBeNull();
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: createResult.value.publicId, actionType: 'MATCH_REQUEST_ACCEPTED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.status).toBe('OPEN');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.status).toBe('ACCEPTED');
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: createResult.value.publicId, eventName: 'match_request.accepted' },
});
expect(outboxEvents).toHaveLength(1);
});
it('이미 ACCEPTED된 매칭 요청은 다시 수락 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const requester = await createTestUser({
email: 'founder5@example.com',
emailNormalized: 'founder5@example.com',
name: '창업자5',
primaryRole: 'FOUNDER',
});
const storePublicId = await createPublishedStore(owner.id, operator.id);
const createResult = await createMatchRequestService(prisma, {
storePublicId,
matchType: 'DEMOLITION',
sourceType: 'USER_REQUEST',
requesterUserId: requester.id.toString(),
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
// 1차 수락
await acceptMatchRequestService(prisma, createResult.value.publicId, operator.id.toString());
// 2차 수락 시도
const secondAccept = await acceptMatchRequestService(
prisma,
createResult.value.publicId,
operator.id.toString(),
);
expect(secondAccept.ok).toBe(false);
if (secondAccept.ok) return;
expect(secondAccept.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('존재하지 않는 매칭 요청은 NOT_FOUND를 반환한다', async () => {
const operator = await createOperator();
const result = await acceptMatchRequestService(
prisma,
'non-existent-match',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
describe('I007: searchStoresService', () => {
it('공개 매장만 검색 결과에 포함된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
// 공개 매장 1개 생성
await createPublishedStore(owner.id, operator.id);
// 비공개 매장 1개 생성 (DRAFT)
await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.KOREAN',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 456',
});
const result = await searchStoresService(prisma, {});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.stores).toHaveLength(1);
expect(result.value.stores[0]!.listingTitle).toBe('강남역 카페 양도');
});
it('페이지네이션과 limit이 올바르게 적용된다', async () => {
const result = await searchStoresService(prisma, { page: 1, limit: 10 });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.page).toBe(1);
expect(result.value.limit).toBe(10);
});
it('limit이 100을 초과하면 100으로 제한된다', async () => {
const result = await searchStoresService(prisma, { limit: 500 });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.limit).toBe(100);
});
it('지역 코드로 필터링할 수 있다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
await createPublishedStore(owner.id, operator.id);
const result = await searchStoresService(prisma, {
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.stores).toHaveLength(1);
// 존재하지 않는 지역 코드
const emptyResult = await searchStoresService(prisma, {
regionClusterCode: 'KR.BUSAN',
});
expect(emptyResult.ok).toBe(true);
if (!emptyResult.ok) return;
expect(emptyResult.value.stores).toHaveLength(0);
});
});
});
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
getStoreForViewer,
} from '../../services/store-service';
describe('Store Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser() {
return prisma.user.create({
data: {
email: 'test@example.com',
emailNormalized: 'test@example.com',
name: '테스트 사용자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
},
});
}
// ---------------------------------------------------------------------------
// I001: POST /api/v1/stores - 매장 초안 생성
// ---------------------------------------------------------------------------
describe('I001: createStoreDraftService', () => {
it('매장 초안을 생성하고 AuditLog/OutboxEvent가 함께 기록된다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('DRAFT');
expect(result.value.publicationStatus).toBe('PRIVATE');
expect(result.value.dealStatus).toBe('OPEN');
expect(result.value.publicId).toBeTruthy();
// DB에 실제 저장되었는지 확인
const store = await prisma.store.findUnique({
where: { publicId: result.value.publicId },
include: { industryLeaf: true, regionCluster: true },
});
expect(store).not.toBeNull();
expect(store!.listingTitle).toBe('강남역 카페 양도');
expect(store!.industryLeaf!.code).toBe('FNB.CAFE');
expect(store!.regionCluster!.code).toBe('KR.BETA.GANGNAM_CORE');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actionType).toBe('STORE_DRAFT_CREATED');
expect(auditLogs[0]!.actorUserId).toBe(user.id);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('store.draft.created');
});
it('lease와 facility 정보를 포함한 매장을 생성한다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남 한식당',
industryLeafCode: 'FNB.KOREAN',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 역삼동 123',
lease: {
depositAmount: 50_000_000,
monthlyRentAmount: 3_000_000,
premiumAmount: 100_000_000,
},
facility: {
exclusiveAreaSqm: 66.12,
seatCount: 30,
floorLevel: 1,
hasGas: true,
},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const store = await prisma.store.findUnique({
where: { publicId: result.value.publicId },
include: { lease: true, facility: true },
});
expect(store!.lease).not.toBeNull();
expect(Number(store!.lease!.depositAmount)).toBe(50_000_000);
expect(Number(store!.lease!.monthlyRentAmount)).toBe(3_000_000);
expect(Number(store!.lease!.premiumAmount)).toBe(100_000_000);
expect(store!.facility).not.toBeNull();
expect(Number(store!.facility!.exclusiveAreaSqm)).toBeCloseTo(66.12);
expect(store!.facility!.seatCount).toBe(30);
expect(store!.facility!.floorLevel).toBe(1);
expect(store!.facility!.hasGas).toBe(true);
});
it('베타 미지원 지역은 거부한다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '부산 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BUSAN',
roadAddress: '부산시 해운대구 123',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('REGION_NOT_BETA_ENABLED');
});
it('미지원 업종 코드는 거부한다', async () => {
const user = await createTestUser();
const result = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남 뷰티샵',
industryLeafCode: 'BEAUTY.NAIL',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INDUSTRY_NOT_SUPPORTED');
});
});
// ---------------------------------------------------------------------------
// I002: POST /api/v1/stores/:id/submit - 매장 제출
// ---------------------------------------------------------------------------
describe('I002: submitStoreService', () => {
it('DRAFT 상태의 매장을 SUBMITTED로 전환한다', async () => {
const user = await createTestUser();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남역 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const submitResult = await submitStoreService(
prisma,
createResult.value.publicId,
user.id.toString(),
);
expect(submitResult.ok).toBe(true);
if (!submitResult.ok) return;
expect(submitResult.value.reviewStatus).toBe('SUBMITTED');
// AuditLog에 상태 전환 기록 확인
const auditLogs = await prisma.auditLog.findMany({
where: {
resourceId: createResult.value.publicId,
actionType: 'STORE_SUBMITTED',
},
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.reviewStatus).toBe('DRAFT');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.reviewStatus).toBe('SUBMITTED');
});
it('DRAFT가 아닌 매장은 제출을 거부한다', async () => {
const user = await createTestUser();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: user.id.toString(),
listingTitle: '강남역 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
// 1차 제출
await submitStoreService(prisma, createResult.value.publicId, user.id.toString());
// 2차 제출 시도
const secondSubmit = await submitStoreService(
prisma,
createResult.value.publicId,
user.id.toString(),
);
expect(secondSubmit.ok).toBe(false);
if (secondSubmit.ok) return;
expect(secondSubmit.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('존재하지 않는 매장은 NOT_FOUND를 반환한다', async () => {
const result = await submitStoreService(prisma, 'non-existent-id', '1');
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
// ---------------------------------------------------------------------------
// I003: GET /api/v1/master-data - 마스터 데이터 조회
// ---------------------------------------------------------------------------
describe('I003: master-data 쿼리', () => {
it('베타 활성 지역과 리프 업종만 반환한다', async () => {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true, isBetaEnabled: true },
select: {
code: true,
nameKo: true,
regionType: true,
parentId: true,
depth: true,
isBetaEnabled: true,
},
orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }],
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true, isBetaEnabled: true, isLeaf: true },
select: {
code: true,
nameKo: true,
depth: true,
isLeaf: true,
isBetaEnabled: true,
},
orderBy: { sortOrder: 'asc' },
}),
]);
// seedTestMasterData 기준: 베타 클러스터 1개
expect(regions.length).toBeGreaterThanOrEqual(1);
const betaCluster = regions.find((r) => r.code === 'KR.BETA.GANGNAM_CORE');
expect(betaCluster).toBeDefined();
expect(betaCluster!.nameKo).toBe('강남권 베타 클러스터');
// seedTestMasterData 기준: 리프 업종 2개 (카페, 한식)
expect(industries.length).toBeGreaterThanOrEqual(2);
expect(industries.find((i) => i.code === 'FNB.CAFE')).toBeDefined();
expect(industries.find((i) => i.code === 'FNB.KOREAN')).toBeDefined();
// 부모 업종(FNB)은 isLeaf=false이므로 제외
expect(industries.find((i) => i.code === 'FNB')).toBeUndefined();
// 비베타 지역(KR.BUSAN)은 제외
expect(regions.find((r) => r.code === 'KR.BUSAN')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// I004: 운영자 검토 API - 승인/반려 + 감사 로그
// ---------------------------------------------------------------------------
describe('I004: reviewStoreService / publishStoreService', () => {
async function createAndSubmitStore(userId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: userId.toString(),
listingTitle: '강남역 카페',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
detailAddress: '4층 401호',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, userId.toString());
return createResult.value.publicId;
}
it('운영자가 매장을 승인하면 APPROVED로 전환되고 감사 로그가 기록된다', async () => {
const user = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
},
});
const storePublicId = await createAndSubmitStore(user.id);
const result = await reviewStoreService(
prisma,
storePublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('APPROVED');
// 감사 로그 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: storePublicId, actionType: 'STORE_APPROVED' },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actorUserId).toBe(operator.id);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.reviewStatus).toBe('SUBMITTED');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.reviewStatus).toBe('APPROVED');
});
it('운영자가 매장을 반려하면 REJECTED로 전환되고 사유가 기록된다', async () => {
const user = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops2@example.com',
emailNormalized: 'ops2@example.com',
name: '운영자2',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
},
});
const storePublicId = await createAndSubmitStore(user.id);
const result = await reviewStoreService(
prisma,
storePublicId,
'REJECTED',
operator.id.toString(),
'INCOMPLETE_INFO',
'시설 정보가 부족합니다.',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('REJECTED');
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: storePublicId, actionType: 'STORE_REJECTED' },
});
expect(auditLogs).toHaveLength(1);
const afterJson = auditLogs[0]!.afterJson as Record<string, unknown>;
expect(afterJson.reasonCode).toBe('INCOMPLETE_INFO');
expect(afterJson.memo).toBe('시설 정보가 부족합니다.');
});
it('승인된 매장을 정책 버전과 함께 공개하면 PUBLISHED로 전환된다', async () => {
const user = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops3@example.com',
emailNormalized: 'ops3@example.com',
name: '운영자3',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
},
});
const storePublicId = await createAndSubmitStore(user.id);
await reviewStoreService(prisma, storePublicId, 'APPROVED', operator.id.toString());
// 정책 버전 생성
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash',
effectiveFrom: new Date(),
},
});
const publishResult = await publishStoreService(
prisma,
storePublicId,
policyVersion.id.toString(),
operator.id.toString(),
);
expect(publishResult.ok).toBe(true);
if (!publishResult.ok) return;
expect(publishResult.value.publicationStatus).toBe('PUBLISHED');
// DB에서 정책 버전 연결 확인
const store = await prisma.store.findUnique({ where: { publicId: storePublicId } });
expect(store!.policyVersionId).toBe(policyVersion.id);
expect(store!.publishedAt).not.toBeNull();
// 감사 로그 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: storePublicId, actionType: 'STORE_PUBLISHED' },
});
expect(auditLogs).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// I005: 공개 매장 조회 API - 제한 공개 정책 적용
// ---------------------------------------------------------------------------
describe('I005: getStoreForViewer - 제한 공개 정책', () => {
async function createPublishedStore() {
const owner = await createTestUser();
const operator = await prisma.user.create({
data: {
email: 'ops5@example.com',
emailNormalized: 'ops5@example.com',
name: '운영자5',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
phone: '010-9999-8888',
},
});
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
detailAddress: '4층 401호',
});
if (!createResult.ok) throw new Error('create failed');
await submitStoreService(prisma, createResult.value.publicId, owner.id.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operator.id.toString(),
);
const policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-test-hash-2',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
policyVersion.id.toString(),
operator.id.toString(),
);
return { owner, operator, storePublicId: createResult.value.publicId };
}
it('소유자는 공개 매장의 상세 주소와 연락처를 볼 수 있다', async () => {
const { owner, storePublicId } = await createPublishedStore();
const result = await getStoreForViewer(prisma, storePublicId, {
userId: owner.publicId,
role: 'CLOSING_OWNER',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.detailAddress).toBe('4층 401호');
expect(result.value.ownerEmail).toBe('test@example.com');
});
it('운영자는 공개 매장의 상세 주소와 연락처를 볼 수 있다', async () => {
const { operator, storePublicId } = await createPublishedStore();
const result = await getStoreForViewer(prisma, storePublicId, {
userId: operator.publicId,
role: 'OPS_MANAGER',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.detailAddress).toBe('4층 401호');
});
it('일반 창업자는 공개 매장의 상세 주소와 연락처를 볼 수 없다', async () => {
const { storePublicId } = await createPublishedStore();
const result = await getStoreForViewer(prisma, storePublicId, {
userId: 'founder-random',
role: 'FOUNDER',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.detailAddress).toBeUndefined();
expect(result.value.ownerPhone).toBeUndefined();
expect(result.value.ownerEmail).toBeUndefined();
// 공개 정보는 그대로
expect(result.value.listingTitle).toBe('강남역 카페 양도');
expect(result.value.roadAddress).toBe('서울시 강남구 테헤란로 123');
});
it('비공개 매장은 일반 사용자에게 FORBIDDEN을 반환한다', async () => {
const owner = await createTestUser();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
if (!createResult.ok) throw new Error('create failed');
const result = await getStoreForViewer(prisma, createResult.value.publicId, {
userId: 'founder-random',
role: 'FOUNDER',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('FORBIDDEN');
});
});
});
@@ -0,0 +1,403 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
createStoreDraftService,
submitStoreService,
reviewStoreService,
publishStoreService,
} from '../../services/store-service';
import {
createSubsidyCaseService,
reviewSubsidyCaseService,
} from '../../services/subsidy-case-service';
describe('Subsidy Case Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createTestUser(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'owner@example.com',
emailNormalized: 'owner@example.com',
name: '매장 소유자',
primaryRole: 'CLOSING_OWNER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createSubsidyPolicy() {
return prisma.policyVersion.create({
data: {
policyType: 'SUBSIDY_CHECKLIST',
versionCode: 'v1.0',
contentHash: 'sha256-subsidy-policy-hash',
effectiveFrom: new Date(),
isActive: true,
},
});
}
async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) {
const createResult = await createStoreDraftService(prisma, {
ownerUserId: ownerUserId.toString(),
listingTitle: '강남역 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 테헤란로 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operatorUserId.toString(),
);
const storePolicyVersion = await prisma.policyVersion.create({
data: {
policyType: 'STORE_LISTING_POLICY',
versionCode: 'v1.0',
contentHash: 'sha256-store-hash',
effectiveFrom: new Date(),
},
});
await publishStoreService(
prisma,
createResult.value.publicId,
storePolicyVersion.id.toString(),
operatorUserId.toString(),
);
return createResult.value.publicId;
}
// ---------------------------------------------------------------------------
// I008: POST /api/v1/subsidies/cases - 케이스와 초기 체크리스트 생성
// ---------------------------------------------------------------------------
describe('I008: createSubsidyCaseService', () => {
it('공개 매장에 지원금 케이스를 생성하고 체크리스트/AuditLog/OutboxEvent가 기록된다', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
await createSubsidyPolicy();
const result = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('DRAFT');
expect(result.value.programCode).toBe('SMALL_BIZ_CLOSURE_2024');
expect(result.value.publicId).toBeTruthy();
// DB에 실제 저장 확인
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: result.value.publicId },
include: { checklistItems: true },
});
expect(subsidyCase).not.toBeNull();
expect(subsidyCase!.checklistVersionCode).toBe('v1.0');
expect(subsidyCase!.policyVersionId).not.toBeNull();
// 체크리스트 항목 확인
expect(subsidyCase!.checklistItems.length).toBeGreaterThanOrEqual(2);
const requiredItems = subsidyCase!.checklistItems.filter(
(item: { isRequired: boolean }) => item.isRequired,
);
expect(requiredItems.length).toBeGreaterThanOrEqual(2);
expect(
subsidyCase!.checklistItems.every((item: { status: string }) => item.status === 'PENDING'),
).toBe(true);
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: result.value.publicId, actionType: 'SUBSIDY_CASE_CREATED' },
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0]!.actorUserId).toBe(owner.id);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.publicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('subsidy_case.created');
});
it('DRAFT 상태(비공개+미검토) 매장에서는 케이스 생성 불가', async () => {
const owner = await createTestUser();
await createSubsidyPolicy();
// DRAFT 매장 생성 (비공개)
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '비공개 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 123',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
const result = await createSubsidyCaseService(prisma, {
storePublicId: createResult.value.publicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('STORE_NOT_ELIGIBLE');
});
it('SUBMITTED(검토 중) 매장은 케이스 생성 가능', async () => {
const owner = await createTestUser();
await createSubsidyPolicy();
const createResult = await createStoreDraftService(prisma, {
ownerUserId: owner.id.toString(),
listingTitle: '제출된 매장',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울시 강남구 456',
});
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, owner.id.toString());
const result = await createSubsidyCaseService(prisma, {
storePublicId: createResult.value.publicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('DRAFT');
});
it('존재하지 않는 매장에는 케이스 생성 불가', async () => {
const owner = await createTestUser();
await createSubsidyPolicy();
const result = await createSubsidyCaseService(prisma, {
storePublicId: 'non-existent-store',
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('활성 정책이 없으면 케이스 생성 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
// 정책 미생성
const result = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
// ---------------------------------------------------------------------------
// I009: PATCH /api/v1/subsidies/cases/:id/review - 상태 변경과 감사 로그
// ---------------------------------------------------------------------------
describe('I009: reviewSubsidyCaseService', () => {
async function createSubmittedCase() {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
await createSubsidyPolicy();
const createResult = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
if (!createResult.ok) throw new Error('createSubsidyCase failed');
// 상태를 SUBMITTED로 직접 변경 (검토 가능 상태로 만들기)
await prisma.subsidyCase.update({
where: { publicId: createResult.value.publicId },
data: { status: 'SUBMITTED', submittedAt: new Date() },
});
return { owner, operator, subsidyCasePublicId: createResult.value.publicId };
}
it('SUBMITTED 케이스를 승인하면 APPROVED로 전환되고 감사 로그가 기록된다', async () => {
const { operator, subsidyCasePublicId } = await createSubmittedCase();
const result = await reviewSubsidyCaseService(
prisma,
subsidyCasePublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('APPROVED');
// DB 확인
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: subsidyCasePublicId },
});
expect(subsidyCase!.status).toBe('APPROVED');
expect(subsidyCase!.reviewedAt).not.toBeNull();
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: subsidyCasePublicId, actionType: 'SUBSIDY_CASE_APPROVED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.status).toBe('SUBMITTED');
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.status).toBe('APPROVED');
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: subsidyCasePublicId, eventName: 'subsidy_case.approved' },
});
expect(outboxEvents).toHaveLength(1);
});
it('SUBMITTED 케이스를 반려하면 REJECTED로 전환되고 사유가 기록된다', async () => {
const { operator, subsidyCasePublicId } = await createSubmittedCase();
const result = await reviewSubsidyCaseService(
prisma,
subsidyCasePublicId,
'REJECTED',
operator.id.toString(),
'INCOMPLETE_DOCUMENTS',
'사업자등록증 만료. 갱신본 제출 필요.',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe('REJECTED');
// DB 확인
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: subsidyCasePublicId },
});
expect(subsidyCase!.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: subsidyCasePublicId, actionType: 'SUBSIDY_CASE_REJECTED' },
});
expect(auditLogs).toHaveLength(1);
const afterJson = auditLogs[0]!.afterJson as Record<string, unknown>;
expect(afterJson.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS');
expect(afterJson.memo).toBe('사업자등록증 만료. 갱신본 제출 필요.');
});
it('반려 시 사유 코드 없으면 실패', async () => {
const { operator, subsidyCasePublicId } = await createSubmittedCase();
const result = await reviewSubsidyCaseService(
prisma,
subsidyCasePublicId,
'REJECTED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('REJECTION_REASON_REQUIRED');
});
it('DRAFT 상태에서는 검토 불가', async () => {
const owner = await createTestUser();
const operator = await createOperator();
const storePublicId = await createPublishedStore(owner.id, operator.id);
await createSubsidyPolicy();
const createResult = await createSubsidyCaseService(prisma, {
storePublicId,
applicantUserId: owner.id.toString(),
programCode: 'SMALL_BIZ_CLOSURE_2024',
});
if (!createResult.ok) throw new Error('createSubsidyCase failed');
const result = await reviewSubsidyCaseService(
prisma,
createResult.value.publicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('존재하지 않는 케이스는 NOT_FOUND를 반환한다', async () => {
const operator = await createOperator();
const result = await reviewSubsidyCaseService(
prisma,
'non-existent-case',
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
});
@@ -0,0 +1,305 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import {
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
import {
applyVendorCertificationService,
reviewVendorCertificationService,
} from '../../services/vendor-certification-service';
describe('Vendor Certification Service Integration Tests', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await cleanAllTables(prisma);
await seedTestMasterData(prisma);
});
async function createVendorOwner(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'vendor-owner@example.com',
emailNormalized: 'vendor-owner@example.com',
name: '업체 대표',
primaryRole: 'VENDOR_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
async function createOperator(overrides: Record<string, unknown> = {}) {
return prisma.user.create({
data: {
email: 'ops@example.com',
emailNormalized: 'ops@example.com',
name: '운영자',
primaryRole: 'OPS_MANAGER',
status: 'ACTIVE',
...overrides,
},
});
}
// ---------------------------------------------------------------------------
// I010: POST /api/v1/vendors/certifications - 인증 신청과 검토 큐 생성
// ---------------------------------------------------------------------------
describe('I010: applyVendorCertificationService', () => {
it('업체 인증 신청 성공 시 APPLIED 상태로 생성되고 AuditLog/OutboxEvent 기록', async () => {
const owner = await createVendorOwner();
const result = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
businessRegistrationNumber: '123-45-67890',
coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.certificationStatus).toBe('APPLIED');
expect(result.value.vendorPublicId).toBeTruthy();
// DB에 실제 저장 확인
const vendor = await prisma.vendor.findUnique({
where: { publicId: result.value.vendorPublicId },
include: { coverageRegions: true, certifications: true },
});
expect(vendor).not.toBeNull();
expect(vendor!.businessName).toBe('(주)클린철거');
expect(vendor!.vendorType).toBe('DEMOLITION');
// 서비스 권역 확인
expect(vendor!.coverageRegions).toHaveLength(1);
expect(vendor!.coverageRegions[0]!.isPrimary).toBe(true);
// 인증 이력 확인
expect(vendor!.certifications).toHaveLength(1);
expect(vendor!.certifications[0]!.status).toBe('APPLIED');
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: {
resourceId: result.value.vendorPublicId,
actionType: 'VENDOR_CERTIFICATION_APPLIED',
},
});
expect(auditLogs).toHaveLength(1);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: result.value.vendorPublicId },
});
expect(outboxEvents).toHaveLength(1);
expect(outboxEvents[0]!.eventName).toBe('vendor.certification.applied');
});
it('사업자등록증 없으면 인증 신청 실패', async () => {
const owner = await createVendorOwner();
const result = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'INTERIOR',
businessName: '인테리어 스튜디오',
contactName: '이담당',
coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('MISSING_BUSINESS_REGISTRATION');
});
it('서비스 권역이 유효하지 않으면 인증 신청 실패', async () => {
const owner = await createVendorOwner();
const result = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
businessRegistrationNumber: '123-45-67890',
coverageRegionCodes: ['INVALID_REGION'],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('MISSING_COVERAGE_REGION');
});
});
// ---------------------------------------------------------------------------
// I011: PATCH /api/v1/vendors/certifications/:id/approve - 인증 승인
// ---------------------------------------------------------------------------
describe('I011: reviewVendorCertificationService', () => {
async function createAppliedVendor() {
const owner = await createVendorOwner();
const operator = await createOperator();
const applyResult = await applyVendorCertificationService(prisma, {
ownerUserId: owner.id.toString(),
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
businessRegistrationNumber: '123-45-67890',
coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'],
});
if (!applyResult.ok) throw new Error('apply failed');
// 서류 체크리스트 추가 (승인 필수 조건)
const vendor = await prisma.vendor.findUnique({
where: { publicId: applyResult.value.vendorPublicId },
});
const cert = await prisma.vendorCertification.findFirst({
where: { vendorId: vendor!.id },
});
if (cert) {
await prisma.vendorCertification.update({
where: { id: cert.id },
data: { documentChecklistJson: { businessLicense: true, insurance: true } },
});
}
return { owner, operator, vendorPublicId: applyResult.value.vendorPublicId };
}
it('APPLIED 업체를 승인하면 APPROVED로 전환되고 이력+감사 로그 기록', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.certificationStatus).toBe('APPROVED');
// DB 확인
const vendor = await prisma.vendor.findUnique({
where: { publicId: vendorPublicId },
include: { certifications: { orderBy: { createdAt: 'desc' } } },
});
expect(vendor!.certificationStatus).toBe('APPROVED');
// 인증 이력이 2개 (APPLIED + APPROVED)
expect(vendor!.certifications).toHaveLength(2);
expect(vendor!.certifications[0]!.status).toBe('APPROVED');
expect(vendor!.certifications[0]!.reviewedAt).not.toBeNull();
// AuditLog 확인
const auditLogs = await prisma.auditLog.findMany({
where: { resourceId: vendorPublicId, actionType: 'VENDOR_CERTIFICATION_APPROVED' },
});
expect(auditLogs).toHaveLength(1);
expect((auditLogs[0]!.beforeJson as Record<string, unknown>)?.certificationStatus).toBe(
'APPLIED',
);
expect((auditLogs[0]!.afterJson as Record<string, unknown>)?.certificationStatus).toBe(
'APPROVED',
);
// OutboxEvent 확인
const outboxEvents = await prisma.outboxEvent.findMany({
where: { aggregateId: vendorPublicId, eventName: 'vendor.certification.approved' },
});
expect(outboxEvents).toHaveLength(1);
});
it('반려 시 사유 코드가 있으면 REJECTED로 전환', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'REJECTED',
operator.id.toString(),
'INCOMPLETE_DOCUMENTS',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.certificationStatus).toBe('REJECTED');
// 인증 이력에 사유 코드 기록
const vendor = await prisma.vendor.findUnique({
where: { publicId: vendorPublicId },
include: { certifications: { orderBy: { createdAt: 'desc' } } },
});
expect(vendor!.certifications[0]!.reasonCode).toBe('INCOMPLETE_DOCUMENTS');
});
it('반려 시 사유 코드 없으면 실패', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'REJECTED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('REASON_REQUIRED');
});
it('존재하지 않는 업체는 NOT_FOUND를 반환', async () => {
const operator = await createOperator();
const result = await reviewVendorCertificationService(
prisma,
'non-existent-vendor',
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_FOUND');
});
it('이미 APPROVED된 업체는 재검토 불가', async () => {
const { operator, vendorPublicId } = await createAppliedVendor();
// 1차 승인
await reviewVendorCertificationService(
prisma,
vendorPublicId,
'APPROVED',
operator.id.toString(),
);
// 2차 승인 시도
const result = await reviewVendorCertificationService(
prisma,
vendorPublicId,
'APPROVED',
operator.id.toString(),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
});
});
+103
View File
@@ -0,0 +1,103 @@
const SAMPLE_CONTRACTS = [
{ id: 'ac-1', storeTitle: '선릉역 한식당', type: '철거', status: 'ACTIVE', escrow: 'RELEASE_REVIEW', amount: '₩5,000,000', createdAt: '2026-03-02' },
{ id: 'ac-2', storeTitle: '강남역 카페', type: '시설인수', status: 'SIGNED', escrow: 'HOLDING', amount: '₩50,000,000', createdAt: '2026-03-05' },
{ id: 'ac-3', storeTitle: '홍대 디저트카페', type: '인테리어', status: 'ACTIVE', escrow: 'DISPUTED', amount: '₩8,000,000', createdAt: '2026-02-28' },
{ id: 'ac-4', storeTitle: '합정 베이커리', type: '철거', status: 'COMPLETED', escrow: 'RELEASED', amount: '₩3,500,000', createdAt: '2026-02-15' },
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' },
SIGNED: { label: '서명 완료', color: 'bg-indigo-100 text-indigo-700' },
ACTIVE: { label: '진행 중', color: 'bg-green-100 text-green-700' },
COMPLETED: { label: '완료', color: 'bg-emerald-100 text-emerald-700' },
};
const ESCROW_MAP: Record<string, { label: string; color: string }> = {
HOLDING: { label: '보관 중', color: 'bg-blue-100 text-blue-700' },
RELEASE_REVIEW: { label: '정산 검토', color: 'bg-purple-100 text-purple-700' },
RELEASED: { label: '정산 완료', color: 'bg-green-100 text-green-700' },
DISPUTED: { label: '분쟁 중', color: 'bg-red-100 text-red-700' },
};
export default function AdminContractsPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900">/ </h1>
<p className="mt-1 text-sm text-gray-500">
, ,
</p>
{/* 요약 카드 */}
<div className="mt-6 grid grid-cols-4 gap-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-gray-900">2</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-purple-600">1</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-red-600">1</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-2xl font-bold text-gray-900">63M</p>
</div>
</div>
{/* 계약 테이블 */}
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"> </th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_CONTRACTS.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[c.escrow] ?? { label: c.escrow, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{c.storeTitle}</td>
<td className="px-4 py-3 text-gray-600">{c.type}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${escrowInfo.color}`}>
{escrowInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{c.amount}</td>
<td className="px-4 py-3 text-gray-400">{c.createdAt}</td>
<td className="px-4 py-3">
{c.escrow === 'RELEASE_REVIEW' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"> </button>
<button className="text-sm text-orange-600 hover:underline"></button>
</div>
)}
{c.escrow === 'DISPUTED' && (
<button className="text-sm text-blue-600 hover:underline"> </button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from 'next/link';
const ADMIN_NAV = [
{ href: '/admin', label: '대시보드' },
{ href: '/admin/stores', label: '매장 검토' },
{ href: '/admin/vendors', label: '업체 인증' },
{ href: '/admin/subsidies', label: '지원금 검토' },
{ href: '/admin/contracts', label: '계약/정산' },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-[calc(100vh-4rem)]">
<aside className="w-56 border-r border-gray-200 bg-white">
<div className="p-4">
<h2 className="text-sm font-semibold text-gray-500"> </h2>
</div>
<nav className="space-y-1 px-2">
{ADMIN_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="block rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{item.label}
</Link>
))}
</nav>
</aside>
<div className="flex-1">{children}</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import Link from 'next/link';
export default function AdminDashboardPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">Re:Link </p>
{/* KPI 요약 */}
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<KPICard label="등록 매장" value="47" change="+3 이번 주" />
<KPICard label="매칭 요청" value="23" change="+5 이번 주" />
<KPICard label="활성 계약" value="12" change="+2 이번 주" />
<KPICard label="에스크로 보관 중" value="₩15.2M" change="" />
</div>
{/* 검토 큐 */}
<div className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/stores" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="강남역 치킨집" type="SUBMITTED" time="2시간 전" />
<QueueItem title="선릉역 중식당" type="SUBMITTED" time="5시간 전" />
<QueueItem title="논현동 베이커리" type="SUBMITTED" time="1일 전" />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/vendors" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="(주)클린철거" type="APPLIED" time="3시간 전" />
<QueueItem title="모던인테리어" type="APPLIED" time="1일 전" />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/subsidies" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="강남역 카페 - 소상공인 지원금" type="SUBMITTED" time="4시간 전" />
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900"> </h2>
<Link href="/admin/contracts" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="mt-4 space-y-3">
<QueueItem title="선릉역 한식당 - 철거 계약" type="RELEASE_REVIEW" time="6시간 전" />
</div>
</div>
</div>
{/* 최근 활동 */}
<div className="mt-8 rounded-lg border border-gray-200 bg-white p-5">
<h2 className="font-semibold text-gray-900"> </h2>
<div className="mt-4 space-y-2">
{[
{ action: '매장 승인', target: '강남역 카페 양도', actor: '김운영', time: '10분 전' },
{ action: '업체 인증 승인', target: '(주)클린철거', actor: '이운영', time: '1시간 전' },
{ action: '에스크로 입금 확인', target: '선릉역 한식당 계약', actor: 'SYSTEM', time: '2시간 전' },
{ action: '매칭 요청 수락', target: '홍대 디저트카페', actor: '박운영', time: '3시간 전' },
].map((log, i) => (
<div key={i} className="flex items-center justify-between border-b border-gray-50 py-2 last:border-0">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">{log.target}</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{log.actor}</span>
<span>{log.time}</span>
</div>
</div>
))}
</div>
</div>
</main>
);
}
function KPICard({ label, value, change }: { label: string; value: string; change: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">{label}</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{value}</p>
{change && <p className="mt-1 text-xs text-green-600">{change}</p>}
</div>
);
}
function QueueItem({ title, type, time }: { title: string; type: string; time: string }) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-yellow-400" />
<span className="text-sm text-gray-900">{title}</span>
<span className="text-xs text-gray-400">{type}</span>
</div>
<span className="text-xs text-gray-400">{time}</span>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
const SAMPLE_STORES = [
{ id: 's-1', title: '강남역 치킨집', region: '강남구', industry: '한식', status: 'SUBMITTED', owner: '김폐업', submittedAt: '2026-03-07 09:30' },
{ id: 's-2', title: '선릉역 중식당', region: '강남구', industry: '중식', status: 'SUBMITTED', owner: '이폐업', submittedAt: '2026-03-07 06:15' },
{ id: 's-3', title: '논현동 베이커리', region: '강남구', industry: '카페', status: 'SUBMITTED', owner: '박폐업', submittedAt: '2026-03-06 18:00' },
{ id: 's-4', title: '홍대 파스타집', region: '마포구', industry: '양식', status: 'APPROVED', owner: '최폐업', submittedAt: '2026-03-05 14:00' },
{ id: 's-5', title: '합정 디저트카페', region: '마포구', industry: '카페', status: 'REJECTED', owner: '정폐업', submittedAt: '2026-03-04 10:00' },
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' },
APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
PUBLISHED: { label: '공개', color: 'bg-blue-100 text-blue-700' },
};
export default function AdminStoresPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
{/* 필터 */}
<div className="mt-6 flex gap-2">
{['전체', '검토 대기', '승인', '반려'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
</div>
{/* 매장 목록 */}
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_STORES.map((store) => {
const statusInfo = STATUS_MAP[store.status] ?? { label: store.status, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={store.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{store.title}</td>
<td className="px-4 py-3 text-gray-600">{store.region}</td>
<td className="px-4 py-3 text-gray-600">{store.industry}</td>
<td className="px-4 py-3 text-gray-600">{store.owner}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{store.submittedAt}</td>
<td className="px-4 py-3">
{store.status === 'SUBMITTED' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
+75
View File
@@ -0,0 +1,75 @@
const SAMPLE_CASES = [
{ id: 'asc-1', storeTitle: '강남역 카페', owner: '김폐업', status: 'SUBMITTED', checklist: '5/5', createdAt: '2026-03-06' },
{ id: 'asc-2', storeTitle: '선릉역 한식당', owner: '이폐업', status: 'REVIEWING', checklist: '5/5', createdAt: '2026-03-04' },
{ id: 'asc-3', storeTitle: '합정 베이커리', owner: '박폐업', status: 'APPROVED', checklist: '4/4', createdAt: '2026-03-01' },
{ id: 'asc-4', storeTitle: '논현동 분식점', owner: '최폐업', status: 'REJECTED', checklist: '3/5', createdAt: '2026-02-28' },
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-gray-100 text-gray-700' },
SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' },
REVIEWING: { label: '검토 중', color: 'bg-blue-100 text-blue-700' },
APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default function AdminSubsidiesPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
<div className="mt-6 flex gap-2">
{['전체', '검토 대기', '검토 중', '승인', '반려'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_CASES.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{c.storeTitle}</td>
<td className="px-4 py-3 text-gray-600">{c.owner}</td>
<td className="px-4 py-3 text-gray-600">{c.checklist}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{c.createdAt}</td>
<td className="px-4 py-3">
{(c.status === 'SUBMITTED' || c.status === 'REVIEWING') && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
const SAMPLE_VENDORS = [
{ id: 'v-1', name: '(주)클린철거', type: 'DEMOLITION', region: '강남권', status: 'APPLIED', contactName: '김담당', appliedAt: '2026-03-07 08:00' },
{ id: 'v-2', name: '모던인테리어', type: 'INTERIOR', region: '마포권', status: 'APPLIED', contactName: '이담당', appliedAt: '2026-03-06 14:30' },
{ id: 'v-3', name: '서울철거공사', type: 'DEMOLITION', region: '강남권, 마포권', status: 'APPROVED', contactName: '박담당', appliedAt: '2026-03-01 10:00' },
{ id: 'v-4', name: '그린인테리어', type: 'INTERIOR', region: '강남권', status: 'SUSPENDED', contactName: '최담당', appliedAt: '2026-02-25 09:00' },
];
const TYPE_LABELS: Record<string, string> = { DEMOLITION: '철거', INTERIOR: '인테리어', ACQUISITION: '시설인수' };
const STATUS_MAP: Record<string, { label: string; color: string }> = {
APPLIED: { label: '심사 대기', color: 'bg-yellow-100 text-yellow-700' },
REVIEWING: { label: '심사 중', color: 'bg-blue-100 text-blue-700' },
APPROVED: { label: '인증됨', color: 'bg-green-100 text-green-700' },
SUSPENDED: { label: '중지', color: 'bg-red-100 text-red-700' },
REJECTED: { label: '반려', color: 'bg-gray-100 text-gray-700' },
};
export default function AdminVendorsPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> ·· </p>
<div className="mt-6 flex gap-2">
{['전체', '심사 대기', '인증됨', '중지'].map((f) => (
<button
key={f}
className="rounded-full border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100"
>
{f}
</button>
))}
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"> </th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
<th className="px-4 py-3 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{SAMPLE_VENDORS.map((vendor) => {
const statusInfo = STATUS_MAP[vendor.status] ?? { label: vendor.status, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={vendor.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{vendor.name}</td>
<td className="px-4 py-3 text-gray-600">{TYPE_LABELS[vendor.type] ?? vendor.type}</td>
<td className="px-4 py-3 text-gray-600">{vendor.region}</td>
<td className="px-4 py-3 text-gray-600">{vendor.contactName}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</td>
<td className="px-4 py-3 text-gray-400">{vendor.appliedAt}</td>
<td className="px-4 py-3">
{vendor.status === 'APPLIED' && (
<div className="flex gap-2">
<button className="text-sm text-green-600 hover:underline"></button>
<button className="text-sm text-red-600 hover:underline"></button>
</div>
)}
{vendor.status === 'APPROVED' && (
<button className="text-sm text-orange-600 hover:underline"></button>
)}
{vendor.status === 'SUSPENDED' && (
<button className="text-sm text-green-600 hover:underline"></button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</main>
);
}
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { createPrismaClient } from '@relink/database';
const prisma = createPrismaClient();
export async function GET() {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true, isBetaEnabled: true },
select: {
code: true,
nameKo: true,
regionType: true,
parentId: true,
depth: true,
isBetaEnabled: true,
},
orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }],
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true, isBetaEnabled: true, isLeaf: true },
select: {
code: true,
nameKo: true,
depth: true,
isLeaf: true,
isBetaEnabled: true,
},
orderBy: { sortOrder: 'asc' },
}),
]);
return NextResponse.json({
ok: true,
data: { regions, industries },
});
}
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { submitStoreService } from '@/services/store-service';
const prisma = createPrismaClient();
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const actorUserId = (body as Record<string, string>).actorUserId;
if (!actorUserId) {
return NextResponse.json(
{ ok: false, error: { code: 'VALIDATION_ERROR', message: 'actorUserId는 필수입니다.' } },
{ status: 400 },
);
}
const result = await submitStoreService(prisma, id, actorUserId);
if (!result.ok) {
const status = result.error.code === 'NOT_FOUND' ? 404 : 422;
return NextResponse.json({ ok: false, error: result.error }, { status });
}
return NextResponse.json({ ok: true, data: result.value });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { createStoreDraftService } from '@/services/store-service';
import { z } from 'zod';
const prisma = createPrismaClient();
const createStoreSchema = z.object({
ownerUserId: z.string().min(1),
listingTitle: z.string().min(1),
industryLeafCode: z.string().min(1),
regionClusterCode: z.string().min(1),
roadAddress: z.string().min(1),
detailAddress: z.string().optional(),
lease: z
.object({
depositAmount: z.number().min(0),
monthlyRentAmount: z.number().min(0),
premiumAmount: z.number().min(0),
maintenanceFeeAmount: z.number().min(0).optional(),
remainingLeaseMonths: z.number().int().min(0).optional(),
transferable: z.boolean().optional(),
})
.optional(),
facility: z
.object({
exclusiveAreaSqm: z.number().positive(),
seatCount: z.number().int().min(0),
floorLevel: z.number().int().optional(),
hasGas: z.boolean().optional(),
hasDrainage: z.boolean().optional(),
hasDuct: z.boolean().optional(),
electricCapacityKw: z.number().min(0).optional(),
kitchenEquipmentSummary: z.string().optional(),
parkingCount: z.number().int().min(0).optional(),
})
.optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createStoreSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: { code: 'VALIDATION_ERROR', message: parsed.error.message } },
{ status: 400 },
);
}
const result = await createStoreDraftService(prisma, parsed.data);
if (!result.ok) {
const status = result.error.code === 'VALIDATION_ERROR' ? 400 : 422;
return NextResponse.json({ ok: false, error: result.error }, { status });
}
return NextResponse.json({ ok: true, data: result.value }, { status: 201 });
}
+130
View File
@@ -0,0 +1,130 @@
import Link from 'next/link';
const SAMPLE_CONTRACTS = [
{
id: 'ct-1',
storeTitle: '강남역 카페 양도',
contractType: 'ACQUISITION',
status: 'DRAFT',
escrowStatus: 'NOT_STARTED',
createdAt: '2026-03-06',
},
{
id: 'ct-2',
storeTitle: '선릉역 한식당',
contractType: 'DEMOLITION',
status: 'ACTIVE',
escrowStatus: 'HOLDING',
createdAt: '2026-03-02',
},
{
id: 'ct-3',
storeTitle: '합정 베이커리',
contractType: 'INTERIOR',
status: 'COMPLETED',
escrowStatus: 'RELEASED',
createdAt: '2026-02-20',
},
];
const CONTRACT_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '시설인수',
DEMOLITION: '철거',
INTERIOR: '인테리어',
};
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' },
GENERATED: { label: '생성됨', color: 'bg-blue-100 text-blue-700' },
SIGNING: { label: '서명 중', color: 'bg-yellow-100 text-yellow-700' },
SIGNED: { label: '서명 완료', color: 'bg-indigo-100 text-indigo-700' },
ACTIVE: { label: '진행 중', color: 'bg-green-100 text-green-700' },
COMPLETED: { label: '완료', color: 'bg-emerald-100 text-emerald-700' },
CANCELLED: { label: '취소', color: 'bg-red-100 text-red-700' },
};
const ESCROW_MAP: Record<string, { label: string; color: string }> = {
NOT_STARTED: { label: '미시작', color: 'text-gray-500' },
DEPOSIT_PENDING: { label: '입금 대기', color: 'text-yellow-600' },
HOLDING: { label: '보관 중', color: 'text-blue-600' },
RELEASE_REVIEW: { label: '정산 검토', color: 'text-purple-600' },
RELEASED: { label: '정산 완료', color: 'text-green-600' },
REFUNDED: { label: '환불됨', color: 'text-orange-600' },
DISPUTED: { label: '분쟁 중', color: 'text-red-600' },
};
export default function ContractsPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">, , </p>
{/* 요약 */}
<div className="mt-6 grid grid-cols-4 gap-4">
<SummaryCard label="전체 계약" value="3" />
<SummaryCard label="진행 중" value="1" />
<SummaryCard label="에스크로 보관" value="1" />
<SummaryCard label="완료" value="1" />
</div>
{/* 계약 목록 */}
<div className="mt-6 space-y-4">
{SAMPLE_CONTRACTS.map((contract) => {
const statusInfo = STATUS_MAP[contract.status] ?? { label: contract.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[contract.escrowStatus] ?? { label: contract.escrowStatus, color: 'text-gray-500' };
return (
<div key={contract.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{contract.storeTitle}</h3>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{CONTRACT_TYPE_LABELS[contract.contractType] ?? contract.contractType}
</span>
</div>
<p className="mt-1 text-xs text-gray-400">: {contract.createdAt}</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
<div className="mt-3 flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500">: </span>
<span className={`font-medium ${escrowInfo.color}`}>{escrowInfo.label}</span>
</div>
</div>
{contract.status === 'ACTIVE' && (
<div className="mt-4 flex gap-3 border-t border-gray-100 pt-3">
<button className="text-sm text-blue-600 hover:underline"> </button>
<button className="text-sm text-red-600 hover:underline"> </button>
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/matching" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="mt-1 text-xs text-gray-500">{label}</p>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
export const metadata: Metadata = {
title: 'Re:Link',
description: 'Re:Link - 폐업 · 양도 · 창업을 잇는 중개 플랫폼',
};
function Navigation() {
return (
<nav className="border-b border-gray-200 bg-white">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="text-xl font-bold text-blue-600">
Re:Link
</Link>
<div className="flex items-center gap-6">
<Link href="/stores" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/stores/new" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/matching" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/subsidies" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/vendors" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link href="/contracts" className="text-sm text-gray-700 hover:text-blue-600">
</Link>
<Link
href="/admin"
className="rounded-md bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
</Link>
</div>
</div>
</nav>
);
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body className="min-h-screen bg-gray-50">
<Navigation />
{children}
</body>
</html>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from 'next/link';
const SAMPLE_REQUESTS = [
{
id: 'mr-1',
storeTitle: '강남역 카페 양도',
matchType: 'ACQUISITION',
status: 'OPEN',
createdAt: '2026-03-05',
message: '매장 인수 희망합니다.',
},
{
id: 'mr-2',
storeTitle: '선릉역 한식당 양도',
matchType: 'DEMOLITION',
status: 'ACCEPTED',
createdAt: '2026-03-03',
message: '철거 견적 요청드립니다.',
},
{
id: 'mr-3',
storeTitle: '홍대입구 디저트카페',
matchType: 'INTERIOR',
status: 'OPEN',
createdAt: '2026-03-06',
message: '인테리어 리모델링 상담 원합니다.',
},
];
const MATCH_TYPE_LABELS: Record<string, string> = {
ACQUISITION: '인수',
DEMOLITION: '철거',
INTERIOR: '인테리어',
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
OPEN: { label: '대기 중', color: 'bg-blue-100 text-blue-700' },
REVIEWING: { label: '검토 중', color: 'bg-yellow-100 text-yellow-700' },
ACCEPTED: { label: '수락됨', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '거절됨', color: 'bg-red-100 text-red-700' },
};
export default function MatchingPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
<div className="mt-6 space-y-4">
{SAMPLE_REQUESTS.map((req) => {
const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' };
return (
<div key={req.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{req.storeTitle}</h3>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{MATCH_TYPE_LABELS[req.matchType] ?? req.matchType}
</span>
</div>
<p className="mt-1 text-sm text-gray-600">{req.message}</p>
<p className="mt-2 text-xs text-gray-400">: {req.createdAt}</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{req.status === 'ACCEPTED' && (
<div className="mt-4 border-t border-gray-100 pt-3">
<Link
href={`/contracts?matchId=${req.id}`}
className="text-sm text-blue-600 hover:underline"
>
</Link>
</div>
)}
</div>
);
})}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from 'next/link';
export default function HomePage() {
return (
<main className="mx-auto max-w-7xl px-4 py-16">
<div className="text-center">
<h1 className="text-5xl font-bold text-gray-900">Re:Link</h1>
<p className="mt-4 text-xl text-gray-600"> · · </p>
<p className="mt-2 text-gray-500">
1 ··
</p>
<div className="mt-8 flex justify-center gap-4">
<Link
href="/stores"
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
>
</Link>
<Link
href="/stores/new"
className="rounded-lg border border-gray-300 px-6 py-3 text-gray-700 hover:bg-gray-100"
>
</Link>
</div>
</div>
<div className="mt-20 grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-3 text-2xl">🏪</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<p className="mt-2 text-sm text-gray-600">
, , .
</p>
<Link href="/stores/new" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-3 text-2xl">🔍</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<p className="mt-2 text-sm text-gray-600">
.
</p>
<Link href="/stores" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-3 text-2xl">🏗</div>
<h3 className="text-lg font-semibold text-gray-900">· </h3>
<p className="mt-2 text-sm text-gray-600">
.
</p>
<Link href="/vendors" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900"> </h3>
<p className="mt-2 text-sm text-gray-600">
, .
</p>
<Link href="/subsidies" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900"> </h3>
<p className="mt-2 text-sm text-gray-600">
, , .
</p>
<Link href="/contracts" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
</Link>
</div>
</div>
</main>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from 'next/link';
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6">
<Link href="/stores" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> ID: {id}</p>
</div>
<span className="rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-700">
</span>
</div>
{/* 기본 정보 */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="지역" value="강남구 역삼동" />
<InfoItem label="업종" value="카페" />
<InfoItem label="도로명 주소" value="서울시 강남구 테헤란로 123" />
<InfoItem label="등록일" value="2026-03-07" />
</div>
</section>
{/* 임대 정보 */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="보증금" value="5,000만원" />
<InfoItem label="월세" value="300만원" />
<InfoItem label="권리금" value="3,000만원" />
<InfoItem label="임대 잔여 기간" value="18개월" />
</div>
</section>
{/* 시설 정보 */}
<section className="mt-8">
<h2 className="mb-3 text-lg font-semibold text-gray-900"> </h2>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="면적" value="25평" />
<InfoItem label="층수" value="1층" />
</div>
<div className="mt-3">
<p className="text-sm text-gray-500"> </p>
<p className="mt-1 text-sm text-gray-900">
, , . 2024 . 30.
</p>
</div>
</section>
{/* 액션 버튼 */}
<div className="mt-10 flex gap-3 border-t border-gray-200 pt-6">
<Link
href={`/matching?storeId=${id}`}
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</Link>
<Link
href={`/subsidies?storeId=${id}`}
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
>
</Link>
</div>
</div>
</main>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className="mt-0.5 text-sm font-medium text-gray-900">{value}</p>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import Link from 'next/link';
export default function NewStorePage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6">
<Link href="/stores" className="text-sm text-blue-600 hover:underline">
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">
, ,
</p>
<form className="mt-8 space-y-8">
{/* 기본 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: 강남역 카페 양도"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""> </option>
<option value="KR.BETA.GANGNAM_CORE"> (//)</option>
<option value="KR.BETA.MAPO_CORE"> (//)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""> </option>
<option value="FNB.CAFE"></option>
<option value="FNB.KOREAN"></option>
<option value="FNB.WESTERN"></option>
<option value="FNB.JAPANESE"></option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: 서울시 강남구 테헤란로 123"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</section>
{/* 임대 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="50000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="3000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="30000000"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> </label>
<input
type="text"
placeholder="예: 18개월"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
</section>
{/* 시설 정보 */}
<section>
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> ()</label>
<input
type="number"
placeholder="25"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"></label>
<input
type="text"
placeholder="예: 1층"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> </label>
<textarea
rows={3}
placeholder="주방 시설, 인테리어 상태, 포함 장비 등을 자유롭게 작성해주세요"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</section>
{/* 제출 */}
<div className="flex gap-3">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
<Link
href="/stores"
className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
>
</Link>
</div>
</form>
</main>
);
}
+134
View File
@@ -0,0 +1,134 @@
import Link from 'next/link';
const REGION_OPTIONS = [
{ value: '', label: '전체 지역' },
{ value: 'KR.BETA.GANGNAM_CORE', label: '강남권 (역삼/선릉/논현)' },
{ value: 'KR.BETA.MAPO_CORE', label: '마포권 (홍대/합정/연남)' },
];
const INDUSTRY_OPTIONS = [
{ value: '', label: '전체 업종' },
{ value: 'FNB.CAFE', label: '카페' },
{ value: 'FNB.KOREAN', label: '한식' },
{ value: 'FNB.WESTERN', label: '양식' },
{ value: 'FNB.JAPANESE', label: '일식' },
];
const STATUS_OPTIONS = [
{ value: '', label: '전체 상태' },
{ value: 'OPEN', label: '거래 가능' },
{ value: 'MATCHING', label: '매칭 중' },
{ value: 'CONTRACTED', label: '계약 진행 중' },
];
export default function StoresPage() {
return (
<main className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
<Link
href="/stores/new"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
</Link>
</div>
{/* 필터 */}
<div className="mt-6 flex flex-wrap gap-3 rounded-lg border border-gray-200 bg-white p-4">
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{REGION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{INDUSTRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select className="rounded-md border border-gray-300 px-3 py-2 text-sm">
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button className="rounded-md bg-gray-100 px-4 py-2 text-sm text-gray-700 hover:bg-gray-200">
</button>
</div>
{/* 매장 목록 (샘플) */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[
{
id: 'sample-1',
title: '강남역 카페 양도',
region: '강남구 역삼동',
industry: '카페',
status: 'OPEN',
deposit: '5,000만원',
monthlyRent: '300만원',
},
{
id: 'sample-2',
title: '선릉역 한식당 양도',
region: '강남구 선릉동',
industry: '한식',
status: 'MATCHING',
deposit: '8,000만원',
monthlyRent: '450만원',
},
{
id: 'sample-3',
title: '홍대입구 디저트카페',
region: '마포구 서교동',
industry: '카페',
status: 'OPEN',
deposit: '3,000만원',
monthlyRent: '250만원',
},
].map((store) => (
<Link
key={store.id}
href={`/stores/${store.id}`}
className="block rounded-lg border border-gray-200 bg-white p-5 transition hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start justify-between">
<h3 className="font-semibold text-gray-900">{store.title}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
store.status === 'OPEN'
? 'bg-green-100 text-green-700'
: store.status === 'MATCHING'
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{store.status === 'OPEN' ? '거래 가능' : store.status === 'MATCHING' ? '매칭 중' : store.status}
</span>
</div>
<p className="mt-1 text-sm text-gray-500">
{store.region} · {store.industry}
</p>
<div className="mt-3 flex gap-4 text-sm text-gray-600">
<span> {store.deposit}</span>
<span> {store.monthlyRent}</span>
</div>
</Link>
))}
</div>
<div className="mt-8 text-center text-sm text-gray-400">
서비스: 강남권· F&B
</div>
</main>
);
}
+100
View File
@@ -0,0 +1,100 @@
import Link from 'next/link';
const SAMPLE_CASES = [
{
id: 'sc-1',
storeTitle: '강남역 카페',
status: 'DOCUMENTS_PENDING',
checklist: { total: 5, checked: 2 },
createdAt: '2026-03-04',
},
{
id: 'sc-2',
storeTitle: '선릉역 한식당',
status: 'SUBMITTED',
checklist: { total: 5, checked: 5 },
createdAt: '2026-03-01',
},
{
id: 'sc-3',
storeTitle: '합정 베이커리',
status: 'APPROVED',
checklist: { total: 4, checked: 4 },
createdAt: '2026-02-28',
},
];
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-yellow-100 text-yellow-700' },
READY_TO_SUBMIT: { label: '제출 준비 완료', color: 'bg-blue-100 text-blue-700' },
SUBMITTED: { label: '제출됨', color: 'bg-purple-100 text-purple-700' },
REVIEWING: { label: '검토 중', color: 'bg-orange-100 text-orange-700' },
APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' },
REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' },
};
export default function SubsidiesPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">
</p>
{/* 안내 배너 */}
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 className="text-sm font-semibold text-blue-800"> </h3>
<p className="mt-1 text-sm text-blue-700">
Re:Link은 , · · ·
.
</p>
</div>
{/* 케이스 목록 */}
<div className="mt-6 space-y-4">
{SAMPLE_CASES.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
return (
<div key={c.id} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-gray-900">{c.storeTitle}</h3>
<p className="mt-1 text-xs text-gray-400">: {c.createdAt}</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{/* 체크리스트 진행률 */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600"> </span>
<span className="text-gray-900">
{c.checklist.checked}/{c.checklist.total}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-gray-200">
<div
className="h-2 rounded-full bg-blue-500"
style={{ width: `${(c.checklist.checked / c.checklist.total) * 100}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-8 rounded-lg border border-dashed border-gray-300 p-6 text-center">
<p className="text-sm text-gray-500">
{' '}
<Link href="/stores" className="text-blue-600 hover:underline">
</Link>
</p>
</div>
</main>
);
}
+112
View File
@@ -0,0 +1,112 @@
export default function VendorsPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">
</p>
{/* 인증 혜택 */}
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-2 text-xl"></div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-2 text-xl">📋</div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-2 text-xl">💰</div>
<h3 className="font-semibold text-gray-900"> </h3>
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
</div>
{/* 인증 신청 폼 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="예: (주)클린철거"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<select className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value=""></option>
<option value="DEMOLITION"></option>
<option value="INTERIOR"></option>
<option value="ACQUISITION"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="담당자 이름"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<input
type="text"
placeholder="123-45-67890"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> *</label>
<div className="mt-2 flex flex-wrap gap-2">
{['강남권 (역삼/선릉/논현)', '마포권 (홍대/합정/연남)'].map((region) => (
<label key={region} className="flex items-center gap-1.5 text-sm text-gray-700">
<input type="checkbox" className="rounded border-gray-300" />
{region}
</label>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
</div>
</form>
</div>
{/* 인증 현황 */}
<div className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-gray-900"> </h2>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">()</h3>
<p className="mt-0.5 text-sm text-gray-500"> · </p>
</div>
<span className="rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-700">
</span>
</div>
<p className="mt-2 text-xs text-gray-400">신청일: 2026-03-05</p>
</div>
</div>
</main>
);
}
+408
View File
@@ -0,0 +1,408 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createContract,
releaseEscrow,
checkIdempotency,
openDispute,
type ContractType,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
// ---------------------------------------------------------------------------
// I012: 계약 생성 서비스
// ---------------------------------------------------------------------------
export interface CreateContractServiceInput {
readonly matchRequestPublicId: string;
readonly contractType: ContractType;
readonly templateCode: string;
readonly policyVersionId: string;
readonly createdByUserId: string;
}
export interface ContractResult {
readonly publicId: string;
readonly status: string;
readonly escrowStatus: string;
}
export async function createContractService(
prisma: PrismaClient,
input: CreateContractServiceInput,
): Promise<Result<ContractResult, AppError>> {
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: input.matchRequestPublicId },
include: { store: true },
});
if (!matchRequest) {
return failure(
appError('NOT_FOUND', '매칭 요청을 찾을 수 없습니다.', {
matchRequestPublicId: input.matchRequestPublicId,
}),
);
}
const policyVersion = await prisma.policyVersion.findFirst({
where: { id: BigInt(input.policyVersionId), isActive: true },
});
if (!policyVersion) {
return failure(
appError('NOT_FOUND', '정책 버전을 찾을 수 없습니다.', {
policyVersionId: input.policyVersionId,
}),
);
}
const domainResult = createContract({
matchRequestStatus: matchRequest.status,
contractType: input.contractType,
templateCode: input.templateCode,
policyVersionId: input.policyVersionId,
});
if (!domainResult.ok) {
return domainResult;
}
const contract = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.contract.create({
data: {
matchRequestId: matchRequest.id,
storeId: matchRequest.storeId,
contractType: input.contractType,
status: 'DRAFT',
escrowStatus: 'NOT_STARTED',
templateCode: input.templateCode,
policyVersionId: policyVersion.id,
createdByUserId: BigInt(input.createdByUserId),
storeOwnerUserId: matchRequest.store.ownerUserId,
},
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: created.publicId,
actionType: 'CONTRACT_CREATED',
actorUserId: BigInt(input.createdByUserId),
actorRole: 'OPS_MANAGER',
afterJson: {
contractType: input.contractType,
templateCode: input.templateCode,
matchRequestPublicId: input.matchRequestPublicId,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: created.publicId,
eventName: 'contract.created',
payloadJson: {
contractPublicId: created.publicId,
contractType: input.contractType,
matchRequestPublicId: input.matchRequestPublicId,
},
});
return created;
});
return success({
publicId: contract.publicId,
status: contract.status,
escrowStatus: contract.escrowStatus,
});
}
// ---------------------------------------------------------------------------
// I013: 에스크로 정산 해제 서비스
// ---------------------------------------------------------------------------
export async function releaseEscrowService(
prisma: PrismaClient,
contractPublicId: string,
actorUserId: string,
): Promise<Result<ContractResult, AppError>> {
const contract = await prisma.contract.findUnique({
where: { publicId: contractPublicId },
include: {
inspections: { where: { status: 'APPROVED' }, take: 1 },
disputes: { where: { status: { in: ['OPEN', 'INVESTIGATING', 'MEDIATING'] } }, take: 1 },
},
});
if (!contract) {
return failure(appError('NOT_FOUND', '계약을 찾을 수 없습니다.', { contractPublicId }));
}
const domainResult = releaseEscrow({
currentEscrowStatus: contract.escrowStatus,
hasApprovedInspection: contract.inspections.length > 0,
hasOpenDispute: contract.disputes.length > 0,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.contract.update({
where: { id: contract.id },
data: { escrowStatus: 'RELEASE_REVIEW' },
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: contract.publicId,
actionType: 'ESCROW_RELEASE_REQUESTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { escrowStatus: contract.escrowStatus },
afterJson: { escrowStatus: 'RELEASE_REVIEW' },
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: contract.publicId,
eventName: 'escrow.release_requested',
payloadJson: {
contractPublicId: contract.publicId,
previousEscrowStatus: contract.escrowStatus,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
escrowStatus: updated.escrowStatus,
});
}
// ---------------------------------------------------------------------------
// I014: 에스크로 웹훅 처리 (멱등성 보장)
// ---------------------------------------------------------------------------
export interface ProcessEscrowWebhookInput {
readonly contractPublicId: string;
readonly idempotencyKey: string;
readonly transactionType: 'DEPOSIT' | 'RELEASE' | 'REFUND' | 'ADJUSTMENT' | 'HOLD';
readonly amount: number;
readonly providerCode?: string;
readonly providerTransactionId?: string;
}
export interface EscrowWebhookResult {
readonly isNew: boolean;
readonly contractPublicId: string;
readonly escrowStatus: string;
}
export async function processEscrowWebhookService(
prisma: PrismaClient,
input: ProcessEscrowWebhookInput,
): Promise<Result<EscrowWebhookResult, AppError>> {
const existing = await prisma.escrowTransaction.findUnique({
where: { idempotencyKey: input.idempotencyKey },
});
const idempotencyResult = checkIdempotency({
idempotencyKey: input.idempotencyKey,
alreadyProcessed: existing !== null,
});
if (!idempotencyResult.ok) {
return idempotencyResult;
}
if (!idempotencyResult.value.isNew) {
const contract = await prisma.contract.findUnique({
where: { publicId: input.contractPublicId },
});
return success({
isNew: false,
contractPublicId: input.contractPublicId,
escrowStatus: contract?.escrowStatus ?? 'NOT_STARTED',
});
}
const contract = await prisma.contract.findUnique({
where: { publicId: input.contractPublicId },
});
if (!contract) {
return failure(
appError('NOT_FOUND', '계약을 찾을 수 없습니다.', {
contractPublicId: input.contractPublicId,
}),
);
}
const newEscrowStatus = resolveEscrowStatus(input.transactionType, contract.escrowStatus);
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.escrowTransaction.create({
data: {
contractId: contract.id,
transactionType: input.transactionType,
status: 'COMPLETED',
amount: input.amount,
idempotencyKey: input.idempotencyKey,
providerCode: input.providerCode ?? null,
providerTransactionId: input.providerTransactionId ?? null,
occurredAt: new Date(),
},
});
const result = await tx.contract.update({
where: { id: contract.id },
data: { escrowStatus: newEscrowStatus },
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: contract.publicId,
actionType: 'ESCROW_TRANSACTION_PROCESSED',
actorUserId: contract.createdByUserId,
actorRole: 'SYSTEM',
beforeJson: { escrowStatus: contract.escrowStatus },
afterJson: {
escrowStatus: newEscrowStatus,
transactionType: input.transactionType,
idempotencyKey: input.idempotencyKey,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: contract.publicId,
eventName: 'escrow.transaction.processed',
payloadJson: {
contractPublicId: contract.publicId,
transactionType: input.transactionType,
idempotencyKey: input.idempotencyKey,
amount: input.amount,
},
});
return result;
});
return success({
isNew: true,
contractPublicId: updated.publicId,
escrowStatus: updated.escrowStatus,
});
}
type EscrowStatusValue =
| 'NOT_STARTED'
| 'DEPOSIT_PENDING'
| 'HOLDING'
| 'RELEASE_REVIEW'
| 'RELEASED'
| 'REFUNDED'
| 'DISPUTED';
function resolveEscrowStatus(
transactionType: string,
currentStatus: EscrowStatusValue,
): EscrowStatusValue {
switch (transactionType) {
case 'DEPOSIT':
return 'HOLDING';
case 'RELEASE':
return 'RELEASED';
case 'REFUND':
return 'REFUNDED';
case 'HOLD':
return 'HOLDING';
default:
return currentStatus;
}
}
// ---------------------------------------------------------------------------
// I015: 분쟁 접수 서비스
// ---------------------------------------------------------------------------
export async function openDisputeService(
prisma: PrismaClient,
contractPublicId: string,
reasonCode: string,
openedByUserId: string,
description?: string,
): Promise<Result<ContractResult, AppError>> {
const contract = await prisma.contract.findUnique({
where: { publicId: contractPublicId },
});
if (!contract) {
return failure(appError('NOT_FOUND', '계약을 찾을 수 없습니다.', { contractPublicId }));
}
const domainResult = openDispute({
currentEscrowStatus: contract.escrowStatus,
contractStatus: contract.status,
reasonCode,
description,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.contract.update({
where: { id: contract.id },
data: { escrowStatus: 'DISPUTED' },
});
await tx.disputeCase.create({
data: {
contractId: contract.id,
openedByUserId: BigInt(openedByUserId),
status: 'OPEN',
reasonCode,
description: description ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'CONTRACT',
resourceId: contract.publicId,
actionType: 'DISPUTE_OPENED',
actorUserId: BigInt(openedByUserId),
actorRole: 'CLOSING_OWNER',
beforeJson: { escrowStatus: contract.escrowStatus },
afterJson: {
escrowStatus: 'DISPUTED',
reasonCode,
description,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'CONTRACT',
aggregateId: contract.publicId,
eventName: 'dispute.opened',
payloadJson: {
contractPublicId: contract.publicId,
reasonCode,
previousEscrowStatus: contract.escrowStatus,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
escrowStatus: updated.escrowStatus,
});
}
@@ -0,0 +1,223 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createMatchRequest,
acceptMatchRequest,
buildSearchDefaults,
type MatchType,
type MatchSourceType,
type StoreSearchCriteria,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface CreateMatchRequestServiceInput {
readonly storePublicId: string;
readonly matchType: MatchType;
readonly sourceType: MatchSourceType;
readonly requesterUserId: string;
readonly message?: string;
readonly operatorRecommendationReason?: string;
}
export interface MatchRequestResult {
readonly publicId: string;
readonly status: string;
readonly matchType: string;
}
export async function createMatchRequestService(
prisma: PrismaClient,
input: CreateMatchRequestServiceInput,
): Promise<Result<MatchRequestResult, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: input.storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId: input.storePublicId }));
}
const openRequest = await prisma.matchRequest.findFirst({
where: {
storeId: store.id,
requesterUserId: BigInt(input.requesterUserId),
status: 'OPEN',
},
});
const domainResult = createMatchRequest({
storePublicationStatus: store.publicationStatus,
storeDealStatus: store.dealStatus,
matchType: input.matchType,
sourceType: input.sourceType,
requesterUserId: input.requesterUserId,
message: input.message,
hasOpenRequestBySameUser: openRequest !== null,
operatorRecommendationReason: input.operatorRecommendationReason,
});
if (!domainResult.ok) {
return domainResult;
}
const draft = domainResult.value;
const matchRequest = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.matchRequest.create({
data: {
storeId: store.id,
matchType: draft.matchType,
sourceType: draft.sourceType,
status: 'OPEN',
requesterUserId: BigInt(input.requesterUserId),
message: draft.message ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'MATCH_REQUEST',
resourceId: created.publicId,
actionType: 'MATCH_REQUEST_CREATED',
actorUserId: BigInt(input.requesterUserId),
actorRole: 'CLOSING_OWNER',
afterJson: {
storePublicId: input.storePublicId,
matchType: draft.matchType,
sourceType: draft.sourceType,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'MATCH_REQUEST',
aggregateId: created.publicId,
eventName: 'match_request.created',
payloadJson: {
matchRequestPublicId: created.publicId,
storePublicId: input.storePublicId,
matchType: draft.matchType,
},
});
return created;
});
return success({
publicId: matchRequest.publicId,
status: matchRequest.status,
matchType: matchRequest.matchType,
});
}
export async function acceptMatchRequestService(
prisma: PrismaClient,
matchRequestPublicId: string,
actorUserId: string,
): Promise<Result<MatchRequestResult, AppError>> {
const matchRequest = await prisma.matchRequest.findUnique({
where: { publicId: matchRequestPublicId },
});
if (!matchRequest) {
return failure(appError('NOT_FOUND', '매칭 요청을 찾을 수 없습니다.', { matchRequestPublicId }));
}
const domainResult = acceptMatchRequest({
currentStatus: matchRequest.status,
actorUserId,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.matchRequest.update({
where: { id: matchRequest.id },
data: {
status: 'ACCEPTED',
acceptedAt: new Date(),
},
});
await createAuditLog(tx, {
resourceType: 'MATCH_REQUEST',
resourceId: matchRequest.publicId,
actionType: 'MATCH_REQUEST_ACCEPTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { status: matchRequest.status },
afterJson: { status: 'ACCEPTED' },
});
await enqueueOutboxEvent(tx, {
aggregateType: 'MATCH_REQUEST',
aggregateId: matchRequest.publicId,
eventName: 'match_request.accepted',
payloadJson: {
matchRequestPublicId: matchRequest.publicId,
previousStatus: matchRequest.status,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
matchType: updated.matchType,
});
}
export async function searchStoresService(
prisma: PrismaClient,
criteria: StoreSearchCriteria,
): Promise<Result<{ stores: Array<{ publicId: string; listingTitle: string; dealStatus: string }>; total: number; page: number; limit: number }, AppError>> {
const defaults = buildSearchDefaults(criteria);
const where: Record<string, unknown> = {
publicationStatus: defaults.publicationStatus,
};
if (defaults.regionClusterCode) {
where['regionCluster'] = { code: defaults.regionClusterCode };
}
if (defaults.industryLeafCode) {
where['industryLeaf'] = { code: defaults.industryLeafCode };
}
if (defaults.dealStatus) {
where['dealStatus'] = defaults.dealStatus;
}
if (defaults.minDepositAmount !== undefined || defaults.maxDepositAmount !== undefined) {
const leaseFilter: Record<string, unknown> = {};
if (defaults.minDepositAmount !== undefined) {
leaseFilter['gte'] = defaults.minDepositAmount;
}
if (defaults.maxDepositAmount !== undefined) {
leaseFilter['lte'] = defaults.maxDepositAmount;
}
where['lease'] = { depositAmount: leaseFilter };
}
const [stores, total] = await Promise.all([
prisma.store.findMany({
where,
select: {
publicId: true,
listingTitle: true,
dealStatus: true,
},
skip: (defaults.page - 1) * defaults.limit,
take: defaults.limit,
orderBy: { createdAt: 'desc' },
}),
prisma.store.count({ where }),
]);
return success({
stores,
total,
page: defaults.page,
limit: defaults.limit,
});
}
+407
View File
@@ -0,0 +1,407 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createStoreDraft,
reviewStore,
publishStore,
filterStoreForViewer,
type CreateStoreDraftInput,
type RegionChecker,
type IndustryChecker,
type ReviewDecision,
type ReviewableStatus,
type StoreData,
type ViewerContext,
type FilteredStoreData,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
function buildRegionChecker(regions: { code: string; isBetaEnabled: boolean }[]): RegionChecker {
const betaCodes = new Set(regions.filter((r) => r.isBetaEnabled).map((r) => r.code));
return { isBetaEnabled: (code: string) => betaCodes.has(code) };
}
function buildIndustryChecker(
industries: { code: string; isLeaf: boolean; isBetaEnabled: boolean }[],
): IndustryChecker {
const map = new Map(industries.map((i) => [i.code, i]));
return {
isSupported: (code) => {
const ind = map.get(code);
if (!ind) return { exists: false, isLeaf: false, isBetaEnabled: false };
return { exists: true, isLeaf: ind.isLeaf, isBetaEnabled: ind.isBetaEnabled };
},
};
}
export interface CreateStoreResult {
readonly publicId: string;
readonly reviewStatus: string;
readonly publicationStatus: string;
readonly dealStatus: string;
}
export async function createStoreDraftService(
prisma: PrismaClient,
input: CreateStoreDraftInput,
): Promise<Result<CreateStoreResult, AppError>> {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true },
select: { id: true, code: true, isBetaEnabled: true },
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true },
select: { id: true, code: true, isLeaf: true, isBetaEnabled: true },
}),
]);
const regionChecker = buildRegionChecker(regions);
const industryChecker = buildIndustryChecker(industries);
const draftResult = createStoreDraft(input, regionChecker, industryChecker);
if (!draftResult.ok) {
return draftResult;
}
const draft = draftResult.value;
const regionCluster = regions.find(
(r: { id: bigint; code: string }) => r.code === input.regionClusterCode,
);
const industryLeaf = industries.find(
(i: { id: bigint; code: string }) => i.code === input.industryLeafCode,
);
const store = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.store.create({
data: {
ownerUserId: BigInt(input.ownerUserId),
listingTitle: draft.listingTitle,
industryLeafId: industryLeaf?.id ?? null,
regionClusterId: regionCluster?.id ?? null,
roadAddress: draft.roadAddress,
detailAddress: draft.detailAddress ?? null,
reviewStatus: 'DRAFT',
publicationStatus: 'PRIVATE',
dealStatus: 'OPEN',
},
});
if (draft.lease) {
await tx.storeLease.create({
data: {
storeId: created.id,
depositAmount: draft.lease.depositAmount,
monthlyRentAmount: draft.lease.monthlyRentAmount,
premiumAmount: draft.lease.premiumAmount,
maintenanceFeeAmount: draft.lease.maintenanceFeeAmount ?? null,
remainingLeaseMonths: draft.lease.remainingLeaseMonths ?? null,
leaseExpiresAt: draft.lease.leaseExpiresAt ?? null,
transferable: draft.lease.transferable ?? null,
},
});
}
if (draft.facility) {
await tx.storeFacility.create({
data: {
storeId: created.id,
exclusiveAreaSqm: draft.facility.exclusiveAreaSqm,
floorLevel: draft.facility.floorLevel ?? null,
seatCount: draft.facility.seatCount,
hasGas: draft.facility.hasGas ?? null,
hasDrainage: draft.facility.hasDrainage ?? null,
hasDuct: draft.facility.hasDuct ?? null,
electricCapacityKw: draft.facility.electricCapacityKw ?? null,
kitchenEquipmentSummary: draft.facility.kitchenEquipmentSummary ?? null,
parkingCount: draft.facility.parkingCount ?? null,
},
});
}
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: created.publicId,
actionType: 'STORE_DRAFT_CREATED',
actorUserId: BigInt(input.ownerUserId),
actorRole: 'CLOSING_OWNER',
afterJson: {
listingTitle: draft.listingTitle,
regionClusterCode: input.regionClusterCode,
industryLeafCode: input.industryLeafCode,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: created.publicId,
eventName: 'store.draft.created',
payloadJson: {
storePublicId: created.publicId,
ownerUserId: input.ownerUserId,
},
});
return created;
});
return success({
publicId: store.publicId,
reviewStatus: store.reviewStatus,
publicationStatus: store.publicationStatus,
dealStatus: store.dealStatus,
});
}
export async function submitStoreService(
prisma: PrismaClient,
storePublicId: string,
actorUserId: string,
): Promise<Result<{ publicId: string; reviewStatus: string }, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
if (store.reviewStatus !== 'DRAFT') {
return failure(
appError('INVALID_STATUS_TRANSITION', '초안(DRAFT) 상태의 매장만 제출할 수 있습니다.', {
currentStatus: store.reviewStatus,
}),
);
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.store.update({
where: { id: store.id },
data: { reviewStatus: 'SUBMITTED' },
});
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: store.publicId,
actionType: 'STORE_SUBMITTED',
actorUserId: BigInt(actorUserId),
actorRole: 'CLOSING_OWNER',
beforeJson: { reviewStatus: 'DRAFT' },
afterJson: { reviewStatus: 'SUBMITTED' },
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: store.publicId,
eventName: 'store.submitted',
payloadJson: {
storePublicId: store.publicId,
ownerUserId: actorUserId,
},
});
return result;
});
return success({
publicId: updated.publicId,
reviewStatus: updated.reviewStatus,
});
}
export async function reviewStoreService(
prisma: PrismaClient,
storePublicId: string,
decision: ReviewDecision,
actorUserId: string,
reasonCode?: string,
memo?: string,
): Promise<Result<{ publicId: string; reviewStatus: string }, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
const domainResult = reviewStore({
currentReviewStatus: store.reviewStatus as ReviewableStatus,
decision,
reviewerUserId: actorUserId,
reasonCode,
memo,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.store.update({
where: { id: store.id },
data: {
reviewStatus: domainResult.value.reviewStatus,
...(domainResult.value.reviewStatus === 'APPROVED' ? { approvedAt: new Date() } : {}),
...(domainResult.value.reviewStatus === 'REJECTED' ? { rejectedAt: new Date() } : {}),
},
});
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: store.publicId,
actionType: decision === 'APPROVED' ? 'STORE_APPROVED' : 'STORE_REJECTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { reviewStatus: store.reviewStatus },
afterJson: {
reviewStatus: domainResult.value.reviewStatus,
reasonCode: domainResult.value.reasonCode,
memo: domainResult.value.memo,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: store.publicId,
eventName: decision === 'APPROVED' ? 'store.approved' : 'store.rejected',
payloadJson: {
storePublicId: store.publicId,
decision,
reasonCode,
},
});
return result;
});
return success({
publicId: updated.publicId,
reviewStatus: updated.reviewStatus,
});
}
export async function publishStoreService(
prisma: PrismaClient,
storePublicId: string,
policyVersionId: string,
actorUserId: string,
): Promise<Result<{ publicId: string; publicationStatus: string }, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
const policyVersion = await prisma.policyVersion.findFirst({
where: { id: BigInt(policyVersionId) },
});
if (!policyVersion) {
return failure(appError('NOT_FOUND', '정책 버전을 찾을 수 없습니다.', { policyVersionId }));
}
const domainResult = publishStore({
currentReviewStatus: store.reviewStatus,
currentPublicationStatus: store.publicationStatus,
policyVersionId,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.store.update({
where: { id: store.id },
data: {
publicationStatus: 'PUBLISHED',
policyVersionId: policyVersion.id,
publishedAt: new Date(),
},
});
await createAuditLog(tx, {
resourceType: 'STORE',
resourceId: store.publicId,
actionType: 'STORE_PUBLISHED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { publicationStatus: store.publicationStatus },
afterJson: {
publicationStatus: 'PUBLISHED',
policyVersionId,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'STORE',
aggregateId: store.publicId,
eventName: 'store.published',
payloadJson: {
storePublicId: store.publicId,
policyVersionId,
},
});
return result;
});
return success({
publicId: updated.publicId,
publicationStatus: updated.publicationStatus,
});
}
export async function getStoreForViewer(
prisma: PrismaClient,
storePublicId: string,
viewer: ViewerContext,
): Promise<Result<FilteredStoreData, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: storePublicId },
include: {
ownerUser: { select: { publicId: true, phone: true, email: true } },
industryLeaf: { select: { nameKo: true } },
regionCluster: { select: { nameKo: true } },
lease: true,
},
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId }));
}
const storeData: StoreData = {
publicId: store.publicId,
listingTitle: store.listingTitle,
publicSummary: store.publicSummary ?? undefined,
roadAddress: store.roadAddress,
detailAddress: store.detailAddress ?? undefined,
ownerUserId: store.ownerUser.publicId,
ownerPhone: store.ownerUser.phone ?? undefined,
ownerEmail: store.ownerUser.email ?? undefined,
industryName: store.industryLeaf?.nameKo ?? undefined,
regionName: store.regionCluster?.nameKo ?? undefined,
reviewStatus: store.reviewStatus,
publicationStatus: store.publicationStatus,
dealStatus: store.dealStatus,
lease: store.lease
? {
depositAmount: Number(store.lease.depositAmount),
monthlyRentAmount: Number(store.lease.monthlyRentAmount),
premiumAmount: Number(store.lease.premiumAmount),
}
: undefined,
};
const filtered = filterStoreForViewer(storeData, viewer);
if (!filtered) {
return failure(appError('FORBIDDEN', '이 매장을 조회할 권한이 없습니다.', { storePublicId }));
}
return success(filtered);
}
@@ -0,0 +1,213 @@
import type { PrismaClient, Prisma } from '@prisma/client';
import {
createSubsidyCase,
reviewSubsidyCase,
type ChecklistItemTemplate,
type SubsidyReviewDecision,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface CreateSubsidyCaseServiceInput {
readonly storePublicId: string;
readonly applicantUserId: string;
readonly programCode: string;
}
export interface SubsidyCaseResult {
readonly publicId: string;
readonly status: string;
readonly programCode: string;
}
export async function createSubsidyCaseService(
prisma: PrismaClient,
input: CreateSubsidyCaseServiceInput,
): Promise<Result<SubsidyCaseResult, AppError>> {
const store = await prisma.store.findUnique({
where: { publicId: input.storePublicId },
});
if (!store) {
return failure(
appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId: input.storePublicId }),
);
}
// 최신 정책 버전 조회
const policyVersion = await prisma.policyVersion.findFirst({
where: { policyType: 'SUBSIDY_CHECKLIST', isActive: true },
orderBy: { effectiveFrom: 'desc' },
});
if (!policyVersion) {
return failure(appError('NOT_FOUND', '활성화된 지원금 정책을 찾을 수 없습니다.'));
}
// 프로그램별 체크리스트 항목 (정책 버전에 따라 다름)
const checklistItems: ChecklistItemTemplate[] = getChecklistForProgram(input.programCode);
const domainResult = createSubsidyCase({
storeReviewStatus: store.reviewStatus,
storePublicationStatus: store.publicationStatus,
programCode: input.programCode,
policyVersionId: policyVersion.id.toString(),
checklistVersionCode: policyVersion.versionCode,
checklistItems,
});
if (!domainResult.ok) {
return domainResult;
}
const draft = domainResult.value;
const subsidyCase = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.subsidyCase.create({
data: {
storeId: store.id,
applicantUserId: BigInt(input.applicantUserId),
programCode: draft.programCode,
status: 'DRAFT',
policyVersionId: policyVersion.id,
checklistVersionCode: draft.checklistVersionCode,
},
});
// 체크리스트 항목 일괄 생성
for (const item of draft.checklistItems) {
await tx.subsidyChecklistItem.create({
data: {
subsidyCaseId: created.id,
itemCode: item.itemCode,
isRequired: item.isRequired,
status: 'PENDING',
},
});
}
await createAuditLog(tx, {
resourceType: 'SUBSIDY_CASE',
resourceId: created.publicId,
actionType: 'SUBSIDY_CASE_CREATED',
actorUserId: BigInt(input.applicantUserId),
actorRole: 'CLOSING_OWNER',
afterJson: {
storePublicId: input.storePublicId,
programCode: draft.programCode,
checklistVersionCode: draft.checklistVersionCode,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'SUBSIDY_CASE',
aggregateId: created.publicId,
eventName: 'subsidy_case.created',
payloadJson: {
subsidyCasePublicId: created.publicId,
storePublicId: input.storePublicId,
programCode: draft.programCode,
},
});
return created;
});
return success({
publicId: subsidyCase.publicId,
status: subsidyCase.status,
programCode: subsidyCase.programCode,
});
}
export async function reviewSubsidyCaseService(
prisma: PrismaClient,
subsidyCasePublicId: string,
decision: SubsidyReviewDecision,
actorUserId: string,
rejectionReasonCode?: string,
memo?: string,
): Promise<Result<{ publicId: string; status: string }, AppError>> {
const subsidyCase = await prisma.subsidyCase.findUnique({
where: { publicId: subsidyCasePublicId },
});
if (!subsidyCase) {
return failure(
appError('NOT_FOUND', '지원금 케이스를 찾을 수 없습니다.', { subsidyCasePublicId }),
);
}
const domainResult = reviewSubsidyCase({
currentStatus: subsidyCase.status,
decision,
rejectionReasonCode,
memo,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.subsidyCase.update({
where: { id: subsidyCase.id },
data: {
status: domainResult.value.status,
reviewedAt: new Date(),
rejectionReasonCode: domainResult.value.rejectionReasonCode ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'SUBSIDY_CASE',
resourceId: subsidyCase.publicId,
actionType: decision === 'APPROVED' ? 'SUBSIDY_CASE_APPROVED' : 'SUBSIDY_CASE_REJECTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { status: subsidyCase.status },
afterJson: {
status: domainResult.value.status,
rejectionReasonCode: domainResult.value.rejectionReasonCode,
memo: domainResult.value.memo,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'SUBSIDY_CASE',
aggregateId: subsidyCase.publicId,
eventName: decision === 'APPROVED' ? 'subsidy_case.approved' : 'subsidy_case.rejected',
payloadJson: {
subsidyCasePublicId: subsidyCase.publicId,
decision,
rejectionReasonCode,
},
});
return result;
});
return success({
publicId: updated.publicId,
status: updated.status,
});
}
function getChecklistForProgram(programCode: string): ChecklistItemTemplate[] {
// 프로그램별 기본 체크리스트 (실제로는 DB/정책에서 로드)
const checklists: Record<string, ChecklistItemTemplate[]> = {
SMALL_BIZ_CLOSURE_2024: [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true },
{ itemCode: 'LEASE_CONTRACT', isRequired: true },
{ itemCode: 'CLOSURE_REPORT', isRequired: true },
{ itemCode: 'BANK_STATEMENT', isRequired: false },
],
};
return (
checklists[programCode] ?? [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true },
{ itemCode: 'LEASE_CONTRACT', isRequired: true },
]
);
}
@@ -0,0 +1,197 @@
import { Prisma } from '@prisma/client';
import type { PrismaClient } from '@prisma/client';
import {
applyVendorCertification,
approveVendorCertification,
type VendorType,
type VendorCertificationDecision,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface ApplyVendorCertificationServiceInput {
readonly ownerUserId: string;
readonly vendorType: VendorType;
readonly businessName: string;
readonly contactName: string;
readonly businessRegistrationNumber?: string;
readonly coverageRegionCodes: readonly string[];
}
export interface VendorCertificationResult {
readonly vendorPublicId: string;
readonly certificationStatus: string;
}
export async function applyVendorCertificationService(
prisma: PrismaClient,
input: ApplyVendorCertificationServiceInput,
): Promise<Result<VendorCertificationResult, AppError>> {
// 서비스 권역 지역 ID 조회
const regions = await prisma.regionHierarchy.findMany({
where: { code: { in: [...input.coverageRegionCodes] }, isActive: true },
select: { id: true, code: true },
});
const domainResult = applyVendorCertification({
vendorType: input.vendorType,
businessName: input.businessName,
contactName: input.contactName,
hasBusinessRegistration: !!input.businessRegistrationNumber?.trim(),
coverageRegionCount: regions.length,
});
if (!domainResult.ok) {
return domainResult;
}
const vendor = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const created = await tx.vendor.create({
data: {
ownerUserId: BigInt(input.ownerUserId),
vendorType: input.vendorType,
businessName: input.businessName,
contactName: input.contactName,
businessRegistrationNumber: input.businessRegistrationNumber ?? null,
certificationStatus: 'APPLIED',
},
});
// 서비스 권역 등록
for (const region of regions) {
await tx.vendorCoverageRegion.create({
data: {
vendorId: created.id,
regionId: region.id,
isPrimary: region.code === input.coverageRegionCodes[0],
},
});
}
// 인증 이력 생성
await tx.vendorCertification.create({
data: {
vendorId: created.id,
status: 'APPLIED',
},
});
await createAuditLog(tx, {
resourceType: 'VENDOR',
resourceId: created.publicId,
actionType: 'VENDOR_CERTIFICATION_APPLIED',
actorUserId: BigInt(input.ownerUserId),
actorRole: 'VENDOR_MANAGER',
afterJson: {
vendorType: input.vendorType,
businessName: input.businessName,
coverageRegions: input.coverageRegionCodes,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'VENDOR',
aggregateId: created.publicId,
eventName: 'vendor.certification.applied',
payloadJson: {
vendorPublicId: created.publicId,
vendorType: input.vendorType,
},
});
return created;
});
return success({
vendorPublicId: vendor.publicId,
certificationStatus: vendor.certificationStatus,
});
}
export async function reviewVendorCertificationService(
prisma: PrismaClient,
vendorPublicId: string,
decision: VendorCertificationDecision,
actorUserId: string,
reasonCode?: string,
): Promise<Result<VendorCertificationResult, AppError>> {
const vendor = await prisma.vendor.findUnique({
where: { publicId: vendorPublicId },
include: {
coverageRegions: true,
certifications: { where: { documentChecklistJson: { not: Prisma.DbNull } }, take: 1 },
},
});
if (!vendor) {
return failure(appError('NOT_FOUND', '업체를 찾을 수 없습니다.', { vendorPublicId }));
}
const domainResult = approveVendorCertification({
currentStatus: vendor.certificationStatus,
decision,
hasDocumentChecklist: vendor.certifications.length > 0,
coverageRegionCount: vendor.coverageRegions.length,
reasonCode,
});
if (!domainResult.ok) {
return domainResult;
}
const updated = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const result = await tx.vendor.update({
where: { id: vendor.id },
data: {
certificationStatus: domainResult.value.status,
},
});
// U020: 인증 상태 변경 이력 기록
await tx.vendorCertification.create({
data: {
vendorId: vendor.id,
status: domainResult.value.status,
reviewedAt: new Date(),
reviewedByUserId: BigInt(actorUserId),
reasonCode: domainResult.value.reasonCode ?? null,
},
});
await createAuditLog(tx, {
resourceType: 'VENDOR',
resourceId: vendor.publicId,
actionType:
decision === 'APPROVED'
? 'VENDOR_CERTIFICATION_APPROVED'
: decision === 'SUSPENDED'
? 'VENDOR_CERTIFICATION_SUSPENDED'
: 'VENDOR_CERTIFICATION_REJECTED',
actorUserId: BigInt(actorUserId),
actorRole: 'OPS_MANAGER',
beforeJson: { certificationStatus: vendor.certificationStatus },
afterJson: {
certificationStatus: domainResult.value.status,
reasonCode: domainResult.value.reasonCode,
},
});
await enqueueOutboxEvent(tx, {
aggregateType: 'VENDOR',
aggregateId: vendor.publicId,
eventName: `vendor.certification.${decision.toLowerCase()}`,
payloadJson: {
vendorPublicId: vendor.publicId,
decision,
reasonCode,
},
});
return result;
});
return success({
vendorPublicId: updated.publicId,
certificationStatus: updated.certificationStatus,
});
}
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"verbatimModuleSyntax": false
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
name: '@relink/web',
globals: true,
environment: 'node',
testTimeout: 30000,
hookTimeout: 30000,
fileParallelism: false,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});
+43
View File
@@ -0,0 +1,43 @@
services:
postgres:
image: postgis/postgis:16-3.4-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: relink
POSTGRES_PASSWORD: relink_dev
POSTGRES_DB: relink_dev
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U relink -d relink_dev"]
interval: 5s
timeout: 5s
retries: 5
postgres-test:
image: postgis/postgis:16-3.4-alpine
ports:
- "5433:5432"
environment:
POSTGRES_USER: relink
POSTGRES_PASSWORD: relink_test
POSTGRES_DB: relink_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U relink -d relink_test"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
File diff suppressed because it is too large Load Diff
+876
View File
@@ -0,0 +1,876 @@
# Re:Link `schema.prisma` 설계 초안
## 목적
이 문서는 Re:Link MVP의 데이터 모델을 실제 `schema.prisma`로 내리기 전에, 엔티티/필드/관계/인덱스/제약 조건을 고정하기 위한 초안이다.
대상 범위는 `assisted marketplace MVP`이며, 아래 원칙을 따른다.
- 운영자 개입형 플로우를 우선한다.
- 정보 공개는 제한 공개가 기본값이다.
- 결제/정산/분쟁은 append-only 로그와 감사 로그를 남긴다.
- 마스터 데이터는 enum이 아니라 테이블과 seed로 관리한다.
- 하드코딩 대신 `PolicyVersion`, `RegionHierarchy`, `IndustryTaxonomy`를 참조한다.
## 설계 원칙
### 네이밍
- Prisma 모델명: `PascalCase`
- Prisma 필드명: `camelCase`
- 실제 DB 테이블/컬럼명: `snake_case`
- 실제 DB명은 `@@map`, `@map`으로 매핑한다.
### 공통 타입
- 내부 PK: `BigInt @id @default(autoincrement())`
- 외부 노출용 ID: `publicId String @unique`
- 금액: `Decimal @db.Decimal(14, 2)`
- 시각: `DateTime @db.Timestamptz(6)`
- 상태: 안정적인 값만 enum으로 관리
- 자주 바뀌는 코드: enum보다 `String` 코드 또는 마스터 테이블 사용
### 공통 컬럼
대부분의 트랜잭션성 모델은 아래 공통 컬럼을 가진다.
- `id`
- `publicId` 또는 비즈니스 고유 키
- `createdAt`
- `updatedAt`
- 필요한 경우 `deletedAt`
append-only 성격의 모델은 `updatedAt`, `deletedAt` 없이 생성 시점만 가진다.
## 모델 설계
### 1. 사용자/권한
#### `User`
목적:
사용자 기본 계정과 로그인 주체를 관리한다.
주요 필드:
- `id`
- `publicId`
- `email`
- `emailNormalized`
- `phone`
- `phoneNormalized`
- `name`
- `primaryRole`
- `status`
- `emailVerifiedAt`
- `lastLoginAt`
- `createdAt`
- `updatedAt`
관계:
- `profiles UserProfile[]`
- `consents UserConsent[]`
- `stores Store[]`
- `vendors Vendor[]`
인덱스:
- `@unique(emailNormalized)`
- `@@index([primaryRole, status])`
#### `UserProfile`
목적:
역할별 추가 프로필 정보를 확장 가능하게 저장한다.
주요 필드:
- `id`
- `userId`
- `profileType`
- `businessRegistrationNumber`
- `metadataJson`
- `createdAt`
- `updatedAt`
#### `UserConsent`
목적:
개인정보, 정보 공개, 마케팅, 제3자 제공 동의를 버전 단위로 저장한다.
주요 필드:
- `id`
- `userId`
- `consentType`
- `policyVersionId`
- `isGranted`
- `grantedAt`
- `revokedAt`
### 2. 매장 공급 도메인
#### `Store`
목적:
폐업자가 등록한 매장의 중심 aggregate다.
주요 필드:
- `id`
- `publicId`
- `ownerUserId`
- `listingTitle`
- `publicSummary`
- `industryLeafId`
- `regionClusterId`
- `regionLeafId`
- `roadAddress`
- `detailAddress`
- `latitude`
- `longitude`
- `reviewStatus`
- `publicationStatus`
- `dealStatus`
- `policyVersionId`
- `publishedAt`
- `approvedAt`
- `rejectedAt`
- `createdAt`
- `updatedAt`
핵심 설계:
- `StoreStatus` 단일 컬럼 대신 `reviewStatus`, `publicationStatus`, `dealStatus` 3축으로 분리한다.
- 검색/필터에 쓰는 값은 JSON이 아니라 정식 컬럼으로 둔다.
- 주소 공개 정책 때문에 상세 주소는 API 응답 단계에서 권한 필터링한다.
인덱스:
- `@@index([ownerUserId, createdAt])`
- `@@index([reviewStatus, createdAt])`
- `@@index([regionClusterId, industryLeafId, publicationStatus, dealStatus, publishedAt])`
#### `StoreLease`
목적:
임대/권리금 관련 정보를 1:1 확장 테이블로 저장한다.
주요 필드:
- `storeId`
- `depositAmount`
- `monthlyRentAmount`
- `maintenanceFeeAmount`
- `premiumAmount`
- `transferable`
- `leaseExpiresAt`
- `remainingLeaseMonths`
- `createdAt`
- `updatedAt`
제약:
- `storeId` unique
- 금액 필드는 모두 `>= 0`
#### `StoreFacility`
목적:
F&B 필터링과 인수 판단에 필요한 설비 정보를 저장한다.
주요 필드:
- `storeId`
- `exclusiveAreaSqm`
- `floorLevel`
- `seatCount`
- `hasGas`
- `hasDrainage`
- `hasDuct`
- `electricCapacityKw`
- `kitchenEquipmentSummary`
- `parkingCount`
- `restroomType`
- `createdAt`
- `updatedAt`
제약:
- `storeId` unique
- `exclusiveAreaSqm > 0`
#### `StoreLifecycle`
목적:
폐업/인수/철거 일정 정보를 저장한다.
주요 필드:
- `storeId`
- `closingPlannedAt`
- `takeoverAvailableAt`
- `demolitionPreferredAt`
- `vacateByAt`
- `isCurrentlyOperating`
- `urgencyLevel`
- `createdAt`
- `updatedAt`
#### `StorePhoto`
목적:
매장 사진과 공개 범위를 저장한다.
주요 필드:
- `id`
- `storeId`
- `storageKey`
- `photoCategory`
- `visibilityScope`
- `sortOrder`
- `uploadedByUserId`
- `checksumSha256`
- `width`
- `height`
- `takenAt`
- `isRepresentative`
- `createdAt`
인덱스:
- `@@index([storeId, sortOrder])`
### 3. 매칭 도메인
#### `MatchRequest`
목적:
창업자/업체/운영자 추천 기반 매칭 요청을 관리한다.
주요 필드:
- `id`
- `publicId`
- `storeId`
- `matchType`
- `requesterUserId`
- `requesterVendorId`
- `sourceType`
- `status`
- `operatorAssigneeId`
- `message`
- `decisionReasonCode`
- `acceptedAt`
- `rejectedAt`
- `closedAt`
- `createdAt`
- `updatedAt`
핵심 설계:
- `ACQUISITION`, `DEMOLITION`, `INTERIOR`를 한 테이블로 관리한다.
- `matchType`에 따라 필요한 FK가 달라지므로 raw SQL `CHECK` 제약을 둔다.
인덱스:
- `@@index([storeId, status, createdAt])`
- `@@index([requesterUserId, status, createdAt])`
- `@@index([requesterVendorId, status, createdAt])`
- partial unique index:
- 열린 상태에서 동일 사용자/동일 매장/동일 요청 타입 중복 금지
### 4. 지원금 도메인
#### `SubsidyCase`
목적:
매장 기준 지원금 진행 건을 관리한다.
주요 필드:
- `id`
- `publicId`
- `storeId`
- `applicantUserId`
- `programCode`
- `status`
- `eligibilityResult`
- `policyVersionId`
- `operatorAssigneeId`
- `checklistVersionCode`
- `submissionReadyAt`
- `submittedAt`
- `reviewedAt`
- `rejectionReasonCode`
- `eligibilitySnapshotJson`
- `createdAt`
- `updatedAt`
인덱스:
- `@@index([storeId, status, createdAt])`
- `@@index([applicantUserId, status, createdAt])`
- `@@index([operatorAssigneeId, status, updatedAt])`
- partial unique index:
- 같은 `storeId + programCode` 조합의 active case는 1개
#### `SubsidyChecklistItem`
목적:
정책 버전에 따라 생성된 체크리스트 항목을 저장한다.
주요 필드:
- `id`
- `subsidyCaseId`
- `itemCode`
- `isRequired`
- `status`
- `checkedAt`
- `checkedByUserId`
#### `SubsidyDocument`
목적:
지원금 첨부 서류와 검토 상태를 관리한다.
주요 필드:
- `id`
- `subsidyCaseId`
- `documentTypeCode`
- `storageKey`
- `reviewStatus`
- `uploadedByUserId`
- `uploadedAt`
- `reviewedAt`
- `reviewedByUserId`
### 5. 업체 도메인
#### `Vendor`
목적:
철거/인테리어 업체의 기본 프로필을 관리한다.
주요 필드:
- `id`
- `publicId`
- `ownerUserId`
- `vendorType`
- `businessName`
- `contactName`
- `contactPhoneNormalized`
- `businessRegistrationNumber`
- `serviceIntro`
- `primaryRegionId`
- `certificationStatus`
- `latestCertificationId`
- `deletedAt`
- `createdAt`
- `updatedAt`
인덱스:
- `@@index([ownerUserId])`
- `@@index([vendorType, certificationStatus, createdAt])`
#### `VendorCoverageRegion`
목적:
업체 서비스 가능 지역을 다대다 조인으로 관리한다.
주요 필드:
- `id`
- `vendorId`
- `regionId`
- `isPrimary`
- `createdAt`
인덱스:
- `@@unique([vendorId, regionId])`
- `@@index([regionId, vendorId])`
#### `VendorCertification`
목적:
업체 인증 이력을 append-only에 가깝게 유지한다.
주요 필드:
- `id`
- `vendorId`
- `status`
- `requestedScopeCode`
- `documentChecklistJson`
- `appliedAt`
- `reviewedAt`
- `reviewedByUserId`
- `reasonCode`
- `validUntil`
- `createdAt`
- `updatedAt`
### 6. 신뢰 인프라 도메인
#### `Contract`
목적:
매칭에서 생성된 계약의 현재 상태와 참여자를 관리한다.
주요 필드:
- `id`
- `publicId`
- `matchRequestId`
- `storeId`
- `contractType`
- `status`
- `createdByUserId`
- `storeOwnerUserId`
- `buyerUserId`
- `vendorId`
- `policyVersionId`
- `templateCode`
- `currentVersionId`
- `escrowStatus`
- `signedAt`
- `effectiveAt`
- `completedAt`
- `cancelledAt`
- `createdAt`
- `updatedAt`
인덱스:
- `@@unique([matchRequestId])`
- `@@index([storeId, status, createdAt])`
- `@@index([vendorId, status, createdAt])`
- `@@index([buyerUserId, status, createdAt])`
#### `ContractVersion`
목적:
계약 문서를 불변 버전으로 저장한다.
주요 필드:
- `id`
- `contractId`
- `versionNo`
- `templateCode`
- `templateVersion`
- `documentStorageKey`
- `documentSha256`
- `renderedSnapshotJson`
- `changeSummary`
- `createdByUserId`
- `createdAt`
인덱스:
- `@@unique([contractId, versionNo])`
#### `SignatureEvidence`
목적:
전자서명 또는 동의 증적을 저장한다.
주요 필드:
- `id`
- `contractId`
- `contractVersionId`
- `signerRole`
- `signerUserId`
- `evidenceType`
- `providerCode`
- `providerEventId`
- `signedAt`
- `signatureHash`
- `payloadJson`
- `ipAddress`
- `userAgent`
- `createdAt`
인덱스:
- `@@index([contractVersionId, signedAt])`
- 가능한 경우 `@unique(providerEventId)`
#### `EscrowTransaction`
목적:
결제/환불/정산/보류 관련 금전 이벤트를 immutable ledger 형태로 저장한다.
주요 필드:
- `id`
- `contractId`
- `transactionType`
- `providerCode`
- `providerTransactionId`
- `status`
- `amount`
- `currencyCode`
- `requestedByUserId`
- `approvedByUserId`
- `idempotencyKey`
- `rawPayloadJson`
- `occurredAt`
- `createdAt`
인덱스:
- `@unique(providerTransactionId)`
- `@unique(idempotencyKey)`
- `@@index([contractId, occurredAt])`
- `@@index([status, occurredAt])`
#### `InspectionRecord`
목적:
검수 요청과 검토 결과를 저장한다.
주요 필드:
- `id`
- `contractId`
- `inspectionType`
- `status`
- `submittedByUserId`
- `submittedAt`
- `reviewedByUserId`
- `reviewedAt`
- `reviewMemo`
- `evidencePayloadJson`
- `createdAt`
- `updatedAt`
#### `DisputeCase`
목적:
분쟁 접수, 조사, 중재, 종료 상태를 관리한다.
주요 필드:
- `id`
- `contractId`
- `openedByUserId`
- `assignedOperatorId`
- `status`
- `reasonCode`
- `description`
- `resolutionCode`
- `resolutionSummary`
- `evidencePayloadJson`
- `openedAt`
- `resolvedAt`
- `resolvedByUserId`
- `createdAt`
- `updatedAt`
인덱스:
- `@@index([contractId, status, openedAt])`
- `@@index([assignedOperatorId, status, openedAt])`
- partial unique index:
- 계약당 active dispute는 1건
### 7. 운영/분석 도메인
#### `PolicyVersion`
목적:
정책 버전을 모델에 귀속시키기 위한 기준 테이블이다.
주요 필드:
- `id`
- `policyType`
- `versionCode`
- `contentHash`
- `effectiveFrom`
- `isActive`
- `createdAt`
#### `AuditLog`
목적:
누가 무엇을 왜 바꿨는지를 운영 감사 기준으로 남긴다.
주요 필드:
- `id`
- `resourceType`
- `resourceId`
- `actionType`
- `actorUserId`
- `actorRole`
- `reasonCode`
- `memo`
- `beforeJson`
- `afterJson`
- `requestId`
- `correlationId`
- `ipHash`
- `userAgent`
- `createdAt`
인덱스:
- `@@index([resourceType, resourceId, createdAt])`
- `@@index([actorUserId, createdAt])`
- `@@index([actionType, createdAt])`
#### `EventLog`
목적:
도메인 이벤트를 append-only로 보관한다.
주요 필드:
- `id`
- `aggregateType`
- `aggregateId`
- `eventName`
- `eventVersion`
- `eventKey`
- `payloadJson`
- `actorUserId`
- `causationId`
- `correlationId`
- `piiLevel`
- `occurredAt`
- `recordedAt`
인덱스:
- `@unique(eventKey)`
- `@@index([aggregateType, aggregateId, occurredAt])`
- `@@index([eventName, occurredAt])`
#### `OutboxEvent`
목적:
외부 발행/재시도/실패 처리를 EventLog와 분리해 관리한다.
주요 필드:
- `id`
- `aggregateType`
- `aggregateId`
- `eventName`
- `payloadJson`
- `publishStatus`
- `availableAt`
- `retryCount`
- `lastError`
- `createdAt`
인덱스:
- `@@index([publishStatus, availableAt])`
#### `IdempotencyKey`
목적:
웹훅과 중복 요청 방지를 위한 키 저장소다.
주요 필드:
- `id`
- `scope`
- `idempotencyKey`
- `requestHash`
- `responseHash`
- `expiresAt`
- `createdAt`
인덱스:
- `@@unique([scope, idempotencyKey])`
## Enum 초안
### 고정 enum 후보
- `UserRole`
- `CLOSING_OWNER`
- `FOUNDER`
- `VENDOR_MANAGER`
- `OPS_MANAGER`
- `SUBSIDY_OPERATOR`
- `TRUST_OPERATOR`
- `FINANCE_OPERATOR`
- `SUPER_ADMIN`
- `UserStatus`
- `PENDING_VERIFICATION`
- `ACTIVE`
- `SUSPENDED`
- `DEACTIVATED`
- `StoreReviewStatus`
- `DRAFT`
- `SUBMITTED`
- `REVIEWING`
- `APPROVED`
- `REJECTED`
- `StorePublicationStatus`
- `PRIVATE`
- `RESTRICTED`
- `PUBLISHED`
- `UNPUBLISHED`
- `StoreDealStatus`
- `OPEN`
- `MATCHING`
- `RESERVED`
- `CONTRACTED`
- `CLOSED`
- `CANCELLED`
- `PhotoCategory`
- `EXTERIOR`
- `INTERIOR`
- `KITCHEN`
- `EQUIPMENT`
- `FLOOR_PLAN`
- `DOCUMENT`
- `VisibilityScope`
- `INTERNAL`
- `MATCHED_ONLY`
- `PUBLIC_SUMMARY`
- `MatchType`
- `ACQUISITION`
- `DEMOLITION`
- `INTERIOR`
- `MatchRequestStatus`
- `OPEN`
- `REVIEWING`
- `ACCEPTED`
- `REJECTED`
- `CONTRACTING`
- `EXPIRED`
- `CANCELLED`
- `COMPLETED`
- `SubsidyCaseStatus`
- `DRAFT`
- `ELIGIBILITY_CHECKED`
- `DOCUMENTS_PENDING`
- `REVIEWING`
- `READY_TO_SUBMIT`
- `SUBMITTED`
- `APPROVED`
- `REJECTED`
- `CLOSED`
- `EligibilityResult`
- `UNKNOWN`
- `ELIGIBLE`
- `CONDITIONALLY_ELIGIBLE`
- `NOT_ELIGIBLE`
- `VendorType`
- `DEMOLITION`
- `INTERIOR`
- `BOTH`
- `VendorCertificationStatus`
- `APPLIED`
- `REVIEWING`
- `APPROVED`
- `REJECTED`
- `SUSPENDED`
- `EXPIRED`
- `ContractType`
- `ACQUISITION`
- `DEMOLITION`
- `INTERIOR`
- `ContractStatus`
- `DRAFT`
- `GENERATED`
- `SIGNING`
- `SIGNED`
- `ACTIVE`
- `COMPLETED`
- `CANCELLED`
- `TERMINATED`
- `EscrowStatus`
- `NOT_STARTED`
- `DEPOSIT_PENDING`
- `HOLDING`
- `RELEASE_REVIEW`
- `RELEASED`
- `REFUNDED`
- `DISPUTED`
- `EscrowTransactionType`
- `DEPOSIT`
- `RELEASE`
- `REFUND`
- `ADJUSTMENT`
- `HOLD`
- `InspectionStatus`
- `REQUESTED`
- `SUBMITTED`
- `REVIEWING`
- `APPROVED`
- `REJECTED`
- `DisputeStatus`
- `OPEN`
- `INVESTIGATING`
- `MEDIATING`
- `RESOLVED`
- `CLOSED`
- `RegionType`
- `COUNTRY`
- `SIDO`
- `SIGUNGU`
- `ADMIN_DONG`
- `COMMERCIAL_AREA`
- `BETA_CLUSTER`
### enum으로 두지 않을 값
아래는 자주 바뀌거나 운영 정책에 따라 변할 수 있으므로 enum보다 코드 값 또는 마스터 데이터로 둔다.
- `reasonCode`
- `programCode`
- `documentTypeCode`
- `actionType`
- `eventName`
- `templateCode`
## 마이그레이션 전략
### 기본 원칙
- 운영 DB 변경은 항상 migration 파일로 관리한다.
- 스키마 변경과 데이터 backfill은 분리한다.
- 대용량 테이블 인덱스는 raw SQL로 `CREATE INDEX CONCURRENTLY`를 사용한다.
- Prisma가 표현하기 어려운 partial unique index, check constraint, PostGIS index는 custom SQL migration으로 보강한다.
### 첫 스키마에서 넣을 custom SQL 후보
1. 열린 매칭 요청 중복 방지 partial unique index
2. 지원금 active case 중복 방지 partial unique index
3. 계약당 active dispute 1건 제한 partial unique index
4. `amount >= 0`, `exclusive_area_sqm > 0` 같은 check constraint
5. 향후 `locationPoint`를 도입할 경우 GIST index
## MVP에서 나중으로 미룰 모델
아래 모델은 개념만 유지하고 실제 첫 `schema.prisma`에서는 보류할 수 있다.
- `PriceEstimate`
- `VendorQuote`
- `StoreAsset`
- `AlertSubscription`
- `NotificationDeliveryLog`
- `KpiSnapshot`
## 바로 다음 단계
1. 이 문서를 기준으로 실제 `prisma/schema.prisma` 초안을 생성한다.
2. `docs/master-data/beta-master-data.md`와 FK 관계를 맞춘다.
3. `D001`, `D002`, `D003` 정책 문서가 완성되면 nullable/required 필드를 다시 조정한다.
+223
View File
@@ -0,0 +1,223 @@
# Re:Link 베타 마스터 데이터 초안
## 목적
이 문서는 Re:Link MVP 베타에서 사용할 지역/업종 마스터 데이터의 구조와 초기 seed 원칙을 정의한다.
핵심 목표는 아래 3가지다.
- 지역과 업종을 코드 하드코딩 없이 운영 플래그로 제어한다.
- 베타 범위 `강남/마포`, `F&B`를 코드 수정 없이 켜고 끌 수 있게 만든다.
- 이후 Phase 2에서 수도권/광역시 확장 시 seed 추가만으로 확장 가능하게 만든다.
## 기본 원칙
- enum이 아니라 테이블 기반으로 관리한다.
- 내부 참조 키는 `code`를 사용한다.
- 실제 트랜잭션 테이블은 가능한 한 leaf 노드를 참조한다.
- 베타 노출 여부는 `isBetaEnabled` 플래그로 제어한다.
- seed는 `id` 기준이 아니라 `code` 기준 upsert로 관리한다.
- seed 파일은 코드 안 배열이 아니라 별도 파일로 관리한다.
권장 저장 위치:
- `packages/database/seeds/master-data/regions.v1.csv`
- `packages/database/seeds/master-data/industries.v1.csv`
## 1. 지역 마스터 설계
### 1-1. 권장 모델: `RegionHierarchy`
단일 계층 테이블 + self relation 구조를 사용한다.
권장 필드:
- `id`
- `code`
- `nameKo`
- `fullNameKo`
- `regionType`
- `parentId`
- `depth`
- `pathCode`
- `sortOrder`
- `isActive`
- `isBetaEnabled`
- `latitude`
- `longitude`
- `externalCode`
운영 원칙:
- `Store.regionClusterId`는 운영 단위인 `BETA_CLUSTER`를 가리킨다.
- `Store.regionLeafId`는 실제 행정/상권 leaf를 가리킨다.
- 검색과 집계는 우선 `regionClusterId` 기준으로 단순화한다.
- 향후 지도/거리 기반 고도화 시 `latitude`, `longitude`를 활용한다.
### 1-2. 지역 타입
- `COUNTRY`
- `SIDO`
- `SIGUNGU`
- `ADMIN_DONG`
- `COMMERCIAL_AREA`
- `BETA_CLUSTER`
### 1-3. 초기 베타 지역 seed
아래 값은 초안이다. 실제 서비스 운영 단위는 `BETA_CLUSTER`를 기준으로 한다.
| code | nameKo | regionType | parentCode | depth | isBetaEnabled |
|------|--------|------------|------------|-------|---------------|
| `KR` | 대한민국 | `COUNTRY` | - | 0 | false |
| `KR.SEOUL` | 서울특별시 | `SIDO` | `KR` | 1 | false |
| `KR.SEOUL.GANGNAM` | 강남구 | `SIGUNGU` | `KR.SEOUL` | 2 | false |
| `KR.SEOUL.MAPO` | 마포구 | `SIGUNGU` | `KR.SEOUL` | 2 | false |
| `KR.SEOUL.GANGNAM.YEOKSAM` | 역삼 | `COMMERCIAL_AREA` | `KR.SEOUL.GANGNAM` | 3 | true |
| `KR.SEOUL.GANGNAM.SEOLLEUNG` | 선릉 | `COMMERCIAL_AREA` | `KR.SEOUL.GANGNAM` | 3 | true |
| `KR.SEOUL.GANGNAM.NONHYEON` | 논현 | `COMMERCIAL_AREA` | `KR.SEOUL.GANGNAM` | 3 | true |
| `KR.SEOUL.MAPO.HONGDAE` | 홍대 | `COMMERCIAL_AREA` | `KR.SEOUL.MAPO` | 3 | true |
| `KR.SEOUL.MAPO.HAPJEONG` | 합정 | `COMMERCIAL_AREA` | `KR.SEOUL.MAPO` | 3 | true |
| `KR.SEOUL.MAPO.YEONNAM` | 연남 | `COMMERCIAL_AREA` | `KR.SEOUL.MAPO` | 3 | true |
| `KR.BETA.GANGNAM_CORE` | 강남권 베타 클러스터 | `BETA_CLUSTER` | `KR.SEOUL.GANGNAM` | 3 | true |
| `KR.BETA.MAPO_CORE` | 마포권 베타 클러스터 | `BETA_CLUSTER` | `KR.SEOUL.MAPO` | 3 | true |
### 1-4. 클러스터 연결 원칙
운영상 `역삼/선릉/논현`은 하나의 베타 클러스터 `KR.BETA.GANGNAM_CORE`로 묶고, `홍대/합정/연남``KR.BETA.MAPO_CORE`로 묶는다.
이유:
- 검색/매칭/집계를 단순화할 수 있다.
- 베타 성과를 클러스터 단위로 운영할 수 있다.
- 이후 개별 상권 단위 성능을 본 뒤 세분화하기 쉽다.
## 2. 업종 마스터 설계
### 2-1. 권장 모델: `IndustryTaxonomy`
단일 계층 테이블 + self relation 구조를 사용한다.
권장 필드:
- `id`
- `code`
- `nameKo`
- `parentId`
- `depth`
- `sortOrder`
- `isLeaf`
- `isActive`
- `isBetaEnabled`
- `externalCode`
- `searchAliases`
운영 원칙:
- `Store.industryLeafId`는 leaf 업종만 가리킨다.
- 상위 카테고리 필터는 hierarchy를 따라 계산한다.
- 검색 자동완성과 유사어 처리를 위해 `searchAliases`를 둘 수 있다.
### 2-2. 초기 베타 업종 seed
베타는 F&B만 우선 켠다.
| code | nameKo | parentCode | depth | isLeaf | isBetaEnabled |
|------|--------|------------|-------|--------|---------------|
| `FNB` | 음식점/카페 | - | 0 | false | true |
| `FNB.CAFE` | 카페 | `FNB` | 1 | true | true |
| `FNB.BAKERY` | 베이커리 | `FNB` | 1 | true | true |
| `FNB.KOREAN` | 한식 | `FNB` | 1 | true | true |
| `FNB.CHICKEN` | 치킨 | `FNB` | 1 | true | true |
| `FNB.BAR` | 주점 | `FNB` | 1 | true | true |
| `FNB.DESSERT` | 디저트 | `FNB` | 1 | true | true |
| `FNB.FASTCASUAL` | 간편식/패스트캐주얼 | `FNB` | 1 | true | true |
### 2-3. 확장 전략
향후 수도권/전국 확장 시 업종은 아래 방식으로 넓힌다.
1. `FNB` 하위 세분화
2. `RETAIL` 추가
3. `SERVICE` 추가
4. `OFFICE/ETC` 추가
초기 MVP에서는 업종 수를 억지로 늘리지 않는다.
## 3. 마스터 데이터 조회 정책
### 3-1. 기본 API 원칙
`GET /api/v1/master-data`
반환 예시 범위:
- 베타 가능 지역 클러스터 목록
- 베타 가능 leaf 업종 목록
- 각 항목의 `code`, `nameKo`, `parentCode`, `isBetaEnabled`
### 3-2. 응답 정책
- 기본 사용자는 `isActive = true`, `isBetaEnabled = true`만 받는다.
- 운영자는 전체 항목을 조회할 수 있다.
- 삭제 대신 `isActive = false`로 비활성화한다.
## 4. seed 전략
### 4-1. 파일 기준 관리
seed 원본은 코드 내 하드코딩 배열이 아니라 파일 기반으로 관리한다.
예시:
- `regions.v1.csv`
- `industries.v1.csv`
권장 컬럼:
- `code`
- `name_ko`
- `parent_code`
- `depth`
- `sort_order`
- `is_leaf`
- `is_active`
- `is_beta_enabled`
### 4-2. upsert 기준
- `id`가 아니라 `code` 기준 upsert
- 기존 row가 있으면 `name`, `sortOrder`, `isBetaEnabled`만 보정
- 운영 중 이미 참조 중인 row는 삭제하지 않는다
### 4-3. 버전 관리
가능하면 `MasterDataVersion` 또는 seed 파일명 자체로 버전을 남긴다.
예시:
- `regions.v1.csv`
- `regions.v2.csv`
## 5. 구현 체크리스트
- [ ] `RegionHierarchy` 모델 생성
- [ ] `IndustryTaxonomy` 모델 생성
- [ ] `code` unique index 생성
- [ ] `parentId` self relation 연결
- [ ] `isBetaEnabled`, `isActive` 플래그 추가
- [ ] 베타 지역/업종 seed 파일 작성
- [ ] `GET /api/v1/master-data` 응답 계약 고정
## 6. 주의사항
- `강남`, `마포`, `F&B`를 앱 코드에 하드코딩하지 않는다.
- seed 값이 정책의 소스 오브 트루스가 되도록 운영 플래그를 사용한다.
- `Store`에는 사람이 읽는 이름보다 `regionClusterId`, `industryLeafId` FK를 기준으로 저장한다.
- 베타 종료 후 확장성을 해치지 않도록 전국 구조를 염두에 둔 코드 체계를 유지한다.
## 다음 단계
1. 이 문서를 기준으로 `RegionHierarchy`, `IndustryTaxonomy` 모델을 `schema.prisma`에 반영
2. 초기 seed 파일 생성
3. `U002`, `U003`, `I003` 테스트를 이 마스터 데이터를 기준으로 작성
+585
View File
@@ -0,0 +1,585 @@
# Re:Link 계약-에스크로-분쟁 정책서
> 문서 버전: 1.0.0
> 기준일: 2026-03-07
> 적용 범위: Re:Link MVP Phase 1
> DRI: 대표 (제품 정책), FINANCE_OPERATOR (정산 실행), TRUST_OPERATOR (계약·검수·분쟁)
---
## 목적
이 문서는 Re:Link 플랫폼에서 계약 생성, 전자서명 증적 수집, 에스크로 결제, 검수 절차, 정산 해제, 분쟁 처리, 환불, 수동 보정에 관한 운영 기준을 정의한다. 모든 개발 구현과 운영 의사결정은 이 문서를 최우선 기준으로 삼는다.
---
## 1. 계약 생성 규칙
### 1-1. 계약 생성 전제조건
계약은 아래 조건이 모두 충족된 경우에만 생성할 수 있다.
| 조건 | 설명 |
|------|------|
| 매칭 요청 상태 | `MatchRequest.status = ACCEPTED` 상태여야 한다. `PENDING`, `REVIEWING`, `REJECTED`, `EXPIRED` 상태에서는 계약 생성이 불가능하다. |
| 폐업자 계정 상태 | 계약 당사자인 폐업자의 계정이 활성 상태여야 한다. |
| 업체 인증 상태 | 계약 상대방 업체의 `VendorCertification.status = APPROVED` 상태여야 한다. 인증이 유효하지 않거나 `SUSPENDED` 상태인 업체와는 계약을 생성할 수 없다. |
| 매장 상태 | 해당 매장의 상태가 `CONTRACTED` 전환이 허용된 상태 (`MATCHING` 또는 `RESERVED`)여야 한다. |
| 중복 계약 없음 | 동일한 `matchRequestId`로 이미 `DRAFT` 이상의 계약이 존재하면 신규 생성이 불가능하다. |
계약 생성은 매칭 수락 시 시스템이 자동으로 시작하거나, 운영자가 운영 콘솔에서 수동으로 개시할 수 있다.
### 1-2. 계약 유형별 참여자 구성
계약 유형은 `Contract.contractType` 필드로 구분하며, 유형에 따라 계약 당사자와 역할이 달라진다.
| 계약 유형 | 코드 | 폐업자 역할 | 상대방 역할 | 계약 목적 |
|----------|------|------------|------------|----------|
| 시설 인수 | `ACQUISITION` | 양도인 (매도인) | 창업자 (양수인) | 영업시설, 비품, 권리금 일부 양도 |
| 철거 용역 | `DEMOLITION` | 의뢰인 | 철거업체 (수급인) | 내부 시설 및 설비 철거 용역 |
| 인테리어 공사 | `INTERIOR` | 의뢰인 또는 창업자 | 인테리어업체 (수급인) | 매장 인테리어 공사 용역 |
MVP에서 `INTERIOR` 계약의 의뢰인이 창업자인 경우, 폐업자는 계약 당사자가 아니며 매장 상태 전환에만 연동된다.
### 1-3. 템플릿 코드와 버전 관리 원칙
- 계약서는 반드시 `Contract.templateCode`로 지정된 표준 템플릿을 기반으로 생성한다. 임의로 작성한 계약서를 직접 업로드하여 계약 효력을 부여하는 것은 MVP에서 허용하지 않는다.
- 템플릿은 계약 유형별로 구분하며, 법무 검토를 통과한 버전만 유효 템플릿으로 등록한다.
- 계약 생성 시점에 사용된 템플릿 버전은 `ContractVersion.versionNo`에 고정 기록한다. 이후 템플릿이 개정되어도 기존 계약에 소급 적용하지 않는다.
- 템플릿 개정이 발생하면 신규 `templateCode` 또는 버전 번호를 부여하고, 개정 이력과 유효 기간을 별도 마스터 테이블에 관리한다.
- 법무 검토가 완료되지 않은 템플릿은 `feature flag: contract_template_draft` 뒤에 두고 운영 환경에 노출하지 않는다.
### 1-4. 계약서 문서 해시 저장과 무결성 검증
- 계약서 문서는 생성 즉시 객체 스토리지에 저장하고 `ContractVersion.documentStorageKey`에 경로를 기록한다.
- 저장 완료 시 문서 전체에 대한 SHA-256 해시를 계산하여 `ContractVersion.documentSha256`에 저장한다.
- 서명 완료 이후 계약서 문서에 변조가 의심되는 경우, 저장된 해시와 현재 파일의 해시를 대조하여 무결성을 검증한다.
- 계약 문서는 계약 종료 후에도 법적 보존 의무 기간(최소 5년) 동안 삭제하지 않는다. 보존 기간은 법무 검토 결과를 우선 적용한다.
### 1-5. 계약 상태 전이
```
DRAFT -> GENERATED -> SIGNING -> SIGNED -> IN_PROGRESS -> COMPLETED
-> CANCELLED
```
| 전이 | 조건 | 실행 주체 |
|------|------|----------|
| `DRAFT -> GENERATED` | 템플릿 렌더링 및 문서 저장 완료 | 시스템 자동 |
| `GENERATED -> SIGNING` | 계약 당사자에게 서명 요청 발송 | 시스템 자동 |
| `SIGNING -> SIGNED` | 양측 서명 완료 | 시스템 자동 (서명 증적 수집 후) |
| `SIGNED -> IN_PROGRESS` | 에스크로 결제 완료 | 시스템 자동 |
| `IN_PROGRESS -> COMPLETED` | 에스크로 정산 해제 완료 | 운영자 승인 후 시스템 |
| `IN_PROGRESS -> CANCELLED` | 분쟁 해결 결과가 전액 환불인 경우, 또는 운영자 판단 | 운영자 직접 |
| `SIGNING -> CANCELLED` | 서명 기한 초과 (기본 7일) | 시스템 자동 또는 운영자 |
---
## 2. 서명 증적 규칙
### 2-1. 전자서명 증적에 반드시 포함해야 할 정보
모든 서명 행위는 `SignatureEvidence` 레코드로 저장한다. 아래 항목은 누락 없이 수집해야 한다.
| 항목 | 필드 | 설명 |
|------|------|------|
| 서명자 식별자 | `userId` | 서명한 사용자의 내부 ID |
| 서명자 역할 | `signerRole` | 계약 내 역할 (예: `TRANSFEROR`, `TRANSFEREE`, `CLIENT`, `CONTRACTOR`) |
| 서명 시각 | `signedAt` | 서명이 완료된 UTC 타임스탬프 (밀리초 단위) |
| 서명 IP | `ipAddress` | 서명 요청이 전송된 클라이언트 IP 주소 |
| User-Agent | `userAgent` | 서명에 사용된 브라우저/기기 정보 |
| 문서 버전 | `contractVersionId` | 서명 당시 적용된 `ContractVersion`의 ID |
| 문서 해시 | `documentHash` | 서명 당시 문서의 SHA-256 해시 (저장 해시와 일치 여부 검증용) |
| 증적 유형 | `evidenceType` | 서명 방식 코드 (아래 참조) |
| 제공자 코드 | `providerCode` | 서명 기술 제공자 (예: `RELINK_CHECKBOX`, `KAKAOPAY_SIGN`) |
| 서명 해시 | `signatureHash` | 서명 행위 자체에 대한 해시 (재현 가능한 증적) |
### 2-2. MVP 서명 방식
MVP에서는 법적 요건을 충족하는 최소 증적을 수집하는 체크박스 동의 방식으로 시작한다.
**MVP 서명 방식 (evidenceType: `CHECKBOX_CONSENT`)**
1. 사용자가 계약서 전문을 화면에서 열람한다. 열람 이벤트와 스크롤 완료 여부를 기록한다.
2. "본 계약서의 내용을 확인하였으며 동의합니다" 체크박스를 선택한다.
3. 선택 시점의 타임스탬프, IP, User-Agent, 문서 버전, 문서 해시를 수집하여 `SignatureEvidence`를 생성한다.
4. 사용자에게 서명 완료 확인 이메일 또는 알림톡을 발송하고 발송 기록을 저장한다.
**MVP 이후 확장 계획**
| 단계 | 방식 | 조건 |
|------|------|------|
| Phase 1 (MVP) | 체크박스 동의 + 메타데이터 저장 | 현재 적용 |
| Phase 1.5 | 카카오페이 전자서명 | 카카오페이 전자서명 API 계약 완료 후 |
| Phase 2 | 공인전자서명 (PASS 등) | 법무 검토 및 거래 규모 기준 충족 시 |
서명 방식이 확장될 때 기존 `CHECKBOX_CONSENT` 서명의 법적 효력에 영향을 주지 않는다. 확장 방식은 신규 계약부터 적용한다.
### 2-3. 양측 서명 완료 시 계약 상태 전이
- 계약에 참여하는 모든 역할의 `SignatureEvidence`가 수집된 시점에 계약 상태를 `SIGNING -> SIGNED`로 전환한다.
- 단방향 서명(한쪽만 완료)은 서명 완료로 인정하지 않는다.
- 서명 만료 기간은 계약 생성 후 7일로 설정한다. 기간 내에 양측 서명이 완료되지 않으면 시스템이 `SIGNING -> CANCELLED`로 전환하고 관련자에게 알림을 발송한다.
- 만료된 계약을 재활성화하려면 운영자가 새 계약을 수동으로 생성해야 한다.
---
## 3. 에스크로 결제 규칙
### 3-1. 결제 시점
에스크로 결제는 아래 조건이 모두 충족된 직후에 개시한다.
- `Contract.status = SIGNED` (양측 서명 완료)
- 모든 당사자의 `SignatureEvidence`가 유효한 상태로 저장됨
- 계약 금액이 0원 초과
결제 요청은 계약 서명 완료 이벤트가 발생한 즉시 결제 페이지로 안내하거나, 사용자 확인 단계를 거친 후 PG사에 결제 요청을 보낸다.
### 3-2. 에스크로 상태 전환 규칙
```
NOT_STARTED -> DEPOSIT_PENDING -> HOLDING -> RELEASE_REVIEW -> RELEASED
-> REFUNDED
HOLDING -> DISPUTED
RELEASE_REVIEW -> DISPUTED
```
| 상태 전이 | 조건 | 전환 가능 주체 |
|----------|------|--------------|
| `NOT_STARTED -> DEPOSIT_PENDING` | 결제 요청 발송 | 시스템 자동 |
| `DEPOSIT_PENDING -> HOLDING` | PG 결제 성공 웹훅 수신 및 idempotency 검증 완료 | 시스템 자동 (웹훅 처리) |
| `DEPOSIT_PENDING -> NOT_STARTED` | PG 결제 실패 또는 취소 웹훅 수신 | 시스템 자동 |
| `HOLDING -> RELEASE_REVIEW` | 검수 승인 완료 (`InspectionRecord.status = APPROVED`) | 시스템 자동 |
| `RELEASE_REVIEW -> RELEASED` | 운영자 정산 승인 | `TRUST_OPERATOR`, `FINANCE_OPERATOR`, `SUPER_ADMIN` |
| `RELEASE_REVIEW -> REFUNDED` | 운영자 전액 환불 결정 | `FINANCE_OPERATOR`, `SUPER_ADMIN` |
| `HOLDING -> DISPUTED` | 분쟁 접수 완료 | 시스템 자동 (분쟁 접수 즉시) |
| `RELEASE_REVIEW -> DISPUTED` | 정산 검토 중 분쟁 접수 | 시스템 자동 |
| `DISPUTED -> RELEASED` | 분쟁 해결 결과 `FULL_RELEASE` 또는 `NEGOTIATED` (정산 포함) | 운영자 분쟁 해결 처리 |
| `DISPUTED -> REFUNDED` | 분쟁 해결 결과 `FULL_REFUND` | 운영자 분쟁 해결 처리 |
모든 상태 전환 시 `AuditLog``EventLog`를 동시에 기록한다.
### 3-3. PG 웹훅 처리 원칙
**idempotency 보장**
- 모든 PG 웹훅은 `EscrowTransaction.idempotencyKey`를 기준으로 중복 처리를 방지한다.
- 동일한 `idempotencyKey`로 이미 처리된 웹훅이 재수신되면 성공 응답을 반환하되 상태 변경은 실행하지 않는다.
- `idempotencyKey`는 PG 거래 ID와 이벤트 유형을 조합하여 생성한다.
**재시도 정책**
- PG 웹훅 수신 실패 시 자동 재시도를 지원한다. 재시도는 지수 백오프(Exponential Backoff) 방식으로 최대 5회 시도한다.
- 재시도 간격: 1분 -> 5분 -> 15분 -> 1시간 -> 4시간
- 5회 모두 실패한 경우 `FINANCE_OPERATOR`에게 알림을 발송하고 수동 처리 큐에 등록한다.
**실패 처리**
- 웹훅 처리 중 내부 오류가 발생한 경우 PG에 오류 응답(5xx)을 반환하여 PG의 재전송을 유도한다.
- 내부 오류 발생 시 관련 정보를 구조화 로그로 남기고 Sentry에 오류를 전송한다.
- 웹훅 원문 페이로드는 전체를 보관하여 이후 재처리(replay)가 가능하도록 한다.
**검증 원칙**
- 수신된 웹훅의 서명(Signature) 또는 HMAC을 반드시 검증한다.
- 웹훅 페이로드의 금액이 `Contract`에 기록된 계약 금액과 일치하지 않는 경우 처리를 거부하고 운영자에게 알림을 발송한다.
### 3-4. 금액 단위와 통화
- 모든 금액은 **대한민국 원(KRW)** 단위로 저장한다.
- 소수점을 허용하지 않는다. 금액 계산 결과에 소수점이 발생한 경우 내림(floor) 처리한다.
- 금액 필드는 부호 없는 정수(unsigned integer)로 저장한다. 음수 금액은 허용하지 않는다.
- 환불 또는 공제 금액은 별도의 `EscrowTransaction` 레코드(transactionType: `REFUND` 또는 `ADJUSTMENT`)로 기록한다.
---
## 4. 검수 규칙
### 4-1. 검수 요청 시점
- 검수는 용역 계약(`DEMOLITION`, `INTERIOR`) 또는 인수 계약(`ACQUISITION`)에서 업체(수급인) 또는 창업자가 작업 완료를 주장하며 사진을 업로드할 때 시작한다.
- 검수 요청은 `Contract.status = IN_PROGRESS` 상태에서만 접수할 수 있다.
- 검수 요청 시 `InspectionRecord`를 생성하고 상태를 `REQUESTED`로 설정한다.
### 4-2. 검수 승인 주체
검수는 2단계로 진행한다.
| 단계 | 승인 주체 | 역할 |
|------|----------|------|
| 1차 | 폐업자 (의뢰인) | 작업 결과를 육안으로 확인하고 만족 여부를 표시한다. |
| 2차 | 운영자 (`TRUST_OPERATOR`) | 1차 승인된 검수를 최종 확인하여 기준 충족 여부를 판단한다. |
- 폐업자가 1차에서 거부한 경우, 업체는 보완 작업 후 재업로드할 수 있다. 재검수는 새로운 `InspectionRecord`로 처리한다.
- 폐업자가 7일 이내에 1차 승인 또는 거부를 하지 않은 경우, 운영자는 직접 2차 검수를 진행할 수 있다. 이 경우 운영자는 사유를 반드시 기록해야 한다.
- 2차 운영자 승인 없이 에스크로 정산 검토 단계로 전이하지 않는다.
### 4-3. 검수 사진 최소 요구사항
| 항목 | 요건 |
|------|------|
| 작업 전 사진 | 필수. 최소 3장. 작업 대상 공간의 전체 상태를 확인할 수 있어야 한다. |
| 작업 후 사진 | 필수. 최소 3장. 작업 완료 후 동일 각도에서 촬영한 사진이어야 한다. |
| 주요 부위 상세 사진 | 계약 유형에 따라 추가 요구 (예: 철거의 경우 바닥/벽/배선 상태) |
| 사진 메타데이터 | 촬영 일시(EXIF) 포함 권장. EXIF가 없는 경우 업로드 시각으로 대체. |
| 파일 형식 | JPG, PNG, HEIC 허용. 단일 파일 최대 20MB. |
| 위조 방지 | 업로드 시 파일 해시를 `InspectionRecord.evidencePayloadJson`에 저장. |
기준 미달(사진 장수 부족, 작업 전 사진 누락 등)인 경우 시스템이 업로드를 거부하고 이유를 안내한다.
### 4-4. 검수 상태 전이
```
REQUESTED -> SUBMITTED -> REVIEWING -> APPROVED
-> REJECTED
```
| 전이 | 조건 | 실행 주체 |
|------|------|----------|
| `REQUESTED -> SUBMITTED` | 사진 업로드 완료 및 최소 요건 충족 | 시스템 자동 |
| `SUBMITTED -> REVIEWING` | 폐업자 1차 검토 시작 | 시스템 자동 |
| `REVIEWING -> APPROVED` | 폐업자 1차 승인 + 운영자 2차 승인 | 2단계 각각 실행 |
| `REVIEWING -> REJECTED` | 폐업자 또는 운영자 거부 | 해당 주체 |
검수 승인 후 에스크로 상태가 `HOLDING -> RELEASE_REVIEW`로 자동 전환된다.
---
## 5. 정산 해제 규칙
### 5-1. 정산 해제 전제조건
아래 조건이 모두 충족되어야 정산 해제 절차를 시작할 수 있다.
| 조건 | 설명 |
|------|------|
| 검수 승인 완료 | 해당 계약의 최종 `InspectionRecord.status = APPROVED` 상태여야 한다. |
| 분쟁 없음 | 해당 계약에 `DisputeCase.status``OPEN`, `INVESTIGATING`, `MEDIATING`인 건이 없어야 한다. |
| 에스크로 상태 | `EscrowTransaction`의 에스크로 상태가 `RELEASE_REVIEW`여야 한다. |
| 운영자 승인 | MVP에서 모든 정산은 운영자의 명시적 승인이 필요하다. 자동 정산 해제는 허용하지 않는다. |
### 5-2. 운영자 승인 (MVP 원칙)
MVP 기간 동안 모든 정산 해제는 `TRUST_OPERATOR`, `FINANCE_OPERATOR`, `SUPER_ADMIN` 중 한 명이 운영 콘솔에서 직접 승인해야 실행된다. 자동 정산 해제 기능은 `feature flag: auto_escrow_release` 뒤에 두고 MVP에서 비활성화 상태로 유지한다.
### 5-3. 정산 금액 계산
```
정산 금액 = 계약 금액 - 플랫폼 수수료
플랫폼 수수료 = 계약 금액 * 수수료율 (소수점 이하 내림)
```
수수료는 KRW 단위이며 소수점 이하를 내림(floor) 처리한다. 수수료 계산 결과와 정산 금액은 `EscrowTransaction` 레코드에 각각 기록한다.
### 5-4. 수수료율 정의
| 계약 유형 | 수수료율 | 비고 |
|----------|---------|------|
| `ACQUISITION` (시설 인수) | 3% | 거래 금액의 3% |
| `DEMOLITION` (철거 용역) | 5% | 용역 금액의 5% |
| `INTERIOR` (인테리어 공사) | 5% | 용역 금액의 5% |
수수료율 변경은 운영 정책 개정 절차를 거쳐야 하며, 변경 효력은 변경 시점 이후 신규 생성 계약부터 적용한다. 기존 계약의 수수료율에 소급 적용하지 않는다.
수수료율은 코드에 하드코딩하지 않고 별도 설정 테이블(`FeePolicy`)에 관리한다.
### 5-5. 정산 실행 후 상태 전이
정산 승인이 완료되면 아래 상태가 순차적으로 전환된다.
1. `EscrowTransaction` 에스크로 상태: `RELEASE_REVIEW -> RELEASED`
2. 실제 출금 처리: PG사 또는 수동 정산 방식으로 업체 계좌에 정산 금액 송금
3. `Contract.status`: `IN_PROGRESS -> COMPLETED`
4. `Store.status`: `CONTRACTED -> CLOSED` (해당되는 경우)
5. `EventLog`: `escrow_released`, `transaction_completed` 이벤트 기록
PG 출금 API 호출 실패 시 에스크로 상태를 `RELEASE_REVIEW`로 복귀하고 `FINANCE_OPERATOR`에게 알림을 발송한다.
---
## 6. 분쟁 처리 규칙
### 6-1. 분쟁 접수 가능 시점
분쟁은 아래 조건에서만 접수할 수 있다.
- 에스크로 상태가 `HOLDING` 또는 `RELEASE_REVIEW`인 상태
- 계약 상태가 `IN_PROGRESS`인 상태
- 동일 계약에 이미 `OPEN`, `INVESTIGATING`, `MEDIATING` 상태의 분쟁 건이 없는 경우
분쟁 접수 주체는 계약 당사자(폐업자, 창업자, 업체) 또는 운영자가 될 수 있다.
### 6-2. 분쟁 사유 코드
| 코드 | 설명 |
|------|------|
| `QUALITY_ISSUE` | 작업 품질이 계약 기준을 충족하지 못함 |
| `SCOPE_DIFFERENCE` | 실제 작업 범위가 계약서 내용과 다름 |
| `TIMELINE_BREACH` | 계약에서 정한 작업 기간을 초과함 |
| `DAMAGE` | 작업 중 발생한 시설 또는 비품 파손 |
| `INCOMPLETE_WORK` | 작업이 완전히 완료되지 않은 채 검수 요청됨 |
| `OTHER` | 위 항목에 해당하지 않는 기타 사유 |
`OTHER` 선택 시 사유 텍스트 입력을 필수로 요구한다.
### 6-3. 분쟁 발생 시 에스크로 자동 보류
분쟁이 접수되면 시스템은 즉시 아래 처리를 실행한다.
1. `DisputeCase.status = OPEN`으로 생성
2. 에스크로 상태를 `DISPUTED`로 전환 (단, PG 자금은 실제 동결 상태를 유지)
3. 자동 정산 해제 프로세스가 중단됨
4. 계약 당사자 전원 및 `TRUST_OPERATOR`에게 분쟁 접수 알림 발송
5. 분쟁 건이 운영자 처리 큐에 등록됨
분쟁이 접수된 상태에서 정산을 임의로 해제하는 것은 불가능하다. 정산을 재개하려면 반드시 분쟁 해결 절차를 완료해야 한다.
### 6-4. 운영자 조사 및 중재 프로세스
| 단계 | 상태 | 주요 활동 | 담당 역할 |
|------|------|----------|----------|
| 접수 | `OPEN` | 분쟁 내용 확인, 당사자 진술 수집 요청 | `TRUST_OPERATOR` |
| 조사 | `INVESTIGATING` | 계약서, 검수 사진, 커뮤니케이션 이력 검토, 추가 증빙 요청 | `TRUST_OPERATOR` |
| 중재 | `MEDIATING` | 당사자에게 조정안 제시 및 수락 여부 확인 | `TRUST_OPERATOR`, `OPS_MANAGER` |
| 해결 | `RESOLVED` | 해결 방식과 사유 기록, 후속 정산 또는 환불 처리 | `TRUST_OPERATOR`, `FINANCE_OPERATOR` |
| 종료 | `CLOSED` | 분쟁 건 최종 종료 (합의 또는 해결 불가 종료 포함) | `TRUST_OPERATOR` |
- 분쟁 접수 후 2영업일 이내에 운영자가 조사를 시작해야 한다.
- 조사 기간은 최대 10영업일로 한다. 기간 내 해결이 어려운 경우 당사자에게 사유를 안내하고 기간을 연장할 수 있다.
- 모든 조사 및 중재 단계에서 운영자의 메모와 사유 코드를 `DisputeCase`에 기록한다.
### 6-5. 분쟁 해결 유형
| 해결 코드 | 설명 | 정산 처리 |
|----------|------|----------|
| `FULL_RELEASE` | 분쟁 기각, 정산 정상 진행 | 전액 정산 해제 |
| `PARTIAL_REFUND` | 일부 환불 후 나머지 정산 | 환불액과 정산액을 분리하여 각각 처리 |
| `FULL_REFUND` | 전액 환불 | 업체에 정산 없음, 의뢰인에게 전액 환불 |
| `NEGOTIATED` | 당사자 합의 도달 | 합의 내용에 따라 정산 및 환불 처리 |
분쟁 해결 유형(`DisputeCase.resolutionCode`)과 해결 요약(`resolutionSummary`)은 반드시 기록해야 한다. 기록 없이 분쟁 상태를 `RESOLVED`로 전환할 수 없다.
### 6-6. 분쟁 해결 후 정산 처리
분쟁 해결 시 정산 처리는 아래 순서로 진행한다.
1. 운영자가 `DisputeCase`의 해결 코드와 금액(환불액, 정산액)을 기록
2. `DisputeCase.status = RESOLVED`로 전환
3. 에스크로 상태를 해결 방식에 따라 `RELEASED` 또는 `REFUNDED`로 전환
4. `PARTIAL_REFUND`의 경우 환불 `EscrowTransaction`과 정산 `EscrowTransaction`을 각각 생성
5. PG 또는 수동 방식으로 환불 및 정산 실행
6. 계약 상태 전환: `IN_PROGRESS -> COMPLETED` 또는 `CANCELLED`
---
## 7. 환불 규칙
### 7-1. 환불 가능 사유
환불은 아래 사유에 해당하는 경우에만 진행할 수 있다.
| 사유 | 설명 | 처리 방식 |
|------|------|----------|
| 분쟁 해결 결과 환불 | `DisputeCase.resolutionCode``FULL_REFUND` 또는 `PARTIAL_REFUND` | 분쟁 해결 절차 완료 후 처리 |
| 계약 서명 전 취소 | `Contract.status = SIGNING` 이전 취소 | 자동 환불 (PG 취소) |
| 업체 귀책 철수 | 업체가 계약을 이행하지 않고 철수한 경우 운영자 판단 | 운영자 승인 후 전액 환불 |
| 매장 사정으로 인한 계약 불능 | 폐업자의 불가피한 사유로 계약 진행 불가 | 운영자 검토 후 전액 또는 부분 환불 |
| 기타 운영 판단 | 서비스 오류, 플랫폼 귀책 등 | `SUPER_ADMIN` 또는 `FINANCE_OPERATOR` 승인 |
환불은 **원칙적으로 전액 또는 부분**으로만 처리하며, 계약 금액을 초과하는 환불은 허용하지 않는다.
### 7-2. 환불 금액 계산
| 환불 유형 | 계산 방식 |
|----------|----------|
| 전액 환불 | 에스크로에 입금된 원금 전액 반환. 플랫폼 수수료를 환불 금액에서 공제하지 않는다. |
| 부분 환불 | 운영자가 환불액을 직접 지정. 나머지는 업체에 정산. 환불액 + 정산액 = 계약 금액. |
수수료 환급 여부:
- 업체 귀책 또는 플랫폼 귀책의 경우: 수수료 전액 환급
- 의뢰인 단순 변심 또는 의뢰인 귀책의 경우: 수수료 비환급 (단, MVP에서는 운영자 재량 허용)
### 7-3. 환불 운영자 승인 필수
모든 환불은 `FINANCE_OPERATOR` 또는 `SUPER_ADMIN`의 명시적 승인이 필요하다. 시스템이 자동으로 환불을 실행하는 경우는 계약 서명 전 PG 취소 건에 한한다.
환불 승인 시 아래 정보를 반드시 기록한다.
- 환불 사유 코드
- 환불 금액 (전액 또는 부분)
- 승인자 ID
- 승인 시각
- 운영 메모
### 7-4. 환불 후 계약 상태 전이
| 환불 유형 | 계약 상태 전이 | 에스크로 상태 전이 |
|----------|--------------|-----------------|
| 전액 환불 | `IN_PROGRESS -> CANCELLED` | `DISPUTED -> REFUNDED` 또는 `RELEASE_REVIEW -> REFUNDED` |
| 부분 환불 | `IN_PROGRESS -> COMPLETED` | `DISPUTED -> RELEASED` (정산 완료 후) |
| 서명 전 취소 | `SIGNING -> CANCELLED` | `DEPOSIT_PENDING -> NOT_STARTED` |
---
## 8. 운영자 수동 보정 규칙
### 8-1. 수동 보정이 필요한 상황
수동 보정은 시스템이 자동으로 처리할 수 없는 예외 상황에서만 실행한다.
- PG 웹훅 수신 실패로 인한 에스크로 상태 불일치
- 분쟁 해결 후 부분 환불 금액의 수동 분배
- 계약 금액 오류 정정 (계약 생성 단계의 시스템 오류)
- 운영자 판단에 의한 에스크로 보류 또는 해제
- 기타 정산 이상 건 처리
### 8-2. 수동 보정 가능 주체
| 작업 | 허용 역할 |
|------|----------|
| 에스크로 상태 수동 전환 | `TRUST_OPERATOR`, `FINANCE_OPERATOR`, `SUPER_ADMIN` |
| 정산 금액 수동 보정 | `FINANCE_OPERATOR`, `SUPER_ADMIN` |
| 환불 금액 수동 결정 | `FINANCE_OPERATOR`, `SUPER_ADMIN` |
| 계약 상태 수동 전환 | `TRUST_OPERATOR`, `SUPER_ADMIN` |
| 수수료 면제 처리 | `SUPER_ADMIN` |
`OPS_MANAGER``SUBSIDY_OPERATOR`는 계약-에스크로-정산 영역의 수동 보정 권한을 갖지 않는다.
### 8-3. 수동 보정 시 필수 기록 항목
수동 보정 실행 전 아래 정보를 입력하지 않으면 보정을 진행할 수 없다.
| 항목 | 설명 |
|------|------|
| 보정 사유 코드 | 미리 정의된 코드 중 선택 (예: `WEBHOOK_FAILURE`, `DISPUTE_RESOLUTION`, `CONTRACT_ERROR`, `OPERATOR_OVERRIDE`) |
| 보정 전 상태 | 변경 전 에스크로 상태 또는 금액 (시스템이 자동 캡처) |
| 보정 후 상태 | 변경 후 예상 상태 또는 금액 |
| 운영 메모 | 보정 배경과 판단 근거를 서술 (최소 20자 이상) |
| 관련 문서 또는 링크 | 분쟁 건 ID, 채팅 스레드 ID, 외부 근거 링크 등 |
### 8-4. AuditLog 필수 기록
모든 수동 보정은 `AuditLog`에 아래 정보를 반드시 기록한다.
| 필드 | 내용 |
|------|------|
| `actorId` | 보정을 실행한 운영자 ID |
| `actorRole` | 운영자 역할 |
| `targetEntity` | 보정 대상 엔티티 (예: `EscrowTransaction`, `Contract`) |
| `targetId` | 보정 대상의 ID |
| `action` | 수행한 작업 코드 |
| `beforeState` | 변경 전 상태 (JSON 스냅샷) |
| `afterState` | 변경 후 상태 (JSON 스냅샷) |
| `reasonCode` | 보정 사유 코드 |
| `memo` | 운영 메모 |
| `occurredAt` | 보정 실행 시각 |
`AuditLog` 기록은 삭제하거나 수정할 수 없다. 감사 로그는 최소 7년간 보존한다.
---
## 9. Feature Flag 규칙
### 9-1. MVP Feature Flag 목록
MVP Phase 1에서 아래 기능은 feature flag 뒤에 두고 기본값은 비활성화(`false`) 상태로 유지한다.
| Flag 이름 | 기능 설명 | MVP 기본값 | 활성화 조건 |
|----------|----------|----------|------------|
| `contract_template_draft` | 법무 검토 미완료 템플릿 사용 허용 | `false` | 법무 검토 완료 후 `true`로 전환 |
| `auto_escrow_release` | 검수 승인 후 운영자 승인 없이 자동 정산 해제 | `false` | 정산 운영 안정화 확인 후 단계적 활성화 |
| `kakao_sign_enabled` | 카카오페이 전자서명 사용 | `false` | 카카오페이 전자서명 API 계약 완료 후 |
| `partial_refund_ui` | 부분 환불 UI 운영 콘솔 노출 | `true` | MVP 출시 시 활성화. 단, 실행은 `FINANCE_OPERATOR` 이상만 가능. |
| `dispute_auto_hold` | 분쟁 접수 시 에스크로 자동 보류 | `true` | MVP 출시 시 활성화. 비활성화 금지. |
| `inspection_photo_min` | 검수 사진 최소 요건 강제 검증 | `true` | MVP 출시 시 활성화. |
| `fee_policy_override` | 개별 계약 수수료율 수동 오버라이드 | `false` | `SUPER_ADMIN` 필요 시 개별 활성화 |
| `vendor_auto_contract` | 업체 인증 승인 즉시 계약 생성 자동화 | `false` | 운영 안정화 후 검토 |
### 9-2. Feature Flag 운영 원칙
- Feature flag 값의 변경은 `SUPER_ADMIN`만 실행할 수 있다.
- Flag 변경 시 변경자 ID, 변경 시각, 이전 값, 이후 값, 사유를 `AuditLog`에 기록한다.
- `dispute_auto_hold``inspection_photo_min`은 운영 안전을 위해 비활성화를 원칙적으로 금지한다. 비활성화가 필요한 경우 대표와 CTO의 동시 승인이 필요하다.
- Feature flag는 환경별(개발/스테이징/운영)로 독립적으로 관리한다. 스테이징에서 활성화한 flag가 운영 환경에 자동으로 적용되지 않는다.
---
## 10. 계약-에스크로 흐름 전체 요약
```
[매칭 수락]
|
v
[계약 생성 (DRAFT -> GENERATED)]
|
v
[서명 요청 발송 (SIGNING)]
|
[양측 서명 완료]
|
v
[계약 SIGNED / 에스크로 DEPOSIT_PENDING]
|
[PG 결제 완료 웹훅]
|
v
[에스크로 HOLDING / 계약 IN_PROGRESS]
|
+--[분쟁 접수]---> [에스크로 DISPUTED / DisputeCase OPEN]
| |
| [조사/중재]
| |
| [해결 유형 결정]
| |
| +-----------+-----------+
| | | |
| [FULL_RELEASE] [PARTIAL] [FULL_REFUND]
| | | |
| v v v
| [RELEASED] [RELEASED [REFUNDED]
| +REFUNDED]
|
v
[검수 요청 (REQUESTED -> SUBMITTED -> REVIEWING)]
|
[폐업자 1차 승인 + 운영자 2차 승인]
|
v
[에스크로 RELEASE_REVIEW]
|
[운영자 정산 승인]
|
v
[에스크로 RELEASED / 계약 COMPLETED]
```
---
## 부록 A. 관련 운영 역할 요약
| 역할 | 계약 | 에스크로 | 검수 | 분쟁 | 정산 | 환불 | 수동 보정 |
|------|------|---------|------|------|------|------|----------|
| `SUPER_ADMIN` | O | O | O | O | O | O | O |
| `TRUST_OPERATOR` | O | 보류/해제 | O | O | - | - | 상태 전환 |
| `FINANCE_OPERATOR` | - | 정산/환불 | - | 정산 | O | O | 금액 보정 |
| `OPS_MANAGER` | 조회 | 조회 | - | 중재 지원 | - | - | - |
| `SUBSIDY_OPERATOR` | - | - | - | - | - | - | - |
---
## 부록 B. 주요 용어 정의
| 용어 | 정의 |
|------|------|
| 에스크로 | 거래 대금을 제3자(PG사 또는 플랫폼)가 보관하다가 조건 충족 시 수령인에게 지급하는 결제 방식 |
| 정산 해제 | 에스크로에 보관된 금액을 수급인(업체)에게 지급하는 행위 |
| 검수 | 용역 또는 거래 완료 여부를 사진 및 현장 확인을 통해 검증하는 절차 |
| 분쟁 | 계약 당사자 간 계약 이행 관련 이의 제기 |
| 수동 보정 | 시스템 자동화 범위 밖의 예외 상황에서 운영자가 직접 상태 또는 금액을 변경하는 행위 |
| idempotency | 동일한 요청이 여러 번 실행되어도 결과가 동일하게 유지되는 성질 |
| AuditLog | 운영자 및 시스템의 모든 상태 변경 이력을 변경 불가 형태로 기록하는 감사 로그 |
---
*이 문서는 Re:Link MVP 운영 기준 문서이며, 구현 코드보다 이 문서가 우선합니다. 정책 변경이 필요한 경우 이 문서를 먼저 개정하고 개정 이력을 기록한 뒤 코드 구현에 반영합니다.*
+500
View File
@@ -0,0 +1,500 @@
# 폐업 정보 공개 정책서
> Re:Link MVP — 정보 공개 기준 및 역할별 열람 권한 정책
> 문서 버전: v1.0.0
> 최초 작성: 2026-03-07
> 적용 범위: Phase 1 MVP 전체, Phase 2 데이터 구조 준비 포함
> DRI: 대표 (정책), CTO (기술 구현)
---
## 목차
1. [정보 공개 등급 체계](#1-정보-공개-등급-체계)
2. [역할별 열람 범위 매트릭스](#2-역할별-열람-범위-매트릭스)
3. [데이터 항목별 공개 등급 분류](#3-데이터-항목별-공개-등급-분류)
4. [공개 시점 규칙](#4-공개-시점-규칙)
5. [동의 체계](#5-동의-체계)
6. [열람 이력 기록](#6-열람-이력-기록)
7. [API 응답 필터링 원칙](#7-api-응답-필터링-원칙)
8. [개인정보보호법 준수](#8-개인정보보호법-준수)
---
## 1. 정보 공개 등급 체계
Re:Link는 폐업자의 민감한 정보를 보호하면서 거래 목적에 맞는 정보만 단계적으로 공개하는 4단계 등급 체계를 운영한다. 폐업 등록 즉시 모든 정보가 공개되지 않으며, 매칭 진행 상황과 역할에 따라 정보 접근이 제한적으로 확대된다.
### 1-1. 등급 정의
| 등급 | 코드 | 열람 대상 | 목적 |
|------|------|-----------|------|
| 공개 요약 | `PUBLIC_SUMMARY` | 인증 여부 무관, 접근 가능한 모든 사용자 | 매장 탐색 및 관심 유도 |
| 제한 공개 | `RESTRICTED` | 역할 인증이 완료된 플랫폼 회원 | 매칭 의사결정을 위한 상세 정보 제공 |
| 매칭 전용 | `MATCHED_ONLY` | 매칭 수락이 완료된 당사자 양측 | 실제 거래 진행에 필요한 연락 및 계약 정보 |
| 내부 전용 | `INTERNAL` | 운영자 및 시스템 자동 처리 | 감사, 컴플라이언스, 내부 운영 |
### 1-2. 등급별 상세 기준
#### PUBLIC_SUMMARY — 공개 요약
누구나 볼 수 있는 비식별화된 요약 정보다. 상세 주소 대신 지역 클러스터 단위(예: 역삼·선릉권)를 표시하며, 임대 금액 대신 범위(예: 보증금 1억~2억대)만 표시한다.
- 포함: 지역 클러스터, 업종 분류, 전용면적 범위, 대표 사진 1장, 폐업 예정 시기 범위(월 단위), 시설 키워드(예: 주방 있음, 덕트 있음)
- 제외: 정확한 주소, 동호수, 연락처, 정확한 임대 금액, 폐업 사유, 소유자 개인정보
#### RESTRICTED — 제한 공개
역할 인증이 완료된 플랫폼 회원에게 공개하는 상세 정보다. 거래 의향을 판단할 수 있는 수준의 정보를 제공하되, 직접 연락은 불가능하다.
- 포함: 시설 상세(전용면적 정확값, 좌석 수, 설비 목록, 층수, 주차), 임대 조건 개요(보증금·월세·권리금 정확값, 잔여 임대기간), 내부 사진 전체, 폐업 예정일(일 단위), 폐업 사유 유형(경영악화/임대만료/이전 등 분류 수준), 인수 가능일
- 제외: 상세 주소(동호수), 소유자 연락처, 임대인 정보, 사업자등록 번호
#### MATCHED_ONLY — 매칭 전용
매칭 요청이 수락되어 계약 절차가 시작된 당사자 양측에게만 공개하는 정보다. 이 단계부터 직접 연락과 현장 방문이 가능하다.
- 포함: 상세 주소(동호수 포함), 소유자 연락처(전화, 이메일), 임대인 정보(운영자 중개 시), 사업자등록번호(계약 목적), 추가 현장 사진 및 도면, 임대차 계약서 사본(운영자 검토 후 제공)
- 제외: 정부 지원금 수령 정보, 세금 관련 내부 메모, 타 매칭 이력
#### INTERNAL — 내부 전용
운영자와 시스템만 접근 가능한 정보다. 외부에 절대 노출되어서는 안 되며, 접근 시마다 감사 로그가 생성된다.
- 포함: 운영자 내부 메모, 반려 사유 상세, 검토 히스토리, 개인정보 원본(주민등록번호 등), 정부 지원금 수령 내역 및 금액, 세금계산서 정보, 분쟁 처리 내부 기록, 시스템 감사 로그 전체, 법적 처리 관련 서류
---
## 2. 역할별 열람 범위 매트릭스
아래 매트릭스는 각 사용자 역할이 각 공개 등급 정보를 열람할 수 있는지를 나타낸다. O는 열람 가능, X는 열람 불가, C는 조건부 열람(별도 조건 명시)을 의미한다.
| 역할 | PUBLIC_SUMMARY | RESTRICTED | MATCHED_ONLY | INTERNAL |
|------|:--------------:|:----------:|:------------:|:--------:|
| 비인증 방문자 | O | X | X | X |
| CLOSING_OWNER (본인 매장) | O | O | O | X |
| CLOSING_OWNER (타인 매장) | O | X | X | X |
| FOUNDER (창업자) | O | O | C¹ | X |
| VENDOR_MANAGER (철거/인테리어) | O | C² | C³ | X |
| OPS_MANAGER | O | O | O | O |
| SUBSIDY_OPERATOR | O | O | C⁴ | C⁵ |
| TRUST_OPERATOR | O | O | O | C⁶ |
| FINANCE_OPERATOR | O | O | C⁷ | C⁸ |
| SUPER_ADMIN | O | O | O | O |
**조건 주석**
- C¹: 창업자(FOUNDER)가 해당 매장에 대해 매칭 요청을 보내고 폐업자가 수락한 경우에만 열람 가능
- C²: 역할 인증(업체 인증)이 완료된 VENDOR_MANAGER만 열람 가능. 미인증 업체는 PUBLIC_SUMMARY만 접근
- C³: VENDOR_MANAGER는 철거 또는 인테리어 견적 요청이 매칭 수락된 경우에만 열람 가능. 단, 연락처는 운영자 중개 방식으로만 제공
- C⁴: SUBSIDY_OPERATOR는 해당 매장의 지원금 케이스가 할당된 경우에만 MATCHED_ONLY 열람 가능
- C⁵: SUBSIDY_OPERATOR는 지원금 관련 정보(INTERNAL 중 지원금 항목)만 열람 가능. 일반 내부 메모와 분쟁 기록은 접근 불가
- C⁶: TRUST_OPERATOR는 분쟁·계약·검수 처리 목적의 INTERNAL 항목만 열람 가능. 재무 정보는 접근 불가
- C⁷: FINANCE_OPERATOR는 결제·정산 관련 MATCHED_ONLY 정보(사업자등록번호, 계좌 정보)만 열람 가능
- C⁸: FINANCE_OPERATOR는 세금계산서, 에스크로 거래 내역, 정산 로그만 열람 가능. 분쟁·감사 로그 전체는 접근 불가
---
## 3. 데이터 항목별 공개 등급 분류
### 3-1. 매장 기본 정보
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 매장 제목 (상호 제외) | PUBLIC_SUMMARY | "역삼동 1층 카페" 형식, 상호명은 제외 |
| 업종 분류 (대분류) | PUBLIC_SUMMARY | F&B, 소매업 등 |
| 업종 분류 (중·세분류) | RESTRICTED | 커피전문점, 한식당 등 |
| 지역 클러스터 | PUBLIC_SUMMARY | 구·상권 단위, 동 단위 이상은 RESTRICTED |
| 정확한 주소 (동·번지) | RESTRICTED | |
| 상세 주소 (호수·층·동) | MATCHED_ONLY | |
| 상호명 | MATCHED_ONLY | 계약 및 권리금 협상 목적 |
| 사업자등록번호 | MATCHED_ONLY | 계약 목적으로만 제공 |
| 매장 등록 날짜 | RESTRICTED | |
### 3-2. 시설 정보
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 전용면적 (범위, 10평 단위) | PUBLIC_SUMMARY | 예: 30~40평대 |
| 전용면적 (정확값, ㎡·평) | RESTRICTED | |
| 건물 층수 | RESTRICTED | |
| 주차 가능 여부 | PUBLIC_SUMMARY | |
| 주차 대수 | RESTRICTED | |
| 좌석 수 (범위) | PUBLIC_SUMMARY | |
| 좌석 수 (정확값) | RESTRICTED | |
| 주방 설비 유무 | PUBLIC_SUMMARY | 유/무만 표시 |
| 주방 설비 상세 | RESTRICTED | 장비 목록, 상태 |
| 전기 용량 | RESTRICTED | |
| 가스 공급 여부 | PUBLIC_SUMMARY | |
| 가스 용량 및 종류 | RESTRICTED | |
| 배수 설비 | RESTRICTED | |
| 덕트 설비 | PUBLIC_SUMMARY | 유/무만 표시 |
| 덕트 상세 스펙 | RESTRICTED | |
| 냉난방 설비 | RESTRICTED | |
| 기타 설비 목록 | RESTRICTED | |
### 3-3. 임대 정보
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 보증금 (억 단위 범위) | PUBLIC_SUMMARY | 예: 1억~2억대 |
| 보증금 (정확값) | RESTRICTED | |
| 월세 (만 단위 범위) | PUBLIC_SUMMARY | 예: 200~300만 원대 |
| 월세 (정확값) | RESTRICTED | |
| 관리비 유무 | PUBLIC_SUMMARY | |
| 관리비 정확값 | RESTRICTED | |
| 권리금 존재 여부 | PUBLIC_SUMMARY | 유/무만 표시 |
| 권리금 정확값 | RESTRICTED | |
| 잔여 임대기간 (개월 단위 범위) | PUBLIC_SUMMARY | 예: 6~12개월 |
| 잔여 임대기간 (정확값) | RESTRICTED | |
| 임대차 계약 만료일 | RESTRICTED | |
| 임대인 정보 (성명, 연락처) | MATCHED_ONLY | 운영자 중개 방식으로만 제공 |
| 임대차 계약서 사본 | MATCHED_ONLY | 운영자 검토 후 제공 |
### 3-4. 사진
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 대표 사진 (외관) 1장 | PUBLIC_SUMMARY | 주소 특정 불가한 앵글로 운영자 검수 |
| 대표 사진 (내부 전경) 1장 | PUBLIC_SUMMARY | |
| 내부 전체 사진 | RESTRICTED | |
| 주방 설비 사진 | RESTRICTED | |
| 외관 전체 사진 (간판 포함) | RESTRICTED | 상호 노출 포함 가능 |
| 도면 및 평면도 | RESTRICTED | |
| 설비 상세 사진 | RESTRICTED | |
| 현장 추가 사진 (매칭 후 요청) | MATCHED_ONLY | |
> 사진 분류 시 `StorePhoto.visibilityScope` 필드를 기준으로 서버에서 필터링하며, 사진 업로드 단계에서 폐업자가 카테고리를 지정하고 운영자가 최종 검수한다.
### 3-5. 폐업 관련 정보
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 폐업 예정 시기 (월 단위) | PUBLIC_SUMMARY | |
| 폐업 예정일 (일 단위) | RESTRICTED | |
| 인수 가능일 | RESTRICTED | |
| 철거 예정일 | RESTRICTED | |
| 폐업 사유 유형 (경영악화/임대만료/이전 등) | RESTRICTED | 세부 사유는 INTERNAL |
| 폐업 사유 상세 서술 | INTERNAL | |
| 폐업 확인서 등 공문서 | INTERNAL | |
### 3-6. 정부 지원금 관련 정보
모든 정부 지원금 관련 정보는 INTERNAL 등급이며, SUBSIDY_OPERATOR 이상의 운영자만 접근 가능하다.
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 지원금 신청 자격 여부 | INTERNAL | |
| 신청 대상 지원금 목록 | INTERNAL | |
| 제출 서류 및 상태 | INTERNAL | |
| 지원금 수령 금액 및 내역 | INTERNAL | |
| 운영자 검토 메모 | INTERNAL | |
> 폐업자에게는 지원금 신청 과정에서 본인의 케이스 상태(체크리스트 완료 여부, 운영자 검토 결과)는 별도 지원금 전용 화면에서 확인 가능하다. 이 화면은 공개 등급 체계와 별도로 동작하는 개인화 뷰이며, 타인에게 노출되지 않는다.
### 3-7. 연락처 및 개인정보
| 항목 | 공개 등급 | 비고 |
|------|----------|------|
| 전화번호 | MATCHED_ONLY | 운영자 중개 방식 우선 권장 |
| 이메일 | MATCHED_ONLY | |
| 성명 | MATCHED_ONLY | 계약 목적으로만 제공 |
| 주민등록번호 / 생년월일 | INTERNAL | 본인확인 목적, 암호화 저장 |
| 계좌번호 | INTERNAL | 정산 목적, 암호화 저장 |
---
## 4. 공개 시점 규칙
매장의 `publicationStatus``reviewStatus`의 조합에 따라 공개 범위가 결정된다. 공개 상태의 전환은 운영자 또는 시스템이 `AuditLog`와 함께 기록한다.
### 4-1. 상태별 공개 범위
| 매장 상태 | 공개 범위 | 접근 가능 역할 |
|-----------|----------|----------------|
| `DRAFT` | 공개 없음 | 본인(CLOSING_OWNER) + 운영자 |
| `SUBMITTED` | 공개 없음 | 본인(CLOSING_OWNER) + 운영자 |
| `REVIEWING` | 공개 없음 | 본인(CLOSING_OWNER) + 운영자 |
| `APPROVED` + `PUBLISHED` | 공개 등급에 따라 역할별 노출 | 역할 매트릭스 참조 |
| `APPROVED` + `PRIVATE` | 공개 없음 | 본인(CLOSING_OWNER) + 운영자 |
| 매칭 수락 완료 | MATCHED_ONLY 정보 해제 | 매칭 당사자 양측 + 운영자 |
| `UNPUBLISHED` | 기존 매칭 당사자 외 비공개 | 기존 매칭 당사자 + 운영자 |
| `CLOSED` / `CANCELLED` | 운영자만 | 운영자 전용 |
### 4-2. 공개 전환 규칙
- DRAFT → SUBMITTED: 폐업자가 직접 제출. 공개 범위 변동 없음.
- SUBMITTED → REVIEWING: 운영자가 검토 시작. 공개 범위 변동 없음.
- REVIEWING → APPROVED: 운영자가 승인. `publicationStatus`는 여전히 `PRIVATE`이며, 폐업자가 공개 동의 후 `PUBLISHED`로 전환.
- APPROVED + PRIVATE → APPROVED + PUBLISHED: 폐업자의 정보 공개 동의 완료 시점에 자동 전환. 이 시점부터 역할 매트릭스에 따른 공개 시작.
- PUBLISHED → MATCHING: 매칭 요청이 수락되어도 publicationStatus는 유지. MATCHED_ONLY 정보만 추가 해제.
- PUBLISHED → UNPUBLISHED: 폐업자 요청 또는 운영자 결정. 기존 매칭 진행 중인 당사자에게는 MATCHED_ONLY 정보 유지, 신규 접근자에게는 PUBLIC_SUMMARY도 비공개.
### 4-3. MATCHED_ONLY 정보 해제 조건
MATCHED_ONLY 정보는 아래 조건이 모두 충족될 때 해당 당사자에게만 해제된다.
1. 매칭 요청의 상태가 `ACCEPTED`로 전환되었을 것
2. 폐업자(CLOSING_OWNER)가 정보 공개 동의를 완료했을 것 (최초 PUBLISHED 전환 시 동의 수집)
3. 상대방(FOUNDER 또는 VENDOR_MANAGER)이 플랫폼 이용약관과 개인정보 처리방침에 동의했을 것
VENDOR_MANAGER의 경우 연락처는 운영자 중개 방식을 우선 사용하며, 운영자가 직접 연결을 승인한 경우에만 직접 노출한다.
### 4-4. 매장 삭제 및 비공개 전환 시 처리
- 폐업자가 매장을 `UNPUBLISHED`로 전환하면 즉시 검색 결과에서 제거된다.
- 기존에 `ACCEPTED` 상태인 매칭이 있는 경우, 해당 매칭 당사자에게는 MATCHED_ONLY 정보가 유지되며 운영자에게 알림이 발송된다.
- 매장 데이터는 실제로 삭제되지 않으며, `publicationStatus`만 변경된다. 법적 보관 의무 기간 동안 INTERNAL 접근으로 유지된다.
---
## 5. 동의 체계
### 5-1. 동의 수집 항목
Re:Link는 `UserConsent` 모델을 통해 동의 유형별로 수집 시점, 버전, 철회 가능 여부를 관리한다.
| 동의 항목 | `consentType` 코드 | 수집 시점 | 철회 가능 여부 |
|----------|-------------------|-----------|----------------|
| 서비스 이용약관 동의 | `TERMS_OF_SERVICE` | 회원가입 시 | 회원 탈퇴로만 가능 |
| 개인정보 처리방침 동의 (필수) | `PRIVACY_POLICY_REQUIRED` | 회원가입 시 | 회원 탈퇴로만 가능 |
| 개인정보 처리방침 동의 (마케팅) | `PRIVACY_POLICY_MARKETING` | 회원가입 시 (선택) | 언제든지 철회 가능 |
| 정보 공개 동의 (매장 공개) | `STORE_PUBLICATION_CONSENT` | 최초 공개 전환 직전 | 매장 비공개 전환으로 실질 효력 중단 |
| MATCHED_ONLY 정보 제공 동의 | `MATCHED_INFO_DISCLOSURE` | 매칭 수락 직전 | 매칭 취소 시 효력 중단 |
| 제3자 정보 제공 동의 (매칭 상대방) | `THIRD_PARTY_MATCHED_PARTY` | 매칭 수락 직전 | 매칭 취소 시 효력 중단 |
| 알림톡 수신 동의 | `NOTIFICATION_KAKAO` | 회원가입 시 (선택) | 언제든지 철회 가능 |
### 5-2. 동의 수집 방법
- 동의 항목은 체크박스 형식으로 표시하며, 선택 동의와 필수 동의를 시각적으로 명확히 구분한다.
- 동의서 본문 전체를 읽을 수 있어야 하며, 요약본과 전문(全文)을 함께 제공한다.
- 동의 완료 시 `UserConsent` 레코드에 `policyVersionId`, `isGranted: true`, `grantedAt` 타임스탬프, 동의 당시 클라이언트 정보(IP, User-Agent)를 저장한다.
- 동의 화면을 건너뛰거나 묵시적으로 동의 처리하지 않는다.
### 5-3. 정책 버전 관리
정책 본문이 변경될 때마다 `policyVersionId`를 새로 부여하며, 주요 변경의 경우 기존 동의자에게 재동의를 요청한다.
| 변경 유형 | 대응 방식 |
|-----------|----------|
| 오탈자 수정, 법령 명칭 변경 등 경미한 수정 | 버전 업데이트 후 고지만 진행 (재동의 불요) |
| 수집 항목 추가, 이용 목적 변경 등 실질적 변경 | 버전 업데이트 + 기존 동의자 재동의 요청, 미동의 시 해당 기능 이용 제한 |
| 제3자 제공 범위 확대 | 버전 업데이트 + 전원 재동의 필수 |
### 5-4. 동의 철회 시 처리
- 철회 요청은 즉시 처리되며, `UserConsent.isGranted``false`로 업데이트하고 `revokedAt` 타임스탬프를 기록한다.
- `STORE_PUBLICATION_CONSENT` 철회 시: 해당 매장의 `publicationStatus`를 즉시 `PRIVATE`으로 전환하고, 진행 중인 매칭이 있으면 운영자에게 알림을 발송한다.
- `MATCHED_INFO_DISCLOSURE` 철회 시: 철회 시점 이후 새로운 매칭에는 MATCHED_ONLY 정보를 제공하지 않는다. 이미 정보를 열람한 매칭 당사자의 열람 이력은 삭제되지 않는다.
- 마케팅 동의 철회는 즉시 효력이 발생하며 영업일 기준 3일 이내에 마케팅 수신이 중단된다.
- 동의 철회 이력(이전 동의 기록)은 법적 보관 의무에 따라 일정 기간 유지된다.
---
## 6. 열람 이력 기록
### 6-1. 기록 원칙
모든 RESTRICTED 등급 이상의 정보 열람은 `AuditLog`에 기록된다. PUBLIC_SUMMARY 열람은 집계 통계로만 처리하며, 개인 단위 기록은 남기지 않는다.
### 6-2. AuditLog 기록 항목
| 필드 | 내용 | 비고 |
|------|------|------|
| `id` | 고유 감사 로그 ID | |
| `actorId` | 열람자 사용자 ID | |
| `actorRole` | 열람자 역할 | |
| `targetType` | 열람 대상 엔티티 타입 | `STORE`, `STORE_PHOTO`, `MATCH_REQUEST` 등 |
| `targetId` | 열람 대상 엔티티 ID | |
| `action` | 수행한 행동 | `VIEW`, `DOWNLOAD`, `EXPORT`, `ADMIN_OVERRIDE` |
| `visibilityScope` | 열람한 정보의 등급 | `RESTRICTED`, `MATCHED_ONLY`, `INTERNAL` |
| `fieldAccessed` | 열람한 특정 필드 목록 | JSON 배열 |
| `ipAddress` | 열람자 IP | 마스킹 처리 후 저장 |
| `userAgent` | 클라이언트 정보 | |
| `requestId` | API 요청 추적 ID | |
| `createdAt` | 열람 시각 | UTC 기준 |
### 6-3. B2B 알림 상품을 위한 데이터 준비 (Phase 2 준비)
Phase 1 MVP에서는 B2B 알림 상품을 출시하지 않는다. 단, Phase 2에서 "지역·업종별 폐업 예정 정보 알림" 구독 상품을 출시할 수 있도록 아래 데이터 구조를 지금부터 축적한다.
**축적할 데이터:**
| 데이터 항목 | 용도 | 공개 등급 |
|------------|------|----------|
| 지역 클러스터별 매장 등록 건수 | 지역별 수요 파악 | INTERNAL (집계값) |
| 업종별 매장 등록 트렌드 | 알림 상품 단가 설정 | INTERNAL (집계값) |
| `AlertSubscription` 관심 등록 수 | 잠재 구독자 규모 파악 | INTERNAL |
| 매장별 열람 횟수 (역할별) | 콘텐츠 수요 측정 | INTERNAL (집계값) |
| VENDOR_MANAGER의 관심 표시 이력 | 리드 품질 측정 | INTERNAL |
**B2B 알림 상품 정책 원칙 (Phase 2 적용 예정):**
- 알림 발송 대상은 익명화된 집계 정보(지역·업종·시설 유형)에 한정하며, 개인 식별 정보(상호명, 주소, 연락처)는 구독만으로 제공하지 않는다.
- 구독자가 관심 매장에 매칭 요청을 보내고 수락된 경우에만 MATCHED_ONLY 정보 접근이 허용된다.
- 원본 개인정보를 B2B 데이터로 직접 판매하거나 제3자에게 제공하지 않는다.
### 6-4. 열람 이력 보관 기간
| 정보 등급 | 보관 기간 | 근거 |
|----------|----------|------|
| PUBLIC_SUMMARY 집계 통계 | 5년 | 서비스 품질 분석 |
| RESTRICTED 열람 이력 | 3년 | 분쟁 소멸시효 고려 |
| MATCHED_ONLY 열람 이력 | 5년 | 계약 분쟁 대응 |
| INTERNAL 열람 이력 | 10년 | 법적 감사 의무 |
---
## 7. API 응답 필터링 원칙
### 7-1. 서버 사이드 필터링 원칙
정보 공개 등급에 따른 필드 필터링은 반드시 서버에서 처리한다. 클라이언트(프론트엔드)에서 조건부로 UI를 숨기는 방식은 보안 통제 수단으로 인정하지 않는다.
- API 응답 생성 시 호출자의 역할(`actorRole`)과 매장과의 관계(`matchStatus`)를 검사한다.
- 공개 등급을 초과하는 필드는 응답 객체에서 제거(삭제)하며, `null` 또는 마스킹된 값으로 대체하지 않는다.
- 단, 사용자 경험을 위해 접근 불가 필드가 존재함을 클라이언트에 알려야 할 경우에는 `_restricted: true` 메타 플래그를 해당 필드 위치에 포함할 수 있다.
### 7-2. 필터링 레이어 구조
```
HTTP 요청
→ 인증 미들웨어 (세션/토큰 검증)
→ 인가 미들웨어 (역할 + 매장 관계 확인)
→ 서비스 레이어 (비즈니스 로직)
→ 응답 직렬화 레이어 (visibilityScope 기준 필드 필터링)
→ HTTP 응답
```
응답 직렬화 레이어에서 `visibilityScope`와 호출자의 접근 등급을 대조하여 필드를 제거한다. 이 레이어는 도메인 로직과 분리된 별도 계층으로 구현한다.
### 7-3. 캐시 레이어에서의 개인정보 노출 방지
- RESTRICTED 이상의 정보를 포함한 API 응답은 공유 캐시(Redis, CDN)에 저장하지 않는다.
- PUBLIC_SUMMARY 응답만 공유 캐시에 저장 가능하며, 캐시 키에 사용자 식별자가 포함되지 않도록 한다.
- MATCHED_ONLY 및 INTERNAL 응답은 캐시를 완전히 비활성화하고, `Cache-Control: no-store` 헤더를 반드시 포함한다.
- 사용자별 개인화 캐시가 필요한 경우, 캐시 키에 사용자 ID와 매칭 관계 해시를 포함하여 다른 사용자에게 노출되지 않도록 격리한다.
### 7-4. 사진 접근 제어
- 사진 파일 자체는 서명된 URL(Signed URL)로만 제공하며, 직접 URL을 통한 무제한 접근을 허용하지 않는다.
- 서명 URL의 만료 시간: PUBLIC_SUMMARY 사진 24시간, RESTRICTED 사진 1시간, MATCHED_ONLY 사진 1시간
- 서명 URL 생성 시마다 AuditLog를 기록한다.
- 서명 URL이 만료된 이후에는 재생성 API를 통해서만 접근 가능하며, 재생성 시에도 동일한 권한 검사를 수행한다.
### 7-5. 관리자 직접 접근 시 추가 보호
INTERNAL 정보에 접근하는 운영자 API는 아래 추가 보호를 적용한다.
- 운영자 계정에 대한 다단계 인증(MFA) 필수
- 접근 사유 코드를 API 요청 파라미터로 반드시 포함
- 모든 접근을 AuditLog에 기록하며 실시간 모니터링 알림 설정
- 개인정보 원본(주민등록번호, 계좌번호) 조회는 SUPER_ADMIN과 TRUST_OPERATOR(사유 코드 포함 시)만 가능
---
## 8. 개인정보보호법 준수
### 8-1. PII 식별 및 분리 저장
Re:Link에서 개인정보(PII)로 분류하는 항목과 저장 방식은 아래와 같다.
| 분류 | 항목 | 저장 방식 | 접근 통제 |
|------|------|----------|----------|
| 식별 정보 | 성명, 이메일, 전화번호 | 암호화 저장 (AES-256) | MATCHED_ONLY 이상 |
| 고유 식별자 | 주민등록번호 | 단방향 해시 + 분리 저장 | INTERNAL, SUPER_ADMIN만 |
| 금융 정보 | 계좌번호, 카드 정보 | PG사 위탁 또는 토큰화 | INTERNAL, FINANCE_OPERATOR만 |
| 위치 정보 | 상세 주소 | 암호화 저장 | MATCHED_ONLY 이상 |
| 사업자 정보 | 사업자등록번호 | 평문 저장 (공개 정보) | MATCHED_ONLY 이상 |
사업자등록번호는 공개 정보이나, Re:Link 내에서는 계약 목적으로만 사용하며 검색 노출은 MATCHED_ONLY로 제한한다.
### 8-2. 개인정보 수집 및 이용 목적
Re:Link가 수집하는 개인정보는 아래 목적으로만 사용한다.
| 수집 목적 | 해당 항목 | 보관 기간 |
|----------|----------|----------|
| 서비스 회원 관리 및 인증 | 이메일, 전화번호, 소셜 계정 ID | 회원 탈퇴 후 30일 |
| 매장 등록 및 매칭 서비스 제공 | 성명, 전화번호, 이메일, 상세 주소 | 거래 종료 후 5년 |
| 계약 이행 및 정산 | 사업자등록번호, 계좌번호 | 계약 종료 후 5년 (세법 기준) |
| 정부 지원금 신청 보조 | 개인 서류 일체 | 지원금 케이스 종료 후 5년 |
| 분쟁 처리 및 법적 대응 | 관련 기록 전체 | 분쟁 종료 후 5년 |
| 서비스 개선 및 통계 분석 | 익명화된 이용 이력 | 5년 |
### 8-3. 개인정보 보관 기간 및 파기 정책
- 회원 탈퇴 시: 서비스 이용 기록은 탈퇴 후 30일 이내 파기. 단, 진행 중인 거래, 계약, 분쟁이 있는 경우 해당 케이스 종료 후 법정 보관 기간을 적용한다.
- 매장 등록 정보: 거래 종료(CLOSED/CANCELLED) 후 5년. 운영자가 법적 근거를 문서화한 경우에 한해 연장 가능.
- 정부 지원금 관련 서류: 서류에 명시된 법정 보관 기간을 우선 적용하며, 최소 5년.
- 파기 방법: DB에 저장된 데이터는 완전 삭제(DELETE) 처리하며, 파일 스토리지의 파일은 영구 삭제 후 파기 이력을 별도 로그로 보관한다.
- 자동 파기 스케줄러를 운영하며, 파기 실행 결과를 운영자가 확인할 수 있는 보고서를 제공한다.
### 8-4. 제3자 제공 시 동의 요건
개인정보의 제3자 제공은 아래 경우에만 허용된다.
| 제공 유형 | 제공 조건 | 동의 요건 |
|----------|----------|----------|
| 매칭 상대방 (창업자/폐업자 간) | 매칭 수락 완료 | 사전 동의 (`THIRD_PARTY_MATCHED_PARTY`) 필수 |
| 철거/인테리어 업체 | 운영자 승인 + 매칭 수락 | 사전 동의 + 운영자 승인 |
| 정부 기관 (법적 요청) | 수사·법원 요청 등 법적 의무 | 동의 불요, 운영자 승인 + 법무 검토 |
| 정산·결제 대행사 (PG사) | 결제 처리 목적 | 서비스 이용약관에 포함 (별도 동의 불요) |
| 통계·분석 서비스 (익명화) | 개인 식별 불가한 집계 데이터 | 동의 불요 |
B2B 알림 상품 출시 시(Phase 2) 업체에 대한 정보 제공 범위가 확대될 수 있으며, 이 경우 반드시 별도 동의를 재수집해야 한다.
### 8-5. 개인정보 처리 위탁
Re:Link가 개인정보 처리를 위탁하는 수탁자와 위탁 업무 범위는 개인정보 처리방침에 공개한다.
| 수탁자 | 위탁 업무 |
|--------|----------|
| 토스페이먼츠 | 결제 처리 및 에스크로 관리 |
| AWS / IDC 사업자 | 데이터 보관 및 인프라 운영 |
| 카카오 | 알림톡 발송 |
| 소셜 로그인 제공자 (Kakao, Naver, Google) | 본인인증 및 계정 연동 |
수탁자는 위탁 받은 업무 범위 외에 개인정보를 이용하거나 제3자에게 제공하지 않는다. 위탁 계약 체결 시 개인정보 보호 의무를 계약서에 명시한다.
### 8-6. 정보주체의 권리
폐업자를 포함한 모든 사용자는 아래 권리를 행사할 수 있다.
| 권리 | 요청 방법 | 처리 기한 |
|------|----------|----------|
| 열람 요청 | 앱 내 개인정보 관리 메뉴 또는 운영자 이메일 | 10일 이내 |
| 정정·삭제 요청 | 앱 내 개인정보 관리 메뉴 | 10일 이내 |
| 처리 정지 요청 | 운영자 이메일 | 10일 이내 |
| 동의 철회 | 앱 내 동의 관리 메뉴 | 즉시 처리 |
---
## 부록: 용어 정의
| 용어 | 정의 |
|------|------|
| CLOSING_OWNER | 폐업 매장을 등록한 사용자. 본인이 등록한 매장에 대해서는 RESTRICTED 등급까지 기본 접근 권한을 가짐 |
| FOUNDER | 창업 목적으로 매장 인수를 탐색하는 사용자 |
| VENDOR_MANAGER | 철거 또는 인테리어 서비스를 제공하는 업체의 담당자. 업체 인증 완료 후 RESTRICTED 접근 가능 |
| OPS_MANAGER | 매장 등록 검토, 매칭 보정, 운영 품질을 담당하는 운영자 |
| SUBSIDY_OPERATOR | 정부 지원금 서류 검토 및 상태 관리를 담당하는 운영자 |
| TRUST_OPERATOR | 업체 인증, 계약, 검수, 분쟁을 담당하는 운영자 |
| FINANCE_OPERATOR | 정산, 결제 대사, 환불을 담당하는 운영자 |
| SUPER_ADMIN | 전체 시스템 설정 및 긴급 조치 권한을 가진 최고 관리자 |
| visibilityScope | 데이터 항목(특히 사진)에 부여된 공개 등급 필드 |
| publicationStatus | 매장 전체의 공개 상태 (PRIVATE, RESTRICTED, PUBLISHED, UNPUBLISHED) |
| reviewStatus | 운영자 검토 상태 (DRAFT, SUBMITTED, REVIEWING, APPROVED, REJECTED) |
| AuditLog | 시스템 내 모든 주요 행위를 추적하는 감사 로그 |
| PII | 개인식별정보 (Personally Identifiable Information) |
| 서명 URL | 일정 시간 동안만 유효한 인증된 파일 접근 URL |
---
## 문서 이력
| 버전 | 날짜 | 변경 내용 | 작성자 |
|------|------|----------|--------|
| v1.0.0 | 2026-03-07 | 최초 작성 | 대표 + CTO |
> 이 문서는 Re:Link 운영 및 개발의 기준 정책 문서입니다. 변경 시 DRI(대표)의 승인을 거쳐야 하며, 변경 이력을 반드시 기록해야 합니다.
+594
View File
@@ -0,0 +1,594 @@
# Re:Link 지원금 대행 정책서
> 문서 코드: D001
> 버전: 1.0.0
> 작성일: 2026-03-07
> 적용 범위: MVP Phase 1 (희망리턴패키지)
> 승인 상태: 초안 (법무 검토 전)
> 다음 검토일: 2026-04-01 (G0 게이트 정책 확정 전)
---
## 목차
1. [서비스 범위 정의](#1-서비스-범위-정의)
2. [자격 판별 규칙](#2-자격-판별-규칙)
3. [체크리스트 정의](#3-체크리스트-정의)
4. [상태 전환 규칙](#4-상태-전환-규칙)
5. [운영자 역할](#5-운영자-역할)
6. [사용자 경험 흐름](#6-사용자-경험-흐름)
7. [정책 변경 대응](#7-정책-변경-대응)
8. [리스크 및 면책](#8-리스크-및-면책)
---
## 1. 서비스 범위 정의
### 1-1. 포지션 원칙
Re:Link의 지원금 서비스는 **정부 사업의 경쟁자가 아닌 신청 보조 파트너**다.
플랫폼은 폐업자가 정부 지원금을 받기 위해 스스로 준비해야 할 서류와 절차를 안내하고, 운영자가 서류 구비 상태를 검토하여 제출 준비 완료 여부를 판정하는 역할까지만 담당한다. 정부 기관을 대신하여 자동으로 서류를 제출하거나, 심사 결과에 영향을 주는 행위는 MVP에서 하지 않는다.
### 1-2. MVP 서비스 범위
| 구분 | 범위 |
|------|------|
| 서비스 유형 | 가이드형 + 운영자 검토형 |
| 지원 프로그램 | 희망리턴패키지 (단일) |
| 적용 사용자 | 폐업자 (UserRole: CLOSING_OWNER) |
| 케이스 생성 기준 | Store 1개당 1개의 active SubsidyCase |
### 1-3. 지원 가능 프로그램 목록
**MVP (Phase 1) 지원 프로그램:**
| 프로그램 코드 | 프로그램명 | 주관 기관 | 지원 내용 요약 |
|--------------|-----------|----------|----------------|
| `HOPE_RETURN_PKG` | 희망리턴패키지 | 소상공인시장진흥공단 | 폐업 소상공인 대상 점포철거비, 법률·세무 컨설팅, 재취업·재창업 지원 |
**Phase 2 이후 추가 예정 (참고용):**
| 프로그램 코드 | 프로그램명 | 추가 예정 시점 |
|--------------|-----------|----------------|
| `RESTART_LOAN` | 재기지원 융자 | Phase 2 검토 |
| `CLOSURE_CONSULTING` | 폐업 컨설팅 지원 | Phase 2 검토 |
Phase 2 프로그램은 법무 검토 및 운영 준비 완료 후 별도 정책서로 추가한다.
### 1-4. 플랫폼이 하는 것과 하지 않는 것
**플랫폼이 하는 것:**
- 자격 요건 체크리스트를 기반으로 자격 판별 결과를 안내한다.
- 필수/선택 서류 목록을 제공하고 업로드 기능을 지원한다.
- 업로드된 서류의 진행 상태와 운영자 피드백을 사용자에게 보여준다.
- 운영자가 서류 구비 상태를 검토하고 보완 요청 또는 제출 준비 완료를 판정한다.
- 각 상태 변화 시 사용자에게 알림을 발송한다.
**플랫폼이 하지 않는 것:**
- 정부 기관(소진공) 포털에 사용자를 대리하여 자동 제출하지 않는다.
- 정부 심사 결과를 보장하거나 승인 가능성을 수치로 제시하지 않는다.
- 법적 대리인 자격으로 서류에 서명하거나 행위를 대행하지 않는다.
- 세무·법률 전문가의 의견을 대체하는 법적 자문을 제공하지 않는다.
- 지원금 수령액을 미리 보장하거나 확약하지 않는다.
---
## 2. 자격 판별 규칙
### 2-1. 희망리턴패키지 자격 요건
자격 판별은 사용자가 입력한 정보를 기반으로 시스템이 체크하며, 최종 판정은 정부 기관이 수행한다. 플랫폼의 자격 판별 결과는 신청 가능성에 대한 **안내 목적**이며, 법적 효력이 없다.
**기본 요건:**
| 항목 | 조건 | 비고 |
|------|------|------|
| 사업자등록 유지 여부 | 현재 사업자등록이 유효한 상태여야 함 | 이미 폐업 처리된 경우 일부 항목 신청 불가 |
| 사업 영위 기간 | 사업자등록 후 6개월 이상 실제 영업 | 영업 사실 서류 필요 |
| 폐업 의사 | 폐업 예정이거나 폐업 절차 진행 중 | 이미 폐업 처리된 경우 일부 항목은 신청 가능 |
| 매출액 기준 | 연 매출 10억 5,000만 원 이하 소상공인 기준 충족 | 소상공인기본법 기준 |
| 업종 기준 | 제조업·건설업·운수업 등 소상공인 해당 업종 | 금융업·보험업 등 일부 제외 업종 존재 |
| 중복 수혜 이력 | 동일 프로그램 기수혜 이력 없음 | 기수혜자는 항목별 제한 상이 |
**업종 제한 (MVP 기준):**
- 금융·보험업 종사자는 신청 불가
- 부동산업, 임대업 일부는 별도 확인 필요
- 전문직(의사·변호사·회계사 등) 사업자는 별도 확인 필요
상기 업종 제한은 소진공 공지 기준이며 사업 연도별로 변경될 수 있다. 운영자는 최신 공고를 확인하여 판별 기준을 업데이트한다.
### 2-2. 자격 판별 결과 유형
자격 판별 결과는 `eligibilityResult` 필드에 저장되며, 4가지 유형으로 분류된다.
#### ELIGIBLE (신청 가능)
모든 자격 요건을 충족한 것으로 판단되는 경우.
사용자 안내 메시지:
> "입력하신 정보를 기준으로 희망리턴패키지 신청 자격을 갖춘 것으로 확인됩니다. 아래 체크리스트에 따라 필요 서류를 준비하고 업로드해 주세요. 최종 자격 판정은 소상공인시장진흥공단에서 확인합니다."
#### CONDITIONALLY_ELIGIBLE (조건부 가능)
일부 요건이 불확실하거나 추가 확인이 필요한 경우. (예: 업종 경계 케이스, 매출 기준 근접, 중복 수혜 이력 불명확)
사용자 안내 메시지:
> "입력하신 정보 중 일부 항목을 추가로 확인해야 합니다. 운영자가 서류를 검토하면서 자격 여부를 함께 안내드리겠습니다. 체크리스트 서류를 준비하여 업로드해 주세요."
#### NOT_ELIGIBLE (신청 불가)
명확히 자격 요건을 충족하지 못하는 경우. (예: 사업자등록 말소, 연 매출 초과, 제외 업종)
사용자 안내 메시지:
> "입력하신 정보를 기준으로 현재 희망리턴패키지 신청 자격을 충족하기 어렵습니다. 구체적인 사유는 아래에서 확인하실 수 있습니다. 상황이 변경되거나 문의가 필요하시면 운영팀에 연락해 주세요."
NOT_ELIGIBLE 판별 시 사유 코드를 함께 저장한다:
- `ALREADY_CLOSED`: 사업자등록 이미 말소
- `REVENUE_EXCEEDED`: 소상공인 매출 기준 초과
- `EXCLUDED_INDUSTRY`: 제외 업종
- `DUPLICATE_BENEFIT`: 동일 프로그램 기수혜
#### UNKNOWN (판별 불가)
입력 정보 부족 또는 시스템 오류로 판별이 불가능한 경우.
사용자 안내 메시지:
> "자격 판별에 필요한 정보가 부족합니다. 매장 정보와 사업자 정보를 보완해 주시면 다시 확인해 드리겠습니다. 궁금한 사항은 운영팀에 문의해 주세요."
### 2-3. 자격 판별 재시도 원칙
- 사용자가 매장 정보를 수정하거나 보완하면 자격 판별을 재실행할 수 있다.
- 재판별 결과는 새로운 `eligibilitySnapshotJson`으로 저장하되, 기존 SubsidyCase의 `eligibilityResult`를 갱신한다.
- 자격 판별 재실행 시 AuditLog를 남긴다.
---
## 3. 체크리스트 정의
### 3-1. 체크리스트 운영 원칙
- 체크리스트는 `checklistVersionCode`로 버전을 관리한다.
- SubsidyCase 생성 시점의 체크리스트 버전을 케이스에 고정한다.
- 체크리스트 항목은 `SubsidyChecklistItem` 테이블에 생성된다.
- 각 항목은 `itemCode`로 식별하며, 정부 공고 변경 시 새 버전으로 추가한다.
### 3-2. 희망리턴패키지 필수 서류 목록 (체크리스트 버전: `HOPE_RETURN_PKG_V1`)
| itemCode | 서류명 | 설명 | isRequired |
|----------|--------|------|------------|
| `HRP_DOC_001` | 사업자등록증 사본 | 현재 유효한 사업자등록증 사본 (발급일 3개월 이내 권장) | true |
| `HRP_DOC_002` | 폐업 사유서 | 자필 또는 서식 작성, 폐업 사유와 의사를 구체적으로 기재 | true |
| `HRP_DOC_003` | 본인 확인 서류 | 주민등록증, 운전면허증, 여권 중 택1 (사업자 본인) | true |
| `HRP_DOC_004` | 최근 6개월 부가세 신고 내역 또는 매출 확인 서류 | 홈택스 발급 가능, 간이과세자는 대체 서류 허용 | true |
| `HRP_DOC_005` | 임대차 계약서 사본 | 현재 영업 중인 매장의 임대차 계약서 (해당하는 경우) | true |
| `HRP_DOC_006` | 점포 철거비 견적서 | 철거업체 발행 공식 견적서 (철거비 지원 신청 시 필수) | true |
| `HRP_DOC_007` | 통장 사본 | 지원금 수령 계좌 확인용, 사업자 본인 명의 | true |
### 3-3. 선택 서류 목록
| itemCode | 서류명 | 설명 | 제출 권장 상황 |
|----------|--------|------|----------------|
| `HRP_DOC_101` | 가족관계증명서 | 대표자 확인 보완 서류 | 본인 확인 서류 외 추가 요청 시 |
| `HRP_DOC_102` | 영업 사실 확인 서류 | 카드단말기 내역, 영업허가증 등 | 영업 기간이 짧거나 확인이 어려운 경우 |
| `HRP_DOC_103` | 부채 확인 서류 | 대출 잔액 확인서 등 | 재기 지원 신청 동시 진행 시 |
| `HRP_DOC_104` | 건물 등기부등본 | 매장 소재지 확인용 | 임대차 계약서 없이 자가 영업인 경우 |
### 3-4. 서류 상태 코드
`SubsidyDocument``reviewStatus` 값:
| 상태 코드 | 의미 |
|----------|------|
| `PENDING` | 업로드 완료, 운영자 검토 대기 중 |
| `APPROVED` | 운영자 검토 완료, 서류 정상 |
| `REJECTED` | 반려, 재업로드 또는 교체 필요 |
| `RESUBMITTED` | 반려 후 사용자가 재업로드 완료 |
### 3-5. 체크리스트 버전 관리 원칙
- 정부 공고 변경 시 신규 버전 코드를 발행한다. (예: `HOPE_RETURN_PKG_V2`)
- 기존 진행 중인 케이스는 케이스 생성 시점의 버전을 유지한다.
- 신규 케이스부터 최신 버전이 적용된다.
- 버전 변경 내역은 운영 문서로 기록한다.
- 체크리스트 버전과 `PolicyVersion` 테이블은 연동하여 관리한다.
---
## 4. 상태 전환 규칙
### 4-1. SubsidyCase 상태머신
```
DRAFT
|
| [사용자: 자격 판별 요청]
v
ELIGIBILITY_CHECKED
|
| [자격 결과 ELIGIBLE 또는 CONDITIONALLY_ELIGIBLE]
v
DOCUMENTS_PENDING
|
| [사용자: 필수 서류 전체 업로드 완료 후 검토 요청]
v
REVIEWING
|
|-- [운영자: 보완 요청] --> DOCUMENTS_PENDING (보완 요청 상태)
|
| [운영자: 제출 준비 완료 판정]
v
READY_TO_SUBMIT
|
| [사용자: 최종 제출 확인 및 동의]
v
SUBMITTED
|
|-- [결과: 승인] --> APPROVED
|-- [결과: 반려] --> REJECTED
```
NOT_ELIGIBLE 판별 시: ELIGIBILITY_CHECKED 상태에서 케이스를 CLOSED 처리한다.
### 4-2. 각 상태별 전제 조건
#### DRAFT
- SubsidyCase가 생성된 초기 상태.
- 전제 조건: Store가 존재하고, 해당 Store에 대한 active SubsidyCase가 없어야 함.
- 같은 `storeId + programCode` 조합으로 active 케이스는 1개만 허용한다.
#### ELIGIBILITY_CHECKED
- 전제 조건: 사용자가 자격 판별에 필요한 매장 정보 및 사업자 정보를 입력 완료.
- 전환 주체: 사용자 (시스템 자동 판별).
- 저장 내용: `eligibilityResult`, `eligibilitySnapshotJson`, `policyVersionId`.
#### DOCUMENTS_PENDING
- 전제 조건: `eligibilityResult``ELIGIBLE` 또는 `CONDITIONALLY_ELIGIBLE`.
- 전환 주체: 시스템 자동 전환 (ELIGIBILITY_CHECKED 이후).
- NOT_ELIGIBLE인 경우 이 단계로 진입하지 않는다.
- 보완 요청으로 되돌아온 경우: `rejectionReasonCode`와 운영자 메모를 사용자에게 표시.
#### REVIEWING
- 전제 조건: 필수 서류(`isRequired: true`) 전체가 업로드 완료 상태여야 함.
- 전환 주체: 사용자 (검토 요청 버튼 클릭).
- 전환 조건 검증: 시스템이 필수 항목 누락 여부를 실시간으로 검사하며, 누락 항목이 있으면 검토 요청 불가.
#### READY_TO_SUBMIT
- 전제 조건: 운영자가 모든 서류를 APPROVED 처리.
- 전환 주체: `SUBSIDY_OPERATOR` 역할 운영자.
- 이 상태에서 운영자는 제출을 위한 최종 안내 메시지를 사용자에게 작성한다.
#### SUBMITTED
- 전제 조건: 사용자가 최종 제출 동의 완료.
- 전환 주체: 사용자.
- MVP에서는 시스템이 자동으로 정부 포털에 제출하지 않는다. 사용자가 직접 제출하였음을 확인하는 방식으로 운영한다.
- `submittedAt` 타임스탬프 기록.
#### APPROVED / REJECTED
- 전환 주체: `SUBSIDY_OPERATOR` 역할 운영자 (정부 결과 확인 후 수동 업데이트).
- 정부 기관의 최종 결과를 운영자가 플랫폼에 반영한다.
- REJECTED 시 `rejectionReasonCode`와 사유 메모를 함께 기록한다.
### 4-3. 전환 가능한 역할 매트릭스
| 전환 | 전환 가능 역할 |
|------|----------------|
| DRAFT → ELIGIBILITY_CHECKED | 사용자 본인 (CLOSING_OWNER) |
| ELIGIBILITY_CHECKED → DOCUMENTS_PENDING | 시스템 자동 |
| DOCUMENTS_PENDING → REVIEWING | 사용자 본인 (CLOSING_OWNER) |
| REVIEWING → DOCUMENTS_PENDING (보완 요청) | SUBSIDY_OPERATOR, SUPER_ADMIN |
| REVIEWING → READY_TO_SUBMIT | SUBSIDY_OPERATOR, SUPER_ADMIN |
| READY_TO_SUBMIT → SUBMITTED | 사용자 본인 (CLOSING_OWNER) |
| SUBMITTED → APPROVED | SUBSIDY_OPERATOR, SUPER_ADMIN |
| SUBMITTED → REJECTED | SUBSIDY_OPERATOR, SUPER_ADMIN |
| 모든 상태 → CLOSED (케이스 종료) | SUBSIDY_OPERATOR, SUPER_ADMIN |
### 4-4. 상태 전환 시 생성되는 로그
모든 상태 전환은 반드시 아래 두 로그를 동시에 생성한다.
**AuditLog:**
| 필드 | 내용 |
|------|------|
| `resourceType` | `SubsidyCase` |
| `resourceId` | 해당 케이스 ID |
| `actionType` | 전환 액션 코드 (예: `SUBSIDY_CASE_STATUS_CHANGED`) |
| `actorUserId` | 전환을 실행한 사용자 또는 운영자 ID |
| `actorRole` | 실행자 역할 |
| `reasonCode` | 전환 사유 코드 (운영자 전환 시 필수) |
| `memo` | 운영자 메모 (선택, 사용자 피드백 포함 시 필수) |
| `beforeJson` | 전환 전 상태 스냅샷 |
| `afterJson` | 전환 후 상태 스냅샷 |
**EventLog:**
| 필드 | 내용 |
|------|------|
| `aggregateType` | `SubsidyCase` |
| `aggregateId` | 해당 케이스 ID |
| `eventName` | 이벤트명 (예: `subsidy_case_reviewed`) |
| `payloadJson` | `subsidyCaseId`, `result`, `reason_code`, `operator_id` 포함 |
| `piiLevel` | `LOW` (개인 식별 정보 최소화) |
---
## 5. 운영자 역할
### 5-1. 담당 운영자 역할
지원금 관련 운영은 `SUBSIDY_OPERATOR` 역할이 담당한다. `SUPER_ADMIN`은 모든 권한을 보유한다.
### 5-2. 서류 검토 프로세스
운영자는 아래 순서로 서류를 검토한다.
1. 운영 콘솔의 지원금 서류 검토 큐에서 REVIEWING 상태 케이스를 확인한다.
2.`SubsidyDocument`를 순서대로 열람한다.
3. 서류별로 APPROVED 또는 REJECTED를 판정한다.
4. REJECTED 처리 시 반려 사유 코드와 구체적인 보완 요청 내용을 기재한다.
5. 필수 서류 전체 APPROVED 시, 케이스를 READY_TO_SUBMIT으로 전환한다.
6. 보완이 필요한 경우 케이스를 DOCUMENTS_PENDING으로 되돌리고 보완 요청 알림을 발송한다.
### 5-3. 반려 사유 코드 목록
| 코드 | 명칭 | 설명 | 사용자 안내 예시 |
|------|------|------|------------------|
| `MISSING_DOCUMENT` | 서류 누락 | 필수 서류가 업로드되지 않음 | "필수 서류가 누락되어 있습니다. 해당 서류를 업로드해 주세요." |
| `INVALID_DOCUMENT` | 서류 유효하지 않음 | 잘못된 서류 제출 (기간 만료, 타인 명의, 관계없는 서류 등) | "제출하신 서류를 확인할 수 없습니다. 올바른 서류를 다시 업로드해 주세요." |
| `ILLEGIBLE_DOCUMENT` | 서류 판독 불가 | 사진 품질 불량, 잘린 이미지, 흐릿한 스캔 | "서류 내용을 확인하기 어렵습니다. 선명하게 다시 촬영하거나 스캔하여 업로드해 주세요." |
| `EXPIRED_DOCUMENT` | 서류 유효기간 만료 | 유효기간이 지난 서류 제출 | "제출하신 서류의 유효기간이 만료되었습니다. 최신 서류를 발급받아 다시 업로드해 주세요." |
| `ELIGIBILITY_ISSUE` | 자격 요건 미충족 의심 | 서류 검토 결과 자격 요건 충족 여부가 불확실 | "서류 내용을 검토한 결과 추가 확인이 필요합니다. 운영팀에서 연락드리겠습니다." |
| `INCOMPLETE_INFO` | 정보 불완전 | 서류 내용이 부분적으로 기재되지 않거나 서명 누락 | "서류의 일부 항목이 작성되지 않았습니다. 모든 항목을 기재하여 다시 제출해 주세요." |
| `MISMATCHED_INFO` | 정보 불일치 | 다른 서류와 내용이 일치하지 않음 (주소, 이름, 사업자번호 등) | "제출하신 서류의 정보가 다른 서류와 일치하지 않습니다. 내용을 확인하고 수정하여 다시 제출해 주세요." |
| `WRONG_FORMAT` | 형식 오류 | 요구된 형식과 다른 서류 제출 (예: 견적서 양식 불일치) | "올바른 형식의 서류가 아닙니다. 안내된 서식에 맞게 작성하여 다시 업로드해 주세요." |
### 5-4. 보완 요청 절차
1. 운영자가 반려 사유 코드와 메모를 입력하여 케이스를 DOCUMENTS_PENDING으로 전환한다.
2. 시스템이 사용자에게 카카오 알림톡 또는 이메일로 보완 요청 알림을 발송한다.
3. 알림에는 반려된 서류명과 보완 요청 내용이 포함된다.
4. 사용자가 서류를 재업로드하면 해당 서류의 `reviewStatus``RESUBMITTED`로 변경된다.
5. 사용자가 보완 완료 후 다시 검토 요청하면 케이스가 REVIEWING으로 전환된다.
보완 요청은 횟수 제한이 없으나, 동일 사유로 3회 이상 반복 반려 시 운영자는 케이스에 메모를 남기고 별도 상담을 안내한다.
### 5-5. 운영자 SLA (검토 기한)
| 케이스 유형 | 목표 검토 기한 | 최대 기한 |
|------------|----------------|-----------|
| 일반 REVIEWING 케이스 | 영업일 기준 3일 이내 | 영업일 기준 5일 |
| 재제출(RESUBMITTED) 포함 케이스 | 영업일 기준 2일 이내 | 영업일 기준 3일 |
| READY_TO_SUBMIT 이후 사용자 문의 | 영업일 기준 1일 이내 | 영업일 기준 2일 |
검토 기한 초과 시 운영 콘솔에서 해당 케이스를 알림 표시하고, `OPS_MANAGER`에게 에스컬레이션 알림을 발송한다.
베타 운영 기간(2026년 8~9월)에는 SLA 준수율을 주간 단위로 측정하고, 케이스 수량에 따라 기한을 재조정할 수 있다.
---
## 6. 사용자 경험 흐름
### 6-1. 폐업자 화면 흐름
아래는 폐업자(CLOSING_OWNER)가 희망리턴패키지를 신청하기까지의 전체 흐름이다.
**Step 1: 케이스 생성**
- 진입 경로: 매장 상세 페이지 > "지원금 신청" 탭 > "희망리턴패키지 신청 시작"
- 사용자 확인 내용: 서비스 소개, 범위 안내, 면책 고지 동의
- 동의 완료 시 SubsidyCase 생성 (상태: DRAFT)
- 이미 active 케이스가 있는 경우 기존 케이스로 이동
**Step 2: 자격 판별**
- 화면: 자격 확인 체크리스트 (입력 또는 자동 불러오기)
- 항목: 사업자등록 유지 여부, 영업 기간, 업종, 매출 규모, 중복 수혜 이력
- 매장 등록 시 이미 입력된 정보는 자동으로 불러온다.
- 제출 시 시스템이 자격 판별 결과를 생성하고 사용자에게 표시
- 표시 내용: 판별 결과 유형, 결과별 안내 메시지, 다음 단계 안내
**Step 3: 체크리스트 확인**
- ELIGIBLE, CONDITIONALLY_ELIGIBLE인 경우에만 접근 가능
- 화면: 필수 서류 목록, 선택 서류 목록, 각 서류별 설명 및 예시
- 각 서류 항목에 업로드 버튼 제공
- 진행률(%) 표시: 필수 서류 업로드 완료 수 / 전체 필수 서류 수
**Step 4: 서류 업로드**
- 지원 파일 형식: PDF, JPG, PNG (최대 10MB/건)
- 업로드 후 미리보기 가능
- 업로드된 서류는 `reviewStatus: PENDING` 상태로 저장
- 서류 교체 가능 (PENDING 또는 REJECTED 상태에서만)
**Step 5: 검토 요청**
- 모든 필수 서류 업로드 완료 시 "검토 요청" 버튼 활성화
- 버튼 클릭 시 케이스 상태: DOCUMENTS_PENDING → REVIEWING
- 사용자 화면: "운영자가 서류를 검토하고 있습니다. 영업일 기준 3일 내 결과를 알려드립니다."
- 카카오 알림톡 또는 이메일로 접수 확인 알림 발송
**Step 6: 검토 결과 확인**
- 보완 요청인 경우: 알림 수신 → 케이스 페이지에서 반려 서류 및 사유 확인 → 재업로드 → 재검토 요청
- 제출 준비 완료인 경우: 알림 수신 → READY_TO_SUBMIT 상태 화면 확인
**Step 7: 최종 제출**
- READY_TO_SUBMIT 상태에서 "최종 제출" 버튼 표시
- 클릭 시 최종 제출 안내 팝업: 서류 목록 요약, 정부 포털 제출 방법 안내, 면책 동의 재확인
- 동의 및 확인 클릭 시 상태: SUBMITTED
- 사용자 화면: "제출이 완료되었습니다. 소상공인시장진흥공단에서 심사 결과를 안내드립니다."
**Step 8: 결과 확인**
- 정부 심사 결과를 운영자가 플랫폼에 업데이트하면 사용자에게 알림 발송
- APPROVED: "희망리턴패키지 신청이 승인되었습니다. 소진공에서 안내된 절차에 따라 지원금을 수령하세요."
- REJECTED: "안타깝게도 이번 신청이 반려되었습니다. 사유를 확인하고 재신청 여부를 검토해 주세요."
### 6-2. 단계별 알림 발송 기준
| 이벤트 | 발송 대상 | 채널 | 내용 |
|--------|-----------|------|------|
| 케이스 생성 | 사용자 | 이메일 | 신청 접수 확인, 다음 단계 안내 |
| 검토 요청 접수 | 사용자 | 카카오 알림톡 | 검토 요청 접수 확인, 예상 처리 기한 안내 |
| 보완 요청 | 사용자 | 카카오 알림톡 + 이메일 | 반려 서류명, 보완 요청 내용, 재업로드 링크 |
| 제출 준비 완료 | 사용자 | 카카오 알림톡 | 검토 완료 안내, 최종 제출 방법 안내 |
| 제출 완료 | 사용자 | 이메일 | 제출 확인, 소진공 심사 일정 안내 |
| 결과 (승인/반려) | 사용자 | 카카오 알림톡 + 이메일 | 결과 통보, 후속 절차 안내 |
| SLA 초과 | 운영자 | 내부 알림 | 검토 기한 초과 케이스 알림 |
---
## 7. 정책 변경 대응
### 7-1. 정부 프로그램 변경 시 기존 케이스 처리 원칙
정부 프로그램(희망리턴패키지)의 지원 내용, 자격 요건, 서류 목록이 변경되는 경우:
1. `PolicyVersion` 테이블에 신규 버전을 등록한다.
2. 신규 케이스부터 새 버전이 적용된다.
3. 기존 케이스(DRAFT ~ REVIEWING 상태)는 원칙적으로 기존 버전을 유지한다.
4. 변경 내용이 기존 케이스에 불이익을 줄 수 있는 경우, 운영자가 영향받는 케이스를 식별하고 개별 안내한다.
5. READY_TO_SUBMIT 이후 상태의 케이스는 정책 변경 영향을 받지 않는다.
**케이스별 처리 지침:**
| 케이스 상태 | 처리 방법 |
|-------------|-----------|
| DRAFT | 재시작 권유 (새 버전 적용) 또는 기존 버전 유지 (운영자 판단) |
| ELIGIBILITY_CHECKED | 자격 재판별 안내 발송 |
| DOCUMENTS_PENDING | 신규 필수 서류가 추가된 경우 추가 업로드 안내 |
| REVIEWING | 운영자가 새 기준 적용 여부를 케이스별 판단 |
| READY_TO_SUBMIT 이후 | 정책 변경 적용 없이 기존 버전으로 진행 |
### 7-2. 체크리스트 버전 업데이트 시 진행 중 케이스 처리
체크리스트 항목이 추가/변경/삭제되는 경우:
- 삭제된 항목: 기존 케이스에서 해당 항목을 선택 서류로 변경하거나 비활성 처리한다.
- 추가된 필수 항목: 기존 진행 케이스 중 REVIEWING 이전 상태는 추가 항목 업로드 안내를 발송한다.
- 기준 완화: 기존 케이스에도 소급 적용 가능하나 운영자 확인 후 처리한다.
### 7-3. PolicyVersion 연결 원칙
- SubsidyCase 생성 시 해당 시점의 active PolicyVersion ID를 `policyVersionId` 필드에 저장한다.
- 저장된 `policyVersionId`는 케이스 종료 전까지 변경하지 않는다.
- 정책 재판별이 필요한 경우 운영자가 사유와 함께 `policyVersionId`를 갱신할 수 있다. 이 경우 AuditLog를 반드시 남긴다.
- `eligibilitySnapshotJson`에는 판별 시점의 정책 기준 요약을 포함시킨다.
---
## 8. 리스크 및 면책
### 8-1. 법적 면책 고지 문구
서비스 이용 전 사용자 동의 화면에 아래 내용을 명시한다.
**[이용자 안내 및 면책 고지]**
Re:Link 지원금 서비스는 소상공인의 정부 지원금 신청 절차를 안내하는 보조 서비스입니다.
1. Re:Link는 정부 기관(소상공인시장진흥공단 등)을 대리하지 않으며, 지원금 심사 및 승인 결과에 영향을 주지 않습니다.
2. 플랫폼이 제공하는 자격 판별 결과는 입력 정보 기반의 안내용이며, 법적 효력이 없습니다.
3. 최종 지원금 심사 및 승인 여부는 소상공인시장진흥공단의 결정에 따릅니다.
4. Re:Link는 지원금 수령을 보장하거나 확약하지 않습니다.
5. 제출 서류의 진실성과 정확성에 대한 책임은 신청자 본인에게 있습니다.
6. 허위 서류 제출 또는 부정 수급 시 관련 법령에 따라 환수 및 제재를 받을 수 있습니다.
7. Re:Link는 세무·법률 전문가의 의견을 대체하는 자문을 제공하지 않습니다.
### 8-2. 정부 승인 불보장 원칙
플랫폼 내 어떤 화면, 메시지, 알림에서도 아래 표현을 사용하지 않는다.
- "승인 보장", "합격 보장", "수령 확정"에 해당하는 표현
- 승인 가능성을 구체적인 수치(%)로 제시하는 표현
- 플랫폼 검토 완료가 정부 승인을 의미한다는 오해를 줄 수 있는 표현
대신 아래 표현 기준을 따른다:
- "서류 구비 완료", "제출 준비 완료"는 플랫폼 운영자의 서류 검토 완료를 의미함을 명시한다.
- "제출 완료"는 사용자가 정부 포털에 직접 제출하였음을 기록한 것임을 명시한다.
### 8-3. 개인정보 처리 원칙
지원금 서비스에서 수집·처리하는 개인정보는 아래 원칙을 따른다.
**수집 목적 한정:**
- 희망리턴패키지 신청 보조 서비스 제공 목적으로만 사용한다.
- 수집된 서류 및 정보는 마케팅, 제3자 제공에 활용하지 않는다.
**보관 기간:**
| 항목 | 보관 기간 | 근거 |
|------|-----------|------|
| 케이스 처리 내역 | 케이스 종료 후 5년 | 정부 감사 대비 |
| 업로드 서류 원본 | 케이스 종료 후 3년 | 분쟁 대비 (법무 검토 후 조정) |
| AuditLog | 최소 3년 | 운영 감사 기록 |
실제 보관 기간은 개인정보보호법 및 관련 법령 검토 후 최종 확정한다. (법무 검토 필요)
**접근 제한:**
- 업로드 서류는 서명 URL(시간 제한 있는 임시 접근 링크) 방식으로 접근한다.
- 운영자의 서류 열람은 AuditLog에 기록된다.
- 케이스 신청자 본인 및 담당 `SUBSIDY_OPERATOR` 외에는 서류에 직접 접근하지 않는다.
**사용자 권리:**
- 케이스 종료 후 사용자는 업로드한 서류 삭제를 요청할 수 있다. (법정 보관 기간 내 서류는 즉시 삭제 불가)
- 보관 기간 만료 시 서류를 자동 삭제한다.
### 8-4. 법무 검토 필요 항목
현재 초안 단계로, 아래 항목은 법무 검토 완료 전 운영하지 않거나 제한 운영한다.
| 항목 | 현재 상태 | 법무 검토 후 조치 |
|------|-----------|------------------|
| 정부 포털 자동 제출 기능 | 구현 금지 | 법무 검토 + 소진공 협의 후 검토 |
| 보관 기간 확정 | 잠정 기준 적용 | 개인정보보호법 해석 확인 후 확정 |
| 대리 신청 가능 범위 | MVP에서 사용자 직접 제출만 허용 | 행정사법 등 검토 후 결정 |
| 지원금 수수료 구조 | 미정 | 법적 대행 허용 범위 확인 후 결정 |
---
## 부록
### A. 관련 문서
| 문서 | 경로 |
|------|------|
| 개발 계획서 | `/DEVELOPMENT_PLAN.md` |
| Prisma 스키마 초안 | `/docs/database/schema-prisma-draft.md` |
| 베타 마스터 데이터 | `/docs/master-data/beta-master-data.md` |
| 정보 공개 정책서 (예정) | `/docs/policies/data-exposure-policy.md` |
| 계약-에스크로-분쟁 정책서 (예정) | `/docs/policies/contract-escrow-policy.md` |
### B. 용어 정의
| 용어 | 정의 |
|------|------|
| 지원금 대행 | MVP에서는 신청 보조 및 서류 검토 지원을 의미하며, 법적 대리 행위를 포함하지 않음 |
| 자격 판별 | 사용자 입력 정보 기반의 자격 안내 (법적 효력 없음) |
| 운영자 검토 | SUBSIDY_OPERATOR가 업로드 서류의 구비 완료 여부를 확인하는 행위 |
| 제출 준비 완료 | 운영자가 서류 구비 상태가 충족되었다고 판단한 상태 (정부 승인 아님) |
| 체크리스트 버전 | 정부 공고 기준으로 관리되는 필수/선택 서류 목록의 버전 |
| PolicyVersion | 플랫폼 정책 기준 시점을 관리하는 내부 버전 테이블 |
### C. 개정 이력
| 버전 | 날짜 | 변경 내용 | 작성자 |
|------|------|-----------|--------|
| 1.0.0 | 2026-03-07 | 초안 작성 (MVP Phase 1 기준) | - |
+32
View File
@@ -0,0 +1,32 @@
{
"name": "re-link",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"type-check": "turbo run type-check",
"clean": "turbo run clean && rm -rf node_modules",
"db:generate": "pnpm --filter @relink/database prisma generate",
"db:push": "pnpm --filter @relink/database prisma db push",
"db:migrate": "pnpm --filter @relink/database prisma migrate dev",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"prettier": "^3.4.2",
"turbo": "^2.3.3",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@8.10.0",
"engines": {
"node": ">=20"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@relink/analytics",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Base analytics event interface.
*/
export interface AnalyticsEvent {
readonly name: string;
readonly timestamp: Date;
readonly properties: Record<string, unknown>;
}
/**
* Creates a new analytics event with the current timestamp.
*/
export function createEvent(
name: string,
properties: Record<string, unknown> = {},
): AnalyticsEvent {
return {
name,
timestamp: new Date(),
properties,
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @relink/analytics - Event schemas and KPI calculations
*/
export type { AnalyticsEvent } from './event.js';
export { createEvent } from './event.js';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@relink/application",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/domain": "workspace:*",
"@relink/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* @relink/application - Use cases and application services
*
* Orchestrates domain entities and defines application-level business rules.
*/
export type { UseCase } from './use-case.js';
+8
View File
@@ -0,0 +1,8 @@
import type { Result } from '@relink/shared';
/**
* Base use case interface. All application use cases implement this.
*/
export interface UseCase<TInput, TOutput, TError = Error> {
execute(input: TInput): Promise<Result<TOutput, TError>>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/domain', '@relink/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+1
View File
@@ -0,0 +1 @@
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@relink/database",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx seeds/seed.ts"
},
"prisma": {
"seed": "tsx seeds/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.1.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"prisma": "^6.1.0",
"tsup": "^8.3.5",
"tsx": "^4.19.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
code,name_ko,parent_code,depth,sort_order,is_leaf,is_active,is_beta_enabled
FNB,음식점/카페,,0,1,false,true,true
FNB.CAFE,카페,FNB,1,1,true,true,true
FNB.BAKERY,베이커리,FNB,1,2,true,true,true
FNB.KOREAN,한식,FNB,1,3,true,true,true
FNB.CHICKEN,치킨,FNB,1,4,true,true,true
FNB.BAR,주점,FNB,1,5,true,true,true
FNB.DESSERT,디저트,FNB,1,6,true,true,true
FNB.FASTCASUAL,간편식/패스트캐주얼,FNB,1,7,true,true,true
1 code name_ko parent_code depth sort_order is_leaf is_active is_beta_enabled
2 FNB 음식점/카페 0 1 false true true
3 FNB.CAFE 카페 FNB 1 1 true true true
4 FNB.BAKERY 베이커리 FNB 1 2 true true true
5 FNB.KOREAN 한식 FNB 1 3 true true true
6 FNB.CHICKEN 치킨 FNB 1 4 true true true
7 FNB.BAR 주점 FNB 1 5 true true true
8 FNB.DESSERT 디저트 FNB 1 6 true true true
9 FNB.FASTCASUAL 간편식/패스트캐주얼 FNB 1 7 true true true
@@ -0,0 +1,13 @@
code,name_ko,full_name_ko,region_type,parent_code,depth,path_code,sort_order,is_active,is_beta_enabled,latitude,longitude
KR,대한민국,대한민국,COUNTRY,,0,KR,1,true,false,,
KR.SEOUL,서울특별시,서울특별시,SIDO,KR,1,KR.SEOUL,1,true,false,37.5665,126.9780
KR.SEOUL.GANGNAM,강남구,서울특별시 강남구,SIGUNGU,KR.SEOUL,2,KR.SEOUL.GANGNAM,1,true,false,37.5172,127.0473
KR.SEOUL.MAPO,마포구,서울특별시 마포구,SIGUNGU,KR.SEOUL,2,KR.SEOUL.MAPO,2,true,false,37.5663,126.9014
KR.SEOUL.GANGNAM.YEOKSAM,역삼,서울특별시 강남구 역삼,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.YEOKSAM,1,true,true,37.5007,127.0365
KR.SEOUL.GANGNAM.SEOLLEUNG,선릉,서울특별시 강남구 선릉,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.SEOLLEUNG,2,true,true,37.5045,127.0490
KR.SEOUL.GANGNAM.NONHYEON,논현,서울특별시 강남구 논현,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.NONHYEON,3,true,true,37.5112,127.0276
KR.SEOUL.MAPO.HONGDAE,홍대,서울특별시 마포구 홍대,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.HONGDAE,1,true,true,37.5563,126.9237
KR.SEOUL.MAPO.HAPJEONG,합정,서울특별시 마포구 합정,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.HAPJEONG,2,true,true,37.5496,126.9139
KR.SEOUL.MAPO.YEONNAM,연남,서울특별시 마포구 연남,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.YEONNAM,3,true,true,37.5660,126.9247
KR.BETA.GANGNAM_CORE,강남권 베타 클러스터,강남권 베타 클러스터,BETA_CLUSTER,KR.SEOUL.GANGNAM,3,KR.BETA.GANGNAM_CORE,10,true,true,37.5058,127.0370
KR.BETA.MAPO_CORE,마포권 베타 클러스터,마포권 베타 클러스터,BETA_CLUSTER,KR.SEOUL.MAPO,3,KR.BETA.MAPO_CORE,10,true,true,37.5573,126.9192
1 code name_ko full_name_ko region_type parent_code depth path_code sort_order is_active is_beta_enabled latitude longitude
2 KR 대한민국 대한민국 COUNTRY 0 KR 1 true false
3 KR.SEOUL 서울특별시 서울특별시 SIDO KR 1 KR.SEOUL 1 true false 37.5665 126.9780
4 KR.SEOUL.GANGNAM 강남구 서울특별시 강남구 SIGUNGU KR.SEOUL 2 KR.SEOUL.GANGNAM 1 true false 37.5172 127.0473
5 KR.SEOUL.MAPO 마포구 서울특별시 마포구 SIGUNGU KR.SEOUL 2 KR.SEOUL.MAPO 2 true false 37.5663 126.9014
6 KR.SEOUL.GANGNAM.YEOKSAM 역삼 서울특별시 강남구 역삼 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.YEOKSAM 1 true true 37.5007 127.0365
7 KR.SEOUL.GANGNAM.SEOLLEUNG 선릉 서울특별시 강남구 선릉 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.SEOLLEUNG 2 true true 37.5045 127.0490
8 KR.SEOUL.GANGNAM.NONHYEON 논현 서울특별시 강남구 논현 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.NONHYEON 3 true true 37.5112 127.0276
9 KR.SEOUL.MAPO.HONGDAE 홍대 서울특별시 마포구 홍대 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.HONGDAE 1 true true 37.5563 126.9237
10 KR.SEOUL.MAPO.HAPJEONG 합정 서울특별시 마포구 합정 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.HAPJEONG 2 true true 37.5496 126.9139
11 KR.SEOUL.MAPO.YEONNAM 연남 서울특별시 마포구 연남 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.YEONNAM 3 true true 37.5660 126.9247
12 KR.BETA.GANGNAM_CORE 강남권 베타 클러스터 강남권 베타 클러스터 BETA_CLUSTER KR.SEOUL.GANGNAM 3 KR.BETA.GANGNAM_CORE 10 true true 37.5058 127.0370
13 KR.BETA.MAPO_CORE 마포권 베타 클러스터 마포권 베타 클러스터 BETA_CLUSTER KR.SEOUL.MAPO 3 KR.BETA.MAPO_CORE 10 true true 37.5573 126.9192
+157
View File
@@ -0,0 +1,157 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const prisma = new PrismaClient();
interface RegionRow {
code: string;
name_ko: string;
full_name_ko: string;
region_type: string;
parent_code: string;
depth: string;
path_code: string;
sort_order: string;
is_active: string;
is_beta_enabled: string;
latitude: string;
longitude: string;
}
interface IndustryRow {
code: string;
name_ko: string;
parent_code: string;
depth: string;
sort_order: string;
is_leaf: string;
is_active: string;
is_beta_enabled: string;
}
function parseCsv(filePath: string): Record<string, string>[] {
const content = readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
const headers = lines[0]!.split(',');
return lines.slice(1).map((line) => {
const values = line.split(',');
const row: Record<string, string> = {};
for (let i = 0; i < headers.length; i++) {
row[headers[i]!] = values[i] ?? '';
}
return row;
});
}
async function seedRegions(): Promise<void> {
const csvPath = resolve(__dirname, 'master-data/regions.v1.csv');
const rows = parseCsv(csvPath) as unknown as RegionRow[];
// code → id 매핑을 위한 맵
const codeToId = new Map<string, bigint>();
// depth 순으로 정렬하여 부모를 먼저 upsert
const sorted = [...rows].sort((a, b) => parseInt(a.depth) - parseInt(b.depth));
for (const row of sorted) {
const parentId = row.parent_code ? (codeToId.get(row.parent_code) ?? null) : null;
const result = await prisma.regionHierarchy.upsert({
where: { code: row.code },
update: {
nameKo: row.name_ko,
fullNameKo: row.full_name_ko || null,
regionType: row.region_type as never,
parentId,
depth: parseInt(row.depth),
pathCode: row.path_code,
sortOrder: parseInt(row.sort_order),
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
latitude: row.latitude ? parseFloat(row.latitude) : null,
longitude: row.longitude ? parseFloat(row.longitude) : null,
},
create: {
code: row.code,
nameKo: row.name_ko,
fullNameKo: row.full_name_ko || null,
regionType: row.region_type as never,
parentId,
depth: parseInt(row.depth),
pathCode: row.path_code,
sortOrder: parseInt(row.sort_order),
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
latitude: row.latitude ? parseFloat(row.latitude) : null,
longitude: row.longitude ? parseFloat(row.longitude) : null,
},
});
codeToId.set(row.code, result.id);
}
console.log(`Seeded ${sorted.length} regions`);
}
async function seedIndustries(): Promise<void> {
const csvPath = resolve(__dirname, 'master-data/industries.v1.csv');
const rows = parseCsv(csvPath) as unknown as IndustryRow[];
const codeToId = new Map<string, bigint>();
const sorted = [...rows].sort((a, b) => parseInt(a.depth) - parseInt(b.depth));
for (const row of sorted) {
const parentId = row.parent_code ? (codeToId.get(row.parent_code) ?? null) : null;
const result = await prisma.industryTaxonomy.upsert({
where: { code: row.code },
update: {
nameKo: row.name_ko,
parentId,
depth: parseInt(row.depth),
sortOrder: parseInt(row.sort_order),
isLeaf: row.is_leaf === 'true',
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
},
create: {
code: row.code,
nameKo: row.name_ko,
parentId,
depth: parseInt(row.depth),
sortOrder: parseInt(row.sort_order),
isLeaf: row.is_leaf === 'true',
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
},
});
codeToId.set(row.code, result.id);
}
console.log(`Seeded ${sorted.length} industries`);
}
async function main(): Promise<void> {
console.log('Starting seed...');
await seedRegions();
await seedIndustries();
console.log('Seed completed successfully');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+26
View File
@@ -0,0 +1,26 @@
import { PrismaClient } from '@prisma/client';
let prismaInstance: PrismaClient | null = null;
/**
* Creates or returns a singleton PrismaClient instance.
* In development, reuses the instance across hot-reloads via a global variable.
*/
export function createPrismaClient(): PrismaClient {
if (prismaInstance) {
return prismaInstance;
}
const client = new PrismaClient({
log:
process.env['NODE_ENV'] === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env['NODE_ENV'] !== 'production') {
prismaInstance = client;
}
return client;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* @relink/database - Prisma client and database utilities
*/
export { createPrismaClient } from './client.js';
export {
getTestPrismaClient,
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from './test-helpers.js';
+194
View File
@@ -0,0 +1,194 @@
import { PrismaClient } from '@prisma/client';
import { execSync } from 'node:child_process';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, '../prisma/schema.prisma');
const TEST_DATABASE_URL =
process.env['DATABASE_TEST_URL'] ??
'postgresql://relink:relink_test@localhost:5433/relink_test';
let testClient: PrismaClient | null = null;
export function getTestPrismaClient(): PrismaClient {
if (testClient) {
return testClient;
}
testClient = new PrismaClient({
datasourceUrl: TEST_DATABASE_URL,
log: ['error'],
});
return testClient;
}
export async function setupTestDatabase(): Promise<PrismaClient> {
execSync(`DATABASE_URL="${TEST_DATABASE_URL}" npx prisma db push --schema="${SCHEMA_PATH}" --skip-generate --accept-data-loss`, {
stdio: 'pipe',
cwd: resolve(__dirname, '..'),
});
const client = getTestPrismaClient();
await client.$connect();
return client;
}
export async function teardownTestDatabase(): Promise<void> {
if (testClient) {
await testClient.$disconnect();
testClient = null;
}
}
const TABLE_NAMES = [
'signature_evidences',
'contract_versions',
'escrow_transactions',
'inspection_records',
'dispute_cases',
'contracts',
'subsidy_documents',
'subsidy_checklist_items',
'subsidy_cases',
'match_requests',
'store_photos',
'store_lifecycles',
'store_facilities',
'store_leases',
'stores',
'vendor_coverage_regions',
'vendor_certifications',
'vendors',
'user_consents',
'user_profiles',
'users',
'outbox_events',
'event_logs',
'audit_logs',
'idempotency_keys',
'feature_flags',
'policy_versions',
'industry_taxonomies',
'region_hierarchies',
];
export async function cleanAllTables(prisma: PrismaClient): Promise<void> {
for (const table of TABLE_NAMES) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
}
}
export async function seedTestMasterData(prisma: PrismaClient): Promise<void> {
// 지역 마스터 데이터
const kr = await prisma.regionHierarchy.create({
data: {
code: 'KR',
nameKo: '대한민국',
regionType: 'COUNTRY',
depth: 0,
pathCode: 'KR',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
const seoul = await prisma.regionHierarchy.create({
data: {
code: 'KR.SEOUL',
nameKo: '서울특별시',
regionType: 'SIDO',
parentId: kr.id,
depth: 1,
pathCode: 'KR.SEOUL',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
const gangnam = await prisma.regionHierarchy.create({
data: {
code: 'KR.SEOUL.GANGNAM',
nameKo: '강남구',
regionType: 'SIGUNGU',
parentId: seoul.id,
depth: 2,
pathCode: 'KR.SEOUL.GANGNAM',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
await prisma.regionHierarchy.create({
data: {
code: 'KR.BETA.GANGNAM_CORE',
nameKo: '강남권 베타 클러스터',
regionType: 'BETA_CLUSTER',
parentId: gangnam.id,
depth: 3,
pathCode: 'KR.BETA.GANGNAM_CORE',
sortOrder: 10,
isActive: true,
isBetaEnabled: true,
},
});
// 비베타 지역 (테스트용)
await prisma.regionHierarchy.create({
data: {
code: 'KR.BUSAN',
nameKo: '부산광역시',
regionType: 'SIDO',
parentId: kr.id,
depth: 1,
pathCode: 'KR.BUSAN',
sortOrder: 2,
isActive: true,
isBetaEnabled: false,
},
});
// 업종 마스터 데이터
const fnb = await prisma.industryTaxonomy.create({
data: {
code: 'FNB',
nameKo: '음식점/카페',
depth: 0,
sortOrder: 1,
isLeaf: false,
isActive: true,
isBetaEnabled: true,
},
});
await prisma.industryTaxonomy.create({
data: {
code: 'FNB.CAFE',
nameKo: '카페',
parentId: fnb.id,
depth: 1,
sortOrder: 1,
isLeaf: true,
isActive: true,
isBetaEnabled: true,
},
});
await prisma.industryTaxonomy.create({
data: {
code: 'FNB.KOREAN',
nameKo: '한식',
parentId: fnb.id,
depth: 1,
sortOrder: 3,
isLeaf: true,
isActive: true,
isBetaEnabled: true,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@relink/domain",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { checkIdempotency } from '../check-idempotency.js';
describe('checkIdempotency', () => {
// U024: IdempotencyKey 중복 처리 방지
it('U024-1: 새로운 키는 isNew=true 반환', () => {
const result = checkIdempotency({
idempotencyKey: 'webhook-evt-123',
alreadyProcessed: false,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.isNew).toBe(true);
expect(result.value.idempotencyKey).toBe('webhook-evt-123');
}
});
it('U024-2: 이미 처리된 키는 isNew=false 반환', () => {
const result = checkIdempotency({
idempotencyKey: 'webhook-evt-123',
alreadyProcessed: true,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.isNew).toBe(false);
}
});
it('U024-3: 빈 키는 실패', () => {
const result = checkIdempotency({
idempotencyKey: '',
alreadyProcessed: false,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U024-4: 공백 키도 실패', () => {
const result = checkIdempotency({
idempotencyKey: ' ',
alreadyProcessed: false,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
});
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { createContract, type CreateContractInput } from '../create-contract.js';
function validInput(overrides: Partial<CreateContractInput> = {}): CreateContractInput {
return {
matchRequestStatus: 'ACCEPTED',
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: 'pv-1',
...overrides,
};
}
describe('createContract', () => {
// U021: 수락된 매칭만 계약 생성 가능
it('U021-1: ACCEPTED 매칭에서 계약 생성 성공', () => {
const result = createContract(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('DRAFT');
expect(result.value.contractType).toBe('ACQUISITION');
}
});
it('U021-2: OPEN 매칭에서는 계약 생성 불가', () => {
const result = createContract(validInput({ matchRequestStatus: 'OPEN' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
}
});
it('U021-3: REVIEWING 매칭에서는 계약 생성 불가', () => {
const result = createContract(validInput({ matchRequestStatus: 'REVIEWING' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
}
});
it('U021-4: REJECTED 매칭에서는 계약 생성 불가', () => {
const result = createContract(validInput({ matchRequestStatus: 'REJECTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
}
});
// U022: 템플릿 버전과 정책 버전 함께 저장
it('U022-1: 템플릿 코드와 정책 버전이 결과에 포함', () => {
const result = createContract(validInput({
templateCode: 'DEM_STANDARD_V2',
policyVersionId: 'pv-5',
contractType: 'DEMOLITION',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.templateCode).toBe('DEM_STANDARD_V2');
expect(result.value.policyVersionId).toBe('pv-5');
expect(result.value.contractType).toBe('DEMOLITION');
}
});
it('U022-2: 빈 템플릿 코드는 실패', () => {
const result = createContract(validInput({ templateCode: ' ' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U022-3: 빈 정책 버전은 실패', () => {
const result = createContract(validInput({ policyVersionId: '' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U022-4: INTERIOR 타입 계약 생성 성공', () => {
const result = createContract(validInput({ contractType: 'INTERIOR' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.contractType).toBe('INTERIOR');
}
});
});
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import { openDispute, type OpenDisputeInput } from '../open-dispute.js';
function validInput(overrides: Partial<OpenDisputeInput> = {}): OpenDisputeInput {
return {
currentEscrowStatus: 'HOLDING',
contractStatus: 'ACTIVE',
reasonCode: 'QUALITY_ISSUE',
description: '시공 품질이 계약 내용과 다릅니다.',
...overrides,
};
}
describe('openDispute', () => {
// U025: 분쟁 시 에스크로 DISPUTED 전환
it('U025-1: ACTIVE 계약 + HOLDING 에스크로에서 분쟁 성공', () => {
const result = openDispute(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('DISPUTED');
expect(result.value.reasonCode).toBe('QUALITY_ISSUE');
expect(result.value.description).toBe('시공 품질이 계약 내용과 다릅니다.');
}
});
it('U025-2: SIGNED 계약에서도 분쟁 가능', () => {
const result = openDispute(validInput({ contractStatus: 'SIGNED' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('DISPUTED');
}
});
it('U025-3: RELEASE_REVIEW 에스크로에서도 분쟁 가능', () => {
const result = openDispute(validInput({ currentEscrowStatus: 'RELEASE_REVIEW' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('DISPUTED');
}
});
it('U025-4: DRAFT 계약에서는 분쟁 불가', () => {
const result = openDispute(validInput({ contractStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_CONTRACT_STATUS');
}
});
it('U025-5: COMPLETED 계약에서는 분쟁 불가', () => {
const result = openDispute(validInput({ contractStatus: 'COMPLETED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_CONTRACT_STATUS');
}
});
it('U025-6: NOT_STARTED 에스크로에서는 분쟁 불가', () => {
const result = openDispute(validInput({ currentEscrowStatus: 'NOT_STARTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U025-7: RELEASED 에스크로에서는 분쟁 불가', () => {
const result = openDispute(validInput({ currentEscrowStatus: 'RELEASED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U025-8: 사유 코드 없으면 실패', () => {
const result = openDispute(validInput({ reasonCode: '' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U025-9: description 없이도 분쟁 가능', () => {
const result = openDispute(validInput({ description: undefined }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.description).toBeUndefined();
}
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { releaseEscrow, type ReleaseEscrowInput } from '../release-escrow.js';
function validInput(overrides: Partial<ReleaseEscrowInput> = {}): ReleaseEscrowInput {
return {
currentEscrowStatus: 'HOLDING',
hasApprovedInspection: true,
hasOpenDispute: false,
...overrides,
};
}
describe('releaseEscrow', () => {
// U023: 검수 승인 전에는 정산 해제 불가
it('U023-1: 검수 승인 완료 시 RELEASE_REVIEW로 전환 성공', () => {
const result = releaseEscrow(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('RELEASE_REVIEW');
}
});
it('U023-2: 검수 미승인 시 정산 해제 불가', () => {
const result = releaseEscrow(validInput({ hasApprovedInspection: false }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INSPECTION_NOT_APPROVED');
}
});
it('U023-3: HOLDING이 아닌 상태에서는 정산 해제 불가', () => {
const result = releaseEscrow(validInput({ currentEscrowStatus: 'NOT_STARTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U023-4: RELEASED 상태에서는 정산 해제 불가', () => {
const result = releaseEscrow(validInput({ currentEscrowStatus: 'RELEASED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U023-5: 열린 분쟁이 있으면 정산 해제 차단', () => {
const result = releaseEscrow(validInput({ hasOpenDispute: true }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('DISPUTE_OPEN');
}
});
});
@@ -0,0 +1,34 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface CheckIdempotencyInput {
readonly idempotencyKey: string;
readonly alreadyProcessed: boolean;
}
export interface IdempotencyCheckResult {
readonly isNew: boolean;
readonly idempotencyKey: string;
}
// U024: 같은 웹훅 이벤트는 IdempotencyKey 기준으로 한 번만 처리
export function checkIdempotency(
input: CheckIdempotencyInput,
): Result<IdempotencyCheckResult, AppError> {
if (!input.idempotencyKey.trim()) {
return failure(
appError('VALIDATION_ERROR', 'IdempotencyKey는 필수입니다.', { field: 'idempotencyKey' }),
);
}
if (input.alreadyProcessed) {
return success({
isNew: false,
idempotencyKey: input.idempotencyKey,
});
}
return success({
isNew: true,
idempotencyKey: input.idempotencyKey,
});
}
@@ -0,0 +1,51 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type ContractType = 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
export interface CreateContractInput {
readonly matchRequestStatus: string;
readonly contractType: ContractType;
readonly templateCode: string;
readonly policyVersionId: string;
}
export interface ContractDraft {
readonly contractType: ContractType;
readonly status: 'DRAFT';
readonly templateCode: string;
readonly policyVersionId: string;
}
export function createContract(
input: CreateContractInput,
): Result<ContractDraft, AppError> {
// U021: 수락된 매칭만 계약 생성 가능
if (input.matchRequestStatus !== 'ACCEPTED') {
return failure(
appError('MATCH_NOT_ACCEPTED', '수락된 매칭 요청만 계약을 생성할 수 있습니다.', {
matchRequestStatus: input.matchRequestStatus,
}),
);
}
// U022: 템플릿 코드 필수
if (!input.templateCode.trim()) {
return failure(
appError('VALIDATION_ERROR', '계약 템플릿 코드는 필수입니다.', { field: 'templateCode' }),
);
}
// U022: 정책 버전 필수
if (!input.policyVersionId.trim()) {
return failure(
appError('VALIDATION_ERROR', '정책 버전은 필수입니다.', { field: 'policyVersionId' }),
);
}
return success({
contractType: input.contractType,
status: 'DRAFT' as const,
templateCode: input.templateCode,
policyVersionId: input.policyVersionId,
});
}
+24
View File
@@ -0,0 +1,24 @@
export {
createContract,
type ContractType,
type CreateContractInput,
type ContractDraft,
} from './create-contract.js';
export {
releaseEscrow,
type ReleaseEscrowInput,
type ReleaseEscrowResult,
} from './release-escrow.js';
export {
checkIdempotency,
type CheckIdempotencyInput,
type IdempotencyCheckResult,
} from './check-idempotency.js';
export {
openDispute,
type OpenDisputeInput,
type OpenDisputeResult,
} from './open-dispute.js';
@@ -0,0 +1,50 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface OpenDisputeInput {
readonly currentEscrowStatus: string;
readonly contractStatus: string;
readonly reasonCode: string;
readonly description?: string;
}
export interface OpenDisputeResult {
readonly escrowStatus: 'DISPUTED';
readonly reasonCode: string;
readonly description?: string;
}
const DISPUTABLE_CONTRACT_STATUSES = new Set(['ACTIVE', 'SIGNED']);
const DISPUTABLE_ESCROW_STATUSES = new Set(['HOLDING', 'RELEASE_REVIEW']);
// U025: 분쟁이 열리면 에스크로는 DISPUTED로 전환
export function openDispute(
input: OpenDisputeInput,
): Result<OpenDisputeResult, AppError> {
if (!DISPUTABLE_CONTRACT_STATUSES.has(input.contractStatus)) {
return failure(
appError('INVALID_CONTRACT_STATUS', '활성 상태의 계약만 분쟁을 열 수 있습니다.', {
contractStatus: input.contractStatus,
}),
);
}
if (!DISPUTABLE_ESCROW_STATUSES.has(input.currentEscrowStatus)) {
return failure(
appError('INVALID_ESCROW_STATUS', 'HOLDING 또는 RELEASE_REVIEW 상태에서만 분쟁을 열 수 있습니다.', {
currentEscrowStatus: input.currentEscrowStatus,
}),
);
}
if (!input.reasonCode.trim()) {
return failure(
appError('VALIDATION_ERROR', '분쟁 사유 코드는 필수입니다.', { field: 'reasonCode' }),
);
}
return success({
escrowStatus: 'DISPUTED' as const,
reasonCode: input.reasonCode,
description: input.description,
});
}
@@ -0,0 +1,42 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface ReleaseEscrowInput {
readonly currentEscrowStatus: string;
readonly hasApprovedInspection: boolean;
readonly hasOpenDispute: boolean;
}
export interface ReleaseEscrowResult {
readonly escrowStatus: 'RELEASE_REVIEW';
}
export function releaseEscrow(
input: ReleaseEscrowInput,
): Result<ReleaseEscrowResult, AppError> {
// U023: HOLDING 상태에서만 정산 해제 가능
if (input.currentEscrowStatus !== 'HOLDING') {
return failure(
appError('INVALID_ESCROW_STATUS', 'HOLDING 상태에서만 정산 해제를 요청할 수 있습니다.', {
currentEscrowStatus: input.currentEscrowStatus,
}),
);
}
// U023: 검수 승인이 완료되어야 정산 해제 가능
if (!input.hasApprovedInspection) {
return failure(
appError('INSPECTION_NOT_APPROVED', '검수 승인이 완료되어야 정산 해제를 요청할 수 있습니다.'),
);
}
// U026: 분쟁이 열려있으면 정산 해제 차단
if (input.hasOpenDispute) {
return failure(
appError('DISPUTE_OPEN', '열린 분쟁이 있어 정산 해제가 차단됩니다.'),
);
}
return success({
escrowStatus: 'RELEASE_REVIEW' as const,
});
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Base entity interface. All domain entities extend this.
*/
export interface Entity<TId = string> {
readonly id: TId;
readonly createdAt: Date;
readonly updatedAt: Date;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @relink/domain - Pure TypeScript domain layer
*
* Contains entities, value objects, and business rules.
* No external dependencies allowed.
*/
export type { Entity } from './entity.js';
export type { ValueObject } from './value-object.js';
// Store domain
export {
StoreLease,
StoreFacility,
createStoreDraft,
reviewStore,
publishStore,
filterStoreForViewer,
} from './store/index.js';
export type {
StoreLeaseInput,
StoreLeaseProps,
StoreFacilityInput,
StoreFacilityProps,
CreateStoreDraftInput,
StoreDraft,
RegionChecker,
IndustryChecker,
ReviewStoreInput,
ReviewStoreResult,
ReviewDecision,
ReviewableStatus,
PublishStoreInput,
PublishStoreResult,
StoreData,
ViewerContext,
FilteredStoreData,
} from './store/index.js';
// Matching domain
export { createMatchRequest, acceptMatchRequest, buildSearchDefaults } from './matching/index.js';
export type {
MatchType,
MatchSourceType,
CreateMatchRequestInput,
MatchRequestDraft,
MatchRequestStatus,
AcceptMatchRequestInput,
AcceptMatchRequestResult,
StoreSearchCriteria,
StoreSearchDefaults,
} from './matching/index.js';
// Subsidy domain
export { createSubsidyCase, advanceSubsidyToReady, reviewSubsidyCase } from './subsidy/index.js';
export type {
SubsidyCaseStatus,
ChecklistItemTemplate,
CreateSubsidyCaseInput,
SubsidyCaseDraft,
ChecklistItemStatus,
ChecklistItemState,
AdvanceToReadyInput,
AdvanceToReadyResult,
SubsidyReviewDecision,
ReviewSubsidyCaseInput,
ReviewSubsidyCaseResult,
} from './subsidy/index.js';
// Vendor domain
export {
applyVendorCertification,
approveVendorCertification,
isVendorSearchable,
filterVendorsForSearch,
} from './vendor/index.js';
export type {
VendorCertificationStatus,
VendorType,
ApplyVendorCertificationInput,
VendorCertificationApplication,
VendorCertificationDecision,
ApproveVendorCertificationInput,
ApproveVendorCertificationResult,
VendorSearchEntry,
} from './vendor/index.js';
// Contract domain
export { createContract, releaseEscrow, checkIdempotency, openDispute } from './contract/index.js';
export type {
ContractType,
CreateContractInput,
ContractDraft,
ReleaseEscrowInput,
ReleaseEscrowResult,
CheckIdempotencyInput,
IdempotencyCheckResult,
OpenDisputeInput,
OpenDisputeResult,
} from './contract/index.js';
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { acceptMatchRequest, type AcceptMatchRequestInput, type MatchRequestStatus } from '../accept-match-request.js';
function validInput(overrides: Partial<AcceptMatchRequestInput> = {}): AcceptMatchRequestInput {
return {
currentStatus: 'OPEN',
actorUserId: 'operator-1',
...overrides,
};
}
describe('acceptMatchRequest', () => {
// U013: 매칭 요청 수락
it('U013-1: OPEN 상태에서 수락 가능', () => {
const result = acceptMatchRequest(validInput({ currentStatus: 'OPEN' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('ACCEPTED');
}
});
it('U013-2: REVIEWING 상태에서 수락 가능', () => {
const result = acceptMatchRequest(validInput({ currentStatus: 'REVIEWING' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('ACCEPTED');
}
});
// U013: 수락 불가능한 상태들
const nonAcceptableStatuses: MatchRequestStatus[] = [
'ACCEPTED',
'REJECTED',
'CONTRACTING',
'EXPIRED',
'CANCELLED',
'COMPLETED',
];
nonAcceptableStatuses.forEach((status) => {
it(`U013-3: ${status} 상태에서는 수락 불가`, () => {
const result = acceptMatchRequest(validInput({ currentStatus: status }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
expect(result.error.details?.currentStatus).toBe(status);
}
});
});
});
@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { createMatchRequest, type CreateMatchRequestInput } from '../create-match-request.js';
function validInput(overrides: Partial<CreateMatchRequestInput> = {}): CreateMatchRequestInput {
return {
storePublicationStatus: 'PUBLISHED',
storeDealStatus: 'OPEN',
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: 'user-1',
message: '매장 인수 희망합니다.',
hasOpenRequestBySameUser: false,
...overrides,
};
}
describe('createMatchRequest', () => {
// U010: 매칭 요청 생성 성공
it('U010-1: PUBLISHED + OPEN 매장에 매칭 요청 생성 성공', () => {
const result = createMatchRequest(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.matchType).toBe('ACQUISITION');
expect(result.value.sourceType).toBe('USER_REQUEST');
expect(result.value.status).toBe('OPEN');
expect(result.value.message).toBe('매장 인수 희망합니다.');
}
});
it('U010-2: PUBLISHED + MATCHING 매장에도 매칭 요청 가능', () => {
const result = createMatchRequest(validInput({ storeDealStatus: 'MATCHING' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('OPEN');
}
});
it('U010-3: DEMOLITION 타입 매칭 요청 생성 성공', () => {
const result = createMatchRequest(validInput({ matchType: 'DEMOLITION' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.matchType).toBe('DEMOLITION');
}
});
it('U010-4: INTERIOR 타입 매칭 요청 생성 성공', () => {
const result = createMatchRequest(validInput({ matchType: 'INTERIOR' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.matchType).toBe('INTERIOR');
}
});
it('U010-5: message 없이도 매칭 요청 생성 가능', () => {
const result = createMatchRequest(validInput({ message: undefined }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.message).toBeUndefined();
}
});
// U010: 실패 케이스 - 비공개 매장
it('U010-6: 비공개(DRAFT) 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storePublicationStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('STORE_NOT_PUBLISHED');
}
});
it('U010-7: UNPUBLISHED 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storePublicationStatus: 'UNPUBLISHED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('STORE_NOT_PUBLISHED');
}
});
// U010: 실패 케이스 - 매칭 불가능 거래 상태
it('U010-8: CLOSED 거래 상태 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storeDealStatus: 'CLOSED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_DEAL_STATUS');
}
});
it('U010-9: CONTRACTED 거래 상태 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storeDealStatus: 'CONTRACTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_DEAL_STATUS');
}
});
// U011: 동일 사용자 중복 열린 매칭 요청 방지
it('U011: 동일 사용자가 이미 열린 요청이 있으면 중복 요청 불가', () => {
const result = createMatchRequest(validInput({ hasOpenRequestBySameUser: true }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('DUPLICATE_OPEN_REQUEST');
}
});
// U012: 운영자 수동 추천 시 추천 사유 필수
it('U012-1: 운영자 추천 시 추천 사유 없으면 실패', () => {
const result = createMatchRequest(validInput({
sourceType: 'OPERATOR_RECOMMENDATION',
operatorRecommendationReason: undefined,
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U012-2: 운영자 추천 시 빈 문자열 사유도 실패', () => {
const result = createMatchRequest(validInput({
sourceType: 'OPERATOR_RECOMMENDATION',
operatorRecommendationReason: ' ',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U012-3: 운영자 추천 시 유효한 추천 사유가 있으면 성공', () => {
const result = createMatchRequest(validInput({
sourceType: 'OPERATOR_RECOMMENDATION',
operatorRecommendationReason: '해당 매장과 창업 희망 업종이 일치합니다.',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.sourceType).toBe('OPERATOR_RECOMMENDATION');
}
});
// U012: SYSTEM_MATCH는 추천 사유 불필요
it('U012-4: SYSTEM_MATCH는 추천 사유 없어도 성공', () => {
const result = createMatchRequest(validInput({
sourceType: 'SYSTEM_MATCH',
operatorRecommendationReason: undefined,
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.sourceType).toBe('SYSTEM_MATCH');
}
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { buildSearchDefaults, type StoreSearchCriteria } from '../search-stores.js';
describe('buildSearchDefaults', () => {
// U010: 매장 검색 기본값 설정
it('U010-S1: publicationStatus는 항상 PUBLISHED로 고정', () => {
const result = buildSearchDefaults({});
expect(result.publicationStatus).toBe('PUBLISHED');
});
it('U010-S2: page 미지정 시 기본값 1', () => {
const result = buildSearchDefaults({});
expect(result.page).toBe(1);
});
it('U010-S3: limit 미지정 시 기본값 20', () => {
const result = buildSearchDefaults({});
expect(result.limit).toBe(20);
});
it('U010-S4: limit이 100 초과하면 100으로 제한', () => {
const result = buildSearchDefaults({ limit: 500 });
expect(result.limit).toBe(100);
});
it('U010-S5: limit이 100 이하면 그대로 사용', () => {
const result = buildSearchDefaults({ limit: 50 });
expect(result.limit).toBe(50);
});
it('U010-S6: 사용자 지정 page와 limit 유지', () => {
const result = buildSearchDefaults({ page: 3, limit: 30 });
expect(result.page).toBe(3);
expect(result.limit).toBe(30);
});
it('U010-S7: 검색 조건(regionClusterCode, industryLeafCode 등) 유지', () => {
const criteria: StoreSearchCriteria = {
regionClusterCode: 'GANGNAM',
industryLeafCode: 'CAFE',
dealStatus: 'OPEN',
minDepositAmount: 1000,
maxDepositAmount: 5000,
page: 2,
limit: 15,
};
const result = buildSearchDefaults(criteria);
expect(result.regionClusterCode).toBe('GANGNAM');
expect(result.industryLeafCode).toBe('CAFE');
expect(result.dealStatus).toBe('OPEN');
expect(result.minDepositAmount).toBe(1000);
expect(result.maxDepositAmount).toBe(5000);
expect(result.page).toBe(2);
expect(result.limit).toBe(15);
expect(result.publicationStatus).toBe('PUBLISHED');
});
});
@@ -0,0 +1,38 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type MatchRequestStatus =
| 'OPEN'
| 'REVIEWING'
| 'ACCEPTED'
| 'REJECTED'
| 'CONTRACTING'
| 'EXPIRED'
| 'CANCELLED'
| 'COMPLETED';
export interface AcceptMatchRequestInput {
readonly currentStatus: MatchRequestStatus;
readonly actorUserId: string;
}
export interface AcceptMatchRequestResult {
readonly status: 'ACCEPTED';
}
export function acceptMatchRequest(
input: AcceptMatchRequestInput,
): Result<AcceptMatchRequestResult, AppError> {
const acceptableStatuses: MatchRequestStatus[] = ['OPEN', 'REVIEWING'];
if (!acceptableStatuses.includes(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'OPEN 또는 REVIEWING 상태의 요청만 수락할 수 있습니다.', {
currentStatus: input.currentStatus,
}),
);
}
return success({
status: 'ACCEPTED' as const,
});
}

Some files were not shown because too many files have changed in this diff Show More