Skip to content

Commit 955b356

Browse files
SOIVclaude
andcommitted
feat(subscription): 구독 상태 이력 및 활성 기간 기반 누적 통계 구현
- subscription_status_history 테이블 마이그레이션 추가 - StatusHistoryEntry / SubscriptionStatus 타입 추가 - 구독 생성/해지/재개 시 상태 이력 자동 기록 (recordStatusChange) - GET /services/:id/status-history API 엔드포인트 추가 - 누적 통계(getCumulative)에 activeDays 반영: buildActiveWindows로 해지 기간 제외한 활성 창 구성 countPaymentsInWindows로 활성 창 교집합 결제 횟수 계산 평균 월 비용을 activeDays 기준으로 재산정 - parseLocalDate 도입: UTC 파싱으로 인한 09:00 시차 방지 - plan_change 이벤트를 금액 변경 이벤트로 통합 처리 (validation, service, frontend 일관 반영) - 구독 재개 시 다음 결제일 오늘 기준 재산정 - 프론트: 사용 기간 표시를 activeDays 우선으로 전환 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c10a1a8 commit 955b356

6 files changed

Lines changed: 234 additions & 33 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- ── 005_subscription_status_history.sql ────────────────────────────
2+
-- 구독 상태 이력: 해지/재개 기록 (누적 계산 시 비활성 기간 제외용)
3+
4+
CREATE TABLE IF NOT EXISTS subscription_status_history (
5+
id {{UUID_PRIMARY_KEY}},
6+
subscription_id UUID NOT NULL REFERENCES subscription_services(id) ON DELETE CASCADE,
7+
status TEXT NOT NULL CHECK (status IN ('active', 'cancelled')),
8+
changed_at DATE NOT NULL,
9+
reason TEXT,
10+
created_at TIMESTAMPTZ NOT NULL DEFAULT {{NOW}}
11+
);
12+
13+
CREATE INDEX IF NOT EXISTS idx_subscription_status_history_sub_id
14+
ON subscription_status_history (subscription_id);
15+
16+
CREATE INDEX IF NOT EXISTS idx_subscription_status_history_changed_at
17+
ON subscription_status_history (changed_at);

modules/subscription/backend/routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,19 @@ export function createSubscriptionRouter(
202202
}
203203
});
204204

205+
// ── 상태 이력 ─────────────────────────────────────────────────
206+
router.get('/services/:id/status-history', auth, async (req, res) => {
207+
try {
208+
const userId = (req as AuthRequest).userId;
209+
const sub = await service.findById(userId, req.params['id']);
210+
if (!sub) { res.status(404).json({ success: false, error: 'Not found' }); return; }
211+
const history = await service.getStatusHistory(req.params['id']);
212+
res.json({ success: true, data: history });
213+
} catch {
214+
res.status(500).json({ success: false, error: 'Internal server error' });
215+
}
216+
});
217+
205218
// ── 누적 결제 금액 ────────────────────────────────────────────
206219
router.get('/services/:id/cumulative', auth, async (req, res) => {
207220
try {

modules/subscription/backend/service.ts

Lines changed: 178 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import type {
99
CumulativeResult,
1010
HistoryEvent,
1111
HistoryEventType,
12-
Subscription,
12+
StatusHistoryEntry,
1313
SubscriptionCurrency,
1414
SubscriptionNote,
15+
SubscriptionStatus,
1516
SubscriptionSummary,
1617
UpdateSubscriptionDto,
1718
} from '../types/index.js';
@@ -37,8 +38,24 @@ function parseDateField(v: unknown): string {
3738
return String(v).slice(0, 10);
3839
}
3940

41+
/** YYYY-MM-DD 문자열을 로컬 자정 Date로 파싱 (UTC 파싱으로 인한 09:00 시차 방지) */
42+
function parseLocalDate(dateStr: string): Date {
43+
const [yRaw, mRaw, dRaw] = dateStr.split('-');
44+
const y = Number(yRaw);
45+
const m = Number(mRaw);
46+
const d = Number(dRaw);
47+
48+
if (!Number.isNaN(y) && !Number.isNaN(m) && !Number.isNaN(d)) {
49+
return new Date(y, m - 1, d);
50+
}
51+
52+
const parsed = new Date(dateStr);
53+
parsed.setHours(0, 0, 0, 0);
54+
return parsed;
55+
}
56+
4057
function calcNextPaymentDate(billingDay: number, billingCycle: BillingCycle, from?: string): string {
41-
const base = from ? new Date(from) : new Date();
58+
const base = from ? parseLocalDate(from) : new Date();
4259
base.setHours(0, 0, 0, 0);
4360

4461
const year = base.getFullYear();
@@ -115,6 +132,9 @@ export class SubscriptionService {
115132
reason: '최초 등록',
116133
});
117134

135+
// 초기 활성 상태 기록
136+
await this.recordStatusChange(sub.id, 'active', startedAt);
137+
118138
return sub;
119139
}
120140

@@ -140,11 +160,14 @@ export class SubscriptionService {
140160

141161
const billingDay = dto.billingDay ?? existing.billingDay;
142162
const billingCycle = dto.billingCycle ?? existing.billingCycle;
143-
// 기존 nextPaymentDate는 parseDateField로 YYYY-MM-DD 보장됨;
144-
// billingDay/billingCycle 변경 시에만 재계산
145-
const nextPaymentDate = (dto.billingDay !== undefined || dto.billingCycle !== undefined)
146-
? calcNextPaymentDate(billingDay, billingCycle)
147-
: existing.nextPaymentDate;
163+
// 다음 결제일 재계산 조건:
164+
// 1) billingDay / billingCycle 변경
165+
// 2) 구독 재개 (isActive false → true): 오늘 기준으로 다음 결제일 재산정
166+
const isResuming = dto.isActive === true && !existing.isActive;
167+
const nextPaymentDate =
168+
(dto.billingDay !== undefined || dto.billingCycle !== undefined || isResuming)
169+
? calcNextPaymentDate(billingDay, billingCycle)
170+
: existing.nextPaymentDate;
148171

149172
// cancelled_at: undefined → 기존 값 유지 / null → 명시적 null 설정 / 날짜 → 업데이트
150173
const cancelledAt = dto.cancelledAt !== undefined ? dto.cancelledAt : existing.cancelledAt;
@@ -186,7 +209,17 @@ export class SubscriptionService {
186209
],
187210
);
188211

189-
return rows.length ? this.mapRow(rows[0]) : null;
212+
const updated = rows.length ? this.mapRow(rows[0]) : null;
213+
214+
// isActive 전환 시 상태 이력 기록
215+
if (updated && dto.isActive !== undefined && dto.isActive !== existing.isActive) {
216+
const changedAt = dto.isActive === false
217+
? (dto.cancelledAt ?? toDateString(new Date()))
218+
: toDateString(new Date());
219+
await this.recordStatusChange(id, dto.isActive ? 'active' : 'cancelled', changedAt);
220+
}
221+
222+
return updated;
190223
}
191224

192225
async delete(userId: string, id: string): Promise<boolean> {
@@ -210,7 +243,7 @@ export class SubscriptionService {
210243
);
211244
if (!owned.length) throw new Error('Forbidden');
212245

213-
const isPriceChange = dto.eventType === 'price_change';
246+
const isAmountEvent = dto.eventType === 'price_change' || dto.eventType === 'plan_change';
214247
const rows = await this.db.query<Record<string, unknown>>(
215248
`INSERT INTO subscription_price_history
216249
(subscription_id, event_type, effective_date, amount, currency, reason, note)
@@ -220,14 +253,14 @@ export class SubscriptionService {
220253
subscriptionId,
221254
dto.eventType,
222255
dto.effectiveDate,
223-
isPriceChange ? (dto.amount ?? 0) : 0,
224-
isPriceChange ? (dto.currency ?? 'KRW') : 'KRW',
256+
isAmountEvent ? (dto.amount ?? 0) : 0,
257+
isAmountEvent ? (dto.currency ?? 'KRW') : 'KRW',
225258
dto.reason ?? null,
226259
dto.note ?? null,
227260
],
228261
);
229262

230-
if (isPriceChange && dto.amount !== undefined && dto.currency !== undefined) {
263+
if (isAmountEvent && dto.amount !== undefined && dto.currency !== undefined) {
231264
await this.db.query(
232265
`UPDATE subscription_services
233266
SET current_amount = $1, currency = $2, updated_at = NOW()
@@ -303,24 +336,55 @@ export class SubscriptionService {
303336
if (!sub) return null;
304337

305338
const allHistory = await this.getHistory(id);
306-
const history = allHistory.filter((h) => h.eventType === 'price_change' && h.amount !== null);
339+
const history = allHistory.filter(
340+
(h) =>
341+
(h.eventType === 'price_change' || h.eventType === 'plan_change') &&
342+
h.amount !== null,
343+
);
307344
if (history.length === 0) return null;
308345

309-
const startDate = new Date(sub.startedAt);
346+
const startDate = parseLocalDate(sub.startedAt);
310347
const today = new Date();
311348
today.setHours(0, 0, 0, 0);
312349

313350
const daysSinceStart = Math.floor((today.getTime() - startDate.getTime()) / 86400000);
314351

352+
// 활성 기간 창 구성
353+
// subscription_price_history의 cancelled/resumed 이벤트를 소스로 사용.
354+
// subscription_status_history는 partial 데이터(최근 토글만 기록)일 수 있으므로
355+
// 누적 계산에는 항상 완전한 이력을 보유한 price_history를 기준으로 함.
356+
const statusEvents: StatusHistoryEntry[] = allHistory
357+
.filter((h) => h.eventType === 'cancelled' || h.eventType === 'resumed')
358+
.map((h) => ({
359+
id: h.id,
360+
subscriptionId: h.subscriptionId,
361+
status: (h.eventType === 'cancelled' ? 'cancelled' : 'active') as SubscriptionStatus,
362+
changedAt: h.effectiveDate,
363+
reason: h.reason,
364+
createdAt: h.createdAt,
365+
}));
366+
367+
const activeWindows = buildActiveWindows(sub.startedAt, today, statusEvents);
368+
const activeDays = activeWindows.reduce(
369+
(sum, w) => sum + Math.floor((w.to.getTime() - w.from.getTime()) / 86400000),
370+
0,
371+
);
372+
315373
const periods: CumulativePeriod[] = [];
316374
let totalKrw = 0;
317375

318376
for (let i = 0; i < history.length; i++) {
319377
const h = history[i];
320-
const periodStart = new Date(h.effectiveDate);
321-
const periodEnd = history[i + 1] ? new Date(history[i + 1].effectiveDate) : today;
322-
323-
const paymentCount = countPayments(sub.billingCycle, sub.billingDay, periodStart, periodEnd);
378+
const periodStart = parseLocalDate(h.effectiveDate);
379+
const periodEnd = history[i + 1] ? parseLocalDate(history[i + 1].effectiveDate) : today;
380+
381+
const paymentCount = countPaymentsInWindows(
382+
sub.billingCycle,
383+
sub.billingDay,
384+
periodStart,
385+
periodEnd,
386+
activeWindows,
387+
);
324388
const periodTotal = paymentCount * (h.amount ?? 0);
325389

326390
periods.push({
@@ -339,8 +403,8 @@ export class SubscriptionService {
339403
const lastPeriod = periods[periods.length - 1];
340404
const currentPricePaid = lastPeriod?.periodTotal ?? 0;
341405

342-
const monthsElapsed = Math.max(daysSinceStart / 30, 1);
343-
const averageMonthly = Math.round(totalKrw / monthsElapsed);
406+
const activeMonths = Math.max(activeDays / 30, 1);
407+
const averageMonthly = Math.round(totalKrw / activeMonths);
344408

345409
return {
346410
subscriptionId: id,
@@ -350,6 +414,7 @@ export class SubscriptionService {
350414
priceChangeCount: Math.max(history.length - 1, 0),
351415
averageMonthly,
352416
daysSinceStart,
417+
activeDays,
353418
periods,
354419
};
355420
}
@@ -374,7 +439,7 @@ export class SubscriptionService {
374439

375440
// 가장 가까운 결제일
376441
const sorted = [...active].sort(
377-
(a, b) => new Date(a.nextPaymentDate).getTime() - new Date(b.nextPaymentDate).getTime(),
442+
(a, b) => parseLocalDate(a.nextPaymentDate).getTime() - parseLocalDate(b.nextPaymentDate).getTime(),
378443
);
379444
const nextDate = sorted[0]?.nextPaymentDate ?? null;
380445
const nextServices = nextDate
@@ -424,6 +489,32 @@ export class SubscriptionService {
424489
);
425490
}
426491

492+
// ── 상태 이력 ─────────────────────────────────────────────────
493+
494+
private async recordStatusChange(
495+
subscriptionId: string,
496+
status: SubscriptionStatus,
497+
changedAt: string,
498+
reason?: string,
499+
): Promise<void> {
500+
await this.db.query(
501+
`INSERT INTO subscription_status_history
502+
(subscription_id, status, changed_at, reason)
503+
VALUES ($1,$2,$3,$4)`,
504+
[subscriptionId, status, changedAt, reason ?? null],
505+
);
506+
}
507+
508+
async getStatusHistory(subscriptionId: string): Promise<StatusHistoryEntry[]> {
509+
const rows = await this.db.query<Record<string, unknown>>(
510+
`SELECT * FROM subscription_status_history
511+
WHERE subscription_id = $1
512+
ORDER BY changed_at ASC, created_at ASC`,
513+
[subscriptionId],
514+
);
515+
return rows.map((r) => this.mapStatusHistoryRow(r));
516+
}
517+
427518
// ── 행 매핑 ──────────────────────────────────────────────────
428519

429520
private mapRow(r: Record<string, unknown>): Subscription {
@@ -460,23 +551,86 @@ export class SubscriptionService {
460551
};
461552
}
462553

554+
private mapStatusHistoryRow(r: Record<string, unknown>): StatusHistoryEntry {
555+
return {
556+
id: r['id'] as string,
557+
subscriptionId: r['subscription_id'] as string,
558+
status: r['status'] as SubscriptionStatus,
559+
changedAt: parseDateField(r['changed_at']),
560+
reason: (r['reason'] as string) ?? null,
561+
createdAt: r['created_at'] as string,
562+
};
563+
}
564+
463565
private mapHistoryRow(r: Record<string, unknown>): HistoryEvent {
464566
const eventType = ((r['event_type'] as string) ?? 'price_change') as HistoryEventType;
465-
const isPriceChange = eventType === 'price_change';
567+
const isAmountEvent = eventType === 'price_change' || eventType === 'plan_change';
466568
return {
467569
id: r['id'] as string,
468570
subscriptionId: r['subscription_id'] as string,
469571
eventType,
470572
effectiveDate: parseDateField(r['effective_date']),
471-
amount: isPriceChange ? Number(r['amount']) : null,
472-
currency: isPriceChange ? (r['currency'] as SubscriptionCurrency) : null,
573+
amount: isAmountEvent ? Number(r['amount']) : null,
574+
currency: isAmountEvent ? (r['currency'] as SubscriptionCurrency) : null,
473575
reason: (r['reason'] as string) ?? null,
474576
note: (r['note'] as string) ?? null,
475577
createdAt: r['created_at'] as string,
476578
};
477579
}
478580
}
479581

582+
// ── 활성 기간 창 구성 ─────────────────────────────────────────────
583+
584+
function buildActiveWindows(
585+
startedAt: string,
586+
today: Date,
587+
statusHistory: StatusHistoryEntry[],
588+
): Array<{ from: Date; to: Date }> {
589+
const windows: Array<{ from: Date; to: Date }> = [];
590+
591+
// 구독은 항상 startedAt부터 활성 상태로 시작
592+
let windowStart: Date | null = parseLocalDate(startedAt);
593+
594+
for (const entry of statusHistory) {
595+
const date = parseLocalDate(entry.changedAt);
596+
if (entry.status === 'cancelled' && windowStart !== null) {
597+
// 활성 구간 종료 (역순·중복 이벤트 방어: date가 windowStart보다 커야 유효)
598+
if (date > windowStart) {
599+
windows.push({ from: windowStart, to: date });
600+
}
601+
windowStart = null;
602+
} else if (entry.status === 'active' && windowStart === null) {
603+
// 비활성 구간 종료 → 새 활성 구간 시작
604+
windowStart = date;
605+
}
606+
// 이미 같은 상태인 중복 이벤트는 무시
607+
}
608+
609+
if (windowStart !== null) {
610+
windows.push({ from: windowStart, to: today });
611+
}
612+
613+
return windows;
614+
}
615+
616+
function countPaymentsInWindows(
617+
cycle: BillingCycle,
618+
billingDay: number,
619+
periodStart: Date,
620+
periodEnd: Date,
621+
activeWindows: Array<{ from: Date; to: Date }>,
622+
): number {
623+
let count = 0;
624+
for (const window of activeWindows) {
625+
const from = new Date(Math.max(periodStart.getTime(), window.from.getTime()));
626+
const to = new Date(Math.min(periodEnd.getTime(), window.to.getTime()));
627+
if (from < to) {
628+
count += countPayments(cycle, billingDay, from, to);
629+
}
630+
}
631+
return count;
632+
}
633+
480634
// ── 결제 횟수 계산 ────────────────────────────────────────────────
481635

482636
function countPayments(

modules/subscription/backend/validation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,18 @@ export const createNoteSchema = z.object({
2929
});
3030

3131
export const createHistoryEventSchema = z.discriminatedUnion('eventType', [
32+
// 금액 변경을 수반하는 이벤트 (price_change, plan_change)
3233
z.object({
33-
eventType: z.literal('price_change'),
34+
eventType: z.enum(['price_change', 'plan_change']),
3435
effectiveDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
3536
amount: z.number().nonnegative(),
3637
currency: z.enum(CURRENCIES),
3738
reason: z.string().max(200).optional(),
3839
note: z.string().max(500).optional(),
3940
}),
41+
// 금액 변경 없는 이벤트
4042
z.object({
41-
eventType: z.enum(['cancelled', 'resumed', 'plan_change', 'memo']),
43+
eventType: z.enum(['cancelled', 'resumed', 'memo']),
4244
effectiveDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
4345
reason: z.string().max(200).optional(),
4446
note: z.string().max(500).optional(),

0 commit comments

Comments
 (0)