Skip to content

parser(comdirect): fix withholding tax sometimes (e.g. when tax >15%) being only partially regarded by detecting and adding missing amount in the post stage#5718

Merged
Nirus2000 merged 2 commits into
portfolio-performance:masterfrom
verybadsoldier:dev-parser_comdirect_fix_partial_withholding_tax
May 30, 2026
Merged

parser(comdirect): fix withholding tax sometimes (e.g. when tax >15%) being only partially regarded by detecting and adding missing amount in the post stage#5718
Nirus2000 merged 2 commits into
portfolio-performance:masterfrom
verybadsoldier:dev-parser_comdirect_fix_partial_withholding_tax

Conversation

@verybadsoldier
Copy link
Copy Markdown
Contributor

Store information about withholding tax from both PDFs separately and check in post processing stage if withholding amount was incompletely regarded in second stage. Then add the missing amount to the final tax amount if needed.

… being only partially regarded by detecting and adding missing amount in the post stage
@verybadsoldier verybadsoldier force-pushed the dev-parser_comdirect_fix_partial_withholding_tax branch from 8607901 to 3b8e200 Compare May 25, 2026 14:29
@Nirus2000 Nirus2000 added pdf do-not-merge Merging is temporarily blocked. labels May 25, 2026
@verybadsoldier
Copy link
Copy Markdown
Contributor Author

@Nirus2000
I saw you have put do-no-merge tag. Is it possible to have information about why this should not be merged? Thanks

@Nirus2000
Copy link
Copy Markdown
Member

Nirus2000 commented May 26, 2026

Hello @verybadsoldier,
thank you for the PR and the effort put into tracking down this withholding tax edge case — it's a genuinely tricky scenario and the test document is well-chosen.

Converting to draft while the following items are addressed:

  1. hasExDate() is missing in the test assertion.
  2. The fix should be applied in addTaxesTreatmentTransaction — not in the preliminary dividend notification. The document itself states that the final tax treatment is provided separately via the Steuermitteilung.
  3. The following tests are missing, analogous to the existing Dividende40 test suite:
    • testDividende41()
    • testSteuerbehandlungVonDividende41()
    • testDividende41MitSteuerbehandlungVonDividende41_SourceFilesReversed()
    • ... and so on... look in the other tests 😄

As a side note: we are aware that hasExDate() is still missing in many of the existing dividend tests and that the extraction itself still needs to be implemented for a number of cases. Nobody has had the time to catch up on this recently, but it is on the to-do list.

Once these are addressed, the PR can be moved back to "Ready for review". Thanks again for the contribution!

Regards
Alex

@Nirus2000 Nirus2000 marked this pull request as draft May 26, 2026 12:08
@verybadsoldier
Copy link
Copy Markdown
Contributor Author

Hey @Nirus2000 thanks for your feedback, I really appreciate.

Regarding the tests: I'll get those updated to match the project conventions, thanks for pointing that out.

Regarding the approach in this PR in general: if I understand your comment correctly then you are not happy with the general direction taken to fix this, right?

The fix should be applied in addTaxesTreatmentTransaction — not in the preliminary dividend notification. The document itself states that the final tax treatment is provided separately via the Steuermitteilung.

Just to clarify, the fix in this PR does not happen in the preliminary dividend notification but in the post processing stage later. As my experience here is quite limited, I tried to tackle the problem without changing the core concept of how I understand the multi-stage parsers are working.
So, in the preliminary dividend notification and also in the tax treatment, there is basically no added logic, only the raw amount of the withholding tax is forwarded to the post processing stage.

Instead of doing that this way, you propose to fix the problem inside of addTaxesTreatmentTransaction, right? I agree that would be the preferred approach. The problem I ran into when trying to do so: I was not able to find the total withholding tax amount (46.75 €) only in the Steuermitteilung. In the Steuermitteilung I could only find the amount that is "anrechenbar" (creditable) of 23.94 €. According to my understand, that is basically the problem in the first place.
Is there a way to parse the full withholding tax amount strictly from the Steuermitteilung that I might have missed? If so, I'd be happy to refactor the PR to use your suggested approach. Thanks!

@Nirus2000
Copy link
Copy Markdown
Member

Hello @verybadsoldier

First, try to look at the two documents separately. The gross amount of the dividend entry is €187.00.
Since we do not charge tax on the dividend statement, the following applies: Gross = Net

Next, create the tax document.
Here, you only collect the taxes.
Thus, €187.00 - €116.19 equals €70.81 in total tax.
€140.25 - €116.19 = €24.06 in German taxes.
The difference between €23.94 and €46.75 is therefore €22.81.

The €22.81 is the portion of the foreign withholding tax that was withheld by the foreign country but exceeds the DTA credit limit and is therefore neither credited nor refunded.

Regards
Alex

@verybadsoldier
Copy link
Copy Markdown
Contributor Author

@Nirus2000
Thanks alot for the great explanation. While this works nicely, it makes a couple of other tests fail that seemingly cover other edge cases. At least the way I did it. That's the same experience I had with other (probably worse) approaches that I took that directly modified the tax treatment code.
That lead me into looking into all those other edge cases as well (that I don't understand well enough) trying to fix them again which ended basically in whac-a-mole game... :)

The root problem in the end is my lack of fundamental understanding about how all those different taxes and special cases. I'll again try to wrap my head around that but possibly I might give up at some point.

@Nirus2000
Copy link
Copy Markdown
Member

Hello @verybadsoldier
We might as well go ahead and make these changes here. What do you think?

@verybadsoldier
Copy link
Copy Markdown
Contributor Author

@Nirus2000
Oh ok, I gave it a try myself also here already:
https://github.com/verybadsoldier/portfolio/commits/dev_comdirect2/

I fiddle around quite a bit until I had it working. In the end it worked without the additional "Zu Ihren Gunsten nach Steuern" but maybe that is more a bad sign than a good sign :D

So, well yeah, let's go with your solution, you are surely more experience with this. I only have some minor code comments if you want.

@Nirus2000
Copy link
Copy Markdown
Member

Oh ok, I gave it a try myself also here already:
https://github.com/verybadsoldier/portfolio/commits/dev_comdirect2/

Can you summarize the commits so they're easier to read? It's too tedious to keep clicking back and forth between three commits.

@verybadsoldier
Copy link
Copy Markdown
Contributor Author

Oh ok, I gave it a try myself also here already:
https://github.com/verybadsoldier/portfolio/commits/dev_comdirect2/

Can you summarize the commits so they're easier to read? It's too tedious to keep clicking back and forth between three commits.

Yes, sure, I squashed it now.

@Nirus2000
Copy link
Copy Markdown
Member

Nirus2000 commented May 28, 2026

Hey @verybadsoldier,
took a closer look at your approach vs. what I ended up implementing — figured I'd share my thinking since it's not immediately obvious why I went a different route.

Your missingTaxes idea is actually really neat conceptually — you're explicitly naming the thing that causes the bug (the non-creditable chunk of foreign withholding), which makes the intent crystal clear. I
genuinely like it.

Where I got a bit uneasy though: when deductedTaxes == 0 and missingTaxes gets clamped to zero (because foreignWithholdingTax >= foreignTaxes), the isZero() override that comes right after can actually pull the value
back down to something smaller than what the missingTaxes block already set. Two ifs basically stepping on each other's toes in a weird edge case.

The reason I went with grossAssessmentBasis − grossAfterTaxes as a fallback is pretty simple — if the document literally tells you "you got X after all taxes", then the difference between the gross basis and that
number is the tax burden. No formula, no components to sum up, just take what the PDF says at face value. It also quietly absorbs rounding weirdness and any tax positions we didn't think to model explicitly.

But honestly the core insight in your fix is solid and you clearly understand the problem well. Nice catch on the >15% DTA limit case — that one's sneaky!

Do you agree with that?

Regards
Alex

@Nirus2000 Nirus2000 marked this pull request as ready for review May 28, 2026 19:23
@Nirus2000 Nirus2000 added first pull request Keep friendly :-) and removed do-not-merge Merging is temporarily blocked. labels May 28, 2026
@verybadsoldier
Copy link
Copy Markdown
Contributor Author

Hey @Nirus2000 , yes sure, makes totally sense, I am happy if your are ;)

The biggest challenge for me was fixing the behavior in this special case without hurting any other cases. I was also not totally satisfied with the conditions I chose to decide when to apply the fix. There were some cases I just did not understand. E.g. SteuerbehandlungVonDividende13.txt:

 Zu  Ih r e n G u n s t e n v o r S te u e r n :                                                                                                    E U R             128,0 0   
                                                                                                                                
                                                                                                                                
S  te u e rb e m  e ss u n g s g r u n d la g e v o r V e r lu s tv e r re c h n u n g                  E  U   R                             1   2  8 , 0 0                          
                                                                                                                                
                                                                                                                                
                                                                                                                                
 in A n s p ru c h  g e n o m m e n e r F r ei s te ll un g s a u ft ra g                                         E    U   R                             1   1  0 , 4 1                          
                                                                                                                                
 S te u e rb e m  e ss u n g s g r u n d la g e n a c h V e r lu s t ve r r ec h n u n g               E   U  R                                1  7 , 5 9                          
                                                                                                                                
                                                                                                                                
 K ap i ta le r tr a gs t e ue r                                                                        E  U   R                                  0 , 0 0                          
(angerechnete ausländische Quellensteuer:       EUR               4,40  )                                                       
 S ol id a ri tä t sz u s c hl a g                                                                      E  U   R                                  0 , 0 0                          
 K irc h e n s te u e r                                                                              _E  _U   _R  _  _  _   _  _  _   _  _  _  _   _  _  _   _  0_ ,_ _0 0_                          
a b g e f ü h rt e S t e u er n                                                                                                                    _E _U R_ _ _ _ _ _ _ _ _ _ _ _  _ __ 0__,_0 _0   
                                                                                                                                
Z u  Ih r e n G u n s t e n n a c h S t e u er n :                                                                                                   E U R             128,0 0 

As I understand it, the 4.40€ under "Quellensteuer" are actually the result of the "Kapitalertragssteuer" of the "Steuerbemessungsgrundlage" of 17.59€. But it still baffles me why the document list 0€ as Kapitalertragssteuer but lists the 4.40€ as "angerechnete Quellensteuer".

Anyway, happy to go with your approach, thanks alot for guidance!


// Calculate total deducted taxes based on assessment basis and received amount after taxes,
// as this reflects the actual tax burden more accurately (including allowances and loss offset)
var totalDeductedTaxesFromAmounts = grossAssessmentBasis.subtract(grossAfterTaxes);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move down closer to where it is used the first time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -1210,6 +1207,12 @@ private void addTaxesTreatmentTransaction()
if (deductedTaxes.isZero() && grossAssessmentBasis.isGreaterThan(grossBeforeTaxes))
t.setMonetaryAmount(grossAssessmentBasis.subtract(grossBeforeTaxes));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value here set by call setMonetaryAmount might get overwritten in line 1214 by another call to setMonetaryAmount. Also, both calls would overwrite the value from line 1193. Possible to only set the value once to make the intent more clear?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@Nirus2000 Nirus2000 force-pushed the dev-parser_comdirect_fix_partial_withholding_tax branch from 82792b1 to e58f90d Compare May 30, 2026 07:37
@Nirus2000
Copy link
Copy Markdown
Member

Hey @Nirus2000 , yes sure, makes totally sense, I am happy if your are ;)

The biggest challenge for me was fixing the behavior in this special case without hurting any other cases. I was also not totally satisfied with the conditions I chose to decide when to apply the fix. There were some cases I just did not understand. E.g. SteuerbehandlungVonDividende13.txt:

 Zu  Ih r e n G u n s t e n v o r S te u e r n :                                                                                                    E U R             128,0 0   
                                                                                                                                
                                                                                                                                
S  te u e rb e m  e ss u n g s g r u n d la g e v o r V e r lu s tv e r re c h n u n g                  E  U   R                             1   2  8 , 0 0                          
                                                                                                                                
                                                                                                                                
                                                                                                                                
 in A n s p ru c h  g e n o m m e n e r F r ei s te ll un g s a u ft ra g                                         E    U   R                             1   1  0 , 4 1                          
                                                                                                                                
 S te u e rb e m  e ss u n g s g r u n d la g e n a c h V e r lu s t ve r r ec h n u n g               E   U  R                                1  7 , 5 9                          
                                                                                                                                
                                                                                                                                
 K ap i ta le r tr a gs t e ue r                                                                        E  U   R                                  0 , 0 0                          
(angerechnete ausländische Quellensteuer:       EUR               4,40  )                                                       
 S ol id a ri tä t sz u s c hl a g                                                                      E  U   R                                  0 , 0 0                          
 K irc h e n s te u e r                                                                              _E  _U   _R  _  _  _   _  _  _   _  _  _  _   _  _  _   _  0_ ,_ _0 0_                          
a b g e f ü h rt e S t e u er n                                                                                                                    _E _U R_ _ _ _ _ _ _ _ _ _ _ _  _ __ 0__,_0 _0   
                                                                                                                                
Z u  Ih r e n G u n s t e n n a c h S t e u er n :                                                                                                   E U R             128,0 0 

As I understand it, the 4.40€ under "Quellensteuer" are actually the result of the "Kapitalertragssteuer" of the "Steuerbemessungsgrundlage" of 17.59€. But it still baffles me why the document list 0€ as Kapitalertragssteuer but lists the 4.40€ as "angerechnete Quellensteuer".

Anyway, happy to go with your approach, thanks alot for guidance!

The document is actually consistent — here's why:
The tax calculation chain:

  1. Taxable basis after allowance ("Steuerbemessungsgrundlage nach Verlustverrechnung") = 17,59 EUR
  2. German Kapitalertragsteuer on that: 17,59 × 25% = 4,3975 ≈ 4,40 EUR
  3. The foreign country already withheld source tax on this dividend. Under the DTA, 4,40 EUR of that foreign tax is credited ("angerechnet") against the German liability.
  4. Net German tax due: 4,40 − 4,40 = 0,00 EUR → that's why the document shows "Kapitalertragsteuer: 0,00 EUR"

The 4,40 EUR didn't disappear — it was paid to the foreign tax authority at source, credited against the German liability, and so comdirect had nothing left to deduct ("abgeführte Steuern: 0,00 EUR"). The investor received 128,00 EUR
net = gross because all tax was already handled via the foreign credit.

What the importer does with this:

  • creditedForeignWithholdingTax = 4,40, deductedTaxes = 0,00 → totalDeductedTaxes = 4,40 EUR ✓
  • totalDeductedTaxesFromAmounts = 128,00 − 128,00 = 0,00 → the fallback is not triggered (0 is not greater than 4,40)

So the fix handles this case correctly and the existing test (hasAmount("EUR", 4.40)) continues to pass unchanged.

Regards
Alex

@Nirus2000 Nirus2000 merged commit cc9f71e into portfolio-performance:master May 30, 2026
2 checks passed
@verybadsoldier
Copy link
Copy Markdown
Contributor Author

@Nirus2000
Thanks alot for your help and for the merge, highly appreciated!

One last thing about the mysterious case 13: I think I understand your explanation and it makes sense. This "angerechnete Quellensteuer" is what happened in our case here also. But, sorry I did not mention the biggest reason for my confusion:

The Quellensteuer does not appear in the original Dividende13.txt document (the part 1). There, it looks like there was no tax at all involved (no Quellensteuer) and the whole amount of 128€ was just passed through:

Dividendengutschrift                                                               
Depotbestand                          Wertpapier-Bezeichnung               WKN/ISIN
p e r  0 9 . 0  5.  2 0 1 8                         Al l  i an z   S E                              8 4 0 4 00 
S  T K              1  6 , 00  0               v i  n k . N am  en  s - Ak t  ie  n  o . N .           D E0 0  0 84  04  00 5 
                                      Emissionsland: DEUTSCHLAND                   
EUR 8,00       Dividende pro Stück für Geschäftsjahr        01.01.17 bis 31.12.17  
zahlbar ab 14.05.2018                                                              
                         Abrechnung Dividendengutschrift                           
Bruttobetrag:                                              EUR             128,00  
Verrechnung über Konto (IBAN)           Valuta       Zu Ihren Gunsten vor Steuern  
DE12 3456 7890 1234 5678 00   EUR       14.05.2018         EUR             128,00  

Then suddenly in part 2 (SteuerbehandlungVonDividende13.txt), a "angerechnete Quellensteuer" is mentioned. So, the main question for me is: why does the Quellensteuer not appear in Dividende13.txt.

@Nirus2000
Copy link
Copy Markdown
Member

@verybadsoldier

That's a great observation — and it trips up most people the first time they see it.

The key is: for a German issuer (Emissionsland: DEUTSCHLAND), no foreign tax is ever deducted from the cash payment itself. Allianz SE pays out EUR 128.00 and that's exactly what lands in the account — the Dividendengutschrift is a pure cash-flow document. There is nothing to deduct, so nothing appears there.

The "angerechnete ausländische Quellensteuer" of EUR 4.40 shows up only in the Steuermitteilung because it is a tax attribute, not a cash deduction. Here's what actually happened:

Allianz SE is a German holding company but earns income internationally. Under §36 Abs. 2 Nr. 2 EStG / §32d (5) EStG, a portion of the foreign taxes paid at company level can be attributed to shareholders and credited against their German Kapitalertragsteuer liability. Allianz SE reports this to the custodians (e.g. Clearstream), who pass it on to comdirect, who then include it in the Steuermitteilung as a tax-reducing credit.

So the flow is:

  1. Allianz SE pays EUR 128.00 gross → Dividendengutschrift shows EUR 128.00, no taxes, because nothing was withheld.
  2. Tax office step: German KeSt on EUR 17.59 (after Freistellungsauftrag) = EUR 4.40.
  3. The attributed foreign credit covers exactly EUR 4.40 → net German tax = EUR 0.00.
  4. Steuermitteilung reflects this credit and shows the final net = EUR 128.00.

In short: it never appeared in Dividende13.txt because it was never a cash deduction. It is purely a bookkeeping credit that exists only in the tax domain.

Regards
Alex

@verybadsoldier
Copy link
Copy Markdown
Contributor Author

@Nirus2000
Okay, thanks alot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

first pull request Keep friendly :-) pdf

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants