Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public List<DefenseCauseSummary> 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
Expand All @@ -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
Expand All @@ -72,14 +77,15 @@ public List<DefenseCalendarPeriodSummary> 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<DefenseChecklistSummary> checklist = DefenseChecklistCatalog.findBy(defenseMode.getCauseCode()).stream()
.map(item -> new DefenseChecklistSummary(item.name(), item.getTitle(), item.getDescription()))
.collect(Collectors.toList());
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -49,18 +57,30 @@ 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(
DefenseModeMapper defenseModeMapper,
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
Expand Down Expand Up @@ -92,6 +112,10 @@ public DefenseMode getCurrent(Long userId) {
return defenseMode;
}

public Integer getCurrentDDay(DefenseMode defenseMode) {
return currentDefenseState(defenseMode).dDay;
}

public List<DefenseMode> getCalendarPeriods(Long userId, LocalDate from, LocalDate to) {
if (userId == null || from == null || to == null) {
throw new ServiceException(DefenseErrorCode.INVALID_REQUEST);
Expand Down Expand Up @@ -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(),
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));

Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
}
Loading