packages feed

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 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](https://img.shields.io/badge/blog-jappieklooster.nl-lightgrey)](https://jappieklooster.nl/tag/haskell.html)+[![Github actions build status](https://img.shields.io/github/workflow/status/jappeace/haskell-template-project/Test)](https://github.com/jappeace/haskell-template-project/actions)+[![Jappiejappie](https://img.shields.io/badge/discord-jappiejappie-black?logo=discord)](https://discord.gg/Hp4agqy)+[![Hackage version](https://img.shields.io/hackage/v/template.svg?label=Hackage)](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 = ""+                            }+                        ]+                    )+        ]