Compare commits

...

8 Commits

Author SHA1 Message Date
24fe93cd90
Lisää READMEhin lyhyt selitys englanniksi 2025-07-21 10:01:19 +03:00
710ff26f50
Lisää READMEhin lyhyt selitys esperantoksi 2025-07-21 10:01:19 +03:00
1a9d7e37ae
Siisti nimistöä
Tämä muutos mm. poistaa tito-osia nimistä, koska ne käyttäjä voi
helposti lisätä tuomalla koko moduulin omaan nimiavaruuteen.
2025-07-21 10:01:19 +03:00
6b399c80eb
Lisää yksinkertaiset funktiot TITO-tiedostojen lukemiseen 2025-07-21 10:01:19 +03:00
0b5dbbd133
Sisällytä erittelyt siirtoihin 2025-07-21 10:01:19 +03:00
f03829a294
Päivitä kanavan pääosoite 2025-07-21 10:01:19 +03:00
fc607b763f
Lisää README 2025-07-21 10:01:19 +03:00
bf06f917a6
Kirjaa testattu Guix-versio channels.scm-tiedostoon 2025-07-21 10:01:19 +03:00
6 changed files with 275 additions and 182 deletions

View File

@ -1,4 +1,4 @@
(channel (channel
(version 0) (version 0)
(url "https://git.olarinmaensamoojat.fi/OMS/haskell-tito") (url "https://git.olarinmaensamoojat.fi/OMS/tito")
(directory ".guix/modules")) (directory ".guix/modules"))

61
README.md Normal file
View File

@ -0,0 +1,61 @@
# TITO
::: {lang=eo}
*Ĉi tiu pakaĵo enhavas datumstrukturoj kaj sintaksa analizilo por TITO, maljuna
finna normo por cifera konteltiroj. Ĉar ĝi kredeble estas senutila ekster
Finnujo, la dokumentaro estas skribita en la finna lingvo.*
::: {lang=eo}
::: {lang=en}
*This package provides data types and a parser for TITO, an old Finnish
standard for digital bank account statements. It is most likely useful only in
Finland and thus the documentation is in Finnish.*
::: {lang=en}
Tito on Haskell-kirjasto TITO-muotoisten tiliotteiden lukemiseen. Moduuli
`Data.TITO` sisältää ylätason funktiot tiliotteen tulkitsemiseen,
`Data.TITO.Types` määrittää tiliotteen tietorakenteen ja `Data.TITO.Parser`
ohjeet sen tekstimuodon tulkitsemisen. Kaikki moduulien nimet on suunniteltu
tuotavaksi omaan nimiavaruuteensa, mutta vain `Data.TITO.readFile` menee
päällekkäin `Prelude`:n nimen kanssa.
## Asentaminen
Lisää tämä git-tietovaranto [Guixin kanavalistaan][guix-channels]
seuraavankaltaisella pätkällä:
[guix-channels]: https://guix.gnu.org/manual/devel/en/guix.html#Specifying-Additional-Channels
```lisp
(channel
(name 'ghc-tito)
(url "https://git.olarinmaensamoojat.fi/OMS/tito")
(branch "main")
(introduction
(make-channel-introduction
"a793f511921f1e962ca0fdc9d988dfe26b4dc6b4"
(openpgp-fingerprint
"A0C9 1947 734F 076F 5F08 E9FF 257D 284A 2A1D 3A32"))))
```
Tämän jälkeen Tito on saatavilla `ghc-tito`-nimisenä pakettina kaikkialla
Guixissa.
## Kehittäminen
Kehitysympäristön saat helposti Guixilla ajamalla komennon [`guix
shell`][guix-shell] tässä hakemistossa. Muutoksia, vikailmoituksia ja
kehitysideoita voi jättää Giteassa tai sähköpostilla osoitteeseen
<saku@laesvuori.fi>. Tarkista, että Tito kääntyy vielä muutostesi jälkeen
ajamalla komento `guix time-machine -C channels.scm -- build -f guix.scm`.
[guix-shell]: https://guix.gnu.org/manual/devel/en/guix.html#Invoking-guix-shell
## Kopioiminen
Laskutin on [GNU AGPL lisenssin version kolme](COPYING.md), tai valintasi
mukaan minkä tahansa myöhemmän [Free Software Foundationin julkaiseman
version][fsf-agpl], alainen vapaa ohjelma, eli se kunniottaa käyttäjiensä
vapautta päättää itse omasta tietojenkäsittelystään ja auttaa toisiaan.
[fsf-agpl]: https://www.gnu.org/licenses/agpl-3.0.html

11
channels.scm Normal file
View File

@ -0,0 +1,11 @@
(list (channel
(name 'guix)
(url "https://git.guix.gnu.org/guix.git")
(branch "master")
(commit
"502ad9da2a7da064aef6a6dcdada3c92ae90aa72")
(introduction
(make-channel-introduction
"9edb3f66fd807b096b48283debdcddccfea34bad"
(openpgp-fingerprint
"BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA")))))

View File

@ -1,2 +1,18 @@
module Data.TITO where module Data.TITO where
import Control.Exception
import qualified Data.ByteString as BS
import qualified Text.Megaparsec as P
import Data.TITO.Parser
import Data.TITO.Types
decode :: BS.ByteString -> Either ParseErrors AccountStatement
decode = P.parse accountStatement ""
readFile :: FilePath -> IO AccountStatement
readFile fp =
BS.readFile fp
>>= either (throwIO . ErrorCall . P.errorBundlePretty) pure
. P.parse accountStatement fp

View File

@ -1,5 +1,6 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NamedFieldPuns #-}
module Data.TITO.Parser where module Data.TITO.Parser where
@ -93,8 +94,8 @@ transactionType = do
case n of case n of
1 -> pure Deposit 1 -> pure Deposit
2 -> pure Withdrawal 2 -> pure Withdrawal
3 -> pure DepositFix 3 -> pure DepositCorrection
4 -> pure WithdrawalFix 4 -> pure WithdrawalCorrection
9 -> pure Declined 9 -> pure Declined
_ -> P.failure Nothing mempty -- TODO: proper error message _ -> P.failure Nothing mempty -- TODO: proper error message
@ -117,119 +118,125 @@ nameSource = do
"A" -> pure Customer "A" -> pure Customer
_ -> P.failure Nothing mempty -- TODO: proper error message _ -> P.failure Nothing mempty -- TODO: proper error message
tito :: Parser TITO accountStatement :: Parser AccountStatement
tito = TITO <$> titoRoot <*> P.many titoRecord <* P.eof accountStatement = do
tito <- record "00" $ do
P.string "100" -- version number
account <- alphaNumeric 14
statementNumber <- alphaNumeric 3
startDate <- date
endDate <- date
created <- timestamp
customer <- alphaNumeric 17
startBalanceDate <- date
startBalance <- money
_records <- optional' numeric 6
currency <- optional' alphaNumeric 3
accountName <- optional' alphaNumeric 30
accountLimit <- fmap Money <$> optional' numeric 18
accountOwnerName <- alphaNumeric 35
bankName <- alphaNumeric 40
contactInformation <- optional' alphaNumeric 40
bankSpecific <- optional' alphaNumeric 30
ibanAndBic <- fmap (fmap $ T.break isSpace) $ optional' alphaNumeric 30 -- FI1234567 XXXXX
return AccountStatement {events = [], ..}
events <- P.many titoRecord <* P.eof
pure $ tito {events}
titoRoot :: Parser TITORoot titoRecord :: Parser Event
titoRoot = record "00" $ do titoRecord = P.try transaction
P.string "100" -- version number
account <- alphaNumeric 14
statementNumber <- alphaNumeric 3
startDate <- date
endDate <- date
created <- timestamp
customer <- alphaNumeric 17
startBalanceDate <- date
startBalance <- money
_records <- optional' numeric 6
currency <- optional' alphaNumeric 3
accountName <- optional' alphaNumeric 30
accountLimit <- fmap Money <$> optional' numeric 18
accountOwnerName <- alphaNumeric 35
bankName <- alphaNumeric 40
contactInformation <- optional' alphaNumeric 40
bankSpecific <- optional' alphaNumeric 30
ibanAndBic <- fmap (fmap $ T.break isSpace) $ optional' alphaNumeric 30 -- FI1234567 XXXXX
return TITORoot {..}
titoRecord :: Parser TITORecord
titoRecord = P.try (transaction' False)
<|> P.try (transactionDetail' False)
<|> P.try balance <|> P.try balance
<|> P.try summary <|> P.try summary
<|> P.try fixSummary <|> P.try correctionSummary
<|> P.try special <|> P.try bankSpecific
<|> P.try info <|> P.try message
<|> P.try (transaction' True) <|> P.try transactionNotification
<|> P.try (transactionDetail' True)
transaction' :: Bool -> Parser TITORecord transaction, transactionNotification :: Parser Event
transaction' isInformational = record (if isInformational then "80" else "10") $ do transaction = BasicTransaction <$> transaction' 0 False
transactionNumber <- numeric 6 transactionNotification = TransactionNotification <$> transaction' 0 True
archiveId <- optional' alphaNumeric 18
parsedDate <- date
valueDate <- optional 6 date
paymentDate <- optional 6 date
transactionType <- transactionType
descriptionCode <- alphaNumeric 3
descriptionText <- alphaNumeric 35
amount <- money
receiptCode <- alphaNumeric 1
transferMethod <- alphaNumeric 1
payeeName <- optional' alphaNumeric 35
nameSource <- optional 1 nameSource
recipientAccount <- optional' alphaNumeric 14
accountChanged <- optional' alphaNumeric 1
reference <- optional' numeric 20
formNumber <- optional' alphaNumeric 8
depth <- alphaNumeric 1
return Transaction {date = parsedDate, ..}
transactionDetail' :: Bool -> Parser TITORecord transaction' :: Int -> Bool -> Parser Transaction
transactionDetail' isInformational = transaction' minimumDepth isNotification = do
record' (if isInformational then "81" else "11") $ \recordLength -> do (depth, transaction) <- record (if isNotification then "80" else "10") $ do
transactionNumber <- numeric 6
archiveId <- optional' alphaNumeric 18
parsedDate <- date
valueDate <- optional 6 date
paymentDate <- optional 6 date
transactionType <- transactionType
descriptionCode <- alphaNumeric 3
descriptionText <- alphaNumeric 35
amount <- money
receiptCode <- alphaNumeric 1
transferMethod <- alphaNumeric 1
payeeName <- optional' alphaNumeric 35
nameSource <- optional 1 nameSource
recipientAccount <- optional' alphaNumeric 14
accountChanged <- optional' alphaNumeric 1
reference <- optional' numeric 20
formNumber <- optional' alphaNumeric 8
depth <- fromIntegral <$> numeric 1 <|> const 0 <$> P.char 32 -- space
guard $ depth >= minimumDepth
return (depth, Transaction {details = [], itemisation = [], date = parsedDate, ..})
details <- P.many $ P.try $ transactionDetail' isNotification
itemisation <- P.many $ P.try $ transaction' (depth + 1) isNotification
pure $ transaction {details, itemisation}
transactionDetail' :: Bool -> Parser TransactionDetail
transactionDetail' isNotification =
record' (if isNotification then "81" else "11") $ \recordLength -> do
let detailLength = recordLength - 8 let detailLength = recordLength - 8
detailType <- optional' alphaNumeric 2 detailType <- optional' alphaNumeric 2
detail <- case detailType of case detailType of
Just "00" -> do Just "00" -> do
first <- alphaNumeric 35 first <- alphaNumeric 35
rest <- P.count' 0 11 (optional' alphaNumeric 35) rest <- P.count' 0 11 (optional' alphaNumeric 35)
pure $ Freeform $ first :| catMaybes rest pure $ Freeform $ first :| catMaybes rest
Just "01" -> Number <$> numeric 8 Just "01" -> Number <$> numeric 8
Just "02" -> do Just "02" -> do
customer <- alphaNumeric 10 customer <- alphaNumeric 10
alphaNumeric 1 alphaNumeric 1
invoice <- alphaNumeric 15 invoice <- alphaNumeric 15
alphaNumeric 1 alphaNumeric 1
date <- date date <- date
pure Invoice {..} pure Invoice {..}
Just "03" -> Card <$> alphaNumeric 19 <* alphaNumeric 1 <*> optional' alphaNumeric 14 Just "03" -> Card <$> alphaNumeric 19 <* alphaNumeric 1 <*> optional' alphaNumeric 14
Just "04" -> Fix <$> alphaNumeric 18 Just "04" -> Correction <$> alphaNumeric 18
Just "05" -> do Just "05" -> do
foreignAmount <- money foreignAmount <- money
alphaNumeric 1 alphaNumeric 1
currency <- alphaNumeric 3 currency <- alphaNumeric 3
alphaNumeric 1 alphaNumeric 1
exchangeRate <- numeric 11 exchangeRate <- numeric 11
exchangeReference <- optional' alphaNumeric 6 exchangeReference <- optional' alphaNumeric 6
pure ForeignCurrency {..} pure ForeignCurrency {..}
Just "06" -> fmap Notes $ (:|) <$> alphaNumeric 35 <*> (maybeToList <$> optional' alphaNumeric 35) Just "06" -> fmap Notes $ (:|) <$> alphaNumeric 35 <*> (maybeToList <$> optional' alphaNumeric 35)
Just "07" -> do Just "07" -> do
first <- alphaNumeric 35 first <- alphaNumeric 35
rest <- P.count' 0 11 (optional' alphaNumeric 35) rest <- P.count' 0 11 (optional' alphaNumeric 35)
pure $ BankFreeform $ first :| catMaybes rest pure $ BankFreeform $ first :| catMaybes rest
Just "08" -> PaymentSubject <$> numeric 3 <* alphaNumeric 1 <*> alphaNumeric 31 Just "08" -> PaymentSubject <$> numeric 3 <* alphaNumeric 1 <*> alphaNumeric 31
Just "09" -> Name <$> alphaNumeric 35 Just "09" -> Name <$> alphaNumeric 35
Just "11" -> do Just "11" -> do
payersReference <- optional' alphaNumeric 35 payersReference <- optional' alphaNumeric 35
payeeIBAN <- optional' alphaNumeric 35 payeeIBAN <- optional' alphaNumeric 35
payeeBIC <- optional' alphaNumeric 35 payeeBIC <- optional' alphaNumeric 35
payeeName <- optional' alphaNumeric 70 payeeName <- optional' alphaNumeric 70
payerName <- optional' alphaNumeric 70 payerName <- optional' alphaNumeric 70
payerId <- optional' alphaNumeric 35 payerId <- optional' alphaNumeric 35
archiveId <- optional' alphaNumeric 35 archiveId <- optional' alphaNumeric 35
pure SEPA {..} pure SEPA {..}
_ -> Unknown <$> alphaNumeric detailLength _ -> Unknown <$> alphaNumeric detailLength
pure TransactionDetail {..}
balance :: Parser TITORecord balance :: Parser Event
balance = record "40" $ do balance = record "40" $ do
date <- date date <- date
endBalance <- money endBalance <- money
usableBalance <- optional 19 money usableBalance <- optional 19 money
pure Balance {..} pure Balance {..}
summary :: Parser TITORecord summary :: Parser Event
summary = record "50" $ do summary = record "50" $ do
period <- period period <- period
date <- date date <- date
@ -239,22 +246,22 @@ summary = record "50" $ do
withdrawalsTotal <- money withdrawalsTotal <- money
pure Summary {..} pure Summary {..}
fixSummary :: Parser TITORecord correctionSummary :: Parser Event
fixSummary = record "51" $ do correctionSummary = record "51" $ do
period <- period period <- period
date <- date date <- date
depositFixes <- numeric 8 depositCorrections <- numeric 8
depositFixesTotal <- money depositCorrectionsTotal <- money
withdrawalFixes <- numeric 8 withdrawalCorrections <- numeric 8
withdrawalFixesTotal <- money withdrawalCorrectionsTotal <- money
pure FixSummary {..} pure CorrectionSummary {..}
special :: Parser TITORecord bankSpecific :: Parser Event
special = record' "60" $ \recordLength -> do bankSpecific = record' "60" $ \recordLength -> do
BankSpecific <$> alphaNumeric 3 <*> P.takeP Nothing (recordLength - 9) BankSpecific <$> alphaNumeric 3 <*> P.takeP Nothing (recordLength - 9)
info :: Parser TITORecord message :: Parser Event
info = record' "70" $ \recordLength -> do message = record' "70" $ \recordLength -> do
bankId <- alphaNumeric 3 bankId <- alphaNumeric 3
info <- T.unlines . T.chunksOf 80 <$> alphaNumeric (recordLength - 9) message <- T.unlines . T.chunksOf 80 <$> alphaNumeric (recordLength - 9)
pure Info {..} pure Message {..}

View File

@ -8,14 +8,7 @@ import Data.Text (Text)
import Data.Time (Day, LocalTime) import Data.Time (Day, LocalTime)
import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty (NonEmpty)
data TITO = TITO data AccountStatement = AccountStatement
{ root :: TITORoot
, records :: [TITORecord]
} deriving (Show, Eq)
newtype Money = Money Integer deriving (Eq, Show)
data TITORoot = TITORoot -- T00
{ account :: Text { account :: Text
, statementNumber :: Text , statementNumber :: Text
, startDate :: Day , startDate :: Day
@ -32,64 +25,65 @@ data TITORoot = TITORoot -- T00
, contactInformation :: Maybe Text , contactInformation :: Maybe Text
, bankSpecific :: Maybe Text , bankSpecific :: Maybe Text
, ibanAndBic :: Maybe (Text, Text) , ibanAndBic :: Maybe (Text, Text)
, events :: [Event]
} deriving (Show, Eq) } deriving (Show, Eq)
data TITORecord = Transaction -- T80 if isInformational, else T10 newtype Money = Money Integer deriving (Eq, Show)
{ isInformational :: Bool
, transactionNumber :: Integer data Event = BasicTransaction Transaction -- T10
, archiveId :: Maybe Text | TransactionNotification Transaction -- T80
, date :: Day | Balance -- T40
, valueDate :: Maybe Day { date :: Day
, paymentDate :: Maybe Day , endBalance :: Money
, transactionType :: TransactionType , usableBalance :: Maybe Money
, descriptionCode :: Text }
, descriptionText :: Text | Summary -- T50
, amount :: Money { period :: Period
, receiptCode :: Text , date :: Day
, transferMethod :: Text , deposits :: Integer
, payeeName :: Maybe Text , depositsTotal :: Money
, nameSource :: Maybe NameSource , withdrawals :: Integer
, recipientAccount :: Maybe Text , withdrawalsTotal :: Money
, accountChanged :: Maybe Text }
, reference :: Maybe Integer | CorrectionSummary -- T51
, formNumber :: Maybe Text { period :: Period
, depth :: Text , date :: Day
} , depositCorrections :: Integer
| TransactionDetail -- T81 if isInformational, else T11 , depositCorrectionsTotal :: Money
{ isInformational :: Bool , withdrawalCorrections :: Integer
, detailType :: Maybe Text , withdrawalCorrectionsTotal :: Money
, detail :: TransactionDetail }
} | BankSpecific -- T60
| Balance -- T40 { bankId :: Text
{ date :: Day , rawData :: ByteString
, endBalance :: Money }
, usableBalance :: Maybe Money | Message -- T70
} { bankId :: Text
| Summary -- T50 , message :: Text
{ period :: Period }
, date :: Day deriving (Show, Eq)
, deposits :: Integer
, depositsTotal :: Money data Transaction = Transaction
, withdrawals :: Integer { transactionNumber :: Integer
, withdrawalsTotal :: Money , archiveId :: Maybe Text
} , date :: Day
| FixSummary -- T51 , valueDate :: Maybe Day
{ period :: Period , paymentDate :: Maybe Day
, date :: Day , transactionType :: TransactionType
, depositFixes :: Integer , descriptionCode :: Text
, depositFixesTotal :: Money , descriptionText :: Text
, withdrawalFixes :: Integer , amount :: Money
, withdrawalFixesTotal :: Money , receiptCode :: Text
} , transferMethod :: Text
| BankSpecific -- T60 , payeeName :: Maybe Text
{ bankId :: Text , nameSource :: Maybe NameSource
, rawData :: ByteString , recipientAccount :: Maybe Text
} , accountChanged :: Maybe Text
| Info -- T70 , reference :: Maybe Integer
{ bankId :: Text , formNumber :: Maybe Text
, info :: Text , details :: [TransactionDetail]
} , itemisation :: [Transaction]
deriving (Show, Eq) } deriving (Show, Eq)
data TransactionDetail = Freeform (NonEmpty Text) -- 00 data TransactionDetail = Freeform (NonEmpty Text) -- 00
| Number Integer -- 01 | Number Integer -- 01
@ -102,8 +96,8 @@ data TransactionDetail = Freeform (NonEmpty Text) -- 00
{ cardNumber :: Text { cardNumber :: Text
, merchantsReference :: Maybe Text , merchantsReference :: Maybe Text
} }
| Fix -- 04 | Correction -- 04
{ fixedTransaction :: Text} { correctedTransaction :: Text}
| ForeignCurrency -- 05 | ForeignCurrency -- 05
{ foreignAmount :: Money { foreignAmount :: Money
, currency :: Text , currency :: Text
@ -129,7 +123,11 @@ data TransactionDetail = Freeform (NonEmpty Text) -- 00
| Unknown Text -- all others | Unknown Text -- all others
deriving (Eq, Show) deriving (Eq, Show)
data TransactionType = Deposit | Withdrawal | DepositFix | WithdrawalFix | Declined deriving (Show, Eq) data TransactionType = Deposit
| Withdrawal
| DepositCorrection
| WithdrawalCorrection
| Declined deriving (Show, Eq)
data Period = Day | Statement | Month | Year deriving (Show, Eq) data Period = Day | Statement | Month | Year deriving (Show, Eq)