diff --git a/backend/src/main/java/com/dday/domain/assetforecast/dto/response/AssetForecastResponse.java b/backend/src/main/java/com/dday/domain/assetforecast/dto/response/AssetForecastResponse.java index 4c03ec9..2316ed0 100644 --- a/backend/src/main/java/com/dday/domain/assetforecast/dto/response/AssetForecastResponse.java +++ b/backend/src/main/java/com/dday/domain/assetforecast/dto/response/AssetForecastResponse.java @@ -5,6 +5,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.List; @Getter @Builder @@ -61,4 +62,37 @@ public class AssetForecastResponse { * 현재 계산에서는 수익률을 반영하지 않는다. */ private boolean investmentReturnIncluded; + + private Long currentTotalAsset; + private Long currentCashAsset; + private Long currentSavingInvestmentAsset; + private Long currentHousingDeposit; + private Long forecastTotalAsset; + private Long forecastNetAsset; + private Long forecastShortageAmount; + private Long forecastCashAsset; + private Long forecastSavingInvestmentAsset; + private Long forecastHousingDeposit; + private Long monthlyConvertedNetIncome; + private Long averageMonthlySavingInvestment; + private Long totalAssetChange; + private Long savingInvestmentChange; + private Long cashDepletionMonth; + private Long liquidAssetDepletionMonth; + private Long housingDepositDepletionMonth; + private BigDecimal cashRatio; + private BigDecimal savingInvestmentRatio; + private BigDecimal housingDepositRatio; + private CalculationBasis calculationBasis; + private boolean dataSufficient; + + @Getter + @Builder + public static class CalculationBasis { + private List monthlyIncome; + private List monthlyExpense; + private Long currentFixedIncome; + private List monthlySavingInvestment; + private Integer validIncomeMonthCount; + } } diff --git a/backend/src/main/java/com/dday/domain/assetforecast/repository/AssetForecastAccountRepository.java b/backend/src/main/java/com/dday/domain/assetforecast/repository/AssetForecastAccountRepository.java index df48880..3471554 100644 --- a/backend/src/main/java/com/dday/domain/assetforecast/repository/AssetForecastAccountRepository.java +++ b/backend/src/main/java/com/dday/domain/assetforecast/repository/AssetForecastAccountRepository.java @@ -31,4 +31,14 @@ Long sumCurrentFutureAsset( @Param("userId") Long userId, @Param("accountTypes") Collection accountTypes ); -} \ No newline at end of file + + @Query(""" + select coalesce(sum(account.balance), 0) + from UserAccount account + where account.user.userId = :userId + and account.active = true + and account.accountType = :accountType + """) + Long sumActiveBalanceByType(@Param("userId") Long userId, + @Param("accountType") AccountType accountType); +} diff --git a/backend/src/main/java/com/dday/domain/assetforecast/service/AssetForecastService.java b/backend/src/main/java/com/dday/domain/assetforecast/service/AssetForecastService.java index 58beb02..762a3ed 100644 --- a/backend/src/main/java/com/dday/domain/assetforecast/service/AssetForecastService.java +++ b/backend/src/main/java/com/dday/domain/assetforecast/service/AssetForecastService.java @@ -4,6 +4,8 @@ import com.dday.domain.assetforecast.repository.AssetForecastAccountRepository; import com.dday.domain.assetforecast.repository.AssetForecastPocketBudgetRepository; import com.dday.domain.budget.entity.MonthlyPocketBudget; +import com.dday.domain.housing.repository.HousingCostRepository; +import com.dday.domain.income.repository.RecurringIncomeRepository; import com.dday.domain.mydata.entity.AccountType; import com.dday.domain.mydata.repository.FinancialTransactionRepository; import com.dday.domain.onboarding.service.OnboardingCalculator; @@ -21,6 +23,7 @@ import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Service @@ -40,6 +43,9 @@ public class AssetForecastService { private final FinancialTransactionRepository financialTransactionRepository; + private final HousingCostRepository housingCostRepository; + private final RecurringIncomeRepository recurringIncomeRepository; + /** * 지원 종료 시 예상 총자산 조회. * @@ -163,20 +169,93 @@ public AssetForecastResponse getForecast( .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(monthlyFutureAmount), 1, RoundingMode.HALF_UP); - /* - * 지원 종료까지 추가될 - * 예상 자산. - */ - long expectedAdditionalAsset = - monthlyFutureAmount - * remainingMonths; + long currentCash = assetForecastAccountRepository + .sumActiveBalanceByType(userId, AccountType.DEPOSIT); + long housingDeposit = housingCostRepository.findById(userId) + .map(cost -> cost.getDeposit() == null ? 0L : cost.getDeposit()) + .orElse(0L); + long currentFixedIncome = recurringIncomeRepository.sumExpectedAmountByUserId(userId); - /* - * 지원 종료 시 예상 총자산. - */ - long expectedAsset = - currentAsset - + expectedAdditionalAsset; + List monthlyIncome = new ArrayList<>(); + List monthlyExpense = new ArrayList<>(); + List monthlyContribution = new ArrayList<>(); + BigDecimal convertedNetSum = BigDecimal.ZERO; + int validIncomeMonths = 0; + + LocalDate historyStart = budgetMonth.minusMonths(3); + for (int index = 0; index < 3; index++) { + LocalDate month = historyStart.plusMonths(index); + LocalDateTime from = month.atStartOfDay(); + LocalDateTime to = month.plusMonths(1).atStartOfDay(); + long income = financialTransactionRepository.sumIncomeByPeriod(userId, from, to); + long expense = financialTransactionRepository.sumExpenseByPeriod(userId, from, to); + long contribution = financialTransactionRepository + .sumMonthlyFutureAssetContribution(userId, from, to); + monthlyIncome.add(income); + monthlyExpense.add(expense); + monthlyContribution.add(contribution); + if (income > 0L) { + BigDecimal adjustedExpense = BigDecimal.valueOf(expense) + .multiply(BigDecimal.valueOf(currentFixedIncome)) + .divide(BigDecimal.valueOf(income), 8, RoundingMode.HALF_UP); + convertedNetSum = convertedNetSum.add( + BigDecimal.valueOf(currentFixedIncome).subtract(adjustedExpense)); + validIncomeMonths++; + } + } + + long convertedMonthlyNet = validIncomeMonths == 0 ? 0L : convertedNetSum + .divide(BigDecimal.valueOf(validIncomeMonths), 0, RoundingMode.HALF_UP) + .longValue(); + long averageMonthlyContribution = BigDecimal.valueOf( + monthlyContribution.stream().mapToLong(Long::longValue).sum()) + .divide(BigDecimal.valueOf(3), 0, RoundingMode.HALF_UP) + .longValue(); + + long forecastCash = currentCash; + long forecastSavingInvestment = currentAsset; + long forecastHousingDeposit = housingDeposit; + Long cashDepletionMonth = null; + Long liquidAssetDepletionMonth = null; + Long housingDepositDepletionMonth = null; + long forecastShortageAmount = 0L; + for (long month = 1; month <= remainingMonths; month++) { + forecastSavingInvestment += averageMonthlyContribution; + forecastCash += convertedMonthlyNet; + if (forecastCash <= 0L) { + long shortfall = Math.abs(Math.min(forecastCash, 0L)); + forecastCash = 0L; + if (cashDepletionMonth == null && convertedMonthlyNet < 0L) { + cashDepletionMonth = month; + } + long savingDeduction = Math.min(forecastSavingInvestment, shortfall); + forecastSavingInvestment -= savingDeduction; + shortfall -= savingDeduction; + if (forecastSavingInvestment == 0L && liquidAssetDepletionMonth == null + && convertedMonthlyNet < 0L) { + liquidAssetDepletionMonth = month; + } + if (shortfall > 0L) { + long housingDeduction = Math.min(forecastHousingDeposit, shortfall); + forecastHousingDeposit -= housingDeduction; + shortfall -= housingDeduction; + if (forecastHousingDeposit == 0L && housingDepositDepletionMonth == null) { + housingDepositDepletionMonth = month; + } + forecastShortageAmount += shortfall; + } + } + } + + long currentTotalAsset = currentCash + currentAsset + housingDeposit; + long forecastTotalAsset = forecastCash + forecastSavingInvestment + forecastHousingDeposit; + long forecastNetAsset = forecastTotalAsset - forecastShortageAmount; + long expectedAdditionalAsset = forecastSavingInvestment - currentAsset; + long expectedAsset = forecastNetAsset; + BigDecimal totalForRatio = BigDecimal.valueOf(forecastTotalAsset); + BigDecimal cashRatio = ratio(forecastCash, totalForRatio); + BigDecimal savingRatio = ratio(forecastSavingInvestment, totalForRatio); + BigDecimal housingRatio = ratio(forecastHousingDeposit, totalForRatio); return AssetForecastResponse .builder() @@ -210,6 +289,42 @@ public AssetForecastResponse getForecast( .investmentReturnIncluded( false ) + .currentTotalAsset(currentTotalAsset) + .currentCashAsset(currentCash) + .currentSavingInvestmentAsset(currentAsset) + .currentHousingDeposit(housingDeposit) + .forecastTotalAsset(forecastTotalAsset) + .forecastNetAsset(forecastNetAsset) + .forecastShortageAmount(forecastShortageAmount) + .forecastCashAsset(forecastCash) + .forecastSavingInvestmentAsset(forecastSavingInvestment) + .forecastHousingDeposit(forecastHousingDeposit) + .monthlyConvertedNetIncome(convertedMonthlyNet) + .averageMonthlySavingInvestment(averageMonthlyContribution) + .totalAssetChange(forecastNetAsset - currentTotalAsset) + .savingInvestmentChange(forecastSavingInvestment - currentAsset) + .cashDepletionMonth(cashDepletionMonth) + .liquidAssetDepletionMonth(liquidAssetDepletionMonth) + .housingDepositDepletionMonth(housingDepositDepletionMonth) + .cashRatio(cashRatio) + .savingInvestmentRatio(savingRatio) + .housingDepositRatio(housingRatio) + .calculationBasis(AssetForecastResponse.CalculationBasis.builder() + .monthlyIncome(monthlyIncome) + .monthlyExpense(monthlyExpense) + .currentFixedIncome(currentFixedIncome) + .monthlySavingInvestment(monthlyContribution) + .validIncomeMonthCount(validIncomeMonths) + .build()) + .dataSufficient(currentFixedIncome > 0L && validIncomeMonths > 0) .build(); } + + private BigDecimal ratio(long amount, BigDecimal total) { + if (total.signum() <= 0) { + return BigDecimal.ZERO; + } + return BigDecimal.valueOf(amount).multiply(BigDecimal.valueOf(100)) + .divide(total, 1, RoundingMode.HALF_UP); + } } diff --git a/backend/src/main/java/com/dday/domain/income/repository/RecurringIncomeRepository.java b/backend/src/main/java/com/dday/domain/income/repository/RecurringIncomeRepository.java index 55bf785..8d3dae8 100644 --- a/backend/src/main/java/com/dday/domain/income/repository/RecurringIncomeRepository.java +++ b/backend/src/main/java/com/dday/domain/income/repository/RecurringIncomeRepository.java @@ -2,6 +2,8 @@ import com.dday.domain.income.entity.RecurringIncome; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -16,4 +18,7 @@ public interface RecurringIncomeRepository extends JpaRepository findByRecurringIncomeIdAndUserUserId(Long recurringIncomeId, Long userId); + + @Query("select coalesce(sum(income.expectedAmount), 0) from RecurringIncome income where income.user.userId = :userId") + Long sumExpectedAmountByUserId(@Param("userId") Long userId); } diff --git a/backend/src/main/java/com/dday/domain/mydata/repository/FinancialTransactionRepository.java b/backend/src/main/java/com/dday/domain/mydata/repository/FinancialTransactionRepository.java index a76079e..696a50e 100644 --- a/backend/src/main/java/com/dday/domain/mydata/repository/FinancialTransactionRepository.java +++ b/backend/src/main/java/com/dday/domain/mydata/repository/FinancialTransactionRepository.java @@ -269,6 +269,34 @@ Long sumMonthlyFutureAssetContribution(@Param("userId") Long userId, @Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + @Query(""" + select coalesce(sum(t.amount), 0) + from FinancialTransaction t + left join t.account a + left join t.card c + where (a.user.userId = :userId or c.user.userId = :userId) + and t.transactionAt >= :from and t.transactionAt < :to + and t.transactionType = com.dday.domain.mydata.entity.TransactionType.INCOME + and t.transactionStatus = com.dday.domain.mydata.entity.TransactionStatus.NORMAL + """) + Long sumIncomeByPeriod(@Param("userId") Long userId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query(""" + select coalesce(sum(t.amount), 0) + from FinancialTransaction t + left join t.account a + left join t.card c + where (a.user.userId = :userId or c.user.userId = :userId) + and t.transactionAt >= :from and t.transactionAt < :to + and t.transactionType = com.dday.domain.mydata.entity.TransactionType.EXPENSE + and t.transactionStatus = com.dday.domain.mydata.entity.TransactionStatus.NORMAL + """) + Long sumExpenseByPeriod(@Param("userId") Long userId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + /** 적금·투자 계좌별 이번 달 납입액. 결과 행은 {@code [계좌 ID, 합계]}다. */ @Query(""" select targetAccount.accountId, coalesce(sum(t.amount), 0) diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 4e156e9..8d793b0 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -286,7 +286,7 @@ ON DUPLICATE KEY UPDATE credit_limit = VALUES(credit_limit), is_active = VALUES(is_active); --- 계좌 거래내역 — 2026년 8월 한 달 + 9월 진행분. +-- 계좌 거래내역 — 2026년 6~8월 완료분 + 9월 진행분. -- -- 수입(급여 120만 + 자립수당 50만 = 170만)과 지출(월세 45만 + 관리비 7만 + 적금 20만)이 -- 위 recurring_income · housing_cost 값과 맞물린다. 한쪽만 고치면 화면에서 합계가 어긋난다. @@ -299,6 +299,17 @@ INSERT INTO mock_mydata_account_transaction ( merchant_name, merchant_regno, trans_memo, transaction_status, created_at, updated_at ) VALUES + -- 6월 KB 주거래: 수입 1,200,000원, 계좌 지출 2,040,000원. + -- 같은 달 카드 지출 160,000원까지 합치면 총지출 2,200,000원으로 수입보다 1,000,000원 많다. + (9041, 9001, 'KB-TX-202606-01', '2026-06-20 09:12:00', 'INCOME', 1200000, '지에스리테일', NULL, NULL, NULL, '6월 급여', 'NORMAL', '2026-06-20 09:12:00', '2026-06-20 09:12:00'), + (9042, 9001, 'KB-TX-202606-02', '2026-06-21 10:00:00', 'EXPENSE', 700000, 'LH청년행복주택', NULL, 'LH청년행복주택', '1298200036', '6월 주거비', 'NORMAL', '2026-06-21 10:00:00', '2026-06-21 10:00:00'), + (9043, 9001, 'KB-TX-202606-03', '2026-06-27 18:30:00', 'EXPENSE', 1340000, '생활비 정산', NULL, NULL, NULL, '6월 생활비', 'NORMAL', '2026-06-27 18:30:00', '2026-06-27 18:30:00'), + -- 7월 KB 주거래: 수입 1,000,000원, 계좌 지출 750,000원. + -- 같은 달 카드 지출 150,000원까지 합치면 총지출 900,000원으로 수입보다 100,000원 적다. + (9044, 9001, 'KB-TX-202607-01', '2026-07-20 09:12:00', 'INCOME', 1000000, '지에스리테일', NULL, NULL, NULL, '7월 급여', 'NORMAL', '2026-07-20 09:12:00', '2026-07-20 09:12:00'), + (9045, 9001, 'KB-TX-202607-02', '2026-07-22 10:00:00', 'EXPENSE', 750000, 'LH청년행복주택', NULL, 'LH청년행복주택', '1298200036', '7월 주거비', 'NORMAL', '2026-07-22 10:00:00', '2026-07-22 10:00:00'), + -- 기존 DB에도 같은 외부 거래 ID가 남아 있으므로 행을 제거하지 않고 OTHER로 갱신해 지출 집계에서 제외한다. + (9046, 9001, 'KB-TX-202607-03', '2026-07-28 18:30:00', 'OTHER', 200000, '계좌 정산', NULL, NULL, NULL, '정산 이체', 'NORMAL', '2026-07-28 18:30:00', '2026-07-28 18:30:00'), -- 8월 KB 주거래 (9001, 9001, 'KB-TX-202608-01', '2026-08-20 09:12:00', 'INCOME', 500000, '강남구청', NULL, NULL, NULL, '자립수당', 'NORMAL', '2026-08-20 09:12:00', '2026-08-20 09:12:00'), (9002, 9001, 'KB-TX-202608-02', '2026-08-25 10:03:00', 'INCOME', 1200000, '지에스리테일', NULL, NULL, NULL, '8월 급여', 'NORMAL', '2026-08-25 10:03:00', '2026-08-25 10:03:00'), diff --git a/frontend/src/api/assetForecast.ts b/frontend/src/api/assetForecast.ts index 708b198..26e05be 100644 --- a/frontend/src/api/assetForecast.ts +++ b/frontend/src/api/assetForecast.ts @@ -13,6 +13,38 @@ export interface AssetForecastResponse { remainingAmount: number achievementRate: number remainingMonths: number + supportEndDate: string + expectedAdditionalAsset: number + expectedAsset: number + investmentReturnIncluded: boolean + currentTotalAsset: number + currentCashAsset: number + currentSavingInvestmentAsset: number + currentHousingDeposit: number + forecastTotalAsset: number + forecastNetAsset: number + forecastShortageAmount: number + forecastCashAsset: number + forecastSavingInvestmentAsset: number + forecastHousingDeposit: number + monthlyConvertedNetIncome: number + averageMonthlySavingInvestment: number + totalAssetChange: number + savingInvestmentChange: number + cashDepletionMonth: number | null + liquidAssetDepletionMonth: number | null + housingDepositDepletionMonth: number | null + cashRatio: number + savingInvestmentRatio: number + housingDepositRatio: number + calculationBasis: { + monthlyIncome: number[] + monthlyExpense: number[] + currentFixedIncome: number + monthlySavingInvestment: number[] + validIncomeMonthCount: number + } + dataSufficient: boolean } export const assetForecastApi = { diff --git a/frontend/src/assets/icons/pocket-emergency.svg b/frontend/src/assets/icons/pocket-emergency.svg new file mode 100644 index 0000000..b251079 --- /dev/null +++ b/frontend/src/assets/icons/pocket-emergency.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/pocket/EmergencyPocketCard.vue b/frontend/src/components/pocket/EmergencyPocketCard.vue index 2ca1463..c21415b 100644 --- a/frontend/src/components/pocket/EmergencyPocketCard.vue +++ b/frontend/src/components/pocket/EmergencyPocketCard.vue @@ -9,7 +9,7 @@ defineEmits<{ adjust: [] }>()