snelstart-import (empty) → 1.0.0
raw patch · 18 files changed
+973/−0 lines, 18 filesdep +basedep +base64dep +blaze-html
Dependencies added: base, base64, blaze-html, bytestring, cassava, hexml, neat-interpolation, optparse-applicative, shakespeare, snelstart-import, tasty, tasty-hunit, tasty-quickcheck, text, time, vector, warp, yesod-core, yesod-form
Files
- Changelog.md +9/−0
- LICENSE +21/−0
- Readme.md +54/−0
- app/Main.hs +7/−0
- snelstart-import.cabal +122/−0
- src/SnelstartImport.hs +52/−0
- src/SnelstartImport/Convert.hs +47/−0
- src/SnelstartImport/Currency.hs +10/−0
- src/SnelstartImport/ING.hs +108/−0
- src/SnelstartImport/N26.hs +74/−0
- src/SnelstartImport/Options.hs +54/−0
- src/SnelstartImport/SepaDirectCoreScheme.hs +86/−0
- src/SnelstartImport/Web.hs +28/−0
- src/SnelstartImport/Web/Handler.hs +149/−0
- src/SnelstartImport/Web/Layout.hs +32/−0
- src/SnelstartImport/Web/Message.hs +13/−0
- src/SnelstartImport/Web/Routes.hs +30/−0
- test/Test.hs +77/−0
+ Changelog.md view
@@ -0,0 +1,9 @@+# Change log for template project++## Version 1.0.0 ++++ Add support n26 import++ Add support SepaDirectCoreScheme++ Add a web UI++ Add CLI parser
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2022 Jappie Klooster++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ Readme.md view
@@ -0,0 +1,54 @@+[](https://jappieklooster.nl/tag/haskell.html)+[](https://github.com/jappeace/haskell-template-project/actions)+[](https://discord.gg/Hp4agqy)+[](https://hackage.haskell.org/package/template) ++> In lyk man is in ryk man++Dit is een snelstart importeer programma voor n26.++ Pas SnelstartImport.hs met je account gegevens.++ Exporteer n26 als csv en zet input.csv++ nix develop++ cabal run++ nu kun je out.csv importeren in snelstart.++Onderwater zet dit de gegevens om van n26 formaat naar+dat van ING.++Neem contact op met mij als er problemen zijn+++### Architecture++generally we go:++1. parse raw (potential partial) format such as csv or xml,+2. then go to typed intermiedatry (eg SepaDirectCoreScheme, or N26).+3. then go to ING.+4. write out ING+++#### xml parsing+choice appears to be out of+- xml conduit, which is definetly maintained: https://hackage.haskell.org/package/xml-conduit+- hexml, looks like it got an upload last year, so that's good https://hackage.haskell.org/package/hexml++### Tools+Enter the nix shell.+```+nix develop+```+You can checkout the makefile to see what's available:+```+cat makefile+```++### Running+```+make run+```++### Fast filewatch which runs tests+```+make ghcid+```
+ app/Main.hs view
@@ -0,0 +1,7 @@++module Main where++import qualified SnelstartImport++main :: IO ()+main = SnelstartImport.main
+ snelstart-import.cabal view
@@ -0,0 +1,122 @@+cabal-version: 3.0++-- This file has been generated from package.yaml by hpack version 0.34.7.+--+-- see: https://github.com/sol/hpack++name: snelstart-import+version: 1.0.0+homepage: https://github.com/jappeace/snelstart-import#readme+bug-reports: https://github.com/jappeace/snelstart-import/issues+description: Import to snelstart from various formats such as sepa direct debit or n26 bank format. Converts the format to ING csv. Has a server for an easy UI and a cli for quick conversions.+synopsis: Import to snelstart.+author: Jappie Klooster+maintainer: jappieklooster@hotmail.com+copyright: 2020 Jappie Klooster+category: Finance +license: MIT+license-file: LICENSE+build-type: Simple+extra-source-files:+ Readme.md+ LICENSE+extra-doc-files:+ Changelog.md++source-repository head+ type: git+ location: https://github.com/jappeace/snelstart-import++common common-options+ default-extensions: + EmptyCase+ FlexibleContexts+ FlexibleInstances+ InstanceSigs+ MultiParamTypeClasses+ LambdaCase+ MultiWayIf+ NamedFieldPuns+ TupleSections+ DeriveFoldable+ DeriveFunctor+ DeriveGeneric+ DeriveLift+ DeriveTraversable+ DerivingStrategies+ GeneralizedNewtypeDeriving+ StandaloneDeriving+ OverloadedStrings+ TypeApplications+ NumericUnderscores++ ghc-options:+ -O2 -Wall -Wincomplete-uni-patterns+ -Wincomplete-record-updates -Widentities -Wredundant-constraints+ -Wcpp-undef -fwarn-tabs -Wpartial-fields+ -fdefer-diagnostics -Wunused-packages+ -fenable-th-splice-warnings+ -fno-omit-yields++ build-depends:+ base >=4.9.1.0 && <5,+ cassava < 1,+ text < 3,+ time < 2, + bytestring < 1,+ vector < 1,+ neat-interpolation < 1,+ optparse-applicative < 1,+ yesod-core < 2,+ blaze-html < 1,+ yesod-form < 2,+ hexml < 1,+ warp < 4, + base64 >= 1 && < 2,+ shakespeare < 3++ default-language: Haskell2010++library+ import: common-options+ exposed-modules:+ SnelstartImport+ SnelstartImport.N26+ SnelstartImport.ING+ SnelstartImport.Convert+ SnelstartImport.Currency+ SnelstartImport.Options+ SnelstartImport.SepaDirectCoreScheme+ SnelstartImport.Web.Routes+ SnelstartImport.Web.Handler+ SnelstartImport.Web+ SnelstartImport.Web.Layout+ SnelstartImport.Web.Message+ hs-source-dirs:+ src+ other-modules:+ Paths_snelstart_import+ autogen-modules:+ Paths_snelstart_import++executable snelstart-import+ import: common-options+ main-is: Main.hs+ hs-source-dirs:+ app+ build-depends:+ snelstart-import+ ghc-options: -Wno-unused-packages++test-suite unit+ import: common-options+ type: exitcode-stdio-1.0+ main-is: Test.hs+ ghc-options: -Wno-unused-packages+ hs-source-dirs:+ test+ build-depends:+ tasty,+ tasty-hunit,+ tasty-quickcheck,+ snelstart-import
+ src/SnelstartImport.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE RecordWildCards #-}++module SnelstartImport+ ( main,+ )+where++import qualified Data.ByteString.Lazy as BS+import SnelstartImport.ING+import SnelstartImport.N26+import Data.Vector(toList)+import SnelstartImport.Options+import Paths_snelstart_import (version)+import Options.Applicative+import Text.Printf+import Data.Version (showVersion)+import SnelstartImport.Web+import SnelstartImport.Convert++currentVersion :: String+currentVersion = showVersion version++readSettings :: IO (ProgramOptions)+readSettings = customExecParser (prefs showHelpOnError) $ info+ (helper <*> parseProgram)+ (fullDesc <> header (printf "Snelstart importer %s" currentVersion) <> progDesc+ "Converts various banks and programs to something snelstart understands"+ )+++main :: IO ()+main = do+ putStrLn ""+ putStrLn "starting snelstart import"+ putStrLn ""++ settings <- readSettings+ case settings of+ Convert cli -> convertCli cli+ Webserver options -> webMain options++convertCli :: CliOptions -> IO ()+convertCli options = do+ result <- readN26 (cliInputFile options)+ case result of+ Left x -> error x+ Right n26Vec -> BS.writeFile (cliOutputFile options) $ let+ n26 :: [ING]+ n26 = n26ToING (cliOwnAccount options) <$> toList n26Vec+ in+ writeCsv n26
+ src/SnelstartImport/Convert.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE RecordWildCards #-}++-- | Go from various formats to snelstart (ing)+module SnelstartImport.Convert+ ( n26ToING,+ sepaDirectCoreSchemeToING+ )+where++import SnelstartImport.ING+import SnelstartImport.N26+import Data.Text(Text)+import SnelstartImport.SepaDirectCoreScheme (SepaDirectCoreScheme(..))+import Data.Time++sepaDirectCoreSchemeToING :: Text -> SepaDirectCoreScheme -> ING+sepaDirectCoreSchemeToING ownAccoun SepaDirectCoreScheme{..} = ING{+ datum = UTCTime{ utctDay = dtOfSgntr, utctDayTime = 0},+ naamBescrhijving = dbtr,+ rekening = ownAccoun,+ tegenRekening = dbtrAcct,+ mutatieSoort = Overschijving, -- TODO how can we figure this out?+ bijAf = Af, -- TODO looks like it only deducts from the account, is this right?+ bedragEur = instdAmt ,+ mededeling = ""+ }++n26ToING :: Text -> N26 -> ING+n26ToING ownAccoun N26{..} = ING {+ datum = unDate date,+ naamBescrhijving = payee,+ rekening = ownAccoun,+ tegenRekening = accountNumber,+ mutatieSoort = toType transactionType , -- eg ook voor code+ bijAf = if amountEur < 0 then Af else Bij,+ bedragEur = abs amountEur ,+ mededeling = paymentReference+ }++toType :: TransactionType -> MutatieSoort+toType = \case+ MastercardPayment -> Diversen+ OutgoingTransfer -> Overschijving+ Income -> Overschijving+ N26Referal -> Diversen+ DirectDebit -> Overschijving
+ src/SnelstartImport/Currency.hs view
@@ -0,0 +1,10 @@+{-# LANGUAGE DeriveAnyClass #-}+module SnelstartImport.Currency+ ( Currency(..)+ )+where++import Data.Csv++newtype Currency = Currency Double+ deriving newtype (Ord, Show, Eq, FromField, Num, Fractional)
+ src/SnelstartImport/ING.hs view
@@ -0,0 +1,108 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE QuasiQuotes #-}++-- | We target the ING bank as the output format.+-- "snelstart" understands this csv format+module SnelstartImport.ING+ ( ING(..),+ toCode,+ writeCsv,+ MutatieSoort (..),+ BijAf(..)+ )+where++import SnelstartImport.Currency+import Data.Text+import Data.Text.Encoding+import Data.Csv+import Data.Time+import GHC.Generics+import qualified Data.Vector as Vector+import NeatInterpolation(text)+import qualified Data.ByteString.Lazy as LBS++data MutatieSoort = Overschijving | Diversen | Incasso+ deriving Show++mutatieSoortToFIeld :: MutatieSoort -> Text+mutatieSoortToFIeld = \case+ Overschijving -> "Overschrijving"+ Diversen -> "Diversen"+ Incasso -> "Incasso"++toCode :: MutatieSoort -> Text+toCode = \case+ Overschijving -> "OV"+ Diversen -> "DV"+ Incasso -> "IC"++data BijAf = Bij | Af+ deriving Show++bijAfToField :: BijAf -> Text+bijAfToField = \case+ Bij -> "Bij"+ Af -> "Af"+++data ING = ING {+ datum :: UTCTime,+ naamBescrhijving :: Text,+ rekening :: Text,+ tegenRekening :: Text,+ mutatieSoort :: MutatieSoort, -- eg ook voor code+ bijAf :: BijAf,+ bedragEur :: Currency ,+ mededeling :: Text+ }+ deriving stock (Generic, Show)++instance ToRecord ING where+ toRecord (ING{..}) =+ Vector.fromList+ [ toField $ formatTime defaultTimeLocale "%Y%m%d" datum+ , toField naamBescrhijving+ , toField rekening+ , toField tegenRekening+ , toField $ toCode mutatieSoort+ , toField $ bijAfToField bijAf+ , toField $ replaceDotWithComma <$> Prelude.show bedragEur+ , toField $ mutatieSoortToFIeld mutatieSoort+ , toField mededeling+ ]++data TransactionType = MastercardPayment | OutgoingTransfer | Income | N26Referal | DirectDebit+ deriving stock (Show, Eq)++replaceDotWithComma :: Char -> Char+replaceDotWithComma '.' = ','+replaceDotWithComma other = other++instance FromField TransactionType where+ parseField field = case decodeUtf8 field of+ "MasterCard Payment" -> pure MastercardPayment+ "Outgoing Transfer" -> pure OutgoingTransfer+ "Income" -> pure Income+ "N26 Referral" -> pure N26Referal+ "Direct Debit" -> pure DirectDebit+ other -> fail $ "TransactionType unkown" <> unpack other++newtype Date = Date UTCTime+ deriving newtype (Show, Eq, Read)++instance FromField Date where+ parseField field =+ fmap Date $ parseTimeM True defaultTimeLocale "%Y-%m-%d" $ unpack $ decodeUtf8 field+++writeCsv :: [ING] -> LBS.ByteString+writeCsv lines' = LBS.fromStrict header' <> data'+ where+ data' :: LBS.ByteString+ data' = encodeWith opts lines'+ header' = encodeUtf8 $ [text|"Datum","Naam / Omschrijving","Rekening","Tegenrekening","Code","Af Bij","Bedrag (EUR)","Mutatiesoort","Mededelingen"|] <> "\n"++opts :: EncodeOptions+opts = defaultEncodeOptions { encQuoting = QuoteAll}
+ src/SnelstartImport/N26.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE DeriveAnyClass #-}+-- | this is whatever N26 spits out, it's some custom csv format.+-- it appears unstable. (has changed once before)+module SnelstartImport.N26+ ( readN26,+ readN26BS,+ N26 (..),+ TransactionType(..),+ Date(..)+ )+where++import SnelstartImport.Currency+import Data.Text+import Data.Text.Encoding+import Data.Csv+import Data.Time+import GHC.Generics+import qualified Data.ByteString.Lazy as BS+import Data.Vector++data MutatieSoort = Overschijving | Diversen | Incasso+ deriving Show++data BijAf = Bij | Af+ deriving Show++data TransactionType = MastercardPayment | OutgoingTransfer | Income | N26Referal | DirectDebit+ deriving stock (Show, Eq)+++instance FromField TransactionType where+ parseField field = case decodeUtf8 field of+ "Presentment" -> pure MastercardPayment+ "Debit Transfer" -> pure OutgoingTransfer+ "Income" -> pure Income+ "Credit Transfer" -> pure Income+ "Presentment Refund" -> pure Income+ "Reward" -> pure N26Referal+ "Direct Debit" -> pure DirectDebit+ other -> fail $ "TransactionType unkown" <> unpack other++newtype Date = Date { unDate :: UTCTime }+ deriving newtype (Show, Eq, Read)++instance FromField Date where+ parseField field =+ fmap Date $ parseTimeM True defaultTimeLocale "%Y-%m-%d" $ unpack $ decodeUtf8 field++data N26 = N26 {+ date :: Date, -- booking date+ valueDate :: Date,+ payee :: Text, -- partner name+ accountNumber :: Text, -- partner iban+ transactionType :: TransactionType,+ paymentReference :: Text,+ accountName :: Text,+ amountEur :: Currency , -- amount (eur)+ amountForegin :: Maybe Currency , -- original amount+ typeForeign :: Text, -- original currency+ exchangeRate :: Text+ }+ deriving stock (Generic, Show, Eq)+ deriving anyclass FromRecord+++readN26 :: FilePath -> IO (Either String (Vector N26))+readN26 path = do+ lines' <- BS.readFile path+ pure $ readN26BS lines'+++readN26BS :: BS.ByteString -> Either String (Vector N26)+readN26BS = decode HasHeader
+ src/SnelstartImport/Options.hs view
@@ -0,0 +1,54 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE RecordWildCards #-}++-- | deal with cli options+module SnelstartImport.Options+ ( parseProgram,+ CliOptions(..),+ ProgramOptions (..),+ WebOptions(..)+ )+where++import Data.Text+import Options.Applicative++data CliOptions = CliOptions {+ cliOwnAccount :: Text+ , cliInputFile :: FilePath+ , cliOutputFile :: FilePath+ }++data WebOptions = WebOptions {+ webPort :: Int+ }++data ProgramOptions = Convert CliOptions+ | Webserver WebOptions++parseWebOptions :: Parser WebOptions+parseWebOptions = do+ webPort <- (option+ auto+ ( long "port"+ <> help+ "port to bind on"+ <> value 3005+ )+ )+ pure $ WebOptions{..}++parseCli :: Parser CliOptions+parseCli = do+ cliInputFile <- option str (short 'i' <> long "input-file" <> metavar "FILE" <> help "The input file" <> value "input.csv" <> showDefault)+ cliOutputFile <- option str (short 'o' <> long "output-file" <> metavar "FILE" <> help "The output file" <> value "out.csv" <> showDefault)+ -- default is set for my own use+ cliOwnAccount <- option str (short 'a' <> long "own-account" <> metavar "ACCOUNT" <> help "The account written into" <> value "DE92100110012623092722" <> showDefault)+ pure $ CliOptions {..}++parseProgram :: Parser ProgramOptions+parseProgram =+ subparser $+ command "convert" (info (Convert <$> parseCli <**> helper) $ progDesc "Convert from cli to snelstart format")+ <>+ command "server" (info (Webserver <$> parseWebOptions <**> helper) $ progDesc "Start a server to give a UI to do conversion")
+ src/SnelstartImport/SepaDirectCoreScheme.hs view
@@ -0,0 +1,86 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE RecordWildCards #-}++-- | https://www.europeanpaymentscouncil.eu/sites/default/files/kb/file/2022-06/EPC130-08%20SDD%20Core%20C2PSP%20IG%202023%20V1.0.pdf+-- this is some xml format the accountents asked support for+module SnelstartImport.SepaDirectCoreScheme+ ( SepaDirectCoreScheme(..)+ , readSepaDirectCoreScheme+ )+where++import SnelstartImport.Currency+import Text.XML.Hexml(parse, children, name, Node, inner)+import Data.Text (Text)+import Data.ByteString (ByteString)+import Data.Text.Encoding (decodeUtf8)+import qualified Data.Text as Text+import Data.List+import Data.Bifunctor(first)+import Text.Read(readMaybe)+import Data.Time(Day, parseTimeM, defaultTimeLocale)++data SepaDirectCoreScheme = SepaDirectCoreScheme {+ -- -- | Unambiguous identification of the account of the+ -- -- creditor to which a credit entry will be posted as a+ -- -- result of the payment transaction.+ -- cdtrAcct :: Text,+ endToEndId :: Text,+ dbtrAcct :: Text,+ dbtr :: Text,+ instdAmt :: Currency,+ dtOfSgntr :: Day+ } deriving Show++name_ :: Node -> Text+name_ = Text.toLower . decodeUtf8 . name++dig :: Text -> Node -> [Node]+dig tag parent = filter (\x -> name_ x == Text.toLower tag) $ children parent++data SepaParseErrors = ParseXmlError ByteString+ | SepaParseIssues SepaIssues+ deriving Show++readSepaDirectCoreScheme :: ByteString -> Either SepaParseErrors [SepaDirectCoreScheme]+readSepaDirectCoreScheme contents = do+ nodeRes <- first ParseXmlError $ parse contents++ traverse (first SepaParseIssues . parseSepa) (((dig "DrctDbtTxInf")) =<< ((dig "PmtInf") =<< ((dig "CstmrDrctDbtInitn") =<< dig "document" nodeRes)))+--++data SepaIssues = ExpectedOne [Node] Text+ | ExpectedNumber Node Text+ | ExpectedDate Node Text+ deriving Show+++assertOne :: Text -> [Node] -> Either SepaIssues Node+assertOne label nodes =+ case uncons nodes of+ Just (x, _) -> Right x+ Nothing -> Left (ExpectedOne nodes label)++inner_ :: Node -> Text+inner_ = decodeUtf8 . inner++parseCurrency :: Node -> Either SepaIssues Currency+parseCurrency node = case readMaybe (Text.unpack (inner_ node)) of+ Just number -> Right $ Currency number+ Nothing -> Left (ExpectedNumber node (inner_ node))++parseDay :: Node -> Either SepaIssues Day+parseDay node = case parseTimeM True defaultTimeLocale "%F" (Text.unpack (inner_ node)) of+ Nothing -> Left $ ExpectedDate node (inner_ node)+ Just day -> Right day++parseSepa :: Node -> Either SepaIssues SepaDirectCoreScheme+parseSepa node = do+ dbtr <- inner_ <$> assertOne "dbtr" (dig "nm" =<< dig "dbtr" node)+ dbtrAcct <- inner_ <$> assertOne "dbtracct" (dig "IBAN" =<< dig "Id" =<< dig "DbtrAcct" node)+ endToEndId <- inner_ <$> assertOne "endToEndId" (dig "EndToEndId" =<< dig "PmtId" node)+ instdAmt <- parseCurrency =<< assertOne "instdAmt" (dig "instdAmt" node)+ dtOfSgntr <- parseDay =<< assertOne "dtOfSgntr" (dig "DtOfSgntr" =<< dig "MndtRltdInf" =<< dig "DrctDbtTx" node)+ Right $ SepaDirectCoreScheme {+ ..+ }
+ src/SnelstartImport/Web.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TypeFamilies #-}+{-# OPTIONS_GHC -Wno-orphans #-}+++-- | small web frontend+module SnelstartImport.Web+ ( webMain+ )+where++import SnelstartImport.Options+import Yesod.Core.Dispatch(mkYesodDispatch)+import Yesod.Core(toWaiApp)+import Network.Wai.Handler.Warp(run)+import SnelstartImport.Web.Routes+import SnelstartImport.Web.Handler++mkYesodDispatch "App" resourcesApp+++webMain :: WebOptions -> IO ()+webMain options = do+ putStrLn $ "listening on port " <> show (webPort options)+ waiApp <- toWaiApp App+ run (webPort options) waiApp
+ src/SnelstartImport/Web/Handler.hs view
@@ -0,0 +1,149 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TypeFamilies #-}++-- | handlers+module SnelstartImport.Web.Handler+ ( getRootR+ , postRootR+ )+where++import Data.Vector(toList)+import qualified Data.ByteString.Lazy as LBS+import SnelstartImport.Convert+import SnelstartImport.ING+import SnelstartImport.Web.Routes+import SnelstartImport.N26+import Text.Blaze.Html(Html)+import Yesod.Form+import Yesod.Core.Widget+import Yesod.Core.Handler+import Data.Text(Text, pack)+import Data.Text.Encoding+import Data.ByteString.Base64+import Data.Base64.Types(extractBase64)+import qualified Data.Text as Text+import SnelstartImport.SepaDirectCoreScheme(readSepaDirectCoreScheme)+import SnelstartImport.Web.Layout(layout)+import Yesod.Core(lucius)+import SnelstartImport.Web.Message+import Data.Time+import Control.Monad.IO.Class(liftIO)+++type Form a = Html -> MForm Handler (FormResult a, Widget)+++data InputFileForm = InputFileForm {+ ifBank :: Text ,+ ifFileInfo :: FileInfo+ }++inputFileForm :: Form InputFileForm+inputFileForm csrf = do+ (bankRes, bankView) <- mreq textField "own bank account" Nothing+ (inputRes, inputView) <- mreq fileField "xml file" Nothing++ let view = do+ toWidget [lucius|+ form label {+ width: 100%;+ display: inline-block;+ }+ form input{+ margin-bottom: 1em;+ }+ |]+ [whamlet|+ ^{csrf}+ <div>+ <label for=#{fvId bankView}>_{MsgOwnBank}+ ^{fvInput bankView}+ <div>+ <label for=#{fvId bankView}>_{MsgXmlFile}+ ^{fvInput inputView}+ <div>+ <button type=submit >_{MsgConvert}+ |]+ pure $ (InputFileForm <$> bankRes <*> inputRes, view)++getRootR :: Handler Html+getRootR = do+ ((_res, form), enctype) <- runFormPost inputFileForm+ layout $ inputForm [] enctype form++inputForm :: [Text] -> Enctype -> Widget -> Widget+inputForm issues enctype form =+ let+ issuesWidget = if Prelude.null issues then "" else+ [whamlet|+ <ul>+ $forall issue <- issues+ <li> #{issue}+ |]+ in++ [whamlet|+<h1>_{MsgTitle}+^{issuesWidget}+<form method=post enctype=#{enctype}>+ ^{form}+|]++postRootR :: Handler Html+postRootR = do+ ((res, form), enctype) <- runFormPost inputFileForm++ case res of+ FormMissing -> layout $ inputForm ["error - missing data"] enctype form+ FormFailure x -> layout $ inputForm x enctype form+ FormSuccess formRes -> do+ contents <- fileSourceByteString $ ifFileInfo formRes+ let filename = fileName $ ifFileInfo formRes+ if Text.isSuffixOf "xml" filename then+ case readSepaDirectCoreScheme contents of+ Left err -> layout $ inputForm [pack $ show err] enctype form+ Right res' -> renderDownload formRes (sepaDirectCoreSchemeToING (ifBank formRes) <$> res')+ else case readN26BS $ LBS.fromStrict contents of+ Left err -> layout $ inputForm [pack err] enctype form+ Right n26 -> renderDownload formRes (n26ToING (ifBank formRes) <$> toList n26)++renderDownload :: InputFileForm -> [ING] -> Handler Html+renderDownload form ings =+ let+ csvOut = (writeCsv ings)+ contentText = decodeUtf8 $ LBS.toStrict csvOut+ downloadText = "data:text/plain;base64," <> (extractBase64 $ encodeBase64 $ LBS.toStrict csvOut)+ in+ layout $ do+ curTime <- liftIO getCurrentTime+ let+ timeStr :: String+ timeStr = formatTime defaultTimeLocale "%F_%H-%M" curTime+ downloadFileName = "snelstart-import-" <> timeStr <> ".csv"+ toWidget [lucius|+ pre {+ overflow: scroll;+ width: 100%;+ position: absolute;+ left: 0;+ background-color: lightgray;+ padding: 1em;+ }+ |]+ [whamlet|+ <table>+ <tr>+ <th>_{MsgBank}+ <td>#{ifBank form }+ <tr>+ <th>_{MsgFilename}+ <td>#{fileName $ ifFileInfo form }++ <h2>_{MsgContents}+ <a href=#{downloadText} download=#{downloadFileName}>_{MsgDownload}+ <pre>+ <code>+ #{contentText}+ |]
+ src/SnelstartImport/Web/Layout.hs view
@@ -0,0 +1,32 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TypeFamilies #-}++-- | layout, deal with styling+module SnelstartImport.Web.Layout+ ( layout+ )+where++import SnelstartImport.Web.Routes+import Text.Blaze.Html(Html)+import Yesod.Core.Widget+import Yesod.Core(defaultLayout, lucius)++layout :: Widget -> Handler Html+layout widget = do+ defaultLayout $ do+ toWidget [lucius|+ html {+ text-align: center;+ }+ body {+ text-align: left;+ margin: 0 auto;+ width: 50%;+ max-width: 25em;+ padding: 1em;+ }+ |]+ widget
+ src/SnelstartImport/Web/Message.hs view
@@ -0,0 +1,13 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TypeFamilies #-}++-- | translations+module SnelstartImport.Web.Message where+++import Text.Shakespeare.I18N+import SnelstartImport.Web.Routes++mkMessage "App" "i18n" "en"
+ src/SnelstartImport/Web/Routes.hs view
@@ -0,0 +1,30 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}++-- | routes for the app, seperated due to stage restriction+module SnelstartImport.Web.Routes+ (+ resourcesApp,+ App(..),+ Handler,+ Route(..),+ Widget+ )+where++import Yesod.Form.Fields(FormMessage(..), defaultFormMessage)+import Yesod.Core++data App = App++instance RenderMessage App FormMessage where+ renderMessage _ _ = defaultFormMessage++instance RenderRoute App => Yesod App++mkYesodData "App" [parseRoutes|+/ RootR GET POST+|]
+ test/Test.hs view
@@ -0,0 +1,77 @@+module Main where++import qualified Data.Vector as Vector+import SnelstartImport.N26+import Test.Tasty+import Test.Tasty.HUnit++main :: IO ()+main = defaultMain tests++tests :: TestTree+tests = testGroup "Tests" [unitTests]++unitTests :: TestTree+unitTests =+ testGroup+ "Unit tests"+ [ testCase "List comparison (different length)" $ do+ result <- readN26 "n26-v2.csv"+ result+ @?= Right+ ( Vector.fromList+ [ N26+ { date = read "2024-07-10 00:00:00 UTC"+ , valueDate = read "2024-07-10 00:00:00 UTC"+ , payee = "MY COMPUTER"+ , accountNumber = ""+ , transactionType = MastercardPayment+ , paymentReference = ""+ , accountName = "Main Account"+ , amountEur = -206.27+ , amountForegin = Just 222.85+ , typeForeign = "USD"+ , exchangeRate = "0.9256001795"+ }+ , N26+ { date = read "2024-07-15 00:00:00 UTC"+ , valueDate = read "2024-07-15 00:00:00 UTC"+ , payee = "JT Klooster"+ , accountNumber = "NL74INGB0798449241"+ , transactionType = OutgoingTransfer+ , paymentReference = ""+ , accountName = "Main Account"+ , amountEur = -800.0+ , amountForegin = Nothing+ , typeForeign = ""+ , exchangeRate = ""+ }+ , N26+ { date = read "2024-07-17 00:00:00 UTC"+ , valueDate = read "2024-07-17 00:00:00 UTC"+ , payee = "Oeverture belasting advies b.v."+ , accountNumber = "NL90RABO0136021050"+ , transactionType = OutgoingTransfer+ , paymentReference = ""+ , accountName = "Main Account"+ , amountEur = -1185.8+ , amountForegin = Nothing+ , typeForeign = ""+ , exchangeRate = ""+ }+ , N26+ { date = read "2024-07-17 00:00:00 UTC"+ , valueDate = read "2024-07-17 00:00:00 UTC"+ , payee = "Administratiekantoor De Wilde via Stichting Mollie Payments"+ , accountNumber = "NL51DEUT0265262461"+ , transactionType = OutgoingTransfer+ , paymentReference = "c37f91dbcae0a3e00b91496426f3c954 8152518244016285 20240754 Administratiekantoor De Wilde"+ , accountName = "Main Account"+ , amountEur = -656.43+ , amountForegin = Nothing+ , typeForeign = ""+ , exchangeRate = ""+ }+ ]+ )+ ]