@@ -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+
6885export 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 ( / T a x r a t e ( \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 ( / B a l a n c e o n t h e r e c o r d d a t e i s ( \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 ( / < c o r p o r a t e _ a c t i o n s > < d e t a i l e d > ( [ \s \S ] * ?) < \/ d e t a i l e d > / ) ?. [ 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 ( / T a x r a t e ( \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 ( / B a l a n c e o n t h e r e c o r d d a t e i s ( \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