Skip to content

Commit 5254d58

Browse files
committed
Added FF dividends
1 parent e4b8aaa commit 5254d58

1 file changed

Lines changed: 186 additions & 3 deletions

File tree

‎lib/ib-xml-parser.ts‎

Lines changed: 186 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,26 @@ export interface FFTrade {
6565
commissionCurrency: string
6666
}
6767

68+
// Freedom Finance dividend types
69+
export interface FFDividend {
70+
date: string // Payment date
71+
symbol: string // Ticker symbol (e.g., VTI.US)
72+
description: string // Full description
73+
isin: string // ISIN code
74+
grossAmount: number // Gross dividend amount
75+
taxAmount: number // Withheld tax amount (positive number)
76+
netAmount: number // Net amount after tax
77+
currency: string // Currency (USD, EUR, etc.)
78+
taxRate: number // Tax rate percentage
79+
quantity: number // Number of shares on record date
80+
exDate: string // Ex-dividend date / record date
81+
source?: BrokerSource
82+
sourceFile?: string
83+
}
84+
6885
export interface ParsedFFData {
6986
trades: FFTrade[]
87+
dividends: FFDividend[]
7088
accountInfo: {
7189
clientName: string
7290
clientCode: string
@@ -550,8 +568,65 @@ export function parseFFXML(xmlContent: string, sourceFileName?: string): ParsedF
550568
trades.push(trade)
551569
})
552570

571+
// Extract dividends from corporate_actions
572+
const dividends: FFDividend[] = []
573+
const corporateActionNodes = xmlDoc.querySelectorAll('corporate_actions > detailed > node')
574+
575+
corporateActionNodes.forEach((node) => {
576+
const typeId = getTextContent(node, 'type_id')
577+
578+
// Only process dividend type corporate actions
579+
if (typeId === 'dividend') {
580+
const ticker = getTextContent(node, 'ticker')
581+
const comment = getTextContent(node, 'comment')
582+
const grossAmount = parseFloat(getTextContent(node, 'amount') || '0')
583+
const taxAmount = Math.abs(parseFloat(getTextContent(node, 'tax_amount') || '0'))
584+
585+
// Parse tax rate from comment or use provided value
586+
let taxRate = 0
587+
const taxRateMatch = comment.match(/Tax rate (\d+)/)
588+
if (taxRateMatch) {
589+
taxRate = parseFloat(taxRateMatch[1])
590+
}
591+
592+
// Calculate gross from net if we have tax rate
593+
// In FF XML: amount is net after tax, tax_amount is the withheld amount
594+
// gross = net + taxAmount
595+
const netAmount = grossAmount
596+
const calculatedGross = netAmount + taxAmount
597+
598+
// Parse quantity from comment or use provided value
599+
let quantity = parseFloat(getTextContent(node, 'q_on_ex_date') || '0')
600+
if (!quantity) {
601+
const qtyMatch = comment.match(/Balance on the record date is (\d+)/)
602+
if (qtyMatch) {
603+
quantity = parseFloat(qtyMatch[1])
604+
}
605+
}
606+
607+
const dividend: FFDividend = {
608+
date: formatFFDate(getTextContent(node, 'date')),
609+
symbol: ticker,
610+
description: comment || `Dividend on ${ticker}`,
611+
isin: getTextContent(node, 'isin'),
612+
grossAmount: calculatedGross,
613+
taxAmount: taxAmount,
614+
netAmount: netAmount,
615+
currency: getTextContent(node, 'currency') || 'USD',
616+
taxRate: taxRate,
617+
quantity: quantity,
618+
exDate: formatFFDate(getTextContent(node, 'ex_date')),
619+
source: 'freedom_finance',
620+
sourceFile: sourceFileName,
621+
}
622+
623+
dividends.push(dividend)
624+
}
625+
})
626+
553627
return {
554628
trades,
629+
dividends,
555630
accountInfo,
556631
source: 'freedom_finance',
557632
}
@@ -607,8 +682,65 @@ function parseFFXMLRegex(xmlContent: string, sourceFileName?: string): ParsedFFD
607682
trades.push(trade)
608683
}
609684

685+
// Extract dividends from corporate_actions
686+
const dividends: FFDividend[] = []
687+
const corporateActionsSection = xmlContent.match(/<corporate_actions><detailed>([\s\S]*?)<\/detailed>/)?.[1] || ''
688+
const caNodeMatches = corporateActionsSection.matchAll(nodeRegex)
689+
690+
for (const match of caNodeMatches) {
691+
const nodeContent = match[1]
692+
const typeId = getTagContent(nodeContent, 'type_id')
693+
694+
// Only process dividend type corporate actions
695+
if (typeId === 'dividend') {
696+
const ticker = getTagContent(nodeContent, 'ticker')
697+
const comment = getTagContent(nodeContent, 'comment')
698+
const grossAmount = parseFloat(getTagContent(nodeContent, 'amount') || '0')
699+
const taxAmount = Math.abs(parseFloat(getTagContent(nodeContent, 'tax_amount') || '0'))
700+
701+
// Parse tax rate from comment
702+
let taxRate = 0
703+
const taxRateMatch = comment.match(/Tax rate (\d+)/)
704+
if (taxRateMatch) {
705+
taxRate = parseFloat(taxRateMatch[1])
706+
}
707+
708+
// In FF XML: amount is net after tax
709+
const netAmount = grossAmount
710+
const calculatedGross = netAmount + taxAmount
711+
712+
// Parse quantity from q_on_ex_date or comment
713+
let quantity = parseFloat(getTagContent(nodeContent, 'q_on_ex_date') || '0')
714+
if (!quantity) {
715+
const qtyMatch = comment.match(/Balance on the record date is (\d+)/)
716+
if (qtyMatch) {
717+
quantity = parseFloat(qtyMatch[1])
718+
}
719+
}
720+
721+
const dividend: FFDividend = {
722+
date: formatFFDate(getTagContent(nodeContent, 'date')),
723+
symbol: ticker,
724+
description: comment || `Dividend on ${ticker}`,
725+
isin: getTagContent(nodeContent, 'isin'),
726+
grossAmount: calculatedGross,
727+
taxAmount: taxAmount,
728+
netAmount: netAmount,
729+
currency: getTagContent(nodeContent, 'currency') || 'USD',
730+
taxRate: taxRate,
731+
quantity: quantity,
732+
exDate: formatFFDate(getTagContent(nodeContent, 'ex_date')),
733+
source: 'freedom_finance',
734+
sourceFile: sourceFileName,
735+
}
736+
737+
dividends.push(dividend)
738+
}
739+
}
740+
610741
return {
611742
trades,
743+
dividends,
612744
accountInfo,
613745
source: 'freedom_finance',
614746
}
@@ -796,6 +928,38 @@ export function convertFFToFormPosition(position: FFMatchedPosition, sourceFileN
796928
}
797929
}
798930

931+
/**
932+
* Convert Freedom Finance dividend to form position format
933+
*/
934+
export function convertFFDividendToFormPosition(dividend: FFDividend, sourceFileName?: string) {
935+
// For dividends: we report the gross amount as income
936+
// The withheld foreign tax can be credited against Ukrainian tax
937+
return {
938+
id: Date.now().toString() + Math.random().toString(36).substring(7),
939+
assetType: "dividends",
940+
assetDescription: `${dividend.symbol} - Дивіденди (ISIN: ${dividend.isin})`,
941+
symbol: dividend.symbol,
942+
currency: dividend.currency,
943+
purchaseDate: "", // Not applicable for dividends
944+
saleDate: dividend.date, // Use payment date as "sale" date for tax purposes
945+
purchasePriceForeign: "0",
946+
salePriceForeign: dividend.grossAmount.toFixed(2), // Report gross dividend amount
947+
purchaseRate: "",
948+
saleRate: "",
949+
purchasePrice: "0",
950+
salePrice: "",
951+
expenses: "0",
952+
quantity: dividend.quantity.toString(),
953+
multiplier: "1",
954+
source: 'freedom_finance' as BrokerSource,
955+
sourceFile: sourceFileName,
956+
// Additional dividend-specific fields
957+
withheldTax: dividend.taxAmount.toFixed(2),
958+
withheldTaxRate: dividend.taxRate.toString(),
959+
exDate: dividend.exDate,
960+
}
961+
}
962+
799963
/**
800964
* Parse and convert any supported XML format
801965
*/
@@ -905,15 +1069,34 @@ export function parseAnyBrokerXML(xmlContent: string, sourceFileName?: string, r
9051069

9061070
const formPositions = matchedPositions.map(pos => convertFFToFormPosition(pos, sourceFileName))
9071071

1072+
// Filter and process dividends
1073+
let filteredDividends = parsedData.dividends
1074+
if (reportYear) {
1075+
filteredDividends = parsedData.dividends.filter(dividend => {
1076+
const divYear = new Date(dividend.date).getFullYear()
1077+
return divYear === reportYear
1078+
})
1079+
1080+
const skippedDividends = parsedData.dividends.length - filteredDividends.length
1081+
if (skippedDividends > 0) {
1082+
warnings.push(`FF: Пропущено ${skippedDividends} дивідендів за інші роки (не ${reportYear})`)
1083+
}
1084+
}
1085+
1086+
const dividendPositions = filteredDividends.map(dividend =>
1087+
convertFFDividendToFormPosition(dividend, sourceFileName)
1088+
)
1089+
9081090
const totalProfit = matchedPositions.reduce((sum, pos) => sum + pos.profit, 0)
1091+
const totalDividends = filteredDividends.reduce((sum, div) => sum + div.grossAmount, 0)
9091092

9101093
return {
911-
positions: formPositions,
1094+
positions: [...formPositions, ...dividendPositions],
9121095
source: 'freedom_finance',
9131096
summary: {
9141097
trades: matchedPositions.length,
915-
dividends: 0,
916-
totalProfit: totalProfit,
1098+
dividends: filteredDividends.length,
1099+
totalProfit: totalProfit + totalDividends,
9171100
currency: parsedData.accountInfo.baseCurrency || 'USD',
9181101
},
9191102
warnings,

0 commit comments

Comments
 (0)