From a95170dec4f6cddddfa197ccec1392c2939a3eae Mon Sep 17 00:00:00 2001 From: dbstj0403 Date: Fri, 27 Jun 2025 14:32:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20=EC=98=A4=EB=8A=98=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=9D=B4=ED=9B=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=80=20=EA=B0=80=EC=A0=B8=EC=98=A4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/get-trasactions.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/actions/get-trasactions.tsx b/app/actions/get-trasactions.tsx index 82ed151..0436354 100644 --- a/app/actions/get-trasactions.tsx +++ b/app/actions/get-trasactions.tsx @@ -57,7 +57,7 @@ export const getTransactions = async (): Promise => { label: string; }; - const normalized: TxCommon[] = [ + const allTxs: TxCommon[] = [ ...generalTxs.map((g) => ({ id: g.id.toString(), rawType: g.transactionType, @@ -74,6 +74,11 @@ export const getTransactions = async (): Promise => { })), ]; + const now = new Date(); + const pastAndTodayTxs = allTxs.filter( + (tx) => tx.at.getTime() <= now.getTime() + ); + const typeMap: Record = { BUY: '매수', SELL: '매도', @@ -85,8 +90,8 @@ export const getTransactions = async (): Promise => { }; const transactionData: Record = {}; - 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, From 6f489bea2a170f25ee2fe793a18c1c15c2e2e461 Mon Sep 17 00:00:00 2001 From: dbstj0403 Date: Fri, 27 Jun 2025 17:22:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20tax-saving=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/services/tax-saving.test.ts | 172 ++++++++++++++++++++++++++ app/(routes)/isa/page.tsx | 1 + app/actions/tax-saving.tsx | 66 +++++----- 3 files changed, 208 insertions(+), 31 deletions(-) create mode 100644 __tests__/services/tax-saving.test.ts diff --git a/__tests__/services/tax-saving.test.ts b/__tests__/services/tax-saving.test.ts new file mode 100644 index 0000000..b979621 --- /dev/null +++ b/__tests__/services/tax-saving.test.ts @@ -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); + }); +}); diff --git a/app/(routes)/isa/page.tsx b/app/(routes)/isa/page.tsx index b4ca582..5a56d34 100644 --- a/app/(routes)/isa/page.tsx +++ b/app/(routes)/isa/page.tsx @@ -20,6 +20,7 @@ const ISAPage = async () => { } const taxData = await taxSaving(); + console.log(taxData); const monthlyReturnsData: MonthlyReturnsSummary = { ...(await getMonthlyReturns('6')), monthlyEvaluatedAmounts: [], diff --git a/app/actions/tax-saving.tsx b/app/actions/tax-saving.tsx index e445a63..b606dfb 100644 --- a/app/actions/tax-saving.tsx +++ b/app/actions/tax-saving.tsx @@ -1,5 +1,6 @@ /** - * ISA 절세 계산기 ― ETF 배당 비과세 + 잔여 한도 계산(배당 전부 반영) + * ISA 절세 계산기 ― ETF 배당 비과세 + 잔여 한도 계산 + * “지금 전부 매도해 현금화(만기 가정)” 시, ISA vs 일반계좌 세금을 비교합니다. */ 'use server'; @@ -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); @@ -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' }, @@ -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: { @@ -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: { @@ -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, };