Ensimmäinen versio
This commit is contained in:
		
						commit
						ff5137d7d7
					
				| 
						 | 
					@ -0,0 +1,99 @@
 | 
				
			||||||
 | 
					(define-module (laskutin-package)
 | 
				
			||||||
 | 
					  #:use-module (guix)
 | 
				
			||||||
 | 
					  #:use-module (guix build-system haskell)
 | 
				
			||||||
 | 
					  #:use-module ((guix licenses) #:prefix license:)
 | 
				
			||||||
 | 
					  #:use-module (guix packages)
 | 
				
			||||||
 | 
					  #:use-module (gnu packages haskell)
 | 
				
			||||||
 | 
					  #:use-module (gnu packages haskell-check)
 | 
				
			||||||
 | 
					  #:use-module (gnu packages haskell-xyz))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					(define-public laskutin
 | 
				
			||||||
 | 
					  (package
 | 
				
			||||||
 | 
					    (name "laskutin")
 | 
				
			||||||
 | 
					    (version "0.0.1")
 | 
				
			||||||
 | 
					    (source (local-file "." "laskutin-checkout"
 | 
				
			||||||
 | 
					                        #:recursive? #t))
 | 
				
			||||||
 | 
					    (build-system haskell-build-system)
 | 
				
			||||||
 | 
					    (inputs (list ghc-cassava
 | 
				
			||||||
 | 
					                  ghc-lens
 | 
				
			||||||
 | 
					                  ghc-optparse-applicative
 | 
				
			||||||
 | 
					                  ghc-purebred-email))
 | 
				
			||||||
 | 
					    (home-page "https://git.datat.fi/ry/laskutin")
 | 
				
			||||||
 | 
					    (synopsis "Simple mass invoicing program based on email and CSV")
 | 
				
			||||||
 | 
					    (description "Laskutin is a simple program for sending and managing lots
 | 
				
			||||||
 | 
					invoices. Invoices are defined as rows in a CSV file and all state is stored
 | 
				
			||||||
 | 
					back into the file.")
 | 
				
			||||||
 | 
					    (license license:agpl3+)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					;;; ghc-purebred-email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					(define ghc-concise
 | 
				
			||||||
 | 
					  (package
 | 
				
			||||||
 | 
					   (name "ghc-concise")
 | 
				
			||||||
 | 
					   (version "0.1.0.1")
 | 
				
			||||||
 | 
					   (source (origin
 | 
				
			||||||
 | 
					             (method url-fetch)
 | 
				
			||||||
 | 
					             (uri (hackage-uri "concise" version))
 | 
				
			||||||
 | 
					             (sha256
 | 
				
			||||||
 | 
					               (base32
 | 
				
			||||||
 | 
					                 "09crgc6gjfidlad6263253xx1di6wfhc9awhira21s0z7rddy9sw"))))
 | 
				
			||||||
 | 
					   (build-system haskell-build-system)
 | 
				
			||||||
 | 
					   (properties '((upstream-name . "concise")))
 | 
				
			||||||
 | 
					   (inputs (list ghc-lens))
 | 
				
			||||||
 | 
					   (native-inputs (list ghc-tasty ghc-tasty-quickcheck ghc-quickcheck
 | 
				
			||||||
 | 
					                        ghc-quickcheck-instances))
 | 
				
			||||||
 | 
					   (home-page "https://github.com/frasertweedale/hs-concise")
 | 
				
			||||||
 | 
					   (synopsis "Utilities for Control.Lens.Cons")
 | 
				
			||||||
 | 
					   (description
 | 
				
			||||||
 | 
					     "concise provides a handful of functions to extend what you can do with
 | 
				
			||||||
 | 
					 Control.Lens.Cons.")
 | 
				
			||||||
 | 
					     (license license:bsd-3)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					(define ghc-purebred-email
 | 
				
			||||||
 | 
					  (package
 | 
				
			||||||
 | 
					   (name "ghc-purebred-email")
 | 
				
			||||||
 | 
					   (version "0.6.0.1")
 | 
				
			||||||
 | 
					   (source (origin
 | 
				
			||||||
 | 
					             (method url-fetch)
 | 
				
			||||||
 | 
					             (uri (hackage-uri "purebred-email" version))
 | 
				
			||||||
 | 
					             (sha256
 | 
				
			||||||
 | 
					               (base32
 | 
				
			||||||
 | 
					                 "0gdzdbxgsyps8hqd903bsyja0cr6kbklkicvma62q48wv0y7230j"))))
 | 
				
			||||||
 | 
					   (build-system haskell-build-system)
 | 
				
			||||||
 | 
					   (properties '((upstream-name . "purebred-email")))
 | 
				
			||||||
 | 
					   (inputs (list ghc-attoparsec
 | 
				
			||||||
 | 
					                 ghc-case-insensitive
 | 
				
			||||||
 | 
					                 ghc-lens
 | 
				
			||||||
 | 
					                 ghc-base64-bytestring
 | 
				
			||||||
 | 
					                 ghc-concise
 | 
				
			||||||
 | 
					                 ghc-random
 | 
				
			||||||
 | 
					                 ghc-semigroupoids
 | 
				
			||||||
 | 
					                 ghc-stringsearch))
 | 
				
			||||||
 | 
					   (native-inputs (list ghc-tasty
 | 
				
			||||||
 | 
					                        ghc-tasty-hedgehog
 | 
				
			||||||
 | 
					                        ghc-tasty-quickcheck
 | 
				
			||||||
 | 
					                        ghc-tasty-hunit
 | 
				
			||||||
 | 
					                        ghc-tasty-golden
 | 
				
			||||||
 | 
					                        ghc-hedgehog
 | 
				
			||||||
 | 
					                        ghc-quickcheck-instances))
 | 
				
			||||||
 | 
					   (home-page "https://github.com/purebred-mua/purebred-email")
 | 
				
			||||||
 | 
					   (synopsis "types and parser for email messages (including MIME)")
 | 
				
			||||||
 | 
					   (description
 | 
				
			||||||
 | 
					     "The purebred email library.  RFC 5322, MIME, etc.  See \"Data.MIME\" for usage,
 | 
				
			||||||
 | 
					examples and API documentation. .  This is a general-purpose library for
 | 
				
			||||||
 | 
					processing and constructing email messages, originally written to meet the needs
 | 
				
			||||||
 | 
					of <https://github.com/purebred-mua/purebred purebred MUA>.  Transmission and
 | 
				
			||||||
 | 
					delivery of mail are not part of this library, but /purebred-email/ could be a
 | 
				
			||||||
 | 
					useful building block for such systems. .  Features and implemented
 | 
				
			||||||
 | 
					specifications include: . - <https://tools.ietf.org/html/rfc5322 RFC 5322>
 | 
				
			||||||
 | 
					message parsing and serialisation - MIME multipart messages
 | 
				
			||||||
 | 
					(<https://tools.ietf.org/html/rfc2046 RFC 2046>) - Convenient APIs for replying
 | 
				
			||||||
 | 
					and forward/bounce - Content transfer and charset decoding/encoding - MIME
 | 
				
			||||||
 | 
					message header extensions for non-ASCII text
 | 
				
			||||||
 | 
					(<https://tools.ietf.org/html/rfc2047 RFC 2047>) - MIME parameter value and
 | 
				
			||||||
 | 
					encoded word extensions (<https://tools.ietf.org/html/rfc2231 RFC 2231>) -
 | 
				
			||||||
 | 
					@@Content-Disposition@@ header field (<https://tools.ietf.org/html/rfc2183 RFC 2183>) - Address syntax in @@From@@ and @@Sender@@ fields
 | 
				
			||||||
 | 
					     (<https://tools.ietf.org/html/rfc6854 RFC 6854>)")
 | 
				
			||||||
 | 
					     (license license:agpl3+)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					laskutin
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,62 @@
 | 
				
			||||||
 | 
					{-# LANGUAGE DuplicateRecordFields #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE NamedFieldPuns #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE RecordWildCards #-}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Laskutin where
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Control.Concurrent (threadDelay)
 | 
				
			||||||
 | 
					import Control.Monad (forM_, forM)
 | 
				
			||||||
 | 
					import Data.Csv (encodeByName)
 | 
				
			||||||
 | 
					import Data.List.NonEmpty (nonEmpty)
 | 
				
			||||||
 | 
					import Data.MIME (Address, renderAddresses)
 | 
				
			||||||
 | 
					import Data.Maybe (catMaybes, mapMaybe)
 | 
				
			||||||
 | 
					import Data.Text.Encoding (decodeUtf8)
 | 
				
			||||||
 | 
					import System.Exit (exitFailure)
 | 
				
			||||||
 | 
					import System.IO (stderr, hPutStrLn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import qualified Data.ByteString.Lazy as LBS
 | 
				
			||||||
 | 
					import qualified Data.Text.IO as T
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Laskutin.CSV
 | 
				
			||||||
 | 
					import Laskutin.Email
 | 
				
			||||||
 | 
					import Laskutin.Options
 | 
				
			||||||
 | 
					import Laskutin.Types
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					main :: IO ()
 | 
				
			||||||
 | 
					main = do
 | 
				
			||||||
 | 
					    Options {csv, command} <- parseOptions
 | 
				
			||||||
 | 
					    case command of
 | 
				
			||||||
 | 
					      Send sendOptions -> sendInvoicesMain csv sendOptions
 | 
				
			||||||
 | 
					      UpdateTable bankCsv -> updateTableMain csv bankCsv
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sendInvoicesMain :: FilePath -> SendOptions -> IO ()
 | 
				
			||||||
 | 
					sendInvoicesMain csvPath options@SendOptions {email, sendmail} = do
 | 
				
			||||||
 | 
					    invoices <- readInvoices csvPath options
 | 
				
			||||||
 | 
					    forM_ invoices $ \invoice@(address, _) -> do
 | 
				
			||||||
 | 
					        sendEmail sendmail $ uncurry (renderEmail [email]) invoice
 | 
				
			||||||
 | 
					        putStr "Lähetetty osoitteeseen: "
 | 
				
			||||||
 | 
					        T.putStrLn $ decodeUtf8 $ renderAddresses address
 | 
				
			||||||
 | 
					        threadDelay (3 * 1000 * 1000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					updateTableMain :: FilePath -> FilePath -> IO ()
 | 
				
			||||||
 | 
					updateTableMain invoiceCsv transactionCsv = do
 | 
				
			||||||
 | 
					    (headers, csvInvoices) <- parseCsvFile invoiceCsv
 | 
				
			||||||
 | 
					    (_, csvTransactions) <- parseCsvFile transactionCsv
 | 
				
			||||||
 | 
					    let newInvoices = fmap (updateInvoices csvTransactions) csvInvoices
 | 
				
			||||||
 | 
					    LBS.writeFile invoiceCsv $ encodeByName headers newInvoices
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					updateInvoices :: [CsvTransaction] -> CsvInvoice -> CsvInvoice
 | 
				
			||||||
 | 
					updateInvoices transactions invoice@CsvInvoice {isPaid, reference}
 | 
				
			||||||
 | 
					  | isPaid = invoice 
 | 
				
			||||||
 | 
					  | otherwise = invoice {isPaid = reference `elem` transactionReferences}
 | 
				
			||||||
 | 
					      where transactionReferences = catMaybes $ transactionReference <$> transactions
 | 
				
			||||||
 | 
					            transactionReference CsvTransaction {referenceOrMessage = Message _} = Nothing
 | 
				
			||||||
 | 
					            transactionReference CsvTransaction {referenceOrMessage = Reference ref} = Just ref
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					readInvoices :: FilePath -> SendOptions -> IO [([Address], Invoice)]
 | 
				
			||||||
 | 
					readInvoices csv SendOptions {account, recipient, subject, message, due} = do
 | 
				
			||||||
 | 
					    (_, csvInvoices) <- parseCsvFile csv
 | 
				
			||||||
 | 
					    forM csvInvoices $ \CsvInvoice {..} -> do
 | 
				
			||||||
 | 
					        invoiceRows <- maybe (hPutStrLn stderr ("No invoice rows in invoice") >> exitFailure) pure $
 | 
				
			||||||
 | 
					            nonEmpty $ mapMaybe invoiceRowFromCsv rows
 | 
				
			||||||
 | 
					        pure ([invoiceRecipient], Invoice { rows = invoiceRows, ..})
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,123 @@
 | 
				
			||||||
 | 
					{-# LANGUAGE OverloadedStrings #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE DisambiguateRecordFields #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE DuplicateRecordFields #-}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Laskutin.CSV (CsvInvoice(..), CsvTransaction(..), TransactionMessage(..), parseCsvFile, invoiceRowFromCsv) where
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Data.Bifunctor (first)
 | 
				
			||||||
 | 
					import Data.Bifunctor (second)
 | 
				
			||||||
 | 
					import Control.Monad ((>=>))
 | 
				
			||||||
 | 
					import Data.HashMap.Strict (delete)
 | 
				
			||||||
 | 
					import Data.List.NonEmpty (NonEmpty, nonEmpty)
 | 
				
			||||||
 | 
					import Data.MIME (Address, address, renderAddress, defaultCharsets, parse)
 | 
				
			||||||
 | 
					import Data.Text (Text)
 | 
				
			||||||
 | 
					import Data.Text.Encoding (decodeUtf8, encodeUtf8)
 | 
				
			||||||
 | 
					import Data.Time (Day)
 | 
				
			||||||
 | 
					import Data.Time.Format.ISO8601 (iso8601ParseM)
 | 
				
			||||||
 | 
					import Data.Vector (Vector, toList)
 | 
				
			||||||
 | 
					import System.Exit (exitFailure)
 | 
				
			||||||
 | 
					import System.IO (stderr, hPutStrLn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Data.Csv
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import qualified Data.ByteString as BS
 | 
				
			||||||
 | 
					import qualified Data.ByteString.Lazy as LBS
 | 
				
			||||||
 | 
					import qualified Data.HashMap.Strict as HM
 | 
				
			||||||
 | 
					import qualified Data.Text as T
 | 
				
			||||||
 | 
					import qualified Data.List.NonEmpty as NE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Laskutin.Types
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data CsvInvoice = CsvInvoice
 | 
				
			||||||
 | 
					    { invoiceRecipient :: Address
 | 
				
			||||||
 | 
					    , reference :: Reference
 | 
				
			||||||
 | 
					    , isPaid :: Bool
 | 
				
			||||||
 | 
					    , rows :: [CsvInvoiceRow]
 | 
				
			||||||
 | 
					    } deriving Show
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data CsvInvoiceRow = CsvInvoiceRow
 | 
				
			||||||
 | 
					    { amount :: Maybe Euro
 | 
				
			||||||
 | 
					    , name :: Text
 | 
				
			||||||
 | 
					    } deriving Show
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data CsvTransaction = CsvTransaction
 | 
				
			||||||
 | 
					    { date :: Day
 | 
				
			||||||
 | 
					    , transactor :: Text
 | 
				
			||||||
 | 
					    , referenceOrMessage :: TransactionMessage
 | 
				
			||||||
 | 
					    , transactionType :: Text
 | 
				
			||||||
 | 
					    , amount :: Euro
 | 
				
			||||||
 | 
					    } deriving Show
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data TransactionMessage = Message Text
 | 
				
			||||||
 | 
					                        | Reference Reference
 | 
				
			||||||
 | 
					                        deriving Show
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromField Address where
 | 
				
			||||||
 | 
					    parseField field = either (const $ fail $ "Invalid email: " <> show field) pure $ parse (address defaultCharsets) field
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance ToField Address where
 | 
				
			||||||
 | 
					    toField = renderAddress
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromField Bool where
 | 
				
			||||||
 | 
					    parseField "x" = pure True
 | 
				
			||||||
 | 
					    parseField _ = pure False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance ToField Bool where
 | 
				
			||||||
 | 
					    toField True = "x"
 | 
				
			||||||
 | 
					    toField False = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromField Day where
 | 
				
			||||||
 | 
					    parseField = iso8601ParseM . T.unpack . decodeUtf8
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromField TransactionMessage where
 | 
				
			||||||
 | 
					    parseField field' =
 | 
				
			||||||
 | 
					        let field = decodeUtf8 field'
 | 
				
			||||||
 | 
					         in case readReference $ T.unpack field of
 | 
				
			||||||
 | 
					              Just reference -> pure $ Reference reference
 | 
				
			||||||
 | 
					              Nothing -> pure $ Message field
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromNamedRecord CsvInvoice where
 | 
				
			||||||
 | 
					    parseNamedRecord row = CsvInvoice
 | 
				
			||||||
 | 
					        <$> row .: emailField
 | 
				
			||||||
 | 
					        <*> row .: referenceField
 | 
				
			||||||
 | 
					        <*> row .: paidField
 | 
				
			||||||
 | 
					        <*> namedRecordToRows (foldr delete row [emailField, referenceField, paidField])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance ToNamedRecord CsvInvoice where
 | 
				
			||||||
 | 
					    toNamedRecord CsvInvoice {invoiceRecipient, reference, isPaid, rows} = namedRecord $
 | 
				
			||||||
 | 
					        [ emailField .= invoiceRecipient
 | 
				
			||||||
 | 
					        , referenceField .= reference
 | 
				
			||||||
 | 
					        , paidField .= isPaid
 | 
				
			||||||
 | 
					        ] <> (invoiceRowToField <$> rows)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					invoiceRowToField :: CsvInvoiceRow -> (BS.ByteString, BS.ByteString)
 | 
				
			||||||
 | 
					invoiceRowToField CsvInvoiceRow {amount, name} = encodeUtf8 name .= amount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromNamedRecord CsvTransaction where
 | 
				
			||||||
 | 
					    parseNamedRecord row = CsvTransaction
 | 
				
			||||||
 | 
					        <$> row .: encodeUtf8 "Päivämäärä"
 | 
				
			||||||
 | 
					        <*> row .: encodeUtf8 "Maksaja tai saaja"
 | 
				
			||||||
 | 
					        <*> row .: encodeUtf8 "Viite tai viesti"
 | 
				
			||||||
 | 
					        <*> row .: encodeUtf8 "Selite"
 | 
				
			||||||
 | 
					        <*> row .: encodeUtf8 "Määrä EUR"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namedRecordToRows :: NamedRecord -> Parser [CsvInvoiceRow]
 | 
				
			||||||
 | 
					namedRecordToRows row = pure
 | 
				
			||||||
 | 
					    $ fmap (uncurry (flip CsvInvoiceRow) . first decodeUtf8)
 | 
				
			||||||
 | 
					    $ HM.toList
 | 
				
			||||||
 | 
					    $ fmap (readEuro . decodeUtf8) row
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					emailField, referenceField, paidField :: BS.ByteString
 | 
				
			||||||
 | 
					emailField = "sposti"
 | 
				
			||||||
 | 
					referenceField = "viite"
 | 
				
			||||||
 | 
					paidField = "maksettu"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					handleParseErrors :: Either String (a, Vector b) -> IO (a, [b])
 | 
				
			||||||
 | 
					handleParseErrors = either (hPutStrLn stderr >=> const exitFailure) (pure . second toList)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					parseCsvFile :: FromNamedRecord a => FilePath -> IO (Header, [a])
 | 
				
			||||||
 | 
					parseCsvFile = LBS.readFile >=> handleParseErrors . decodeByName
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					invoiceRowFromCsv :: CsvInvoiceRow -> Maybe InvoiceRow
 | 
				
			||||||
 | 
					invoiceRowFromCsv CsvInvoiceRow {amount, name} =
 | 
				
			||||||
 | 
					      maybe Nothing (\amount -> Just InvoiceRow {name, amount}) amount
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,55 @@
 | 
				
			||||||
 | 
					{-# LANGUAGE OverloadedStrings #-}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Laskutin.Email where
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Data.Text (Text)
 | 
				
			||||||
 | 
					import Data.MIME (MIMEMessage, Address, createTextPlainMessage, defaultCharsets, headerSubject, headerFrom, headerTo, headerBCC, renderMessage)
 | 
				
			||||||
 | 
					import Control.Lens (set)
 | 
				
			||||||
 | 
					import Data.Semigroup (sconcat)
 | 
				
			||||||
 | 
					import Data.Time.Calendar
 | 
				
			||||||
 | 
					import Data.ByteString.Lazy (ByteString)
 | 
				
			||||||
 | 
					import System.Process (CreateProcess(..), StdStream(..), createProcess, proc, waitForProcess)
 | 
				
			||||||
 | 
					import System.Exit (ExitCode(..))
 | 
				
			||||||
 | 
					import System.IO (hClose)
 | 
				
			||||||
 | 
					import Control.Exception (ErrorCall(..), throwIO)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import qualified Data.List.NonEmpty as NE
 | 
				
			||||||
 | 
					import qualified Data.Text as T
 | 
				
			||||||
 | 
					import qualified Data.ByteString.Lazy as LBS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Laskutin.Types
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderEmail :: [Address] -> [Address] -> Invoice -> ByteString
 | 
				
			||||||
 | 
					renderEmail senders recipients = renderMessage
 | 
				
			||||||
 | 
					    . set (headerBCC defaultCharsets) senders
 | 
				
			||||||
 | 
					    . set (headerTo defaultCharsets) recipients
 | 
				
			||||||
 | 
					    . set (headerFrom defaultCharsets) senders
 | 
				
			||||||
 | 
					    . invoiceEmail
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sendEmail :: FilePath -> ByteString -> IO ()
 | 
				
			||||||
 | 
					sendEmail sendmail email = do
 | 
				
			||||||
 | 
					    (Just sendmailStdin, _, _, sendmailProcess) <- createProcess (proc sendmail ["-t"]) {std_in = CreatePipe}
 | 
				
			||||||
 | 
					    LBS.hPut sendmailStdin email
 | 
				
			||||||
 | 
					    hClose sendmailStdin
 | 
				
			||||||
 | 
					    exitCode <- waitForProcess sendmailProcess
 | 
				
			||||||
 | 
					    case exitCode of
 | 
				
			||||||
 | 
					      ExitSuccess -> pure ()
 | 
				
			||||||
 | 
					      ExitFailure code -> throwIO $ ErrorCall ("sendmail exited with error code " <> show code)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					invoiceEmail :: Invoice -> MIMEMessage
 | 
				
			||||||
 | 
					invoiceEmail Invoice {rows, reference, account, recipient, due, subject, message} =
 | 
				
			||||||
 | 
					    set (headerSubject defaultCharsets) (Just $ "Lasku – " <> subject ) $ createTextPlainMessage $
 | 
				
			||||||
 | 
					        (if T.null message then mempty else (message <> "\n\n\n"))
 | 
				
			||||||
 | 
					        <> sconcat (NE.map renderInvoiceRow rows) <> "\n"
 | 
				
			||||||
 | 
					        <> renderInvoiceRow InvoiceRow {name = "YHTEENSÄ", amount = sum $ invoiceRowAmount <$> rows} <> "\n"
 | 
				
			||||||
 | 
					        <> "Maksu tilisiirrolla\n\n"
 | 
				
			||||||
 | 
					        <> "Viitenumero: " <> renderReference reference <> "\n"
 | 
				
			||||||
 | 
					        <> "Tilinumero: " <> renderIBAN account <> "\n"
 | 
				
			||||||
 | 
					        <> "Vastaanottaja: " <> recipient <> "\n"
 | 
				
			||||||
 | 
					        <> maybe mempty (("Eräpäivä: " <>) . renderDay) due
 | 
				
			||||||
 | 
					            where invoiceRowAmount InvoiceRow {amount} = amount
 | 
				
			||||||
 | 
					                  renderDay (YearMonthDay year month day) =
 | 
				
			||||||
 | 
					                      T.pack $ show day <> "." <> show month <> "." <> show year
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderInvoiceRow :: InvoiceRow -> Text
 | 
				
			||||||
 | 
					renderInvoiceRow InvoiceRow {name, amount} = name <> ": " <> renderEuro amount <> "\n"
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,58 @@
 | 
				
			||||||
 | 
					{-# LANGUAGE OverloadedStrings #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE NoFieldSelectors #-}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Laskutin.Options (Options(..), SendOptions(..), Command(..), parseOptions) where
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Data.Text (Text)
 | 
				
			||||||
 | 
					import Data.Time (Day)
 | 
				
			||||||
 | 
					import Data.MIME (Address, address, defaultCharsets, parse)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import qualified Data.Text as T
 | 
				
			||||||
 | 
					import qualified Data.Text.Encoding as T
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Options.Applicative
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Laskutin.Types
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data Options = Options
 | 
				
			||||||
 | 
					    { csv :: FilePath
 | 
				
			||||||
 | 
					    , command :: Command
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data Command = Send SendOptions
 | 
				
			||||||
 | 
					             | UpdateTable FilePath
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data SendOptions = SendOptions
 | 
				
			||||||
 | 
					    { account :: IBAN
 | 
				
			||||||
 | 
					    , recipient :: Text
 | 
				
			||||||
 | 
					    , subject :: Text
 | 
				
			||||||
 | 
					    , message :: Text
 | 
				
			||||||
 | 
					    , due :: Maybe Day
 | 
				
			||||||
 | 
					    , email :: Address
 | 
				
			||||||
 | 
					    , sendmail :: FilePath
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					parseOptions :: IO Options
 | 
				
			||||||
 | 
					parseOptions = execParser $ info (options <**> helper)
 | 
				
			||||||
 | 
					    (fullDesc <> progDesc "Send email invoices from CSV" <> header "laskutin - email invoice sender")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					options :: Parser Options
 | 
				
			||||||
 | 
					options = Options
 | 
				
			||||||
 | 
					    <$> strOption (long "file" <> help "Path to the csv file" <> metavar "FILE")
 | 
				
			||||||
 | 
					    <*> subparser
 | 
				
			||||||
 | 
					        (command "send" (info (sendOptions <**> helper) (progDesc "Send email invoices from CSV"))
 | 
				
			||||||
 | 
					        <> command "update" (info (updateTableOptions <**> helper) (progDesc "Update invoice CSV from a bank CSV")))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					updateTableOptions :: Parser Command
 | 
				
			||||||
 | 
					updateTableOptions = UpdateTable <$> strOption (long "bank-csv" <> metavar "FILE")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					sendOptions :: Parser Command
 | 
				
			||||||
 | 
					sendOptions = fmap Send $ SendOptions
 | 
				
			||||||
 | 
					    <$> option (maybeReader $ readIBAN . T.pack) (long "iban" <> help "IBAN account" <> metavar "IBAN")
 | 
				
			||||||
 | 
					    <*> strOption (long "recipient" <> help "The recipient of the payments" <> metavar "TEXT")
 | 
				
			||||||
 | 
					    <*> strOption (long "subject" <> help "Subject of the invoice" <> metavar "TEXT")
 | 
				
			||||||
 | 
					    <*> strOption (long "message" <> help "Additional message" <> value "" <> metavar "TEXT")
 | 
				
			||||||
 | 
					    <*> optional (option auto (long "due" <> help "Due date in YYYY-MM-DD format" <> metavar "DATE"))
 | 
				
			||||||
 | 
					    <*> option (eitherReader $ parse (address defaultCharsets) . T.encodeUtf8 . T.pack)
 | 
				
			||||||
 | 
					        (long "email" <> help "Invoice sender email address" <> metavar "EMAIL")
 | 
				
			||||||
 | 
					    <*> strOption (long "sendmail" <> short 'm' <> metavar "FILE" <> help "The sendmail program to use")
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,191 @@
 | 
				
			||||||
 | 
					{-# LANGUAGE OverloadedStrings #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE NoFieldSelectors #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE DuplicateRecordFields #-}
 | 
				
			||||||
 | 
					{-# LANGUAGE NamedFieldPuns #-}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Laskutin.Types 
 | 
				
			||||||
 | 
					    ( InvoiceRow(..)
 | 
				
			||||||
 | 
					    , Euro
 | 
				
			||||||
 | 
					    , IBAN
 | 
				
			||||||
 | 
					    , Invoice(..)
 | 
				
			||||||
 | 
					    , Reference
 | 
				
			||||||
 | 
					    , createReference
 | 
				
			||||||
 | 
					    , readEuro
 | 
				
			||||||
 | 
					    , readIBAN
 | 
				
			||||||
 | 
					    , readReference
 | 
				
			||||||
 | 
					    , renderEuro
 | 
				
			||||||
 | 
					    , renderIBAN
 | 
				
			||||||
 | 
					    , renderReference
 | 
				
			||||||
 | 
					    ) where
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Control.Monad (guard)
 | 
				
			||||||
 | 
					import Data.Char (isSpace, isDigit, toUpper)
 | 
				
			||||||
 | 
					import Data.List.NonEmpty (NonEmpty)
 | 
				
			||||||
 | 
					import Data.Ratio (numerator, denominator)
 | 
				
			||||||
 | 
					import Data.Text (Text)
 | 
				
			||||||
 | 
					import Data.Text.Encoding (decodeUtf8, encodeUtf8)
 | 
				
			||||||
 | 
					import Data.Time (Day)
 | 
				
			||||||
 | 
					import Data.Csv (FromField(..), ToField(..))
 | 
				
			||||||
 | 
					import Data.Maybe (fromMaybe)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import qualified Data.Text as T
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data InvoiceRow = InvoiceRow
 | 
				
			||||||
 | 
					    { amount :: Euro
 | 
				
			||||||
 | 
					    , name :: Text
 | 
				
			||||||
 | 
					    } deriving (Show, Eq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data Invoice = Invoice
 | 
				
			||||||
 | 
					    { rows :: NonEmpty InvoiceRow
 | 
				
			||||||
 | 
					    , reference :: Reference
 | 
				
			||||||
 | 
					    , account :: IBAN
 | 
				
			||||||
 | 
					    , recipient :: Text
 | 
				
			||||||
 | 
					    , subject :: Text
 | 
				
			||||||
 | 
					    , message :: Text
 | 
				
			||||||
 | 
					    , due :: Maybe Day
 | 
				
			||||||
 | 
					    } deriving (Show, Eq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data Reference = Reference {base :: [Digit], checksum :: Digit}
 | 
				
			||||||
 | 
					    deriving (Show, Eq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data Digit = D0 | D1 | D2 | D3 | D4 | D5 | D6 | D7 | D8 | D9
 | 
				
			||||||
 | 
					    deriving (Show, Eq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					data IBAN = IBAN {country :: Text, checksum :: (Digit, Digit), account :: Text}
 | 
				
			||||||
 | 
					    deriving (Show, Eq)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					newtype Euro = Euro Integer -- Cents
 | 
				
			||||||
 | 
					    deriving (Eq, Ord, Show)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					readIBAN :: Text -> Maybe IBAN
 | 
				
			||||||
 | 
					readIBAN text = do
 | 
				
			||||||
 | 
					    let text' = T.map toUpper $ T.filter (not . isSpace) text
 | 
				
			||||||
 | 
					    guard $ T.all ((||) <$> isDigit <*> (`elem` ['A'..'Z'])) text'
 | 
				
			||||||
 | 
					    let (country, rest) = T.splitAt 2 text'
 | 
				
			||||||
 | 
					        (checksum', account) = T.splitAt 2 rest
 | 
				
			||||||
 | 
					    guard $ T.length country == 2
 | 
				
			||||||
 | 
					    [a,b] <- sequence $ readDigit <$> T.unpack checksum'
 | 
				
			||||||
 | 
					    let checksum = (a,b)
 | 
				
			||||||
 | 
					    guard $ T.length account <= 30 -- TODO: this could check some country specific lengths
 | 
				
			||||||
 | 
					    let str = T.unpack $ account <> country <> checksum'
 | 
				
			||||||
 | 
					        charToNum c
 | 
				
			||||||
 | 
					          | isDigit c = [c] 
 | 
				
			||||||
 | 
					          | otherwise = show $ fromEnum c - fromEnum 'A' + 10
 | 
				
			||||||
 | 
					        remainder = read (concatMap charToNum str) `mod` 97
 | 
				
			||||||
 | 
					    guard $ remainder == (1 :: Integer)
 | 
				
			||||||
 | 
					    pure IBAN {country, checksum, account}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderIBAN :: IBAN -> Text
 | 
				
			||||||
 | 
					renderIBAN IBAN {country, checksum, account} =
 | 
				
			||||||
 | 
					    T.unwords $ T.chunksOf 4 $ country <> renderedChecksum <> account
 | 
				
			||||||
 | 
					        where renderedChecksum = let (a,b) = checksum in T.pack $ renderDigit <$> [a,b]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					createReference :: String -> Maybe Reference
 | 
				
			||||||
 | 
					createReference s = do
 | 
				
			||||||
 | 
					    base <- sequence $ readDigit <$> s
 | 
				
			||||||
 | 
					    guard $ length base >= 3
 | 
				
			||||||
 | 
					    let checksum = computeReferenceChecksum base
 | 
				
			||||||
 | 
					    pure Reference {base, checksum}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					readReference :: String -> Maybe Reference
 | 
				
			||||||
 | 
					readReference s = do
 | 
				
			||||||
 | 
					    digits <- sequence $ readDigit <$> filter (not . isSpace) s
 | 
				
			||||||
 | 
					    guard $ length digits >= 4
 | 
				
			||||||
 | 
					    let checksum = last digits
 | 
				
			||||||
 | 
					        base = init digits
 | 
				
			||||||
 | 
					    guard $ computeReferenceChecksum base == checksum
 | 
				
			||||||
 | 
					    pure $ Reference {checksum, base}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					computeReferenceChecksum :: [Digit] -> Digit
 | 
				
			||||||
 | 
					computeReferenceChecksum base =
 | 
				
			||||||
 | 
					    let checksum' = (`mod` 10) $ sum $ zipWith (*) (digitToInt <$> reverse base) (cycle [7,3,1])
 | 
				
			||||||
 | 
					     in fromMaybe (error "Impossible") $ intToDigit $ if checksum' == 0 then 0 else 10-checksum'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderReference :: Reference -> Text
 | 
				
			||||||
 | 
					renderReference Reference {base, checksum} = T.pack $ (renderDigit <$> base) <> [renderDigit checksum]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderReferenceBase :: Reference -> Text
 | 
				
			||||||
 | 
					renderReferenceBase Reference {base} = T.pack $ renderDigit <$> base
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					euroToCents :: Euro -> Integer
 | 
				
			||||||
 | 
					euroToCents (Euro a) = a
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderEuro :: Euro -> Text
 | 
				
			||||||
 | 
					renderEuro = do
 | 
				
			||||||
 | 
					    euros <- T.pack . show . (`div` 100) . euroToCents
 | 
				
			||||||
 | 
					    cents <- T.justifyRight 2 '0' . T.pack . show . (`mod` 100) . euroToCents
 | 
				
			||||||
 | 
					    pure $ euros <> "," <> cents <> "€"
 | 
				
			||||||
 | 
					    -- TODO: Test with negative euros
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					readEuro :: Text -> Maybe Euro
 | 
				
			||||||
 | 
					readEuro text'' = do
 | 
				
			||||||
 | 
					    guard $ not $ T.null text''
 | 
				
			||||||
 | 
					    let text' = if T.last text'' == '€' then T.init text'' else text''
 | 
				
			||||||
 | 
					        (sign, text) = if T.head text' == '-' then (negate, T.tail text') else (id, text')
 | 
				
			||||||
 | 
					    guard $ T.all (\c -> c `elem` (",." :: [Char]) || isSpace c || isDigit c) text
 | 
				
			||||||
 | 
					    sign <$> case T.split (`elem` (",." :: [Char])) $ T.filter (not . isSpace) text of
 | 
				
			||||||
 | 
					      [euros, cents] -> do
 | 
				
			||||||
 | 
					          guard $ T.length cents <= 2
 | 
				
			||||||
 | 
					          pure $ Euro (100 * read (T.unpack euros)) + Euro (read $ T.unpack $ T.justifyLeft 2 '0' cents)
 | 
				
			||||||
 | 
					      [euros] -> pure $ Euro (100 * read (T.unpack euros))
 | 
				
			||||||
 | 
					      _ -> Nothing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- XXX: Do these instances make any sense. Multiplying euros with euros is quite weird
 | 
				
			||||||
 | 
					instance Num Euro where
 | 
				
			||||||
 | 
					    (Euro a) + (Euro b) = Euro (a + b)
 | 
				
			||||||
 | 
					    (Euro a) * (Euro b) = Euro (a * b `div` 100)
 | 
				
			||||||
 | 
					    abs (Euro a) = Euro (abs a)
 | 
				
			||||||
 | 
					    signum (Euro a) = Euro (signum a)
 | 
				
			||||||
 | 
					    negate (Euro a) = Euro (negate a)
 | 
				
			||||||
 | 
					    fromInteger x = Euro (fromInteger x * 100)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance Fractional Euro where
 | 
				
			||||||
 | 
					    fromRational rational = Euro $ (numerator rational * 100) `div` denominator rational
 | 
				
			||||||
 | 
					    (Euro a) / (Euro b) = Euro $ (a * 100) `div` b
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromField Reference where
 | 
				
			||||||
 | 
					    parseField field = maybe (fail $ "Invalid reference: " <> show field) pure $
 | 
				
			||||||
 | 
					        createReference $ T.unpack $ decodeUtf8 field
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance ToField Reference where
 | 
				
			||||||
 | 
					    toField = encodeUtf8 . renderReferenceBase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance FromField Euro where
 | 
				
			||||||
 | 
					    parseField field = maybe (fail $ "Invalid euro field: " <> show field) pure $
 | 
				
			||||||
 | 
					        readEuro $ decodeUtf8 field
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					instance ToField Euro where
 | 
				
			||||||
 | 
					    toField = encodeUtf8 . renderEuro
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					readDigit :: Char -> Maybe Digit
 | 
				
			||||||
 | 
					readDigit '0' = Just D0
 | 
				
			||||||
 | 
					readDigit '1' = Just D1
 | 
				
			||||||
 | 
					readDigit '2' = Just D2
 | 
				
			||||||
 | 
					readDigit '3' = Just D3
 | 
				
			||||||
 | 
					readDigit '4' = Just D4
 | 
				
			||||||
 | 
					readDigit '5' = Just D5
 | 
				
			||||||
 | 
					readDigit '6' = Just D6
 | 
				
			||||||
 | 
					readDigit '7' = Just D7
 | 
				
			||||||
 | 
					readDigit '8' = Just D8
 | 
				
			||||||
 | 
					readDigit '9' = Just D9
 | 
				
			||||||
 | 
					readDigit _ = Nothing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					renderDigit :: Digit -> Char
 | 
				
			||||||
 | 
					renderDigit D0 = '0'
 | 
				
			||||||
 | 
					renderDigit D1 = '1'
 | 
				
			||||||
 | 
					renderDigit D2 = '2'
 | 
				
			||||||
 | 
					renderDigit D3 = '3'
 | 
				
			||||||
 | 
					renderDigit D4 = '4'
 | 
				
			||||||
 | 
					renderDigit D5 = '5'
 | 
				
			||||||
 | 
					renderDigit D6 = '6'
 | 
				
			||||||
 | 
					renderDigit D7 = '7'
 | 
				
			||||||
 | 
					renderDigit D8 = '8'
 | 
				
			||||||
 | 
					renderDigit D9 = '9'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					digitToInt :: Digit -> Int
 | 
				
			||||||
 | 
					digitToInt digit = read [renderDigit digit]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					intToDigit :: Int -> Maybe Digit
 | 
				
			||||||
 | 
					intToDigit x = case show x of
 | 
				
			||||||
 | 
					                 [c] -> readDigit c
 | 
				
			||||||
 | 
					                 _ -> Nothing
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					module Main where
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import qualified Laskutin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					main :: IO ()
 | 
				
			||||||
 | 
					main = Laskutin.main
 | 
				
			||||||
		Loading…
	
		Reference in New Issue