@@ -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+
4057function 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
482636function countPayments (
0 commit comments