/** * @file lib/kis/dashboard.ts * @description 대시보드 전용 KIS 잔고/지수 데이터 어댑터 */ import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks"; import { kisGet } from "@/lib/kis/client"; import type { KisCredentialInput } from "@/lib/kis/config"; import { normalizeTradingEnv } from "@/lib/kis/config"; import type { KisAccountParts } from "@/lib/kis/account"; import { calcProfitRate, calcProfitRateByPurchase, createEmptyJournalSummary, firstDefinedNumber, formatDateLabel, formatTimeLabel, getLookbackRangeYmd, normalizeSignedValue, normalizeTimeDigits, parseFirstRow, parseIndexRow, parseRows, parseTradeSide, pickNonZeroNumber, pickPreferredAmount, resolveCashBalance, sumNumbers, toDigits, toNumber, toOptionalNumber, } from "@/lib/kis/dashboard-helpers"; interface KisBalanceOutput1Row { pdno?: string; prdt_name?: string; hldg_qty?: string; pchs_avg_pric?: string; pchs_amt?: string; prpr?: string; evlu_amt?: string; evlu_pfls_amt?: string; evlu_pfls_rt?: string; } interface KisBalanceOutput2Row { dnca_tot_amt?: string; tot_evlu_amt?: string; scts_evlu_amt?: string; nass_amt?: string; pchs_amt_smtl_amt?: string; evlu_amt_smtl_amt?: string; evlu_pfls_smtl_amt?: string; asst_icdc_erng_rt?: string; } interface KisAccountBalanceOutput2Row { tot_asst_amt?: string; nass_tot_amt?: string; tot_dncl_amt?: string; dncl_amt?: string; loan_amt_smtl?: string; pchs_amt_smtl?: string; evlu_amt_smtl?: string; evlu_pfls_amt_smtl?: string; } interface KisIndexOutputRow { bstp_nmix_prpr?: string; bstp_nmix_prdy_vrss?: string; bstp_nmix_prdy_ctrt?: string; prdy_vrss_sign?: string; } interface KisDailyCcldOutput1Row { ord_dt?: string; ord_tmd?: string; odno?: string; ord_dvsn_name?: string; sll_buy_dvsn_cd?: string; sll_buy_dvsn_cd_name?: string; pdno?: string; prdt_name?: string; ord_qty?: string; ord_unpr?: string; tot_ccld_qty?: string; tot_ccld_amt?: string; avg_prvs?: string; rmn_qty?: string; cncl_yn?: string; } interface KisPeriodTradeProfitOutput1Row { trad_dt?: string; pdno?: string; prdt_name?: string; trad_dvsn_name?: string; buy_qty?: string; buy_amt?: string; sll_qty?: string; sll_amt?: string; rlzt_pfls?: string; pfls_rt?: string; fee?: string; tl_tax?: string; } interface KisPeriodTradeProfitOutput2Row { tot_rlzt_pfls?: string; tot_pftrt?: string; buy_tr_amt_smtl?: string; sll_tr_amt_smtl?: string; tot_fee?: string; tot_tltx?: string; } export interface DomesticBalanceSummary { totalAmount: number; cashBalance: number; totalDepositAmount: number; totalProfitLoss: number; totalProfitRate: number; netAssetAmount: number; evaluationAmount: number; purchaseAmount: number; loanAmount: number; apiReportedTotalAmount: number; apiReportedNetAssetAmount: number; } export interface DomesticHoldingItem { symbol: string; name: string; market: "KOSPI" | "KOSDAQ"; quantity: number; averagePrice: number; currentPrice: number; evaluationAmount: number; profitLoss: number; profitRate: number; } export interface DomesticBalanceResult { summary: DomesticBalanceSummary; holdings: DomesticHoldingItem[]; } export interface DomesticMarketIndexResult { market: "KOSPI" | "KOSDAQ"; code: string; name: string; price: number; change: number; changeRate: number; } export interface DomesticOrderHistoryItem { orderDate: string; orderTime: string; orderNo: string; symbol: string; name: string; side: "buy" | "sell" | "unknown"; orderTypeName: string; orderPrice: number; orderQuantity: number; filledQuantity: number; filledAmount: number; averageFilledPrice: number; remainingQuantity: number; isCanceled: boolean; } export interface DomesticTradeJournalItem { tradeDate: string; symbol: string; name: string; side: "buy" | "sell" | "unknown"; buyQuantity: number; buyAmount: number; sellQuantity: number; sellAmount: number; realizedProfit: number; realizedRate: number; fee: number; tax: number; } export interface DomesticTradeJournalSummary { totalRealizedProfit: number; totalRealizedRate: number; totalBuyAmount: number; totalSellAmount: number; totalFee: number; totalTax: number; } export interface DomesticDashboardActivityResult { orders: DomesticOrderHistoryItem[]; tradeJournal: DomesticTradeJournalItem[]; journalSummary: DomesticTradeJournalSummary; warnings: string[]; } const MARKET_BY_SYMBOL = new Map( KOREAN_STOCK_INDEX.map((item) => [item.symbol, item.market] as const), ); const INDEX_TARGETS: Array<{ market: "KOSPI" | "KOSDAQ"; code: string; name: string; }> = [ { market: "KOSPI", code: "0001", name: "코스피" }, { market: "KOSDAQ", code: "1001", name: "코스닥" }, ]; const DASHBOARD_ORDER_LOOKBACK_DAYS = 30; const DASHBOARD_JOURNAL_LOOKBACK_DAYS = 90; interface DashboardBalanceInquirePreset { inqrDvsn: "01" | "02"; prcsDvsn: "00" | "01"; } const DASHBOARD_BALANCE_INQUIRE_PRESETS: DashboardBalanceInquirePreset[] = [ // 공식 문서(주식잔고조회[v1_국내주식-006].xlsx) 기본값 { inqrDvsn: "01", prcsDvsn: "01" }, // 일부 환경 호환값 { inqrDvsn: "02", prcsDvsn: "00" }, ]; /** * KIS 잔고조회 API를 호출해 대시보드 모델로 변환합니다. * @param account KIS 계좌번호(8-2) 파트 * @param credentials 사용자 입력 키(선택) * @returns 대시보드 요약/보유종목 모델 * @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 응답 생성 */ export async function getDomesticDashboardBalance( account: KisAccountParts, credentials?: KisCredentialInput, ): Promise { const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); const inquireBalanceTrId = tradingEnv === "real" ? "TTTC8434R" : "VTTC8434R"; const inquireAccountBalanceTrId = tradingEnv === "real" ? "CTRP6548R" : "VTRP6548R"; const [balanceResponse, accountBalanceResponse] = await Promise.all([ getDomesticInquireBalanceEnvelope(account, inquireBalanceTrId, credentials), getDomesticAccountBalanceSummaryRow( account, inquireAccountBalanceTrId, credentials, ), ]); const holdingRows = parseRows(balanceResponse.output1); const summaryRow = parseFirstRow(balanceResponse.output2); const normalizedHoldings = holdingRows .map((row) => { const symbol = (row.pdno ?? "").trim(); if (!/^\d{6}$/.test(symbol)) return null; const quantity = toNumber(row.hldg_qty); const averagePrice = toNumber(row.pchs_avg_pric); const currentPrice = toNumber(row.prpr); const purchaseAmountBase = pickPreferredAmount( toOptionalNumber(row.pchs_amt), quantity * averagePrice, ); const evaluationAmount = pickPreferredAmount( toOptionalNumber(row.evlu_amt), quantity * (currentPrice > 0 ? currentPrice : averagePrice), ); const profitLoss = pickNonZeroNumber( toOptionalNumber(row.evlu_pfls_amt), evaluationAmount - purchaseAmountBase, ); const profitRate = pickNonZeroNumber( toOptionalNumber(row.evlu_pfls_rt), calcProfitRateByPurchase(profitLoss, purchaseAmountBase), ); return { symbol, name: (row.prdt_name ?? "").trim() || symbol, market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", quantity, averagePrice, currentPrice, evaluationAmount, profitLoss, profitRate, purchaseAmountBase, } satisfies DomesticHoldingItem & { purchaseAmountBase: number }; }) .filter( (item): item is DomesticHoldingItem & { purchaseAmountBase: number } => Boolean(item), ); const holdings = normalizedHoldings.map((item) => ({ symbol: item.symbol, name: item.name, market: item.market, quantity: item.quantity, averagePrice: item.averagePrice, currentPrice: item.currentPrice, evaluationAmount: item.evaluationAmount, profitLoss: item.profitLoss, profitRate: item.profitRate, })); const holdingsEvalAmount = sumNumbers( normalizedHoldings.map((item) => item.evaluationAmount), ); const holdingsPurchaseAmount = sumNumbers( normalizedHoldings.map((item) => item.purchaseAmountBase), ); const evaluationAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.evlu_amt_smtl), toOptionalNumber(summaryRow?.scts_evlu_amt), toOptionalNumber(summaryRow?.evlu_amt_smtl_amt), holdingsEvalAmount, ); const apiReportedTotalAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.tot_asst_amt), toOptionalNumber(summaryRow?.tot_evlu_amt), ); const apiReportedNetAssetAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.nass_tot_amt), toOptionalNumber(summaryRow?.nass_amt), ); const cashBalance = resolveCashBalance({ apiReportedTotalAmount, apiReportedNetAssetAmount, evaluationAmount, cashCandidates: [ toOptionalNumber(summaryRow?.dnca_tot_amt), toOptionalNumber(accountBalanceResponse?.dncl_amt), toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), ], }); const totalDepositAmount = pickPreferredAmount( toOptionalNumber(summaryRow?.dnca_tot_amt), toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), toOptionalNumber(accountBalanceResponse?.dncl_amt), cashBalance, ); const purchaseAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.pchs_amt_smtl), toOptionalNumber(summaryRow?.pchs_amt_smtl_amt), holdingsPurchaseAmount, Math.max( evaluationAmount - sumNumbers(normalizedHoldings.map((item) => item.profitLoss)), 0, ), ); const loanAmount = pickPreferredAmount( toOptionalNumber(accountBalanceResponse?.loan_amt_smtl), ); // KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다. const grossTotalAmount = pickPreferredAmount( toOptionalNumber(summaryRow?.tot_evlu_amt), apiReportedTotalAmount, evaluationAmount + cashBalance, ); const netAssetAmount = pickPreferredAmount( apiReportedNetAssetAmount, grossTotalAmount - loanAmount, ); // 상단 "내 자산"은 순자산 기준(사용자 체감 자산)으로 표시합니다. const totalAmount = pickPreferredAmount( netAssetAmount, grossTotalAmount, ); const totalProfitLoss = pickNonZeroNumber( toOptionalNumber(accountBalanceResponse?.evlu_pfls_amt_smtl), toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt), sumNumbers(holdings.map((item) => item.profitLoss)), ); const totalProfitRate = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount); return { summary: { totalAmount, cashBalance, totalDepositAmount, totalProfitLoss, totalProfitRate, netAssetAmount, evaluationAmount, purchaseAmount, loanAmount, apiReportedTotalAmount, apiReportedNetAssetAmount, }, holdings, }; } /** * 주식잔고조회(v1_국내주식-006)를 문서 기본값 우선으로 호출합니다. * @param account KIS 계좌번호(8-2) 파트 * @param trId 거래 환경별 TR ID * @param credentials 사용자 입력 키(선택) * @returns KIS 잔고 조회 원본 응답 * @see lib/kis/dashboard.ts getDomesticDashboardBalance */ async function getDomesticInquireBalanceEnvelope( account: KisAccountParts, trId: string, credentials?: KisCredentialInput, ) { const errors: string[] = []; for (const preset of DASHBOARD_BALANCE_INQUIRE_PRESETS) { try { return await kisGet( "/uapi/domestic-stock/v1/trading/inquire-balance", trId, { CANO: account.accountNo, ACNT_PRDT_CD: account.accountProductCode, AFHR_FLPR_YN: "N", OFL_YN: "", INQR_DVSN: preset.inqrDvsn, UNPR_DVSN: "01", FUND_STTL_ICLD_YN: "N", FNCG_AMT_AUTO_RDPT_YN: "N", PRCS_DVSN: preset.prcsDvsn, CTX_AREA_FK100: "", CTX_AREA_NK100: "", }, credentials, ); } catch (error) { errors.push( `INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${error instanceof Error ? error.message : "호출 실패"}`, ); } } throw new Error( `주식잔고조회(v1_국내주식-006) 호출 실패: ${errors.join(" | ")}`, ); } /** * 투자계좌자산현황조회(v1_국내주식-048)의 output2를 조회합니다. * @param account KIS 계좌번호(8-2) 파트 * @param trId 거래 환경별 TR ID (실전: CTRP6548R, 모의: VTRP6548R) * @param credentials 사용자 입력 키(선택) * @returns 계좌 자산 요약(output2) 또는 null * @see C:/dev/auto-trade/.tmp/open-trading-api/examples_llm/domestic_stock/inquire_account_balance/inquire_account_balance.py */ async function getDomesticAccountBalanceSummaryRow( account: KisAccountParts, trId: string, credentials?: KisCredentialInput, ) { try { const response = await kisGet( "/uapi/domestic-stock/v1/trading/inquire-account-balance", trId, { CANO: account.accountNo, ACNT_PRDT_CD: account.accountProductCode, INQR_DVSN_1: "", BSPR_BF_DT_APLY_YN: "", }, credentials, ); return parseFirstRow(response.output2); } catch { // 일부 계좌/환경에서는 v1_국내주식-048 조회가 제한될 수 있어, 잔고 API 값으로 폴백합니다. return null; } } /** * KOSPI/KOSDAQ 지수를 조회해 대시보드 모델로 변환합니다. * @param credentials 사용자 입력 키(선택) * @returns 지수 목록(코스피/코스닥) * @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 응답 생성 */ export async function getDomesticDashboardIndices( credentials?: KisCredentialInput, ): Promise { const results = await Promise.all( INDEX_TARGETS.map(async (target) => { const response = await kisGet( "/uapi/domestic-stock/v1/quotations/inquire-index-price", "FHPUP02100000", { FID_COND_MRKT_DIV_CODE: "U", FID_INPUT_ISCD: target.code, }, credentials, ); const row = parseIndexRow(response.output); const rawChange = toNumber(row.bstp_nmix_prdy_vrss); const rawChangeRate = toNumber(row.bstp_nmix_prdy_ctrt); return { market: target.market, code: target.code, name: target.name, price: toNumber(row.bstp_nmix_prpr), change: normalizeSignedValue(rawChange, row.prdy_vrss_sign), changeRate: normalizeSignedValue(rawChangeRate, row.prdy_vrss_sign), } satisfies DomesticMarketIndexResult; }), ); return results; } /** * 대시보드 하단의 주문내역/매매일지 데이터를 조회합니다. * @param account KIS 계좌번호(8-2) 파트 * @param credentials 사용자 입력 키(선택) * @returns 주문내역/매매일지/요약/경고 목록 * @remarks UI 흐름: 대시보드 하단 진입 -> activity API 호출 -> 주문내역/매매일지 탭 렌더링 * @see app/api/kis/domestic/activity/route.ts 대시보드 활동 데이터 API 응답 생성 * @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 UI 렌더링 */ export async function getDomesticDashboardActivity( account: KisAccountParts, credentials?: KisCredentialInput, ): Promise { const [orderResult, journalResult] = await Promise.allSettled([ getDomesticOrderHistory(account, credentials), getDomesticTradeJournal(account, credentials), ]); const warnings: string[] = []; const orders = orderResult.status === "fulfilled" ? orderResult.value : []; if (orderResult.status === "rejected") { warnings.push( orderResult.reason instanceof Error ? `주문내역 조회 실패: ${orderResult.reason.message}` : "주문내역 조회에 실패했습니다.", ); } const tradeJournal = journalResult.status === "fulfilled" ? journalResult.value.items : []; const journalSummary = journalResult.status === "fulfilled" ? journalResult.value.summary : createEmptyJournalSummary(); if (journalResult.status === "rejected") { const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); const defaultMessage = tradingEnv === "mock" ? "매매일지 API는 모의투자에서 지원되지 않거나 조회 제한이 있을 수 있습니다." : "매매일지 조회에 실패했습니다."; warnings.push( journalResult.reason instanceof Error ? `매매일지 조회 실패: ${journalResult.reason.message}` : defaultMessage, ); } if (orderResult.status === "rejected" && journalResult.status === "rejected") { throw new Error("주문내역/매매일지를 모두 조회하지 못했습니다."); } return { orders, tradeJournal, journalSummary, warnings, }; } /** * 주식일별주문체결조회(v1_국내주식-005)로 최근 주문내역을 조회합니다. * @param account KIS 계좌번호(8-2) 파트 * @param credentials 사용자 입력 키(선택) * @returns 주문내역 목록(최신순) * @see C:/dev/auto-trade/.tmp/open-trading-api/examples_llm/domestic_stock/inquire_daily_ccld/inquire_daily_ccld.py */ async function getDomesticOrderHistory( account: KisAccountParts, credentials?: KisCredentialInput, ) { const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); const trId = tradingEnv === "real" ? "TTTC0081R" : "VTTC0081R"; const range = getLookbackRangeYmd(DASHBOARD_ORDER_LOOKBACK_DAYS); const response = await kisGet( "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", trId, { CANO: account.accountNo, ACNT_PRDT_CD: account.accountProductCode, INQR_STRT_DT: range.startDate, INQR_END_DT: range.endDate, SLL_BUY_DVSN_CD: "00", PDNO: "", CCLD_DVSN: "00", INQR_DVSN: "00", INQR_DVSN_3: "00", ORD_GNO_BRNO: "", ODNO: "", INQR_DVSN_1: "", CTX_AREA_FK100: "", CTX_AREA_NK100: "", EXCG_ID_DVSN_CD: "ALL", }, credentials, ); const rows = parseRows(response.output1); const mappedRows = rows.map((row) => { const orderDateRaw = toDigits(row.ord_dt); const orderTimeRaw = normalizeTimeDigits(row.ord_tmd); const symbol = (row.pdno ?? "").trim(); const name = (row.prdt_name ?? "").trim(); const orderNo = (row.odno ?? "").trim(); const side = parseTradeSide(row.sll_buy_dvsn_cd, row.sll_buy_dvsn_cd_name); return { orderDate: formatDateLabel(orderDateRaw), orderTime: formatTimeLabel(orderTimeRaw), orderNo, symbol, name: name || symbol || "-", side, orderTypeName: (row.ord_dvsn_name ?? "").trim() || "일반", orderPrice: toNumber(row.ord_unpr), orderQuantity: toNumber(row.ord_qty), filledQuantity: toNumber(row.tot_ccld_qty), filledAmount: toNumber(row.tot_ccld_amt), averageFilledPrice: toNumber(row.avg_prvs), remainingQuantity: toNumber(row.rmn_qty), isCanceled: (row.cncl_yn ?? "").trim().toUpperCase() === "Y", sortKey: `${orderDateRaw}${orderTimeRaw}`, }; }); const normalized = mappedRows .sort((a, b) => b.sortKey.localeCompare(a.sortKey)) .slice(0, 100) .map((item) => ({ orderDate: item.orderDate, orderTime: item.orderTime, orderNo: item.orderNo, symbol: item.symbol, name: item.name, side: item.side, orderTypeName: item.orderTypeName, orderPrice: item.orderPrice, orderQuantity: item.orderQuantity, filledQuantity: item.filledQuantity, filledAmount: item.filledAmount, averageFilledPrice: item.averageFilledPrice, remainingQuantity: item.remainingQuantity, isCanceled: item.isCanceled, })); return normalized; } /** * 기간별매매손익현황조회(v1_국내주식-060)로 매매일지 데이터를 조회합니다. * @param account KIS 계좌번호(8-2) 파트 * @param credentials 사용자 입력 키(선택) * @returns 매매일지 목록/요약 * @see C:/dev/auto-trade/.tmp/open-trading-api/examples_llm/domestic_stock/inquire_period_trade_profit/inquire_period_trade_profit.py * @see C:/dev/auto-trade/.tmp/open-trading-api/kis_apis.xlsx v1_국내주식-060 모의 TR 미표기 */ async function getDomesticTradeJournal( account: KisAccountParts, credentials?: KisCredentialInput, ) { const tradingEnv = normalizeTradingEnv(credentials?.tradingEnv); const range = getLookbackRangeYmd(DASHBOARD_JOURNAL_LOOKBACK_DAYS); const trIdCandidates = tradingEnv === "real" ? ["TTTC8715R"] : ["VTTC8715R", "TTTC8715R"]; let response: { output1?: unknown; output2?: unknown } | null = null; let lastError: Error | null = null; for (const trId of trIdCandidates) { try { response = await kisGet( "/uapi/domestic-stock/v1/trading/inquire-period-trade-profit", trId, { CANO: account.accountNo, ACNT_PRDT_CD: account.accountProductCode, SORT_DVSN: "02", INQR_STRT_DT: range.startDate, INQR_END_DT: range.endDate, CBLC_DVSN: "00", PDNO: "", CTX_AREA_FK100: "", CTX_AREA_NK100: "", }, credentials, ); break; } catch (error) { lastError = error instanceof Error ? error : new Error("매매일지 조회 실패"); } } if (!response) { throw lastError ?? new Error("매매일지 조회 실패"); } const rows = parseRows(response.output1); const summaryRow = parseFirstRow(response.output2); const items = rows .map((row) => { const tradeDateRaw = toDigits(row.trad_dt); const symbol = (row.pdno ?? "").trim(); const name = (row.prdt_name ?? "").trim(); return { tradeDate: formatDateLabel(tradeDateRaw), symbol, name: name || symbol || "-", side: parseTradeSide(undefined, row.trad_dvsn_name), buyQuantity: toNumber(row.buy_qty), buyAmount: toNumber(row.buy_amt), sellQuantity: toNumber(row.sll_qty), sellAmount: toNumber(row.sll_amt), realizedProfit: toNumber(row.rlzt_pfls), realizedRate: toNumber(row.pfls_rt), fee: toNumber(row.fee), tax: toNumber(row.tl_tax), sortKey: tradeDateRaw, } satisfies DomesticTradeJournalItem & { sortKey: string }; }) .sort((a, b) => b.sortKey.localeCompare(a.sortKey)) .slice(0, 100) .map((item) => ({ tradeDate: item.tradeDate, symbol: item.symbol, name: item.name, side: item.side, buyQuantity: item.buyQuantity, buyAmount: item.buyAmount, sellQuantity: item.sellQuantity, sellAmount: item.sellAmount, realizedProfit: item.realizedProfit, realizedRate: item.realizedRate, fee: item.fee, tax: item.tax, })); const summary = { totalRealizedProfit: firstDefinedNumber( toOptionalNumber(summaryRow?.tot_rlzt_pfls), sumNumbers(items.map((item) => item.realizedProfit)), ), totalRealizedRate: firstDefinedNumber( toOptionalNumber(summaryRow?.tot_pftrt), calcProfitRate( sumNumbers(items.map((item) => item.realizedProfit)), sumNumbers(items.map((item) => item.buyAmount)), ), ), totalBuyAmount: firstDefinedNumber( toOptionalNumber(summaryRow?.buy_tr_amt_smtl), sumNumbers(items.map((item) => item.buyAmount)), ), totalSellAmount: firstDefinedNumber( toOptionalNumber(summaryRow?.sll_tr_amt_smtl), sumNumbers(items.map((item) => item.sellAmount)), ), totalFee: firstDefinedNumber( toOptionalNumber(summaryRow?.tot_fee), sumNumbers(items.map((item) => item.fee)), ), totalTax: firstDefinedNumber( toOptionalNumber(summaryRow?.tot_tltx), sumNumbers(items.map((item) => item.tax)), ), } satisfies DomesticTradeJournalSummary; return { items, summary, }; }