mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
1 changed files with 444 additions and 0 deletions
@ -0,0 +1,444 @@ |
|||||
|
# Tax Metrics — Withholding-Tax Field Schema |
||||
|
|
||||
|
**Owner:** Tharun Swaminathan |
||||
|
**Iteration:** 1 — Design & Data Modelling |
||||
|
**Date:** September 16, 2026 |
||||
|
|
||||
|
## Objective |
||||
|
|
||||
|
The purpose of this design is to define how dividend withholding tax should be |
||||
|
represented in Ghostfolio before implementation begins in Iteration 2. |
||||
|
|
||||
|
The design builds on the existing Ghostfolio activity model instead of creating |
||||
|
a separate dividend transaction structure. |
||||
|
|
||||
|
This task defines the proposed fields, calculation relationships, validation |
||||
|
rules, currency behavior, backward compatibility, and future API impact. |
||||
|
|
||||
|
No database or application code is changed as part of this task. |
||||
|
|
||||
|
## Existing Dividend Model |
||||
|
|
||||
|
Ghostfolio currently stores dividend transactions as normal `Order` activities |
||||
|
with: |
||||
|
|
||||
|
`type = DIVIDEND` |
||||
|
|
||||
|
The existing activity already contains the main values needed for a dividend, |
||||
|
including: |
||||
|
|
||||
|
- `quantity` |
||||
|
- `unitPrice` |
||||
|
- `currency` |
||||
|
- `date` |
||||
|
- `fee` |
||||
|
|
||||
|
The dividend value can be determined from: |
||||
|
|
||||
|
`grossDividend = quantity × unitPrice` |
||||
|
|
||||
|
The existing `fee` field is a general activity fee and should remain separate |
||||
|
from dividend withholding tax. |
||||
|
|
||||
|
## Proposed Stored Field |
||||
|
|
||||
|
The proposed new field on the existing Prisma `Order` model is: |
||||
|
|
||||
|
`withholdingTax Float?` |
||||
|
|
||||
|
### Meaning |
||||
|
|
||||
|
`withholdingTax` represents the actual tax amount withheld from a dividend |
||||
|
payment. |
||||
|
|
||||
|
The amount is stored in the activity's `currency`. |
||||
|
|
||||
|
Example: |
||||
|
|
||||
|
If the dividend activity uses USD, a value of: |
||||
|
|
||||
|
`withholdingTax = 15.00` |
||||
|
|
||||
|
means that 15.00 USD was withheld from that dividend payment. |
||||
|
|
||||
|
### Why the Amount Is Stored |
||||
|
|
||||
|
The withholding-tax amount should be treated as the authoritative value because |
||||
|
it represents the actual amount deducted from the dividend. |
||||
|
|
||||
|
This also provides better traceability for imported transactions and future tax |
||||
|
reports. |
||||
|
|
||||
|
The withholding percentage can be calculated from the stored amount when |
||||
|
needed, so storing both the amount and percentage would introduce the possibility |
||||
|
of inconsistent values. |
||||
|
|
||||
|
## Null and Zero Behavior |
||||
|
|
||||
|
The field should be nullable. |
||||
|
|
||||
|
`withholdingTax = null` |
||||
|
|
||||
|
means that withholding information is unknown or was not provided. |
||||
|
|
||||
|
`withholdingTax = 0` |
||||
|
|
||||
|
means that withholding information is known and no tax was withheld. |
||||
|
|
||||
|
This distinction is important for imported and historical transactions. |
||||
|
|
||||
|
Missing withholding information should not automatically be interpreted as zero. |
||||
|
|
||||
|
This allows existing Ghostfolio dividend records to remain valid after the new |
||||
|
field is introduced. |
||||
|
|
||||
|
## Derived Values |
||||
|
|
||||
|
The tax feature should keep gross dividend, withholding tax, withholding rate, |
||||
|
and net dividend as separate values. |
||||
|
|
||||
|
### Gross Dividend |
||||
|
|
||||
|
`grossDividend = quantity × unitPrice` |
||||
|
|
||||
|
The gross dividend is derived from the existing activity fields. |
||||
|
|
||||
|
### Withholding-Tax Amount |
||||
|
|
||||
|
`withholdingTax` |
||||
|
|
||||
|
This is the stored value. |
||||
|
|
||||
|
### Withholding-Tax Rate |
||||
|
|
||||
|
The withholding rate is derived rather than stored: |
||||
|
|
||||
|
`withholdingTaxRate = withholdingTax / grossDividend × 100` |
||||
|
|
||||
|
The rate can only be calculated when: |
||||
|
|
||||
|
- `grossDividend > 0` |
||||
|
- `withholdingTax` is not null |
||||
|
|
||||
|
Otherwise: |
||||
|
|
||||
|
`withholdingTaxRate = null` |
||||
|
|
||||
|
### Net Dividend |
||||
|
|
||||
|
The net dividend after withholding is: |
||||
|
|
||||
|
`netDividend = grossDividend - withholdingTax` |
||||
|
|
||||
|
If `withholdingTax` is unknown (`null`), the tax analytics layer should not |
||||
|
silently assume that withholding was zero. |
||||
|
|
||||
|
In that case: |
||||
|
|
||||
|
`netDividend = null` |
||||
|
|
||||
|
for tax reporting purposes. |
||||
|
|
||||
|
This preserves the difference between "no tax was withheld" and "we do not know |
||||
|
whether tax was withheld." |
||||
|
|
||||
|
## Activity Fee |
||||
|
|
||||
|
Activity fees and withholding tax should remain separate. |
||||
|
|
||||
|
For a dividend: |
||||
|
|
||||
|
`grossDividend = quantity × unitPrice` |
||||
|
|
||||
|
`netDividend = grossDividend - withholdingTax` |
||||
|
|
||||
|
If the dividend also has an activity fee, the cash effect can later be expressed |
||||
|
separately as: |
||||
|
|
||||
|
`netCashAmount = netDividend - fee` |
||||
|
|
||||
|
This prevents brokerage fees from being treated as tax. |
||||
|
|
||||
|
## Proposed Prisma Change |
||||
|
|
||||
|
The future Iteration 2 implementation is expected to extend the existing |
||||
|
`Order` model with: |
||||
|
|
||||
|
```prisma |
||||
|
withholdingTax Float? |
||||
|
``` |
||||
|
|
||||
|
This is a proposed design only. |
||||
|
|
||||
|
Using `Float?` follows the existing Ghostfolio storage pattern used for numeric |
||||
|
activity fields such as `fee`, `quantity`, and `unitPrice`. |
||||
|
|
||||
|
Changing the wider application to a different database numeric type would be a |
||||
|
shared architecture change and is outside the scope of this tax task. |
||||
|
|
||||
|
## Validation Rules |
||||
|
|
||||
|
The following validation rules should apply when the feature is implemented: |
||||
|
|
||||
|
1. `withholdingTax` is optional. |
||||
|
2. If provided, `withholdingTax` must be numeric. |
||||
|
3. The value must be greater than or equal to zero. |
||||
|
4. Withholding tax is only valid for activities where `type = DIVIDEND`. |
||||
|
5. For a valid dividend, `withholdingTax <= grossDividend`. |
||||
|
6. A negative withholding amount is invalid. |
||||
|
7. A positive withholding amount on a zero-value dividend is invalid. |
||||
|
8. If an activity containing withholding tax is changed from `DIVIDEND` to |
||||
|
another activity type, the update should be rejected until the withholding |
||||
|
value is removed. |
||||
|
|
||||
|
Rejecting an invalid combination is preferred over silently deleting tax data. |
||||
|
|
||||
|
## Currency Behavior |
||||
|
|
||||
|
The stored `withholdingTax` value uses the same transaction currency as the |
||||
|
activity's existing `currency` field. |
||||
|
|
||||
|
For example, `currency = USD` with `withholdingTax = 15` means that 15 USD was |
||||
|
withheld. |
||||
|
|
||||
|
The future tax implementation should follow Ghostfolio's existing |
||||
|
currency-conversion approach for reporting values. |
||||
|
|
||||
|
The enriched activity response may later expose: |
||||
|
|
||||
|
- `withholdingTaxInAssetProfileCurrency` |
||||
|
- `withholdingTaxInBaseCurrency` |
||||
|
|
||||
|
These should be derived values rather than additional persisted tax amounts. |
||||
|
|
||||
|
The original stored withholding amount should remain unchanged so the value |
||||
|
continues to match the source transaction. |
||||
|
|
||||
|
## Proposed DTO Changes |
||||
|
|
||||
|
During Iteration 2, the existing activity DTOs would need to accept the new |
||||
|
field. |
||||
|
|
||||
|
### CreateOrderDto |
||||
|
|
||||
|
```ts |
||||
|
@IsNumber() |
||||
|
@Min(0) |
||||
|
@IsOptional() |
||||
|
withholdingTax?: number | null; |
||||
|
``` |
||||
|
|
||||
|
### UpdateOrderDto |
||||
|
|
||||
|
```ts |
||||
|
@IsNumber() |
||||
|
@Min(0) |
||||
|
@IsOptional() |
||||
|
withholdingTax?: number | null; |
||||
|
``` |
||||
|
|
||||
|
Cross-field validation that depends on the activity type and gross dividend |
||||
|
should remain in the service/business-logic layer because it uses multiple |
||||
|
activity fields. |
||||
|
|
||||
|
## Proposed API Response Values |
||||
|
|
||||
|
The stored activity would contain: |
||||
|
|
||||
|
`withholdingTax` |
||||
|
|
||||
|
The enriched activity response may later include: |
||||
|
|
||||
|
- `withholdingTaxRate` |
||||
|
- `withholdingTaxInAssetProfileCurrency` |
||||
|
- `withholdingTaxInBaseCurrency` |
||||
|
- `netDividend` |
||||
|
|
||||
|
These should be calculated values instead of additional persisted database |
||||
|
fields. |
||||
|
|
||||
|
The existing meaning of Ghostfolio's activity `value` should not be changed as |
||||
|
part of this feature unless a later integration decision explicitly requires |
||||
|
it. |
||||
|
|
||||
|
This reduces the risk of changing existing portfolio calculations while adding |
||||
|
tax analytics. |
||||
|
|
||||
|
## Example 1 — Dividend With Withholding |
||||
|
|
||||
|
```text |
||||
|
type = DIVIDEND |
||||
|
quantity = 10 |
||||
|
unitPrice = 10.00 |
||||
|
currency = USD |
||||
|
fee = 0 |
||||
|
withholdingTax = 15.00 |
||||
|
``` |
||||
|
|
||||
|
```text |
||||
|
grossDividend = 10 × 10.00 |
||||
|
= 100.00 USD |
||||
|
|
||||
|
withholdingTax = 15.00 USD |
||||
|
|
||||
|
withholdingTaxRate = 15 / 100 × 100 |
||||
|
= 15% |
||||
|
|
||||
|
netDividend = 100.00 - 15.00 |
||||
|
= 85.00 USD |
||||
|
``` |
||||
|
|
||||
|
## Example 2 — Explicitly No Withholding |
||||
|
|
||||
|
```text |
||||
|
type = DIVIDEND |
||||
|
quantity = 10 |
||||
|
unitPrice = 10.00 |
||||
|
currency = USD |
||||
|
withholdingTax = 0 |
||||
|
``` |
||||
|
|
||||
|
```text |
||||
|
grossDividend = 100.00 USD |
||||
|
withholdingTax = 0.00 USD |
||||
|
withholdingTaxRate = 0% |
||||
|
netDividend = 100.00 USD |
||||
|
``` |
||||
|
|
||||
|
This means the system knows that no withholding tax was applied. |
||||
|
|
||||
|
## Example 3 — Historical Dividend With Unknown Withholding |
||||
|
|
||||
|
```text |
||||
|
type = DIVIDEND |
||||
|
quantity = 10 |
||||
|
unitPrice = 10.00 |
||||
|
currency = USD |
||||
|
withholdingTax = null |
||||
|
``` |
||||
|
|
||||
|
```text |
||||
|
grossDividend = 100.00 USD |
||||
|
withholdingTax = unknown |
||||
|
withholdingTaxRate = null |
||||
|
netDividend = null |
||||
|
``` |
||||
|
|
||||
|
The existing dividend remains valid, but the tax analytics layer does not |
||||
|
pretend that the unknown withholding value is zero. |
||||
|
|
||||
|
## Import and Backward Compatibility |
||||
|
|
||||
|
Existing Ghostfolio dividend activities do not currently contain withholding-tax |
||||
|
information. |
||||
|
|
||||
|
The new field therefore needs to be nullable so existing database records, |
||||
|
imports, and historical transactions remain valid. |
||||
|
|
||||
|
Older imports that do not contain the field should result in: |
||||
|
|
||||
|
`withholdingTax = null` |
||||
|
|
||||
|
New imports may provide the field when the source data contains an actual |
||||
|
withholding amount. |
||||
|
|
||||
|
The absence of the field must not cause an otherwise valid dividend import to |
||||
|
fail. |
||||
|
|
||||
|
## Calculation Precision |
||||
|
|
||||
|
The stored field should follow the existing Ghostfolio numeric model for |
||||
|
compatibility. |
||||
|
|
||||
|
Financial calculations involving withholding should use the same |
||||
|
high-precision calculation approach already used by Ghostfolio where practical |
||||
|
rather than relying on repeated raw floating-point arithmetic. |
||||
|
|
||||
|
Rounding should not be applied unnecessarily during intermediate calculations. |
||||
|
|
||||
|
Display and export rounding rules will be defined separately with the tax export |
||||
|
design. |
||||
|
|
||||
|
## Edge Cases |
||||
|
|
||||
|
The implementation and future tests should cover: |
||||
|
|
||||
|
- withholding amount is null |
||||
|
- withholding amount is zero |
||||
|
- normal positive withholding amount |
||||
|
- withholding equal to the full gross dividend |
||||
|
- withholding greater than the gross dividend |
||||
|
- negative withholding amount |
||||
|
- zero-value dividend with positive withholding |
||||
|
- withholding supplied for a non-dividend activity |
||||
|
- imported historical dividend without withholding information |
||||
|
- dividend containing both an activity fee and withholding tax |
||||
|
- foreign-currency dividend |
||||
|
- activity updated from `DIVIDEND` to another activity type |
||||
|
|
||||
|
## Acceptance Criteria |
||||
|
|
||||
|
This design is complete when: |
||||
|
|
||||
|
- the withholding-tax stored field is clearly defined |
||||
|
- null and zero have different documented meanings |
||||
|
- the authoritative stored value is identified |
||||
|
- the withholding rate is defined as a derived value |
||||
|
- gross and net dividend formulas are documented |
||||
|
- withholding tax remains separate from activity fees |
||||
|
- validation rules are defined |
||||
|
- currency behavior is defined |
||||
|
- existing and imported dividends remain backward compatible |
||||
|
- expected DTO and API impact is documented |
||||
|
- important edge cases are identified |
||||
|
- no jurisdiction-specific tax rules are introduced |
||||
|
|
||||
|
## Implementation Impact — Iteration 2 |
||||
|
|
||||
|
The Iteration 2 implementation is expected to affect: |
||||
|
|
||||
|
- `prisma/schema.prisma` |
||||
|
- `libs/common/src/lib/dtos/create-order.dto.ts` |
||||
|
- `libs/common/src/lib/dtos/update-order.dto.ts` |
||||
|
- `apps/api/src/app/activities/activities.service.ts` |
||||
|
- `libs/common/src/lib/interfaces/activities.interface.ts` |
||||
|
|
||||
|
Import handling may also need to recognize the optional field while remaining |
||||
|
compatible with older files. |
||||
|
|
||||
|
Actual source-code changes belong to Iteration 2 and are not part of this design |
||||
|
task. |
||||
|
|
||||
|
## Design Decision Summary |
||||
|
|
||||
|
```text |
||||
|
Stored: |
||||
|
withholdingTax |
||||
|
|
||||
|
Derived: |
||||
|
grossDividend |
||||
|
withholdingTaxRate |
||||
|
netDividend |
||||
|
withholdingTaxInAssetProfileCurrency |
||||
|
withholdingTaxInBaseCurrency |
||||
|
``` |
||||
|
|
||||
|
The stored withholding amount is the source of truth. |
||||
|
|
||||
|
The rate is calculated from the stored amount and gross dividend. |
||||
|
|
||||
|
A null value means the withholding information is unknown, while zero means |
||||
|
that the system knows no withholding occurred. |
||||
|
|
||||
|
This approach keeps the design simple, traceable, backward compatible, and close |
||||
|
to Ghostfolio's existing activity structure. |
||||
|
|
||||
|
## Next Task |
||||
|
|
||||
|
The next scheduled tax task is September 17: |
||||
|
|
||||
|
**Specify the FIFO capital-gains calculation method.** |
||||
|
|
||||
|
That task will define transaction filtering and ordering, acquisition-lot |
||||
|
consumption, partial lot sales, realized gain calculations, traceability, and |
||||
|
edge cases. |
||||
Loading…
Reference in new issue