🏦 Danske Bank Importer
broker_danske_bank imports the equity savings account (osakesäästötili) of Danske Bank
Finland from two exports that only make sense together: the custody transactions (XLSX) and the
cash statement (CSV). It lives in backend/app/services/brim_providers/broker_danske_bank/plugin.py
(plugin_version 1.0.0, 🔬 Alpha — built from the exports of one account, shared in
issue #26) and is LibreFolio's reference
report-set plugin.
This page covers what is specific to Danske Bank. The generic machinery — membership by upload
batch, /sets/preview and /sets/combine, the history start (H0), /gap-fix — is described in
BRIM Architecture → Report sets and
BRIM Plugin Guide → Multi-report plugins,
the wizard side in
Import Wizard → Report sets.
The user's view is Danske Bank: the plugin's
docs_url points there, and so do the set card's How to export it links.
flowchart LR
K["custody XLSX"] --> A["_Analysis<br/>read · rule M · classes<br/>pairs · zones · proofs"]
C["cash CSV"] --> A
A --> T["_TableBuilder<br/>combined CSV"]
T --> P["_CombinedParser<br/>transactions · todos<br/>notices · truth points"]
P --> H["apply_history<br/>H0"]
H --> G["wizard review,<br/>then /gap-fix"]
describe_set and combine both build an _Analysis of the members; combine writes it out
through _TableBuilder, and parse reads back only the combined file (_CombinedParser). A
custody or cash file parsed alone raises BRIMSetRequiredError, naming the other role.
🗂️ Exports, roles and detection
| Role | Export (wizard name) | Extension | max_history |
Coverage axis | must_cover |
|---|---|---|---|---|---|
custody |
Securities transactions — eBanking Sijoitukset → Tapahtumat | .xlsx |
P1Y |
trade date (Kauppapäivä) |
— |
cash |
Cash statement — the account transactions of the account's cash account | .csv |
P5Y |
value date (Pvm) |
custody |
Both roles are required and multiple. The plugin sets settlement_lag_business_days to 5 and
pre_checkpoint_policy to summarize, and keeps the default history tag, danske_bank.
Detection. _file_kind(), behind can_parse and detect_role, answers custody, cash,
combined or None:
.xlsx(and.xlsm) — the first worksheet with data, read withopenpyxl(values only), iscustodywhen one of its first 20 rows (_HEADER_SCAN_ROWS) names every required custody column..csv— decoded with the sharedTEXT_ENCODINGSfallback (UTF-8 with or without BOM, Windows-1252, Latin-1) and split on;. A first row holdinglf_row_kind,lf_source,custody:Toimeksiantotyyppiandcash:Saaja/Maksajaiscombined; otherwise a header among the first 20 rows naming every required cash column makes itcash.- Headers match case- and space-insensitively, a literal
<br/>counting as a space (_norm). Any read error means «not ours».
can_parse accepts the three kinds, detect_role only the first two (detection_priority 100). A
downloaded combined file uploaded again is therefore an original without a role: the preview
reports it as unknown_role. No other plugin's can_parse accepts the sample exports, so the
wizard never offers Read alone with ‹plugin› for them.
| Export | Required columns | Also read |
|---|---|---|
| custody | Kauppapäivä, Arvopäivä, Sijoituskohde, Määrä, Kurssi, Summa, Tila, Toimeksiantotyyppi |
Palkkio sis. Alv; the currency of Summa — Valuutta, or the unnamed column right after Summa (default EUR); Säilytystili, the custody account, read for the account check only and never copied |
| cash | Pvm, Saaja/Maksaja, Määrä, Saldo, Tila |
Tarkastus. There is no currency column: the account is in euro |
Dates are d.m.yyyy. Numbers may use a space, NBSP or narrow NBSP as thousands separator, a comma
or a dot as decimal separator, and a Unicode minus (_to_decimal).
describe_member() reports rows and coverage. A custody file also gets an account_fingerprint,
the SHA-256 of its sorted Säilytystili values: custody files of different accounts make the
preview's mixed_accounts, and the set cannot be combined. Several accounts inside one custody
file only raise mixed_accounts_in_file.
🏷️ Row classes
📈 Custody rows
_classify_custody(): a Tila other than Toteutettu is status; a row without trade date,
value date, name or a non-zero quantity, or valued before its trade date, is invalid. Then the
order type decides:
Toimeksiantotyyppi |
Signs | Class |
|---|---|---|
Rajakurssi, Päivän kurssi, Pikakauppa |
Määrä > 0 and Summa < 0, or Määrä < 0 and Summa > 0; a Kurssi is required |
buy, sell |
Tuotto |
Määrä > 0 and Summa > 0 |
income |
Jakautuminen, vanha |
Määrä < 0, no Summa |
demerger_old |
Jakautuminen, uusi |
Määrä > 0, no Summa |
demerger_new |
| anything else | — | unknown_type |
A known order type with other signs is invalid.
💳 Cash rows
_classify_cash(): a Tila other than Toteutunut, or the label Varaus (a reservation), is
status; a row without a date, or with a zero or missing amount, is invalid. Then the label
(Saaja/Maksaja) and the sign decide (_cash_label_class):
| Label | Sign | Class | Becomes |
|---|---|---|---|
Osto <name> |
− | buy |
half of a pair |
Myynti <name> |
+ | sell |
half of a pair |
<name> <10-digit reference> |
+ | income |
half of a pair |
first word Nosto |
− | withdrawal |
WITHDRAWAL |
first word Vero |
− | tax |
TAX |
first word starting with Palvelumaksu |
− | fee |
FEE |
first word Korko or Hyvityskorko |
+ | interest |
INTEREST |
| any other credit | + | deposit |
DEPOSIT, listed by the deposit_assumed notice: only the holder can pay into an equity savings account |
| any other debit, or a known word with the wrong sign | unknown_type |
excluded |
🧾 What the parse writes
| Combined line | Transaction | Date | Quantity | Cash |
|---|---|---|---|---|
pair of a trade |
BUY or SELL |
cash Pvm |
custody Määrä |
cash Määrä |
pair of a Tuotto |
DIVIDEND |
cash Pvm |
0 | cash Määrä |
standalone cash row |
DEPOSIT, WITHDRAWAL, TAX, FEE or INTEREST |
Pvm |
— | Määrä |
standalone demerger line |
ADJUSTMENT |
custody Arvopäivä |
custody Määrä |
— |
Cash is always EUR, taken as written. Every transaction is tagged import and danske_bank,
demerger lines also demerger. Each security gets one fake asset id per normalised name, with the
name as its only identifier (extracted_name): the exports carry no ISIN and no ticker.
summarized, deferred and excluded lines write no transaction (the last two feed the notices).
On a first import, the standalone rows of the before zone are written but dated before H0, so
the review hides them
(Import Wizard → Rows before H0).
🔗 Pairing trades with their cash
_pair_rows() pairs buy, sell and income rows only, on a key of value date, amount to the
cent, currency and class — Arvopäivä, Summa and the custody currency against Pvm, Määrä
and EUR. The class carries the direction.
- One custody row and one cash row on a key pair.
- More than 8 candidates on either side (
MAX_MATCH_CANDIDATES) are allambiguous. - Interchangeable candidates — the same name, quantity, price, trade date and order type on the custody side, one label on the cash side — pair in file order, so identical partial fills stay two trades.
- Otherwise
_best_assignment()keeps the assignment with the most compatible names, but only when every best assignment imports the same trades; if not, every candidate of the key isambiguous. The plugin never guesses.
Two names are compatible when one normalised name is a prefix of the other, because the cash
statement truncates its labels. The name is a check, not a key: a pair whose names differ still
pairs, and the parse lists it in the name_mismatch notice.
Several files of one role (rule M, _RoleMerge): each day belongs to the file with the latest
coverage covering it — custody files by trade date, cash files by value date. The other files' rows
of that day count once when identical, and become excluded (superseded) when they differ;
describe_set then warns overlap_mismatch.
🗺️ Segments, zones and outcomes
- Segments — the custody files' trade-date spans, merged when they overlap or touch
(
_raw_segments). Two spans with a hole between them stay apart only when the cash statement proves trades in the hole: an unmatched cash trade valued after the first span and no later than 5 business days after the eve of the second (_proven_segments). Otherwise they merge, hole included. Each hole left between two segments is a gap (gaps(), the preview'sgapwarning). - Checkpoint — the eve of each segment; for the first one, the eve of the first booked cash day
when the cash statement starts later (
_timeline). - Window — from the day after the checkpoint to the segment's last day plus 5 business days
(
LAG_BUSINESS_DAYS), capped at the next checkpoint. Its border is the first 5 business days after the checkpoint. Business days skip weekends only: holidays are not known. - Zones of a value date (
_zone):before(up to the first checkpoint),window,gap(after a window, up to the next checkpoint) andafter(beyond the last window).
Every row gets exactly one outcome (_Analysis._settle):
| Row | Outcome |
|---|---|
| Custody row and cash row on one key | pair |
| Deposit, withdrawal, tax, fee or interest | standalone, imported with its own date |
| Demerger line | standalone |
| Cash trade or income without counterpart | summarized in the zone's checkpoint in before and gap, and in its own segment's checkpoint inside the border; valued after the segment's last day, summarized in the next segment's checkpoint, or deferred when there is none; deferred in after; elsewhere excluded (no_counterpart) |
| Custody trade or income without counterpart | excluded: not_yet_settled (valued after the cash statement's last day), outside_cash_coverage (outside the statement's spans), else no_counterpart |
Ambiguous; superseded; status, unknown_type, invalid |
excluded with that reason |
The cash rows of the before zone — standalone, ambiguous or unknown_type — are also absorbed
by the first checkpoint. The full matrix, zone by zone, is in the plugin guide, under Zones and
outcomes.
The wizard's Matching securities ↔ cash reads the combined summary: the outcomes as chips (pairs, standalone rows, summarised in a checkpoint, with the next import, excluded) and one row per reason (Import Wizard → Step analyze):
lf_reason |
Wizard label | For Danske |
|---|---|---|
no_counterpart |
no counterpart | a trade or income in one file only, inside a window |
ambiguous |
ambiguous | candidates that cannot be told apart |
not_yet_settled |
not settled yet | a custody trade valued after the cash statement's last day |
outside_cash_coverage |
settled before the cash statement starts | a custody trade valued outside the cash statement's spans |
status |
not executed or not booked | Tila other than Toteutettu / Toteutunut, or a Varaus |
unknown_type |
unknown type | an order type or a label the plugin does not know |
invalid |
invalid | missing or inconsistent values |
superseded |
replaced by a newer file | rule M |
📄 The combined file
_TableBuilder writes one line per source row — a pair is one line — plus the truth rows
(truth_cash, truth_position, verification), sorted by day, the data lines before the truth
rows of the same day. The columns are the nine lf_* columns, then the verbatim custody:<column>
and cash:<column> values; the custody account (Säilytystili) is never copied.
The core writes the file (write_combined_csv: UTF-8 with BOM, ;) under a generated name,
Danske Bank — combined <first day>…<last day>.csv, because the cash statement's own file name may
carry an account number. What each lf_* column holds is in the plugin guide, under combine is
pure.
summary, kept in the sidecar as combine_summary, holds rows per role, outcomes, zones,
reasons, segments, gaps, checkpoints, verifications, balance_chain (ok or broken)
and overlap_mismatch_days. combine refuses custody files without a dated row
(BRIMParseError, so a 422 combine_failed).
plugin_version covers combine and parse alike: bump it whenever either output changes for the
same files. The existing combined files then show To re-combine, and the next analysis rebuilds
them.
📍 Truth points
🏁 Starting point and «After the gap»
One checkpoint per segment, on its eve: the first is opening, the others gap. The wizard titles
them Starting point · ‹date› and After the gap · ‹date›. On a later import, apply_history()
drops the checkpoints before the eve of H0, turns the ones from H0 on into gap checkpoints and
removes what H0 already represents
(The history start (H0)).
- Cash — the cash statement's running balance at the end of the checkpoint day, plus the cash
of the border rows summarised in it: trades made before the custody export and settled after the
checkpoint (
border_cash). A day the statement does not cover gets no cash (not_covered). - Absorbed rows — every cash row the checkpoint summarises (
lf_checkpoint), with date, amount and label. For the opening checkpoint,opening_cashis the checkpoint's cash minus their total: the balance before them. The gap-fix explanation reads both.
Positions come from proofs inside the segment's window, each brought back to the checkpoint by removing the movements imported in between — at most one per security:
- E1 — a
Tuottostates the shares that earned it: exact. Discarded when another movement of the security falls in the 30 days up to its trade date (recent_trades), when those 30 days start before both the segment and the cash statement (window_not_covered), or when an excluded row of the security lies between the checkpoint and the proof (excluded_rows). - E2 — the old line of a demerger states the whole holding: exact. Discarded on a same-day
movement of the security (
same_day_movement) or on excluded rows. - E3 — when no exact proof holds, selling more than was bought since the checkpoint proves
at least the largest shortfall (
at_least). Discarded on excluded rows. - Exact proofs that disagree, or that give a negative quantity, are all discarded (
conflict), and E3 is tried instead.
A discarded proof is written discarded:<rule>:<reason> in lf_proof and listed by the
proof_discarded notice. Danske never states a unit_cost.
✅ End-of-period check
One verification, on the last day of the last segment — or on the cash statement's last day when
it ends first — provided the running balance covers that day. Its cash is the plain running
balance, and the wizard titles it End-of-period check · ‹date›. It is never a checkpoint: the next
import brings the not_yet_settled and deferred trades one by one, and a correction there would
count them twice (the safety rule of the plugin guide).
The running balance (_chain) takes the booked rows by value date — within a day in reverse file
order, since the statement lists the newest rows first — and checks each day against Saldo, row
by row or at least as a day total. A day that does not add up is a break: the chain goes on from
that day's last Saldo, and the cash of every checkpoint becomes a verification instead
(balance_chain_broken, warned by both describe_set and the parse).
🧮 The gap_fix corrections
compute_gap_fix() (backend/app/services/brim_gap_fix.py) is generic
(Plugin Guide → report sets,
Import Wizard → Step gapFix);
with this plugin's truth points it gives:
- For each checkpoint in date order, LibreFolio's state at the end of the day: the broker's saved transactions (without pending deletions) plus the editor's unsaved rows, the wizard's selection and the corrections proposed at the earlier checkpoints. Transactions entered by hand count like imported ones.
- Cash — a difference above 0.01 € becomes a Deposit or a Withdrawal that brings the
cash to the balance of the cash statement (
DEPOSITwhen the bank holds more,WITHDRAWALwhen it holds less), dated on the checkpoint. - Positions — an
exactposition gets anADJUSTMENTof the difference, positive or negative; anat_leastone only the missing part. With nounit_costfrom Danske, every positive adjustment carries the blocker todogap_fix_costoncost_basis_override: the wizard tags it cost to enter, and the bulk editor waits for the cost. A negative adjustment carries no cost. A position whose security is still unresolved is left out (unresolved_asset). - Tags
import,danske_bankandgap_fix; descriptionGap-fix <date> — Danske Bank: <what> at the end of the day, as stated by the bank. - The end-of-period verification is compared (
okwithin 0.01 €), never corrected.
Once saved, a correction counts in H0 from the day after its date. A later set whose segment covers
its date gets the preview warning covers_gap_fix (delete the correction after the import, or the
cash counts twice); a segment that ends before H0 gets before_history_segment and is not
imported.
💶 The commission estimate
The exports give one total per trade: the fee column (Palkkio sis. Alv) is always 0, and the
commission is inside Summa. Every paired purchase and sale therefore gets the warning todo
danske_trade_charges_included on cash (_charges_todo); dividends get none.
- Context —
split_hint: "trade_charges", so the Corrections step owns the row (isCashSplitWarning) and every trade needs a decision;compare_nominal: false;cash(the absolute amount),currency,row,split_suggestions, andchargeswhen the estimate is plausible. - Estimate — gross = absolute quantity ×
Kurssi, rounded to the cent; charges = absolute amount − gross for a purchase, gross − absolute amount for a sale. It is plausible when 0 ≤ charges ≤ max(15 €, gross ÷ 100): the first suggestion then gives both figures, valid if the price is in euro. Otherwise it says the price may be quoted in another currency, so the commission cannot be computed from the file. - The second suggestion always points to the trade confirmation in eBanking.
✂️ Demergers
Jakautuminen, vanha and Jakautuminen, uusi lines are never paired: booked, with the right
signs, they become cashless ADJUSTMENTs in any zone, dated on Arvopäivä and tagged demerger.
- New line — blocker todo
demergeroncost_basis_override: the file gives no acquisition cost. A demerger is tax-neutral in Finland, and the old cost is split among the new shares in the ratio the Tax Administration publishes. The bulk editor's Save All waits for it. - Old line — warning todo
demerger_old_legonquantity: it removes the whole holding without moving its cost to the new lines, so invested capital does not drop by itself.
Neither todo enters the Corrections step: a cost-basis blocker belongs to the bulk editor, and
the old-line warning has no split_hint. The old line is also proof E2 of the holding.
🇫🇮 Finnish notices
The parse speaks the language of the files: notice messages, todo messages and evidence comments
are in Finnish. Evidence tables are titled Arvopaperitapahtumat (custody columns) and
Tilitapahtumat (cash columns), with the line numbers of the combined file.
| Notice | Severity | Says |
|---|---|---|
excluded_<reason> |
warning | One per exclusion reason, with its rows: they are not imported (not_yet_settled: they come with the next import) |
deferred_rows |
info | Cash trades after the last custody day come with the next import |
name_mismatch |
warning | Paired trades whose names differ: check them |
proof_discarded |
info | Holdings the files could not prove reliably (context.proofs) |
balance_chain_broken |
warning | The statement's balances are only compared, never used for corrections |
deposit_assumed |
info | Credits read as deposits: check them |
The todo codes — danske_trade_charges_included, demerger, demerger_old_leg — are in the
contract suite's KNOWN_REASON_CODES. The set warnings of describe_set (empty_member,
mixed_accounts_in_file, overlap_mismatch, gap, balance_chain_broken) and the gap-fix todo
gap_fix_cost are written in English and localised by the wizard
(importWizard.reportSet.warning.<code>, importWizard.reportSet.gapFix.todo.<code>).
🚦 Set statuses
| Where | Status | For a Danske set |
|---|---|---|
/sets/preview, per role |
present, missing, excess |
excess never occurs: both roles are multiple |
/sets/preview, complete |
every required role present, no excess, no mixed accounts | mixed accounts can only come from custody files |
/sets/preview, missing |
the period a missing export must cover | a missing cash statement: from the eve of the first custody trade date to the last one; a missing custody export: no period |
Step upload |
after an upload, Next stays once on upload while a set of the session is incomplete |
names the missing export, its extension and its period |
Set card (data-set-status) |
loading Checking…, complete Complete, incomplete A file is missing or Cannot be combined, error Check failed |
Cannot be combined means mixed accounts; Already analysed is added when a parsed combined file of exactly these members exists |
| Files table | Set of ‹date›, Incomplete, Combined, Used in a combined file, To re-combine | Import Wizard → Badges |
The cash role's coverage warnings (must_cover: custody) — coverage_starts_late when the
statement starts after the eve of the first custody trade date, coverage_ends_early when it ends
before the last one — never block the set. How the user changes a set (Read as, Remove from the
set) is generic:
Import Wizard → How a set is read.
🧪 Samples and tests
backend/app/services/brim_providers/sample_reports/holds the main set (danske_bank-custody.xlsx,danske_bank-cash.csv) and the gap set (danske_bank-gap-custody-1.xlsx,danske_bank-gap-custody-2.xlsx,danske_bank-gap-cash.csv): synthetic values on the real column layout, declared intest_sample_setsand described in the folder'sREADME.md../dev.py test external brim-danske-bank(test_external/test_brim_danske_bank.py) pins the plugin's own rules; the generic suites are listed in Testing a report-set plugin.