26 KiB
Tax Metrics — CSV/PDF Tax Export Format Specification
Owner: Tharun Swaminathan Iteration: 1 — Design & Data Modelling Date: September 24, 2026
Objective
This document specifies the CSV and PDF export formats for the Ghostfolio tax extension.
The export feature must present the same tax results already produced by the tax calculation pipeline. It must not recalculate FIFO, average cost, withholding, or yearly totals independently.
This is Iteration 1 design work only. No CSV generator, PDF generator, API endpoint, download button, shared React component, or new package dependency is implemented as part of this task.
The export is a tax-analytics report. It is not a jurisdiction-specific tax return, filing form, or personal tax advice.
Relationship to Earlier Tax Designs
The export specification consumes the contracts already defined during Iteration 1.
Sep 16 — Withholding tax
Dividend output keeps these values separate:
gross dividend
withholding amount
withholding rate
net dividend
activity fee
Unknown withholding remains different from known zero withholding.
Sep 17 — FIFO capital gains
FIFO can produce:
sale-level realized-gain record
FIFO acquisition lots
FIFO lot matches
The export must preserve traceability from the realized sale back to the source BUY and SELL activities.
Sep 18 — Average-cost capital gains
Average cost can produce:
sale-level realized-gain record
average-cost events
pool state before/after an activity
The export must not invent FIFO-style lot matches for average cost.
Sep 21 — Tax-lot/data model
The common sale-level contract is:
TaxRealizedGainRecord
Method-specific trace data remains separate.
Sep 22 — Yearly tax summary
The main annual export source is:
YearlyTaxSummary
which already contains:
- selected year
- cost-basis method
- base currency
- dividend totals
- realized-gain totals
- detail records
- completeness status
- warnings/errors
Sep 23 — Tax-relevant filtering
Exports must consume the same tax-filtered result used by tax views.
The export layer must never reload all activities and accidentally include:
- Draft activities
- Exclude from Analysis activities
- activities in excluded accounts
isTaxRelevant = falseactivities- synthetic cash activities
Repository Findings
Existing Ghostfolio export is a JSON backup/export
The current API contains:
apps/api/src/app/export/
with:
export.controller.ts
export.service.ts
get-export.dto.ts
export.module.ts
The current export endpoint returns an ExportResponse object containing
accounts, activities, asset profiles, platforms, tags, user settings, and export
metadata.
That is a portfolio data portability/backup export, not a tax report.
Existing general export deliberately includes excluded data
The current ExportService.export() retrieves activities with:
includeDrafts = true
withExcludedAccountsAndActivities = true
That is appropriate for a full backup because source data should not disappear from an export.
It is not appropriate for the tax report.
The tax CSV/PDF feature must therefore not reuse the existing general-export selection rules as its tax filter.
Existing general export is not CSV/PDF generation
The current export controller returns a structured response.
It does not currently generate the tax CSV/PDF formats described here.
The tax export should reuse useful existing services without changing the purpose of the general backup endpoint.
Existing shared export response is based on Order
ExportResponse.activities derives from the Prisma Order type and is mapped
into a portable activity format.
When the Sep 23 Order.isTaxRelevant field is implemented, the general backup
export/import round trip should preserve that field so an explicit false value
is not lost.
This backup concern is separate from the tax report described here.
Existing CSV dependency
The repository already includes:
papaparse
Iteration 2 can evaluate reusing it for CSV serialization rather than adding a second CSV dependency.
This is an implementation recommendation, not a format requirement.
PDF dependency status
The inspected package.json does not currently show a dedicated PDF-generation
library such as pdfkit, jsPDF, or pdfmake.
The PDF library choice should therefore be made during Iteration 2 after checking build compatibility, pagination support, and testability.
This document defines PDF content independently of the chosen library.
Architecture Decision
Tax export is a presentation layer over tax calculation results.
Persisted activities
|
v
Tax filtering
|
v
FIFO or AVERAGE_COST
|
v
DividendRecord[]
TaxRealizedGainRecord[]
method-specific trace data
|
v
YearlyTaxSummary
|
v
TaxExport
/ \
v v
CSV PDF
The export layer must not independently implement:
- FIFO matching
- average-cost pooling
- withholding calculations
- historical FX conversion
- yearly aggregation
Export Request Context
A yearly tax export requires at least:
year
method
format
Conceptually:
export type TaxExportFormat = 'CSV' | 'PDF';
export interface TaxExportRequest {
year: number;
method: TaxCalculationMethod;
format: TaxExportFormat;
}
The authenticated request supplies user ownership.
Base currency comes from the same calculation context used by
YearlyTaxSummary.
The exact route/query DTO is a shared API decision.
Supported Formats
Initial project scope:
CSV
PDF
No XLSX, jurisdiction-specific XML, or official electronic filing format is required.
Required Export Metadata
Every export identifies:
reportYear
costBasisMethod
baseCurrency
generatedAt
isComplete
The report should also identify itself as:
Ghostfolio Tax Analytics
It must not present itself as an official tax return.
File Naming
Recommended names:
ghostfolio-tax-<year>-<method>.csv
ghostfolio-tax-<year>-<method>.pdf
Examples:
ghostfolio-tax-2026-fifo.csv
ghostfolio-tax-2026-average-cost.pdf
Use lowercase ASCII method slugs.
Do not place the user's name, email, or account ID in the filename.
Incomplete Exports
If:
YearlyTaxSummary.isComplete = false
the export may still be generated.
CSV and PDF must preserve:
- incomplete status
- warnings
- errors
- unavailable values
The export must not drop incomplete rows and then present the remaining totals as complete.
CSV Format
CSV Design Goals
The CSV should be:
- machine-readable
- spreadsheet-friendly
- deterministic
- flat
- traceable
- testable
A single CSV cannot naturally contain nested summary, dividend, realized-gain, and method-specific trace structures.
The initial design therefore uses one typed flat table.
CSV Record Types
The first column is:
recordType
Supported values:
SUMMARY
DIVIDEND
REALIZED_GAIN
FIFO_MATCH
AVERAGE_COST_EVENT
WARNING
ERROR
FIFO exports use:
SUMMARY
DIVIDEND
REALIZED_GAIN
FIFO_MATCH
WARNING
ERROR
Average-cost exports use:
SUMMARY
DIVIDEND
REALIZED_GAIN
AVERAGE_COST_EVENT
WARNING
ERROR
Why One Typed CSV
A single file satisfies the CSV-export requirement without requiring a ZIP or several downloads.
recordType allows filtering in Excel or another spreadsheet.
It also prevents one FIFO sale from being mistaken for several disposals.
Example:
1 REALIZED_GAIN row
3 FIFO_MATCH rows
still represents one SELL disposal.
CSV Encoding
Use:
encoding: UTF-8
delimiter: comma
line ending: CRLF
quote: "
header row: yes
Fields containing commas, quotes, or line breaks must use standard CSV quoting.
Quotes inside a quoted field are doubled.
CSV Numeric Rules
Numeric fields use:
decimal separator: .
thousands separator: none
currency symbol: none
scientific notation: no
Correct:
1250.50
-32.75
0
0.015
Incorrect:
$1,250.50
Currency is represented in explicit currency columns.
Precision and Rounding
The tax engine calculates and aggregates using the full internal Big
precision defined by earlier tax specifications.
The export layer must not round each detail row and then recompute annual totals.
Correct:
calculate at full precision
aggregate at full precision
format result for export
Incorrect:
round each detail row
sum rounded values
replace YearlyTaxSummary totals
CSV values should be serialized as plain decimal strings.
PDF display rounding is presentation only.
Null Values
CSV:
unavailable/null numeric value => empty field
Do not use zero to represent unknown.
PDF:
unavailable value => Not available
or another consistent human-readable label.
Booleans
CSV:
true
false
PDF report status:
Complete
Incomplete
Dates
CSV activity/event dates use ISO 8601.
Example:
2026-06-01T14:30:00.000Z
The report year remains:
2026
PDF may display a shorter readable date while source identifiers preserve traceability.
Fixed CSV Column Order
Recommended initial order:
recordType
reportYear
method
baseCurrency
generatedAt
isComplete
sourceActivityId
buyActivityId
sellActivityId
accountId
symbolProfileId
activityDate
acquiredAt
realizedAt
transactionCurrency
assetCurrency
quantity
matchedQuantity
unmatchedQuantity
grossDividendTransactionCurrency
withholdingTaxTransactionCurrency
withholdingTaxRate
netDividendTransactionCurrency
activityFeeTransactionCurrency
grossDividendBaseCurrency
withholdingTaxBaseCurrency
netDividendBaseCurrency
grossProceedsBaseCurrency
grossCostBasisBaseCurrency
grossRealizedGainBaseCurrency
activityFeesBaseCurrency
realizedGainAfterActivityFeesBaseCurrency
poolQuantityBefore
averageGrossUnitCostBefore
poolQuantityAfter
averageGrossUnitCostAfter
summaryGrossDividends
summaryWithholdingTax
summaryNetDividends
summaryGrossProceeds
summaryGrossCostBasis
summaryGrossRealizedGain
summaryActivityFees
summaryRealizedGainAfterActivityFees
dividendCount
disposalCount
traceId
traceIds
warningCodes
errorCodes
message
Columns not relevant to a row type remain empty.
SUMMARY Row
Exactly one SUMMARY row appears first.
It contains:
reportYear
method
baseCurrency
generatedAt
isComplete
summaryGrossDividends
summaryWithholdingTax
summaryNetDividends
summaryGrossProceeds
summaryGrossCostBasis
summaryGrossRealizedGain
summaryActivityFees
summaryRealizedGainAfterActivityFees
dividendCount
disposalCount
No source activity ID is assigned to this row.
DIVIDEND Row
One row per included DividendRecord.
Relevant fields:
sourceActivityId
accountId
symbolProfileId
activityDate
transactionCurrency
grossDividendTransactionCurrency
withholdingTaxTransactionCurrency
withholdingTaxRate
netDividendTransactionCurrency
activityFeeTransactionCurrency
grossDividendBaseCurrency
withholdingTaxBaseCurrency
netDividendBaseCurrency
warningCodes
errorCodes
Dividend activity fee remains separate from withholding.
REALIZED_GAIN Row
One row per sale-level TaxRealizedGainRecord.
Relevant fields:
sellActivityId
accountId
symbolProfileId
realizedAt
quantity
matchedQuantity
unmatchedQuantity
grossProceedsBaseCurrency
grossCostBasisBaseCurrency
grossRealizedGainBaseCurrency
activityFeesBaseCurrency
realizedGainAfterActivityFeesBaseCurrency
traceIds
warningCodes
errorCodes
A SELL consuming several FIFO lots still emits only one REALIZED_GAIN row.
FIFO_MATCH Row
FIFO export may add one row per TaxLotMatch.
Relevant fields:
traceId
buyActivityId
sellActivityId
acquiredAt
realizedAt
matchedQuantity
grossCostBasisBaseCurrency
grossProceedsBaseCurrency
grossRealizedGainBaseCurrency
activityFeesBaseCurrency
realizedGainAfterActivityFeesBaseCurrency
These are trace rows and do not increase yearly disposal count.
FIFO Trace Scope
For a yearly tax export, include FIFO matches connected to realized SELL records in the requested year.
Do not automatically dump every open lot in the user's entire portfolio into the annual tax report.
A future separate open-lot export can be added if required.
AVERAGE_COST_EVENT Row
Average-cost export may include event/state trace rows connected to selected-year realized SELLs.
Relevant fields:
sourceActivityId
activityDate
quantity
poolQuantityBefore
averageGrossUnitCostBefore
poolQuantityAfter
averageGrossUnitCostAfter
For SELL events, common realized values may also appear.
Do not create fake BUY-lot references for average cost.
Average-Cost Trace Scope
A current-year SELL can depend on pooled acquisition history from older years.
The export should include enough pre/post sale pool state to explain the selected year's basis.
The full event replay remains a tax calculation concern; the export is not a second calculator.
WARNING Row
Warning rows can contain:
recordType = WARNING
reportYear
method
sourceActivityId
traceId
warningCodes
message
Warnings can also remain attached to their related financial row.
ERROR Row
Error rows can contain:
recordType = ERROR
reportYear
method
sourceActivityId
traceId
errorCodes
message
Errors are not silently dropped.
List Fields
Use:
|
inside list-style CSV fields.
Example:
MISSING_BASE_CURRENCY_CONVERSION|TAX_SCOPE_HAS_EXCLUDED_INVESTMENT_HISTORY
This avoids conflict with the comma delimiter.
CSV Row Ordering
Recommended deterministic order:
1. SUMMARY
2. DIVIDEND by activityDate, then sourceActivityId
3. REALIZED_GAIN by realizedAt, then sellActivityId
4. method-specific trace rows grouped by sale
5. WARNING
6. ERROR
FIFO match ordering follows FIFO consumption order.
Average-cost traces follow event order.
Empty-Year CSV
An empty complete year still exports:
header
SUMMARY row
with zero financial totals, zero counts, and:
isComplete = true
No fake financial rows are generated.
PDF Format
PDF Design Goals
The PDF is for human review.
It should:
- summarize the year clearly
- identify the cost-basis method
- identify base currency
- show dividends and withholding
- show realized gains
- provide method-specific traceability
- show warnings/errors clearly
- remain readable across multiple pages
PDF Section Order
Use:
1. Report Header
2. Annual Tax Summary
3. Dividend and Withholding Detail
4. Realized Capital Gains Detail
5. Cost-Basis Trace Detail
6. Warnings and Errors
7. Report Note
Report Header
Display:
Ghostfolio Tax Analytics
Report Year
Cost-Basis Method
Base Currency
Generated At
Status: Complete / Incomplete
Do not place passwords, email, database user IDs, or other unnecessary personal information in the header.
Annual Summary
Show:
Gross Dividends
Withholding Tax
Net Dividends
Gross Sale Proceeds
Gross Cost Basis
Gross Realized Gain
Activity Fees
Realized Gain After Activity Fees
Dividend Count
Disposal Count
Values come directly from YearlyTaxSummary.
Incomplete Report Banner
When incomplete, show near the top:
Status: Incomplete
with a short explanation directing the user to warnings/errors.
Do not hide incompleteness only in a footer.
Dividend Detail Table
Recommended columns:
Date
Account
Asset
Gross Dividend
Withholding
Withholding Rate
Net Dividend
Transaction Currency
Base Gross
Base Withholding
Base Net
Source Activity ID
If source IDs make the main table too wide, place them in a reference/trace column or appendix.
Realized-Gain Detail Table
Recommended columns:
Sell Date
Account
Asset
Quantity
Gross Proceeds
Cost Basis
Gross Gain/Loss
Activity Fees
Gain/Loss After Fees
Base Currency
Sell Activity ID
Negative gains remain negative.
FIFO Trace Table
For FIFO, group by SELL.
Recommended columns:
Sell Activity ID
Buy Activity ID
Acquisition Date
Sell Date
Matched Quantity
Allocated Cost Basis
Allocated Proceeds
Gross Gain/Loss
Allocated Fees
Gain/Loss After Fees
Average-Cost Trace Table
For average cost:
Sell Activity ID
Sell Date
Quantity Sold
Pool Quantity Before
Average Unit Cost Before
Allocated Cost Basis
Pool Quantity After
Average Unit Cost After
Do not show fake lot IDs.
Warnings and Errors Section
Display:
severity
code
source/trace reference
message
Examples:
UNKNOWN_DIVIDEND_WITHHOLDING
MISSING_BASE_CURRENCY_CONVERSION
INSUFFICIENT_OPEN_LOTS
INSUFFICIENT_POOL_QUANTITY
TAX_SCOPE_HAS_EXCLUDED_INVESTMENT_HISTORY
Report Note
Include a short neutral note:
This report contains portfolio tax analytics generated from the activity data
available in Ghostfolio. It is not a tax return or personal tax advice.
Do not add jurisdiction-specific filing instructions.
Account and Asset Display
PDF should prefer human-readable names/symbols when available.
CSV should preserve stable source identifiers such as:
accountId
symbolProfileId
Display labels must not replace stable trace IDs.
Multi-Currency Behavior
Annual portfolio totals use:
YearlyTaxSummary.baseCurrency
Original transaction-currency values are detail fields.
Do not directly sum raw values from different currencies.
Missing FX
CSV:
affected base value = empty
isComplete = false
diagnostic present
PDF:
affected value = Not available
Status = Incomplete
diagnostic present
Never substitute zero or an assumed 1:1 rate.
Unknown Withholding
For:
withholdingTax = null
CSV withholding/net numeric fields are empty.
PDF displays them as unavailable.
For:
withholdingTax = 0
both formats show numeric zero.
Tax-Relevant Filtering
Exports consume results already filtered through Sep 23 rules.
They do not independently decide activity tax relevance.
If the calculation reports excluded historical investment data causing an incomplete result, the export preserves that status and diagnostic.
Reconciliation
CSV and PDF created from the same yearly summary must show the same:
year
method
base currency
completeness
gross dividends
withholding
net dividends
gross proceeds
gross cost basis
gross realized gain
activity fees
fee-adjusted realized gain
dividend count
disposal count
CSV Detail Reconciliation
For a complete result:
sum(DIVIDEND.grossDividendBaseCurrency)
=
SUMMARY.summaryGrossDividends
sum(DIVIDEND.withholdingTaxBaseCurrency)
=
SUMMARY.summaryWithholdingTax
sum(REALIZED_GAIN.grossProceedsBaseCurrency)
=
SUMMARY.summaryGrossProceeds
sum(REALIZED_GAIN.grossCostBasisBaseCurrency)
=
SUMMARY.summaryGrossCostBasis
sum(REALIZED_GAIN.grossRealizedGainBaseCurrency)
=
SUMMARY.summaryGrossRealizedGain
FIFO_MATCH rows are trace rows and must not be added again to sale-level totals.
PDF Pagination
For multi-page output:
- repeat table headers where supported
- do not clip numeric columns
- preserve all rows
- include page numbers when reliably supported
- keep the summary readable on the first page
Exact rendering depends on the selected Iteration 2 PDF library.
Page Size
No jurisdiction-specific page size is required.
Use one consistent project-wide page size and preserve table readability.
CSV Spreadsheet Safety
User-controlled text can be interpreted by spreadsheet programs as formulas when it begins with characters such as:
=
+
-
@
Text columns added to the CSV must be sanitized for spreadsheet formula injection.
Negative numeric values such as:
-250.50
must remain numeric and must not be incorrectly escaped as text.
PDF Text Safety
If PDF generation uses HTML/templates, user-controlled values must be escaped before insertion.
Authorization
Tax export uses the authenticated user's tax calculation context.
The client does not supply an arbitrary user ID.
A user can export only their own authorized tax results.
Determinism
With the same:
source activities
tax-relevant flags
split data
base currency
method
report year
financial values in the export must be identical.
generatedAt may differ.
Proposed Export Result
Conceptually:
export interface TaxExportResult {
format: TaxExportFormat;
fileName: string;
contentType: string;
content: Buffer | string;
}
Possible MIME types:
CSV -> text/csv
PDF -> application/pdf
This is a recommendation, not a locked shared API contract.
Proposed Service Boundary
A tax-domain export service is preferred.
Conceptually:
apps/api/src/app/tax/
tax-export.service.ts
Responsibilities:
- obtain
YearlyTaxSummary - obtain method-specific trace data
- map domain results to export rows/sections
- serialize CSV/PDF
- return filename/content type/content
- perform no tax recalculation
Final location should align with Raniya's architecture.
Existing General Export Boundary
Do not replace the current:
GET /export
backup semantics with tax-report semantics.
The existing endpoint intentionally exports broader source data, including data that tax reporting may exclude.
A separate tax endpoint or clearly separated tax-export mode is safer.
Generic Backup Round-Trip Impact
When Sep 23 isTaxRelevant is implemented, the existing portable backup should
preserve the field.
Otherwise:
export
-> import
could turn an explicit false back into true.
This is an implementation dependency on the existing export module, but it is not the tax CSV/PDF report itself.
CSV Implementation Recommendation
Because papaparse is already installed, Iteration 2 can evaluate:
Papa.unparse(...)
Confirm:
- fixed column order
- CRLF behavior
- quoting
- null-to-empty mapping
- text formula-injection safety
before using it.
PDF Implementation Recommendation
No PDF package is selected in Iteration 1.
Iteration 2 should choose the smallest compatible approach after checking:
- NestJS/Node compatibility
- production build behavior
- table pagination
- output testability
- maintenance cost
Do not rewrite a working frontend subsystem solely to generate PDF.
Planned Iteration 2 CSV Tests
Verify:
- empty year
- dividend with positive withholding
- dividend with zero withholding
- dividend with unknown withholding
- positive realized gain
- realized loss
- FIFO one-lot sale
- FIFO multi-lot sale
- average-cost sale
- multiple accounts
- multiple assets
- missing historical FX
- incomplete tax relevance history
- warning/error rows
- null exported as empty
- zero exported as zero
- ISO date output
- fixed header order
- deterministic row ordering
- commas/quotes/newlines in text
- UTF-8 content
- spreadsheet injection safety
- FIFO trace does not double-count totals
- detail rows reconcile exactly to SUMMARY
Planned Iteration 2 PDF Tests
Verify:
- header year/method/base currency
- Complete/Incomplete status
- summary values match source summary
- dividend details are correct
- realized gain details are correct
- FIFO trace links correct BUY/SELL records
- average cost uses pool state, not fake lots
- unknown values display as unavailable
- negative gains remain negative
- diagnostics are present
- multi-page output does not omit data
- MIME type is PDF
- filename follows convention
Tests should verify financial content, not only that a PDF file opens.
Cross-Format Test
For one source summary, CSV and PDF must expose the same financial totals and counts.
No format may contain independent financial calculation logic.
Acceptance Criteria
The Sep 24 design is complete when:
- CSV and PDF formats are defined
- request context is defined
- filename conventions are defined
- MIME types are defined
- tax export is separated from general backup export
YearlyTaxSummaryis the source- cost-basis method is present
- base currency is present
- completeness is present
- null vs zero behavior is defined
- CSV syntax and encoding are defined
- CSV column order is defined
- CSV record types are defined
- SUMMARY behavior is defined
- DIVIDEND behavior is defined
- REALIZED_GAIN behavior is defined
- FIFO trace behavior is defined
- average-cost trace behavior is defined
- diagnostics are defined
- PDF section order is defined
- PDF summary/detail/trace sections are defined
- precision and rounding behavior are defined
- date formatting is defined
- currency behavior is defined
- missing FX is defined
- unknown withholding is defined
- tax-relevant filtering is preserved
- reconciliation is defined
- FIFO trace rows cannot double-count sales
- authorization is defined
- spreadsheet injection safety is addressed
- generic backup round-trip impact of
isTaxRelevantis identified - Iteration 2 tests are identified
- no jurisdiction-specific filing format is invented
Final Design Decisions
Source:
YearlyTaxSummary + method-specific trace data
Tax recalculation inside export:
none
Formats:
CSV
PDF
CSV:
single typed table
CSV record types:
SUMMARY
DIVIDEND
REALIZED_GAIN
FIFO_MATCH
AVERAGE_COST_EVENT
WARNING
ERROR
CSV encoding:
UTF-8
CSV delimiter:
comma
CSV line ending:
CRLF
CSV null numeric value:
empty
PDF sections:
header
annual summary
dividend detail
realized gain detail
method trace
warnings/errors
report note
PDF unavailable value:
Not available
Incomplete export:
allowed, clearly marked
Filename:
ghostfolio-tax-<year>-<method>.<ext>
General Ghostfolio backup:
remains separate
Backup isTaxRelevant:
must round-trip after Sep 23 implementation
Cross-Member Coordination
This task is mainly within Tharun's tax ownership.
Implementation will touch shared architecture around:
- tax export endpoint
- shared file-download behavior
- generic backup export/import compatibility
- shared numeric formatting
- Iteration 3 React export UI
Coordinate those pieces with Raniya.
Do not change Arthur's chart export behavior or Sesha's risk feature unless the team explicitly chooses a shared download component.
Out of Scope
This Sep 24 task does not implement:
- CSV generation code
- PDF generation code
- API endpoint
- download UI
- package changes
- official government forms
- jurisdiction-specific filing layouts
- XLSX
- electronic filing
- report email/storage
Next Task
The next scheduled tax task is September 25:
Create GitHub issues for every tax sub-feature.
The issue set should translate the completed Iteration 1 tax specifications into Iteration 2 implementation work with acceptance criteria, tests, dependencies, and clear out-of-scope boundaries.