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 @@ -5,6 +5,7 @@

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

@Getter
@Builder
Expand Down Expand Up @@ -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<Long> monthlyIncome;
private List<Long> monthlyExpense;
private Long currentFixedIncome;
private List<Long> monthlySavingInvestment;
private Integer validIncomeMonthCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ Long sumCurrentFutureAsset(
@Param("userId") Long userId,
@Param("accountTypes") Collection<AccountType> accountTypes
);
}

@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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -40,6 +43,9 @@ public class AssetForecastService {
private final FinancialTransactionRepository
financialTransactionRepository;

private final HousingCostRepository housingCostRepository;
private final RecurringIncomeRepository recurringIncomeRepository;

/**
* 지원 종료 시 예상 총자산 조회.
*
Expand Down Expand Up @@ -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<Long> monthlyIncome = new ArrayList<>();
List<Long> monthlyExpense = new ArrayList<>();
List<Long> 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()
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,4 +18,7 @@ public interface RecurringIncomeRepository extends JpaRepository<RecurringIncome
* 남의 수입을 건드리는 요청이 조회 단계에서 빈 값이 되어 애초에 진행되지 않는다.
*/
Optional<RecurringIncome> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion backend/src/main/resources/data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 값과 맞물린다. 한쪽만 고치면 화면에서 합계가 어긋난다.
Expand All @@ -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'),
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/api/assetForecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/assets/icons/pocket-emergency.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion frontend/src/components/pocket/EmergencyPocketCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defineEmits<{ adjust: [] }>()
<template>
<article class="card">
<header>
<AppIcon name="credit" :size="20" aria-hidden="true" />
<AppIcon name="pocket-emergency" :size="20" aria-hidden="true" />
<h2>{{ title }}</h2>
</header>
<dl>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/pocket/PocketStatusCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const props = defineProps<{
overAmount?: number | null
}>()
const emit = defineEmits<{ select: [pocketType: Extract<PocketType, 'ESSENTIAL' | 'FREE'>] }>()
const iconName = computed(() => (props.pocketType === 'ESSENTIAL' ? 'pocket' : 'benefit'))
const iconName = computed(() =>
props.pocketType === 'ESSENTIAL' ? 'category-housing' : 'benefit',
)
const rate = computed(() => props.usageRate ?? 0)
const fillWidth = computed(() => `${Math.min(Math.max(rate.value, 0), 100)}%`)
</script>
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,16 @@ const router = createRouter({
},
},

{
path: '/asset-forecast',
name: 'asset-forecast',
component: () => import('@/views/AssetForecastView.vue'),
meta: {
title: '지원 종료 시 예상 자산',
requiresAuth: true,
},
},

/*
* =========================
* 포켓
Expand Down
Loading