diff --git a/services/defense-service/src/main/java/com/ntropy/defense/client/LocalDefenseModeClient.java b/services/defense-service/src/main/java/com/ntropy/defense/client/LocalDefenseModeClient.java index d082256a..8894bd14 100644 --- a/services/defense-service/src/main/java/com/ntropy/defense/client/LocalDefenseModeClient.java +++ b/services/defense-service/src/main/java/com/ntropy/defense/client/LocalDefenseModeClient.java @@ -43,7 +43,7 @@ public List getCauses() { @Override public DefenseModeSummary enter(DefenseModeEnterCommand command) { - return toSummary(defenseModeService.enter(command), null, null, null); + return toSummary(defenseModeService.enter(command), null, null, null, null); } @Override @@ -55,7 +55,12 @@ public DefenseModeSummary getCurrent(Long userId) { true, "DISPLAY_ONLY", "근무 추천과 자동 저축을 잠시 멈춘 상태입니다."); - return toSummary(defenseMode, fixedExpenseCheck, expectedIncomeLoss, growthMode); + return toSummary( + defenseMode, + fixedExpenseCheck, + expectedIncomeLoss, + growthMode, + defenseModeService.getCurrentDDay(defenseMode)); } @Override @@ -72,14 +77,15 @@ public List getCalendarPeriods(Long userId, LocalD @Override public DefenseModeSummary release(Long defenseId, DefenseModeReleaseCommand command) { - return toSummary(defenseModeService.release(defenseId, command), null, null, null); + return toSummary(defenseModeService.release(defenseId, command), null, null, null, null); } private DefenseModeSummary toSummary( DefenseMode defenseMode, FixedExpenseCheckSummary fixedExpenseCheck, ExpectedIncomeLossSummary expectedIncomeLoss, - GrowthModeSummary growthMode) { + GrowthModeSummary growthMode, + Integer dDayOverride) { List checklist = DefenseChecklistCatalog.findBy(defenseMode.getCauseCode()).stream() .map(item -> new DefenseChecklistSummary(item.name(), item.getTitle(), item.getDescription())) .collect(Collectors.toList()); @@ -89,7 +95,8 @@ private DefenseModeSummary toSummary( defenseMode.getUnavailableStartDate(), defenseMode.getExpectedReturnDate(), defenseMode.getReturnDate(), defenseMode.getReserveAmountSnapshot(), defenseMode.getSafeAssetAmountSnapshot(), defenseMode.getAvailableAssetsSnapshot(), - defenseMode.getAverageMonthlyExpense(), defenseMode.getDailyExpense(), defenseMode.getDDay(), + defenseMode.getAverageMonthlyExpense(), defenseMode.getDailyExpense(), + dDayOverride == null ? defenseMode.getDDay() : dDayOverride, defenseMode.getCalculationStatus() == null ? null : defenseMode.getCalculationStatus().name(), defenseMode.getStatus().name(), defenseMode.getCreatedAt(), checklist, fixedExpenseCheck, expectedIncomeLoss, growthMode); diff --git a/services/defense-service/src/main/java/com/ntropy/defense/service/DefenseModeService.java b/services/defense-service/src/main/java/com/ntropy/defense/service/DefenseModeService.java index a6c00f7c..d3673832 100644 --- a/services/defense-service/src/main/java/com/ntropy/defense/service/DefenseModeService.java +++ b/services/defense-service/src/main/java/com/ntropy/defense/service/DefenseModeService.java @@ -24,18 +24,26 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; import java.time.LocalDate; import java.time.YearMonth; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @Service public class DefenseModeService { + private static final int CRITICAL_REMAINING_DAYS = 30; + private static final int WARNING_REMAINING_DAYS = 60; + private static final int WARNING_REDUCTION_PERCENT = 20; + private final DefenseModeMapper defenseModeMapper; private final DiagnosisQueryClient diagnosisQueryClient; private final FinancialCommitmentQueryClient financialCommitmentQueryClient; private final ExpectedIncomeLossQueryClient expectedIncomeLossQueryClient; + private final Clock clock; @Autowired public DefenseModeService( @@ -49,7 +57,8 @@ public DefenseModeService( financialCommitmentQueryClientProvider.getIfAvailable( () -> (userId, fromDate, toDate) -> Collections.emptyList()), expectedIncomeLossQueryClientProvider.getIfAvailable( - () -> (userId, fromDate, toDate) -> Collections.emptyList())); + () -> (userId, fromDate, toDate) -> Collections.emptyList()), + Clock.system(ZoneId.of("Asia/Seoul"))); } public DefenseModeService( @@ -57,10 +66,21 @@ public DefenseModeService( DiagnosisQueryClient diagnosisQueryClient, FinancialCommitmentQueryClient financialCommitmentQueryClient, ExpectedIncomeLossQueryClient expectedIncomeLossQueryClient) { + this(defenseModeMapper, diagnosisQueryClient, financialCommitmentQueryClient, + expectedIncomeLossQueryClient, Clock.system(ZoneId.of("Asia/Seoul"))); + } + + public DefenseModeService( + DefenseModeMapper defenseModeMapper, + DiagnosisQueryClient diagnosisQueryClient, + FinancialCommitmentQueryClient financialCommitmentQueryClient, + ExpectedIncomeLossQueryClient expectedIncomeLossQueryClient, + Clock clock) { this.defenseModeMapper = defenseModeMapper; this.diagnosisQueryClient = diagnosisQueryClient; this.financialCommitmentQueryClient = financialCommitmentQueryClient; this.expectedIncomeLossQueryClient = expectedIncomeLossQueryClient; + this.clock = clock; } @Transactional @@ -92,6 +112,10 @@ public DefenseMode getCurrent(Long userId) { return defenseMode; } + public Integer getCurrentDDay(DefenseMode defenseMode) { + return currentDefenseState(defenseMode).dDay; + } + public List getCalendarPeriods(Long userId, LocalDate from, LocalDate to) { if (userId == null || from == null || to == null) { throw new ServiceException(DefenseErrorCode.INVALID_REQUEST); @@ -243,23 +267,30 @@ private Long sumNullable(Long first, Long second) { private FixedExpenseSummary toFixedExpense( DefenseMode defenseMode, FinancialCommitmentSummary commitment) { + CurrentDefenseState currentState = currentDefenseState(defenseMode); Integer dDayAfter = null; Integer dDayReduction = null; Long expectedAmount = commitment.getExpectedAmount(); - if (defenseMode.getDDay() != null - && defenseMode.getAvailableAssetsSnapshot() != null + if (currentState.dDay != null + && currentState.availableAssets != null && defenseMode.getDailyExpense() != null && defenseMode.getDailyExpense() > 0 && expectedAmount != null && expectedAmount >= 0 && !"INSUFFICIENT".equals(commitment.getAmountStatus())) { - long assetsAfterPayment = Math.max(defenseMode.getAvailableAssetsSnapshot() - expectedAmount, 0L); + long assetsAfterPayment = Math.max(currentState.availableAssets - expectedAmount, 0L); long calculatedDays = assetsAfterPayment / defenseMode.getDailyExpense(); dDayAfter = calculatedDays > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) calculatedDays; - dDayReduction = Math.max(defenseMode.getDDay() - dDayAfter, 0); + dDayReduction = Math.max(currentState.dDay - dDayAfter, 0); } - FixedExpenseMaintainStatus maintainStatus = maintainStatus(commitment.getExpenseType()); + FixedExpenseMaintainStatus maintainStatus = maintainStatus( + currentState.availableAssets, + currentState.dDay, + expectedAmount, + dDayAfter, + dDayReduction, + commitment.getAmountStatus()); return new FixedExpenseSummary( commitment.getCommitmentId(), commitment.getAccountId(), @@ -271,23 +302,79 @@ private FixedExpenseSummary toFixedExpense( commitment.getNextPaymentDate(), commitment.getAmountStatus(), commitment.getDateStatus(), - defenseMode.getDDay(), + currentState.dDay, dDayAfter, dDayReduction, maintainStatus); } - private FixedExpenseMaintainStatus maintainStatus(String expenseType) { - if ("LOAN_REPAYMENT".equals(expenseType)) { - return FixedExpenseMaintainStatus.NORMAL; + private CurrentDefenseState currentDefenseState(DefenseMode defenseMode) { + if (defenseMode == null + || defenseMode.getAvailableAssetsSnapshot() == null + || defenseMode.getDailyExpense() == null + || defenseMode.getDailyExpense() <= 0 + || defenseMode.getDDay() == null) { + return new CurrentDefenseState(null, null); } - if ("INSURANCE_PREMIUM".equals(expenseType)) { - return FixedExpenseMaintainStatus.DIFFICULT; + + long elapsedDays = Math.max( + ChronoUnit.DAYS.between(defenseMode.getUnavailableStartDate(), LocalDate.now(clock)), + 0L); + long availableAssets = defenseMode.getAvailableAssetsSnapshot(); + long dailyExpense = defenseMode.getDailyExpense(); + long remainingAssets; + if (elapsedDays > availableAssets / dailyExpense) { + remainingAssets = 0L; + } else { + remainingAssets = availableAssets - dailyExpense * elapsedDays; } - if ("SAVING_PAYMENT".equals(expenseType)) { + + long calculatedDays = remainingAssets / dailyExpense; + int remainingDDay = calculatedDays > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) calculatedDays; + return new CurrentDefenseState(remainingAssets, remainingDDay); + } + + private static class CurrentDefenseState { + private final Long availableAssets; + private final Integer dDay; + + private CurrentDefenseState(Long availableAssets, Integer dDay) { + this.availableAssets = availableAssets; + this.dDay = dDay; + } + } + + private FixedExpenseMaintainStatus maintainStatus( + Long availableAssets, + Integer dDayBefore, + Long expectedAmount, + Integer dDayAfter, + Integer dDayReduction, + String amountStatus) { + if (availableAssets == null + || dDayBefore == null + || expectedAmount == null + || expectedAmount < 0 + || dDayAfter == null + || dDayReduction == null + || "INSUFFICIENT".equals(amountStatus)) { + return FixedExpenseMaintainStatus.UNDETERMINED; + } + if (expectedAmount > availableAssets || dDayAfter < CRITICAL_REMAINING_DAYS) { return FixedExpenseMaintainStatus.REVIEW_SUSPENSION; } - return FixedExpenseMaintainStatus.UNDETERMINED; + if (dDayAfter < WARNING_REMAINING_DAYS + || reductionPercentAtLeast(dDayBefore, dDayReduction, WARNING_REDUCTION_PERCENT)) { + return FixedExpenseMaintainStatus.DIFFICULT; + } + return FixedExpenseMaintainStatus.NORMAL; + } + + private boolean reductionPercentAtLeast(Integer dDayBefore, Integer dDayReduction, int thresholdPercent) { + if (dDayBefore <= 0) { + return dDayReduction > 0; + } + return (long) dDayReduction * 100 >= (long) dDayBefore * thresholdPercent; } private String expenseName(String expenseType) { diff --git a/services/defense-service/src/test/java/com/ntropy/defense/service/DefenseModeServiceTest.java b/services/defense-service/src/test/java/com/ntropy/defense/service/DefenseModeServiceTest.java index df16f8c5..9eb2949e 100644 --- a/services/defense-service/src/test/java/com/ntropy/defense/service/DefenseModeServiceTest.java +++ b/services/defense-service/src/test/java/com/ntropy/defense/service/DefenseModeServiceTest.java @@ -15,7 +15,9 @@ import com.ntropy.defense.mapper.DefenseModeMapper; import org.junit.jupiter.api.Test; +import java.time.Clock; import java.time.LocalDate; +import java.time.ZoneId; import java.util.HashMap; import java.util.Arrays; import java.util.Collections; @@ -96,7 +98,8 @@ void calculatesFixedExpenseImpactAndKeepsUnknownLoanAmountUncalculated() { new FinancialCommitmentSummary( 3L, 30L, "INSURANCE_PREMIUM", "실비 보험", null, 100_000L, LocalDate.of(2026, 8, 7), "CONFIRMED", "CONFIRMED")), - (userId, fromDate, toDate) -> Collections.emptyList()); + (userId, fromDate, toDate) -> Collections.emptyList(), + clockAt(LocalDate.of(2026, 8, 3))); DefenseMode entered = fixedExpenseService.enter(new DefenseModeEnterCommand( 1L, "ACCIDENT_INJURY", LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 10))); @@ -106,16 +109,66 @@ void calculatesFixedExpenseImpactAndKeepsUnknownLoanAmountUncalculated() { assertEquals(3, result.getExpenses().size()); assertEquals(38, result.getExpenses().get(0).getDDayAfter()); assertEquals(4, result.getExpenses().get(0).getDDayReduction()); - assertEquals(FixedExpenseMaintainStatus.REVIEW_SUSPENSION, + assertEquals(FixedExpenseMaintainStatus.DIFFICULT, result.getExpenses().get(0).getMaintainStatus()); assertEquals(null, result.getExpenses().get(1).getDDayAfter()); assertEquals(12_500_000L, result.getExpenses().get(1).getOutstandingBalance()); - assertEquals(FixedExpenseMaintainStatus.NORMAL, + assertEquals(FixedExpenseMaintainStatus.UNDETERMINED, result.getExpenses().get(1).getMaintainStatus()); assertEquals(FixedExpenseMaintainStatus.DIFFICULT, result.getExpenses().get(2).getMaintainStatus()); } + @Test + void calculatesMaintainStatusFromAssetsAndPaymentImpactInsteadOfExpenseType() { + DefenseModeService fixedExpenseService = new DefenseModeService( + new MemoryMapper(), + userId -> new DiagnosisDefenseSnapshot(9_000_000L, 0L, 3_000_000L), + (userId, fromDate, toDate) -> Arrays.asList( + new FinancialCommitmentSummary( + 1L, 10L, "SAVING_PAYMENT", "소액 적금", null, + 100_000L, LocalDate.of(2026, 8, 5), "CONFIRMED", "CONFIRMED"), + new FinancialCommitmentSummary( + 2L, 20L, "SAVING_PAYMENT", "고액 적금", null, + 8_000_000L, LocalDate.of(2026, 8, 5), "CONFIRMED", "CONFIRMED")), + (userId, fromDate, toDate) -> Collections.emptyList(), + clockAt(LocalDate.of(2026, 8, 3))); + DefenseMode entered = fixedExpenseService.enter(new DefenseModeEnterCommand( + 1L, "ACCIDENT_INJURY", LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 10))); + + FixedExpenseCheckSummary result = fixedExpenseService.getFixedExpenseCheck(entered); + + assertEquals(FixedExpenseMaintainStatus.NORMAL, + result.getExpenses().get(0).getMaintainStatus()); + assertEquals(FixedExpenseMaintainStatus.REVIEW_SUSPENSION, + result.getExpenses().get(1).getMaintainStatus()); + } + + @Test + void calculatesCurrentDDayFromElapsedDaysWithoutChangingEntrySnapshot() { + MemoryMapper currentMapper = new MemoryMapper(); + DefenseModeService currentService = new DefenseModeService( + currentMapper, + userId -> new DiagnosisDefenseSnapshot(1_280_000L, 3_400_000L, 3_300_000L), + (userId, fromDate, toDate) -> Collections.singletonList( + new FinancialCommitmentSummary( + 1L, 10L, "SAVING_PAYMENT", "청년희망적금", null, + 500_000L, LocalDate.of(2026, 8, 15), "CONFIRMED", "CONFIRMED")), + (userId, fromDate, toDate) -> Collections.emptyList(), + clockAt(LocalDate.of(2026, 8, 13))); + DefenseMode entered = currentService.enter(new DefenseModeEnterCommand( + 1L, "ACCIDENT_INJURY", LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 31))); + + assertEquals(32, currentService.getCurrentDDay(entered)); + assertEquals(42, entered.getDDay()); + assertEquals(42, currentMapper.findById(entered.getDefenseId()).getDDay()); + + FixedExpenseCheckSummary result = currentService.getFixedExpenseCheck(entered); + assertEquals(32, result.getExpenses().get(0).getDDayBefore()); + assertEquals(28, result.getExpenses().get(0).getDDayAfter()); + assertEquals(4, result.getExpenses().get(0).getDDayReduction()); + } + @Test void calculatesExpectedIncomeLossForCurrentMonthDefensePeriod() { LocalDate today = LocalDate.now(); @@ -185,4 +238,9 @@ public int release(DefenseMode defenseMode) { return 1; } } + + private static Clock clockAt(LocalDate date) { + ZoneId zoneId = ZoneId.of("Asia/Seoul"); + return Clock.fixed(date.atStartOfDay(zoneId).toInstant(), zoneId); + } }