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
172 changes: 172 additions & 0 deletions __tests__/services/tax-saving.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { getServerSession } from 'next-auth';
import { taxSaving } from '@/app/actions/tax-saving';
import { prisma } from '@/lib/prisma';

jest.mock('next-auth', () => ({ getServerSession: jest.fn() }));
jest.mock('@/lib/prisma', () => ({
prisma: {
iSAAccount: { findUnique: jest.fn() },
generalTransaction: { aggregate: jest.fn() },
eTFTransaction: { aggregate: jest.fn() },
eTFHolding: { findMany: jest.fn() },
etfDailyTrading: { findFirst: jest.fn() },
},
}));

beforeAll(() => {
jest
.spyOn(Date, 'now')
.mockReturnValue(new Date('2025-06-15T00:00:00Z').getTime());
});
afterAll(() => jest.restoreAllMocks());

describe('taxSaving – 두 시나리오 (usedLimit capped but isaTax on full overflow)', () => {
(getServerSession as jest.Mock).mockResolvedValue({ user: { id: '1' } });

it('1. 일반형 / grossDiv=20만원, estDiv=10만원, gain=500만원', async () => {
// 셋업
(prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({
id: 1,
accountType: '일반형',
});
// grossDiv
(prisma.generalTransaction.aggregate as jest.Mock)
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 200_000 } } })
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } });
// estDiv = 10M*2%*6/12 =100k
(prisma.eTFHolding.findMany as jest.Mock).mockResolvedValue([
{
etfId: 1,
avgCost: { toNumber: () => 10_000_000 },
quantity: { toNumber: () => 1 },
etf: { idxMarketType: '국내' },
},
]);
(prisma.etfDailyTrading.findFirst as jest.Mock).mockResolvedValue({
tddClosePrice: { toNumber: () => 10_000_000 },
});
// 해외 gain
(prisma.eTFTransaction.aggregate as jest.Mock)
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 5_000_000 } } })
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } });

const res = await taxSaving();

// 계산
const grossDiv = 200_000;
const estDiv = 100_000;
const gain = 5_000_000;
const sum = grossDiv + estDiv + gain; // 5_300_000
const tax = sum * 0.154; // 816200
const limit = 2_000_000;
const used = limit; // capped
const remaining = 0;
const isaBase = sum - limit; // 3_300_000
const isaTax = isaBase * 0.099; // 326700
const saved = tax - isaTax; // 489500

expect(res.limit).toBe(limit);
expect(res.totalTaxableGeneral).toBe(sum);
expect(res.generalAccountTax).toBeCloseTo(tax, 2);
expect(res.usedLimit).toBe(used);
expect(res.remainingTaxFreeLimit).toBe(remaining);
expect(res.isaTax).toBeCloseTo(isaTax, 2);
expect(res.savedTax).toBeCloseTo(saved, 2);
});

it('2. 서민형 / grossDiv=10만원, estDiv=20만원, gain=600만원', async () => {
(prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({
id: 2,
accountType: '서민형',
});
(prisma.generalTransaction.aggregate as jest.Mock)
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 100_000 } } })
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } });
(prisma.eTFHolding.findMany as jest.Mock).mockResolvedValue([
{
etfId: 1,
avgCost: { toNumber: () => 20_000_000 },
quantity: { toNumber: () => 1 },
etf: { idxMarketType: '해외' },
},
]);
(prisma.etfDailyTrading.findFirst as jest.Mock).mockResolvedValue({
tddClosePrice: { toNumber: () => 20_000_000 },
});
(prisma.eTFTransaction.aggregate as jest.Mock)
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 6_000_000 } } })
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } });

const res = await taxSaving();

const grossDiv = 100_000;
const estDiv = 200_000;
const gain = 6_000_000;
const sum = grossDiv + estDiv + gain; // 6_300_000
const tax = sum * 0.154; // 970200
const limit = 4_000_000;
const used = limit; // capped
const remaining = 0;
const isaBase = sum - limit; // 2_300_000
const isaTax = isaBase * 0.099; // 227700
const saved = tax - isaTax; // 742500

expect(res.limit).toBe(limit);
expect(res.totalTaxableGeneral).toBe(sum);
expect(res.generalAccountTax).toBeCloseTo(tax, 2);
expect(res.usedLimit).toBe(used);
expect(res.remainingTaxFreeLimit).toBe(remaining);
expect(res.isaTax).toBeCloseTo(isaTax, 2);
expect(res.savedTax).toBeCloseTo(saved, 2);
});

it('3. 일반형 / grossDiv=50만원, estDiv=50만원, gain=20만원 (no cap)', async () => {
// 계좌 타입
(prisma.iSAAccount.findUnique as jest.Mock).mockResolvedValue({
id: 3,
accountType: '일반형',
});
// grossDiv = 500k
(prisma.generalTransaction.aggregate as jest.Mock)
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 500_000 } } })
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } });
// estDiv = 5M*2%*6/12 = 50k
(prisma.eTFHolding.findMany as jest.Mock).mockResolvedValue([
{
etfId: 1,
avgCost: { toNumber: () => 5_000_000 },
quantity: { toNumber: () => 1 },
etf: { idxMarketType: '국내' },
},
]);
(prisma.etfDailyTrading.findFirst as jest.Mock).mockResolvedValue({
tddClosePrice: { toNumber: () => 5_000_000 },
});
// realizedOver = 200k
(prisma.eTFTransaction.aggregate as jest.Mock)
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 200_000 } } })
.mockResolvedValueOnce({ _sum: { price: { toNumber: () => 0 } } });

const res = await taxSaving();

// 올바른 계산
const grossDiv = 500_000;
const estDiv = 50_000;
const gain = 200_000;
const sum = grossDiv + estDiv + gain; // 750_000
const tax = sum * 0.154; // 115,500
const limit = 2_000_000;
const used = sum; // 750_000
const remaining = limit - used; // 1_250_000
const isaTax = 0; // 한도 내 전부 비과세
const saved = tax; // 115,500

expect(res.limit).toBe(limit);
expect(res.totalTaxableGeneral).toBe(sum);
expect(res.generalAccountTax).toBeCloseTo(tax, 2);
expect(res.usedLimit).toBe(used);
expect(res.remainingTaxFreeLimit).toBe(remaining);
expect(res.isaTax).toBe(isaTax);
expect(res.savedTax).toBeCloseTo(saved, 2);
});
});
1 change: 1 addition & 0 deletions app/(routes)/isa/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const ISAPage = async () => {
}

const taxData = await taxSaving();
console.log(taxData);
const monthlyReturnsData: MonthlyReturnsSummary = {
...(await getMonthlyReturns('6')),
monthlyEvaluatedAmounts: [],
Expand Down
11 changes: 8 additions & 3 deletions app/actions/get-trasactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const getTransactions = async (): Promise<CalendarData> => {
label: string;
};

const normalized: TxCommon[] = [
const allTxs: TxCommon[] = [
...generalTxs.map((g) => ({
id: g.id.toString(),
rawType: g.transactionType,
Expand All @@ -74,6 +74,11 @@ export const getTransactions = async (): Promise<CalendarData> => {
})),
];

const now = new Date();
const pastAndTodayTxs = allTxs.filter(
(tx) => tx.at.getTime() <= now.getTime()
);

const typeMap: Record<string, CalendarTx['type']> = {
BUY: '매수',
SELL: '매도',
Expand All @@ -85,8 +90,8 @@ export const getTransactions = async (): Promise<CalendarData> => {
};

const transactionData: Record<string, CalendarTx[]> = {};
normalized.forEach((tx) => {
const key = tx.at.toISOString().slice(0, 10);
pastAndTodayTxs.forEach((tx) => {
const key = tx.at.toISOString().slice(0, 10); // YYYY-MM-DD
if (!transactionData[key]) transactionData[key] = [];
transactionData[key].push({
title: tx.label,
Expand Down
66 changes: 35 additions & 31 deletions app/actions/tax-saving.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* ISA 절세 계산기 ― ETF 배당 비과세 + 잔여 한도 계산(배당 전부 반영)
* ISA 절세 계산기 ― ETF 배당 비과세 + 잔여 한도 계산
* “지금 전부 매도해 현금화(만기 가정)” 시, ISA vs 일반계좌 세금을 비교합니다.
*/
'use server';

Expand All @@ -8,11 +9,11 @@ import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';

const TAX_INT_DIV = 0.154; // 일반 계좌 15.4 %
const ISA_TAX_RATE = 0.099; // ISA 9.9 %
const ISA_TAX_RATE = 0.099; // ISA 9.9 %
const DIV_YIELD = 0.02; // ETF 연 2 % 배당률

export const taxSaving = async () => {
/* 1) 세션 & ISA */
/* 1) 세션 & ISA 정보 */
const s = await getServerSession(authOptions);
if (!s?.user?.id) throw new Error('로그인 필요');
const userId = Number(s.user.id);
Expand All @@ -25,10 +26,10 @@ export const taxSaving = async () => {
const isaId = isa.id;
const limit = isa.accountType === '서민형' ? 4_000_000 : 2_000_000;

/* 2) 경과 월 */
/* 2) 경과 월 계산(1‒12) */
const months = new Date().getMonth() + 1;

/* 3) net → gross 배당·이자 */
/* 3) 배당·이자: net → gross(세전) */
const [intAgg, divAgg] = await Promise.all([
prisma.generalTransaction.aggregate({
where: { isaAccountId: isaId, transactionType: 'INTEREST' },
Expand All @@ -41,9 +42,9 @@ export const taxSaving = async () => {
]);
const netDiv =
(intAgg._sum.price?.toNumber() ?? 0) + (divAgg._sum.price?.toNumber() ?? 0);
const grossDiv = netDiv / (1 - TAX_INT_DIV); // 15.4 % 역환산
const grossDiv = netDiv; // 역산 생략

/* 4) ETF 손익 & 추정 배당(gross) */
/* 4) ETF 평가이익·예상 배당(gross) */
const hlds = await prisma.eTFHolding.findMany({
where: { isaAccountId: isaId },
select: {
Expand All @@ -54,27 +55,30 @@ export const taxSaving = async () => {
},
});

let unAll = 0,
unOver = 0,
estDiv = 0;
let unAll = 0; // 미실현 총 평가이익(국내+해외)
let unOver = 0; // 미실현 해외 평가이익
let estDiv = 0; // 보유 ETF 예상 배당(gross)

for (const h of hlds) {
const cost = h.avgCost.toNumber() * h.quantity.toNumber();

const px = await prisma.etfDailyTrading.findFirst({
where: { etfId: h.etfId },
orderBy: { baseDate: 'desc' },
select: { tddClosePrice: true },
});
const price = px?.tddClosePrice?.toNumber() ?? h.avgCost.toNumber();

const evalV = price * h.quantity.toNumber();
const prft = evalV - cost;
const prft = evalV - cost; // 평가이익(+)/손실(−)

unAll += prft;
if (h.etf.idxMarketType === '해외') unOver += prft;

estDiv += evalV * DIV_YIELD * (months / 12); // ETF 예상 배당(gross)
estDiv += evalV * DIV_YIELD * (months / 12); // 올해 받게 될 예상 배당(gross)
}

/* 5) 실현 해외 ETF 차익 */
/* 5) 해외 ETF 실현 차익 */
const [sell, buy] = await Promise.all([
prisma.eTFTransaction.aggregate({
where: {
Expand All @@ -96,38 +100,38 @@ export const taxSaving = async () => {
const realizedOver =
(sell._sum.price?.toNumber() ?? 0) - (buy._sum.price?.toNumber() ?? 0);

/* 6) 배당·이자(gross) 합산 → 일반 계좌 과세 손익 */
/* === “만기 가정” 핵심 수정 ===
해외 ETF 이익 = 이미 실현 + (현 시점에서 매도 시) 미실현 */
const totalOverseaGain = Math.max(0, realizedOver) + unOver;

/* 6) 배당·이자(gross) */
const genDiv = grossDiv + estDiv;

/* 7) 대시보드 누적 손익 */
const totalProfitAll = genDiv + unAll; // 배당·이자 + ETF 全평가이익
/* 7) 누적 손익(참고용) */
const totalProfitAll = genDiv + unAll; // 배당·이자 + ETF 전체 평가이익

/* 8) 일반 계좌 과세 손익 & 세액 */
const totalTaxableGeneral = genDiv + unOver;
/* 8) 일반계좌 과세 표준 & 세액 (15.4 %) */
const totalTaxableGeneral = genDiv + totalOverseaGain;
const generalAccountTax = totalTaxableGeneral * TAX_INT_DIV;

/* 9) ISA 한도·세액 (배당·이자 + 해외 ETF 모두 포함) */
const usedLimit = // 한도 사용액
grossDiv + // 배당·이자(gross)
Math.max(0, realizedOver); // 실현 해외 ETF 차익
/* 9) ISA 한도 사용액 & 과세표준(초과분만 9.9 %) */
// const usedLimit = grossDiv + totalOverseaGain;
const usedLimit = Math.min(limit, grossDiv + estDiv + totalOverseaGain);
const remainingLimit = Math.max(0, limit - usedLimit);

const isaTaxBase = Math.max(
0, // 과세표준
grossDiv + estDiv + unOver - limit // 배당·이자(gross)+ETF 해외이익 − 한도
);
const isaTaxBase = Math.max(0, grossDiv + estDiv + totalOverseaGain - limit);
const isaTax = isaTaxBase * ISA_TAX_RATE;

/* 10) 절세 효과 */
const savedTax = Math.max(0, generalAccountTax - isaTax);

return {
totalTaxableGeneral, // 일반 계좌 과세 손익
totalTaxableGeneral, // 일반계좌 과세 손익(세전)
unrealizedOversea: unOver,
generalAccountTax,
usedLimit, // ISA 공제 전 세액
isaTax,
savedTax,
generalAccountTax, // 일반계좌 세액
usedLimit, // ISA 비과세 한도 사용액
isaTax, // ISA 세액
savedTax, // 절세 효과(차이)
remainingTaxFreeLimit: remainingLimit,
limit,
};
Expand Down