Skip to content

Commit 43a957e

Browse files
authored
feat: 증권 계좌 평가 스냅샷 및 로컬 실행 정리 (#122)
* feat(db): 투자 계좌 평가 스냅샷 스키마 추가 * feat(api): 평가 스냅샷 API와 순자산 집계 반영 * feat(web): 평가 반영 잔고 표시와 계좌 설정 확장 * docs: 평가 스냅샷 도메인 규칙과 메모리 동기화 * chore(dev): 로컬 DB 중심 Makefile과 시드 경로 정리 * fix(api): 평가 스냅샷 리뷰 피드백 반영
1 parent 3024cd6 commit 43a957e

24 files changed

+2361
-98
lines changed

Makefile

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: dev-up dev-down dev-logs dev-reset api-rebuild migrate migrate-gen seed build lint install db-studio docker-up docker-down
1+
.PHONY: dev-up dev-down dev-logs dev-reset migrate migrate-gen seed build lint install db-studio docker-up docker-down docker-up-build
22

33
DB_USER ?= marrylife
44
DB_PASS ?= marrylife
@@ -30,22 +30,19 @@ dev-logs:
3030

3131
dev-reset:
3232
docker compose down -v
33-
docker compose up -d --build db api
34-
35-
api-rebuild:
36-
docker compose up -d --build api
33+
docker compose up -d db
3734

3835
docker-up:
39-
@echo "🐳 Starting all services in Docker (DB + API + Web)..."
40-
docker compose up -d
36+
@echo "🐳 Starting DB service in Docker..."
37+
docker compose up -d db
4138

4239
docker-down:
4340
@echo "🛑 Stopping all Docker services..."
4441
docker compose down
4542

4643
docker-up-build:
47-
@echo "🐳 Building and starting all services in Docker..."
48-
docker compose up -d --build
44+
@echo "🐳 Building and starting DB service in Docker..."
45+
docker compose up -d --build db
4946

5047
migrate:
5148
DATABASE_URL=$(DATABASE_URL) pnpm --filter @marrylife/db db:push
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
Param,
7+
ParseIntPipe,
8+
Post,
9+
Query,
10+
UseGuards,
11+
} from '@nestjs/common';
12+
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
13+
import { CurrentUser, type AuthUser } from '../common/decorators/current-user.decorator';
14+
import { LedgerMemberGuard } from '../common/guards/ledger-member.guard';
15+
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
16+
import { AccountValuationsService } from './account-valuations.service';
17+
import { CreateAccountValuationDto } from './dto/create-account-valuation.dto';
18+
import { QueryAccountValuationsDto } from './dto/query-account-valuations.dto';
19+
20+
@ApiTags('account-valuations')
21+
@ApiBearerAuth()
22+
@Controller('ledgers/:ledgerId/accounts/:accountId/valuations')
23+
@UseGuards(JwtAuthGuard, LedgerMemberGuard)
24+
export class AccountValuationsController {
25+
constructor(
26+
private readonly accountValuationsService: AccountValuationsService,
27+
) {}
28+
29+
@Get()
30+
@ApiOperation({ summary: '계좌 평가 스냅샷 목록 조회' })
31+
async listByAccount(
32+
@CurrentUser() user: AuthUser,
33+
@Param('ledgerId', ParseIntPipe) ledgerId: number,
34+
@Param('accountId', ParseIntPipe) accountId: number,
35+
@Query() query: QueryAccountValuationsDto,
36+
) {
37+
return await this.accountValuationsService.listByAccount(
38+
ledgerId,
39+
accountId,
40+
user.userId,
41+
query,
42+
);
43+
}
44+
45+
@Post()
46+
@ApiOperation({ summary: '계좌 평가 스냅샷 등록' })
47+
async create(
48+
@CurrentUser() user: AuthUser,
49+
@Param('ledgerId', ParseIntPipe) ledgerId: number,
50+
@Param('accountId', ParseIntPipe) accountId: number,
51+
@Body() dto: CreateAccountValuationDto,
52+
) {
53+
return await this.accountValuationsService.create(
54+
ledgerId,
55+
accountId,
56+
user.userId,
57+
dto,
58+
);
59+
}
60+
61+
@Delete(':valuationId')
62+
@ApiOperation({ summary: '계좌 평가 스냅샷 삭제' })
63+
async remove(
64+
@CurrentUser() user: AuthUser,
65+
@Param('ledgerId', ParseIntPipe) ledgerId: number,
66+
@Param('accountId', ParseIntPipe) accountId: number,
67+
@Param('valuationId', ParseIntPipe) valuationId: number,
68+
) {
69+
return await this.accountValuationsService.remove(
70+
ledgerId,
71+
accountId,
72+
valuationId,
73+
user.userId,
74+
);
75+
}
76+
}
77+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module } from '@nestjs/common';
2+
import { JwtModule } from '@nestjs/jwt';
3+
import { LedgerMemberGuard } from '../common/guards/ledger-member.guard';
4+
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
5+
import { AccountValuationsController } from './account-valuations.controller';
6+
import { AccountValuationsService } from './account-valuations.service';
7+
8+
@Module({
9+
imports: [JwtModule.register({})],
10+
controllers: [AccountValuationsController],
11+
providers: [AccountValuationsService, JwtAuthGuard, LedgerMemberGuard],
12+
})
13+
export class AccountValuationsModule {}
14+
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {
2+
BadRequestException,
3+
ConflictException,
4+
ForbiddenException,
5+
Inject,
6+
Injectable,
7+
NotFoundException,
8+
} from '@nestjs/common';
9+
import {
10+
accountValuations,
11+
accounts,
12+
and,
13+
desc,
14+
eq,
15+
gte,
16+
householdLedgerMembers,
17+
lte,
18+
type DrizzleClient,
19+
} from '@marrylife/db';
20+
import { DRIZZLE } from '../database/database.constants';
21+
import { CreateAccountValuationDto } from './dto/create-account-valuation.dto';
22+
import { QueryAccountValuationsDto } from './dto/query-account-valuations.dto';
23+
24+
type LedgerAccountRow = {
25+
id: number;
26+
householdLedgerId: number;
27+
accountType: 'ASSET' | 'LIABILITY';
28+
valuationMode: 'LEDGER' | 'MARK_TO_MARKET';
29+
isDeleted: boolean;
30+
};
31+
32+
@Injectable()
33+
export class AccountValuationsService {
34+
constructor(@Inject(DRIZZLE) private readonly db: DrizzleClient) {}
35+
36+
private async assertMembership(ledgerId: number, userId: number) {
37+
const membership = await this.db.query.householdLedgerMembers.findFirst({
38+
where: and(
39+
eq(householdLedgerMembers.householdLedgerId, ledgerId),
40+
eq(householdLedgerMembers.userId, userId),
41+
),
42+
});
43+
44+
if (!membership) {
45+
throw new ForbiddenException({
46+
code: 'FORBIDDEN',
47+
message: '해당 가계부의 멤버가 아닙니다.',
48+
});
49+
}
50+
}
51+
52+
private async getAccountOrThrow(ledgerId: number, accountId: number): Promise<LedgerAccountRow> {
53+
const account = await this.db.query.accounts.findFirst({
54+
where: and(
55+
eq(accounts.id, accountId),
56+
eq(accounts.householdLedgerId, ledgerId),
57+
eq(accounts.isDeleted, false),
58+
),
59+
columns: {
60+
id: true,
61+
householdLedgerId: true,
62+
accountType: true,
63+
valuationMode: true,
64+
isDeleted: true,
65+
},
66+
});
67+
68+
if (!account) {
69+
throw new NotFoundException({
70+
code: 'ACCOUNT_NOT_FOUND',
71+
message: '계좌를 찾을 수 없습니다.',
72+
});
73+
}
74+
75+
return account;
76+
}
77+
78+
private assertValuationEnabledAccount(account: LedgerAccountRow) {
79+
if (account.accountType !== 'ASSET') {
80+
throw new BadRequestException({
81+
code: 'INVALID_ACCOUNT_TYPE',
82+
message: '평가 스냅샷은 ASSET 계좌에서만 사용할 수 있습니다.',
83+
});
84+
}
85+
86+
if (account.valuationMode !== 'MARK_TO_MARKET') {
87+
throw new BadRequestException({
88+
code: 'VALUATION_MODE_REQUIRED',
89+
message: 'MARK_TO_MARKET 계좌에서만 평가 스냅샷을 사용할 수 있습니다.',
90+
});
91+
}
92+
}
93+
94+
async listByAccount(
95+
ledgerId: number,
96+
accountId: number,
97+
userId: number,
98+
query: QueryAccountValuationsDto,
99+
) {
100+
await this.assertMembership(ledgerId, userId);
101+
const account = await this.getAccountOrThrow(ledgerId, accountId);
102+
this.assertValuationEnabledAccount(account);
103+
104+
return await this.db.query.accountValuations.findMany({
105+
where: and(
106+
eq(accountValuations.householdLedgerId, ledgerId),
107+
eq(accountValuations.accountId, accountId),
108+
query.from ? gte(accountValuations.capturedAt, new Date(query.from)) : undefined,
109+
query.to ? lte(accountValuations.capturedAt, new Date(query.to)) : undefined,
110+
),
111+
orderBy: (table) => [desc(table.capturedAt), desc(table.id)],
112+
limit: query.limit ?? 100,
113+
});
114+
}
115+
116+
async create(
117+
ledgerId: number,
118+
accountId: number,
119+
userId: number,
120+
dto: CreateAccountValuationDto,
121+
) {
122+
await this.assertMembership(ledgerId, userId);
123+
const account = await this.getAccountOrThrow(ledgerId, accountId);
124+
this.assertValuationEnabledAccount(account);
125+
126+
const capturedAt = new Date(dto.capturedAt);
127+
128+
const [created] = await this.db
129+
.insert(accountValuations)
130+
.values({
131+
householdLedgerId: ledgerId,
132+
accountId,
133+
capturedAt,
134+
marketValue: dto.marketValue,
135+
source: dto.source ?? 'MANUAL',
136+
memo: dto.memo ?? null,
137+
createdBy: userId,
138+
createdAt: new Date(),
139+
})
140+
.onConflictDoNothing()
141+
.returning();
142+
143+
if (!created) {
144+
throw new ConflictException({
145+
code: 'ACCOUNT_VALUATION_DUPLICATED',
146+
message: '동일 시점의 평가 스냅샷이 이미 존재합니다.',
147+
});
148+
}
149+
150+
return created;
151+
}
152+
153+
async remove(
154+
ledgerId: number,
155+
accountId: number,
156+
valuationId: number,
157+
userId: number,
158+
) {
159+
await this.assertMembership(ledgerId, userId);
160+
const account = await this.getAccountOrThrow(ledgerId, accountId);
161+
this.assertValuationEnabledAccount(account);
162+
163+
const existing = await this.db.query.accountValuations.findFirst({
164+
where: and(
165+
eq(accountValuations.id, valuationId),
166+
eq(accountValuations.householdLedgerId, ledgerId),
167+
eq(accountValuations.accountId, accountId),
168+
),
169+
});
170+
171+
if (!existing) {
172+
throw new NotFoundException({
173+
code: 'ACCOUNT_VALUATION_NOT_FOUND',
174+
message: '평가 스냅샷을 찾을 수 없습니다.',
175+
});
176+
}
177+
178+
const [deleted] = await this.db
179+
.delete(accountValuations)
180+
.where(
181+
and(
182+
eq(accountValuations.id, valuationId),
183+
eq(accountValuations.householdLedgerId, ledgerId),
184+
eq(accountValuations.accountId, accountId),
185+
),
186+
)
187+
.returning();
188+
189+
return deleted;
190+
}
191+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsIn, IsInt, IsISO8601, IsOptional, IsString, MaxLength, Min } from 'class-validator';
3+
4+
export class CreateAccountValuationDto {
5+
@ApiProperty({
6+
example: '2026-04-01T09:00:00Z',
7+
description: '평가 시점 (ISO 8601 datetime)',
8+
})
9+
@IsISO8601()
10+
capturedAt!: string;
11+
12+
@ApiProperty({ example: 12500000, description: '평가 금액 (0 이상 정수)' })
13+
@IsInt()
14+
@Min(0)
15+
marketValue!: number;
16+
17+
@ApiProperty({
18+
required: false,
19+
enum: ['MANUAL', 'BROKER_API'],
20+
example: 'MANUAL',
21+
description: '평가 입력 출처',
22+
})
23+
@IsOptional()
24+
@IsIn(['MANUAL', 'BROKER_API'])
25+
source?: 'MANUAL' | 'BROKER_API';
26+
27+
@ApiProperty({ required: false, example: '장 종료 기준 평가금액' })
28+
@IsOptional()
29+
@IsString()
30+
@MaxLength(500)
31+
memo?: string;
32+
}
33+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Transform } from 'class-transformer';
3+
import { IsInt, IsISO8601, IsOptional, Max, Min } from 'class-validator';
4+
5+
export class QueryAccountValuationsDto {
6+
@ApiProperty({
7+
required: false,
8+
example: '2026-03-01T00:00:00Z',
9+
description: '조회 시작 시점 (inclusive)',
10+
})
11+
@IsOptional()
12+
@IsISO8601()
13+
from?: string;
14+
15+
@ApiProperty({
16+
required: false,
17+
example: '2026-04-01T23:59:59Z',
18+
description: '조회 종료 시점 (inclusive)',
19+
})
20+
@IsOptional()
21+
@IsISO8601()
22+
to?: string;
23+
24+
@ApiProperty({
25+
required: false,
26+
example: 100,
27+
description: '조회 건수 제한 (기본 100, 최대 500)',
28+
})
29+
@IsOptional()
30+
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
31+
@IsInt()
32+
@Min(1)
33+
@Max(500)
34+
limit?: number;
35+
}
36+

0 commit comments

Comments
 (0)