ADR-0031: recorded tax-statement snapshots — capture the broker’s tax pots, never derive them
- Status: Accepted (decision gate per ADR-0026; owner sign-off 2026-07-25, issue #612)
- Date: 2026-07-25
Context
The maintainer’s recurring question when sizing a trim is: how much realised equity gain is still free of Kapitalertragsteuer this year? In the German retail-tax model that number is
tax-free trim budget = unused equity loss pot + remaining Freistellungsauftrag
Both terms live on the broker’s tax statement (the
Verlustverrechnungstöpfe / Freistellungsauftrag block of a comdirect-style
Steuerreport or Erträgnisaufstellung). Today they are re-read out of PDFs by
hand whenever the question comes up, and they are invisible to the allocation
and drift surfaces where the trim decision is actually made.
The obvious move — derive the loss pots from the ledger Portfolixir already has — is structurally impossible, and that is the force behind this ADR.
Why Portfolixir cannot derive the tax pots
Correction (2026-07-29, owner review). The first version of this section called the cost-basis method “the disqualifier” and stated that Portfolixir has no FIFO. That was wrong, and the error mattered because it made the whole decision rest on a premise that does not hold. Portfolixir has carried a real FIFO lot matcher (
Ledger.TradeMatcher) since before this ADR, surfaced atGET /api/v1/securities/:id/trades. The decision is unchanged — the reasons below are sufficient on their own — but its argument is not the one originally written down. Point 1 is restated accordingly.
1. Cost-basis method mismatch (a real gap, not the disqualifier). Portfolixir maintains two cost models, each for its own question:
Ledger.cost_lots/1folds a running average for holdings valuation (ADR-0011, ADR-0004) — “what did the position I hold cost on average?”;Ledger.TradeMatchermatches FIFO, lot by lot, including split scaling — “which stock did this sale actually consume?”.
German capital-gains taxation mandates strict FIFO per depot, so the taxable question is the matcher’s, not the average’s. Reading the average-cost gain as a tax figure would be systematically wrong for any position built in tranches and partly sold.
But that is a presentation gap, not a derivation blocker: the FIFO gross gain per sale is already computed. What the matcher yields is a gross gain, and a gross gain is not a tax pot. Points 2 and 3 are what actually disqualify derivation, and they hold no matter how exact the lot matching is.
2. Four inputs are simply not in the transaction data (the disqualifier). Even with the exact FIFO lot tracking Portfolixir already has, the pots do not reconstruct, because these facts never enter the ledger at all:
- Teilfreistellung — the 0 / 15 / 30 / 60 % partial exemption by fund type. It is a property of the fund’s asset ratio, visible only because the broker prints it on the settlement.
- Vorabpauschale — the annual advance lump sum on accumulating funds, which is taxed in-year and later raises the basis at sale.
- Freistellungsauftrag consumption — allowance is consumed chronologically across all investment income at that institution, in the order the broker settled it.
- Loss carry-forward from prior years — certified balances that exist before the first transaction Portfolixir has ever seen.
3. The pot is per institution, not per depot. The pots are maintained by the bank for a taxpayer at that bank. Portfolixir models depots and cash accounts, not tax-reporting institutions, so there is no existing entity the derived number could even hang off.
The repository already has the right precedent for exactly this situation: ADR-0009 records cash as a stated balance rather than mirroring a second ledger, and ADR-0027 records depot snapshots as named markers. Both accept “recorded, dated, honest” over “computed, plausible, subtly wrong”.
Decision
Record the broker’s tax-statement block verbatim as a dated snapshot. Compute nothing that the statement does not state. Validate what was recorded against the statutory withholding formula and surface disagreement as an advisory.
1. New bounded context Portfolixir.Tax
Tax-jurisdiction rules are their own concern. They are not portfolio structure
(Portfolios), not instrument master data (Catalog), and emphatically not
ledger projection (Ledger) — the entire point of this ADR is that these rows
are not ledger entries and must never be reduced as if they were.
Portfolixir.Tax # recorded tax-statement snapshots + consistency checks
Portfolixir.Tax.StatementSnapshot # the schema
Portfolixir.Tax.Consistency # pure engine, no Repo/clock (AR-2)
On acceptance this adds one line to the Active Architecture block in
AGENTS.md; that amendment rides in the first implementation PR, not before.
Naming note: the concept was drafted as tax_ledger_snapshots. It is
renamed to tax_statement_snapshots deliberately: Ledger is a bounded
context in this codebase, and reusing the word invites precisely the reading
this ADR forbids — that these rows are bookable, reducible ledger events. They
are transcriptions of an external document.
2. Schema — tax_statement_snapshots
Identity and provenance:
| Column | Type | Notes |
|---|---|---|
institution |
:string, NOT NULL |
The tax-reporting entity as printed on the statement. Free text, trimmed, non-empty. |
holder |
:string, NOT NULL |
The taxpayer the statement is issued to. Free text (a placeholder label is fine); each taxpayer has their own Freistellungsauftrag. |
tax_year |
:integer, NOT NULL |
CHECK BETWEEN 1990 AND 2200 (int4 bound discipline, cf. ADR-0028 fix round). |
as_of |
:date, NOT NULL |
The statement’s stated position date. Not in the future — validated against a today injected by the context shell, never a clock inside the schema (AR-2). |
source |
:string, NOT NULL, default "manual" |
manual today; pdf_import reserved for ADR-0021 intake. validate_inclusion + DB CHECK. |
church_tax_rate |
:decimal(6,4), NOT NULL, default 0 |
k in the §32d formula below; 0 means not liable, which is the default. CHECK >= 0 AND < 1 — a range, not a value list, because the rate is not a constant of nature (see §3). Prefilled from the holder’s tax profile in force at as_of, then frozen on the row. |
note |
:text, nullable |
Free-form provenance (“page 4 of the annual report”). |
The eleven recorded money fields, each :decimal, precision: 20, scale: 6,
null: false, default: 0 per the money-column convention
(ADR-0003):
| Column | Statement line |
|---|---|
taxable_income |
Steuerpflichtige Kapitalerträge / Bemessungsgrundlage, before allowance |
allowance_granted |
Freistellungsauftrag erteilt |
allowance_used |
Freistellungsauftrag verbraucht |
loss_pot_equities |
Verlustverrechnungstopf Aktien — unused equity-loss volume |
loss_pot_other |
Verlustverrechnungstopf Sonstige — unused other-loss volume |
loss_carryforward_prior_years |
Certified Verlustvortrag brought into this tax year |
withholding_tax_pot |
Quellensteuertopf — creditable foreign withholding still available |
withholding_tax_credited |
Angerechnete ausländische Quellensteuer |
capital_gains_tax_withheld |
Abgeführte Kapitalertragsteuer |
solidarity_surcharge_withheld |
Solidaritätszuschlag |
church_tax_withheld |
Kirchensteuer |
Uniqueness: unique_index(:tax_statement_snapshots, [:institution, :holder,
:tax_year, :as_of]). All four are NOT NULL, so no NULLS NOT DISTINCT trap.
Re-recording the same statement is a conflict, not a silent duplicate; a
corrected re-issue for the same date is an update.
Sign convention — magnitudes only. Every money column carries a DB CHECK
>= 0 and a changeset validate_number(greater_than_or_equal_to: 0). A loss
pot is stored as the volume of loss available for offsetting, not as the
negative number the statement prints. This follows the ledger’s
positive-magnitude discipline (Transaction amount guards) and keeps the
arithmetic in §3 free of sign bookkeeping.
A negative input is rejected with a message naming the convention — never silently flipped. Silent normalisation of a sign on a money field is how a transcription error becomes a permanently wrong number. The entry form labels state the direction explicitly, and the display renders the pots with the statement’s sign so the recorded row is visually comparable to the paper.
Nothing about a real position, security, or transaction is stored here. The row is a transcription of an aggregate statement block.
3. Configuration that changes over time — parameters, profile, allowance orders
Tax rates, statutory allowances and a person’s own tax situation are not constants. They change by legislation, by where the taxpayer lives, and by what happens in their life. Two consequences follow, and both are binding.
Nothing statutory is hardcoded in the engine. A new table
tax_parameters, keyed by (jurisdiction, tax_year) and unique on that pair,
carries the numbers the consistency checks need:
| Column | Meaning | German values today |
|---|---|---|
jurisdiction |
ISO country code; "DE" is the only value today |
— |
tax_year |
the year the row governs | — |
capital_gains_tax_rate |
Kapitalertragsteuer | 0.25 |
solidarity_surcharge_rate |
Solidaritätszuschlag on the withheld KESt | 0.055 (unchanged for Abgeltungsteuer by the 2021 partial abolition) |
saver_allowance_single |
Sparer-Pauschbetrag, single assessment | 801.00 through 2022, 1000.00 from 2023 |
saver_allowance_joint |
Sparer-Pauschbetrag, joint assessment | 1602.00 through 2022, 2000.00 from 2023 |
church_tax_rates |
the rates in force, for prefill and an advisory | {0.08, 0.09} — 8 % in Bavaria and Baden-Württemberg, 9 % elsewhere |
Rows are seeded with the known German history and are editable by the
operator, so a rate change never requires a code release. The allowance history
is not academic: recording a statement for a year before 2023 against a
hardcoded 1.000 € would flag every correct transcription as inconsistent.
Tax.Consistency takes the resolved parameter row as an argument — it stays
a pure engine with no config lookup inside (AR-2). Rows for a closed tax year
are not edited; a legislative correction is a new row for the affected year.
The taxpayer’s own situation is effective-dated. A second table
tax_profiles, keyed by (holder, valid_from), records what is true of a
person from a date onwards:
| Column | Meaning |
|---|---|
holder |
the taxpayer label, same key as the snapshot |
valid_from |
:date, NOT NULL — the row governs from here until the next row’s valid_from |
jurisdiction |
"DE" today |
church_tax_liable |
:boolean, NOT NULL, default false — not liable is the default; no church tax is the plain, unremarkable case |
church_tax_rate |
:decimal(6,4), NOT NULL, default 0; CHECK >= 0 AND < 1, and CHECK church_tax_liable OR church_tax_rate = 0 |
assessment_type |
single or joint — selects which saver_allowance_* ceiling applies |
Effective dating is the point, not decoration: moving between federal states
changes 9 % to 8 %, marrying changes the assessment type and doubles the
allowance ceiling, joining or leaving a church changes liability. Each of those
happens on a date, and none of them may retroactively rewrite what a past
statement reconstructs to. A snapshot resolves the profile in force at its
as_of and freezes the resulting church_tax_rate onto its own row (§2), so
editing a profile later changes future prefills and never a recorded
transcription. This is the same as-of discipline quotes and exchange rates
already follow.
The Freistellungsauftrag is configured, not only observed. The statutory
allowance is one budget per taxpayer that they distribute across their banks by
instruction. A third table allowance_orders, unique on
(holder, institution, tax_year), records that instruction:
| Column | Meaning |
|---|---|
holder, institution, tax_year |
the key |
amount_granted |
:decimal(20,6), NOT NULL, CHECK >= 0 — the amount instructed to that bank |
note |
free-form |
This is deliberately a separate axis from the snapshot’s recorded
allowance_granted: the order is what the taxpayer instructed, the snapshot is
what the bank reports it applied. Holding both is what makes the comparison in
§4 possible — a divergence means either the instruction never landed or the
configuration is stale, and either way it is worth knowing before the allowance
is silently missed for a year.
4. Consistency checks — the free win
The statement block is internally reconstructable, because withholding follows the closed formula of § 32d Abs. 1 EStG:
e = taxable_income − allowance_used (assessment base after allowance)
q = withholding_tax_credited (creditable foreign withholding)
k = church_tax_rate (0 when not liable — the default)
s = solidarity_surcharge_rate (from tax_parameters for the year)
expected KESt = (e − 4q) / (4 + k)
expected Soli = capital_gains_tax_withheld × s
expected KiSt = capital_gains_tax_withheld × k
k and s are resolved, never hardcoded: k from the snapshot’s own
frozen rate (§2), s and the allowance ceilings from the tax_parameters row
for (jurisdiction, tax_year) (§3). The 4 in the formula is the statute’s
own algebra for the 25 % rate, so a future change of the capital-gains rate
means a new formula clause keyed to the year, not an edited constant — the ADR
that changes it is the place to decide that.
With k = 0 — no church tax, the default case — this collapses to the familiar
e × 25 % − q. Worked synthetic example:
taxable_income 12,000.00
allowance_used 1,000.00 → e = 11,000.00
withholding_credited 200.00 → q = 200.00
church_tax_rate 0 → k = 0
expected KESt = (11,000.00 − 800.00) / 4 = 2,550.00
expected Soli = 2,550.00 × 5.5 % = 140.25
Portfolixir.Tax.Consistency is a pure engine (no Repo, no clock, no
config — AR-2) returning a list of findings for a snapshot:
| Rule | Kind | Statement |
|---|---|---|
| C1 | hard (changeset error) | allowance_used ≤ allowance_granted — definitional within one institution. |
| C2 | hard | church_tax_rate = 0 ⟹ church_tax_withheld = 0. |
| C3 | advisory | recorded capital_gains_tax_withheld vs. expected KESt. |
| C4 | advisory | recorded solidarity_surcharge_withheld vs. expected Soli. |
| C5 | advisory | recorded church_tax_withheld vs. expected KiSt. |
| C6 | advisory | year-to-date monotonicity: for the same (institution, holder, tax_year), a later as_of must not report a lower capital_gains_tax_withheld or allowance_used. Catches “recorded the wrong year’s statement”. |
| C7 | advisory | instruction vs. reality: the snapshot’s recorded allowance_granted matches the allowance_orders row for the same (holder, institution, tax_year). A divergence means the instruction never landed at the bank, or the configuration is stale. |
| C8 | advisory | allowance budget: SUM(allowance_orders.amount_granted) over (holder, tax_year) does not exceed the saver_allowance_single / saver_allowance_joint ceiling for that year, selected by the profile’s assessment_type. Over-allocating across banks is an error the taxpayer must correct with the banks — the app states it, it does not fix it. Advisory rather than hard, because the recorded set of institutions may be incomplete and a planned redistribution can legitimately overlap for a moment. |
Tolerance band for the advisory rules: max(1.00, 0.05 % of expected) in the
statement currency. Withholding is rounded to cents on every individual
settlement, so a year’s worth of settlements legitimately accumulates a
few cents of drift against a single closed-form reconstruction. The band
absorbs that and still catches a transposed digit.
Advisories never block a save. Teilfreistellung applied at source, a mid-year allowance change, and broker-side corrections can all break the simple identity while the recorded numbers are perfectly correct. The checks are a transcription-error detector, not a tax authority. A finding states which two numbers disagree and by how much — it never proposes a “corrected” value.
Findings are computed at read time and are not stored
(ADR-0012 precedent). Write
functions keep the plain {:ok, struct} / {:error, changeset} contract.
5. Derived read model
Two figures are derived from a snapshot, both carrying the snapshot’s as_of
and its staleness:
allowance_remaining = allowance_granted − allowance_used
tax_free_trim_budget = loss_pot_equities + allowance_remaining
tax_free_trim_budget answers the question this feature exists for: the volume
of realised equity gain still free of Kapitalertragsteuer at that
institution. Its presentation is bound by two honesty rules:
- it is always stated with its
as_ofdate and flagged stale once newer investment income can have landed (the allowance is consumed chronologically by dividends and interest, so the remaining allowance decays without any action by the maintainer); - it is a decision input, never an instruction. The ADR-0023 boundary holds unchanged: nothing here creates, stores, or transmits an order.
Across institutions, the same two figures roll up per (holder, tax_year) —
the loss pots summed, and the allowance budget taken from the year’s statutory
ceiling for the profile’s assessment_type minus the consumption the snapshots
report. That roll-up is what makes cross-taxpayer allowance comparison a
listing rather than a project, and it is only correct when a snapshot exists
for every institution: the roll-up therefore always states which institutions
it covers and as of when, and is marked incomplete when an allowance_orders
row exists for an institution with no snapshot for that year.
Placement next to the allocation drift is a named follow-on slice, not part of the foundation.
6. Write path, API and MCP
Writes follow the established shape exactly — Actor as the first positional
argument, Ecto.Multi with Journal.record/3 in the same DB transaction
(ADR-0017, AR-1), and an
arm_tax_statement_snapshots_journal migration attaching the
portfolixir_require_journal_actor trigger so an unjournaled write fails loudly:
Tax.create_statement_snapshot(actor, attrs, opts \\ [])
Tax.update_statement_snapshot(actor, snapshot, attrs, opts \\ [])
Tax.delete_statement_snapshot(actor, snapshot_or_id)
Tax.list_statement_snapshots(opts) # filter: institution, holder, tax_year
Tax.fetch_statement_snapshot(id)
Tax.latest_statement_snapshot(institution, holder, tax_year)
Tax.list_parameters(opts) / Tax.fetch_parameters(jurisdiction, tax_year)
Tax.upsert_parameters(actor, attrs)
Tax.list_profiles(holder) / Tax.profile_in_force(holder, on_date)
Tax.create_profile(actor, attrs) / Tax.update_profile(actor, profile, attrs)
Tax.list_allowance_orders(opts)
Tax.put_allowance_order(actor, attrs) / Tax.delete_allowance_order(actor, order)
tax_parameters is statutory reference data, not financial state — it is
seeded, jurisdiction-wide, and describes the law rather than the maintainer’s
money. It is journaled anyway, because an edit to a rate changes what every
consistency finding for that year says, and an unexplained flip of findings is
exactly the kind of thing the journal exists to make traceable.
tax_profiles and allowance_orders are the taxpayer’s own configuration and
are journaled on the same grounds as any other financial write.
Per AR-11, the JSON API gets /api/v1/tax/statement-snapshots,
/api/v1/tax/parameters, /api/v1/tax/profiles and
/api/v1/tax/allowance-orders (list, create, show, update, delete) behind
ApiAuthPlug, serialized through the
shared Api.V1.JSON presenter with every financial decimal as a
Decimal.to_string(:normal) string, and the MCP companion gets matching
portfolixir.tax_snapshots.*, portfolixir.tax_parameters.*,
portfolixir.tax_profiles.* and portfolixir.tax_allowance_orders.* tools with hand-written
JSON Schema plus parallel zod validators, calling the JSON API only
(ADR-0002). Consistency findings and the
derived read model ride on the read payloads. The MCP tool description states
the recorded-not-derived nature and the FIFO reason, so an operating LLM does
not attempt to compute the pots from holdings.
7. Explicitly deferred — forward projection (Layer B)
Projecting the pots forward from the last snapshot (booked tax /tax_refund
amounts plus a coarse per-bucket gain estimate) is not part of this
decision. It needs a new tax_bucket attribute on securities
(equity / other / tax_free) — physically-backed gold via an exchange-traded
commodity belongs in tax_free after the twelve-month holding period, which no
heuristic over asset_class can infer — and it inherits the FIFO error this ADR
was written to avoid.
If it is ever built, two constraints are binding from here:
- the result is never labelled as a pot balance. It reads as an estimate with its drift basis stated (“estimated, drift since the snapshot of <date>”), never “Verlustverrechnungstopf: X”;
- it is a separate ADR with its own decision gate, because it is the point at which a computed number re-enters the picture.
8. Scope lock
This feature records numbers and checks their internal arithmetic. It does not:
- track tax lots or implement FIFO matching (the reason it exists);
- compute a tax liability, a Teilfreistellung, or a Vorabpauschale;
- produce anything filed with, or transmitted to, any authority or broker;
- constitute tax advice — the recorded statement remains the authority;
- make any network call.
Consequences
- Positive. The number the trim decision hangs on becomes a first-class,
dated, auditable record instead of a PDF re-read. It is cheap: one table, one
pure engine, the existing write/API/MCP machinery. Because the block is
internally reconstructable, every recorded snapshot validates itself on save —
a transposed digit or a stale statement surfaces immediately, at close to zero
implementation cost. The
holderkey makes cross-taxpayer allowance comparison (two depots, two Freistellungsaufträge) a listing, not a project. - Negative / accepted. This is manually maintained data that goes stale
silently between statements;
as_ofplus staleness display is the whole mitigation, and the maintainer owns re-recording.institutionandholderare free text with no referential integrity — deliberate, since Portfolixir has no institution entity and inventing one for this is heavier than the feature; if one ever lands, these become FKs in a follow-up migration. The advisory band cannot detect a statement transcribed correctly for the wrong period beyond the C6 monotonicity rule. - Configuration cost — accepted. The feature is four tables, not one:
the snapshot plus year-scoped statutory parameters, an effective-dated
taxpayer profile, and the allowance orders. That is more than the “cheap”
first sketch, and it is the minimum that survives contact with time. The
alternative — constants in the engine — cannot record a pre-2023 statement
correctly, cannot represent a taxpayer who is not liable for church tax
without a special case, and silently rewrites the meaning of past records
whenever the maintainer’s situation changes. Seeded reference data is its
own small risk: a wrong
tax_parametersrow makes every finding for that year wrong, which is why those writes are journaled and why the seed carries the German history rather than leaving the operator to type it. - Jurisdiction coupling — the real trade-off. These are the first columns in
Portfolixir that encode one country’s tax law. That is accepted because the
alternative (a generic “tax attributes” bag) would be unvalidatable, and the
§32d reconstruction is exactly what makes the feature worth building. The
containment rule is that it stays inside
Portfolixir.Tax: a second jurisdiction gets its own table and its own engine, never nullable columns bolted onto this one. - Delivery. One epic batch under ADR-0026: the table and context, the consistency engine, API/MCP coverage, the entry surface plus EN/DE documentation. It introduces no ledger, projection, or import-idempotency change, so it is not a risk-tier exception — but every write is journaled and every money column is Decimal, exactly as the money domain requires.
References
- ADR-0003 — Decimal for all financial values
- ADR-0004 / ADR-0011 — the average-cost projection this cannot reproduce under FIFO
- ADR-0009 — the recorded-balance precedent
- ADR-0012 — derived values computed at read time, not stored
- ADR-0017 — journaled financial writes
- ADR-0021 — the sandboxed PDF intake a later
source: "pdf_import"would reuse - ADR-0023 — display-only boundary the trim budget stays inside
- ADR-0026 — decision gate and batch delivery
- ADR-0027 — the as-of snapshot precedent
- FR-36 in the Requirements Inventory, and E19 in the Tracker Index, of
_bmad-output/planning-artifacts/epics.md(E19’s Epic Detail and story rows were removed by ADR-0042; this ADR is the spec) - § 32d Abs. 1 EStG — the closed withholding formula the consistency checks reconstruct