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