Four changes to statement handling in the web app, made together and touching the same upload and statements-list code. Name NLB uploads by statement date. parser.Namer is an optional interface, like Warner, through which a parser names its statements; nlb reads the "Datum izpiska" from the izpisek header and names it izpisek_YYYY_MM_DD, lowercase, extension included -- ported from the rename_izpiski.py it replaces. Uploads are staged as dotfiles, invisible to import, so the parser can read them; two downloads of one statement then meet under one name and the second is recognised as already there, while a different statement of the same date is numbered _2 as the script did. Only uploads are named: source_files records statements by path, so renaming a file already in a folder would orphan its rows. Delete a statement from the statements list. The file is removed from disk for good -- the page says so before it asks -- and store.ForgetSourceFile drops its transactions and their transfer rows. A row two overlapping statements share is stored once, under the file imported first, so it goes too; the account's other statements forget their checksums and show as changed until the next Import re-reads them and restores it. A file already gone from disk can be forgotten. Upload and delete no longer import. Importing stays the user's call, made with the Import button, so a batch can be put together and looked over first. Delete still re-pairs transfers, which reads no statement. Show rows and new rows per statement. The list read "0" for a file whose rows an earlier, overlapping statement already held, which looked like a file that failed to parse. source_files now records how many transactions each statement holds, and the list reads "3 rows · 0 new". This adds a column the code reads, so an index built by an earlier version fails with "no such column: s.rows": delete index.db and import again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.4 KiB
Go
127 lines
4.4 KiB
Go
package parser
|
|
|
|
import "testing"
|
|
|
|
// A page as pdftotext -layout renders it: a header, transaction lines ending
|
|
// in amount and balance, and indented continuation lines carrying wrapped
|
|
// descriptions and the other side's IBAN.
|
|
// The description column sits at 15, which is what the script's
|
|
// CONTINUATION_INDENT tells us about the real layout.
|
|
const nlbPage = ` NLB d.d.
|
|
IZPISEK 001/2026
|
|
|
|
DATUM OPIS ZNESEK STANJE
|
|
|
|
02.01.26 PLACILO S KARTICO -45,20 1.234,56
|
|
LIDL SOFIA 4412 SI56 1234 5678 9012 345
|
|
05.01.26 PRILIV PLACE +2.500,00 3.734,56
|
|
ACME DOO
|
|
10.01.26 PRENOS NA VARCEVALNI -500,00 3.234,56
|
|
15.01.26 NEGATIVNO STANJE -3.300,00 65,44-
|
|
|
|
Stran 1
|
|
`
|
|
|
|
func TestParseNLBText(t *testing.T) {
|
|
txns, err := parseNLBText(nlbPage, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(txns) != 4 {
|
|
t.Fatalf("got %d transactions, want 4: %+v", len(txns), txns)
|
|
}
|
|
|
|
first := txns[0]
|
|
if first.Date != "2026-01-02" {
|
|
t.Errorf("date = %q, want 2026-01-02", first.Date)
|
|
}
|
|
// The continuation line is appended to the description, and the IBAN in
|
|
// its second column goes last so that it stays contiguous however many
|
|
// lines it wrapped over.
|
|
if first.Description != "PLACILO S KARTICO LIDL SOFIA 4412 SI56 1234 5678 9012 345" {
|
|
t.Errorf("description = %q", first.Description)
|
|
}
|
|
if first.AmountMinor != -4520 {
|
|
t.Errorf("amount = %d, want -4520", first.AmountMinor)
|
|
}
|
|
if first.BalanceMinor == nil || *first.BalanceMinor != 123456 {
|
|
t.Errorf("balance = %v, want 123456", first.BalanceMinor)
|
|
}
|
|
|
|
if txns[1].AmountMinor != 250000 {
|
|
t.Errorf("credit amount = %d, want 250000", txns[1].AmountMinor)
|
|
}
|
|
if txns[1].Description != "PRILIV PLACE ACME DOO" {
|
|
t.Errorf("description = %q", txns[1].Description)
|
|
}
|
|
|
|
// A transaction with no continuation line gets nothing appended.
|
|
if txns[2].Description != "PRENOS NA VARCEVALNI" {
|
|
t.Errorf("description = %q, want no trailing account number", txns[2].Description)
|
|
}
|
|
|
|
// A trailing minus marks a negative balance.
|
|
if txns[3].BalanceMinor == nil || *txns[3].BalanceMinor != -6544 {
|
|
t.Errorf("balance = %v, want -6544", txns[3].BalanceMinor)
|
|
}
|
|
}
|
|
|
|
// A wrapped line at the top of a page must not attach to the last transaction
|
|
// of the previous page.
|
|
func TestParseNLBPageBoundary(t *testing.T) {
|
|
text := "02.01.26 FIRST -10,00 100,00\n" +
|
|
"\f" +
|
|
" STRAY CONTINUATION AT TOP OF PAGE\n" +
|
|
"03.01.26 SECOND -20,00 80,00\n" +
|
|
" REAL CONTINUATION\n"
|
|
|
|
txns, err := parseNLBText(text, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(txns) != 2 {
|
|
t.Fatalf("got %d transactions, want 2", len(txns))
|
|
}
|
|
if txns[0].Description != "FIRST" {
|
|
t.Errorf("first description = %q, want FIRST (page furniture must not leak in)", txns[0].Description)
|
|
}
|
|
if txns[1].Description != "SECOND REAL CONTINUATION" {
|
|
t.Errorf("second description = %q", txns[1].Description)
|
|
}
|
|
}
|
|
|
|
// Shallowly indented lines are footers, not wrapped descriptions.
|
|
func TestParseNLBIgnoresShallowIndent(t *testing.T) {
|
|
text := "02.01.26 FIRST -10,00 100,00\n" +
|
|
" Stran 1 od 2\n"
|
|
|
|
txns, err := parseNLBText(text, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(txns) != 1 || txns[0].Description != "FIRST" {
|
|
t.Errorf("got %+v, want a single FIRST transaction", txns)
|
|
}
|
|
}
|
|
|
|
// A date-led line without an amount and balance is a real problem, not
|
|
// something to silently drop.
|
|
func TestParseNLBRejectsMalformedLine(t *testing.T) {
|
|
text := "02.01.26 NO AMOUNT OR BALANCE HERE\n"
|
|
if _, err := parseNLBText(text, 2); err == nil {
|
|
t.Error("expected an error for a transaction line with no amount")
|
|
}
|
|
}
|
|
|
|
// An izpisek is named after its statement date, as rename_izpiski.py did.
|
|
func TestNLBStatementName(t *testing.T) {
|
|
header := " IZPISEK 001/2026\n" +
|
|
" Datum izpiska 31.01.2026 Stran 1\n"
|
|
if got := nlbStatementName(header + nlbPage); got != "izpisek_2026_01_31" {
|
|
t.Errorf("name = %q, want izpisek_2026_01_31", got)
|
|
}
|
|
if got := nlbStatementName(nlbPage); got != "" {
|
|
t.Errorf("name without a statement date = %q, want none", got)
|
|
}
|
|
}
|