summaryrefslogtreecommitdiff
path: root/src/Settlr/Storage.hs
blob: 8a893f654f0783fc0a2f8444b363d83d4385fa9e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}

module Settlr.Storage
  ( Group(..)
  , Member(..)
  , Money(..)
  , Currency(..)
  , GroupMetadata(..)
  , getMetadata
  , createGroup
  , deleteGroup
  , Expense(..)
  , expAmount
  , expSaldo
  , getExpenses
  , getSaldo
  , addExpense
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import Data.Map (Map)
import qualified Data.Map as M
import Data.UUID (UUID)
import Data.Maybe (fromMaybe)
import Data.Time (UTCTime)
import Control.Monad.Catch (MonadThrow, throwM)
import Text.Read (readMaybe)
import Control.Exception (Exception, try, IOException)
import Data.List (find, stripPrefix)
import Data.Maybe (mapMaybe)
import Settlr.Scripts (ScriptM)
import Control.Monad.Reader (asks, MonadIO (liftIO))
import Settlr.Config (Config(..))
import Network.CGI (formDecode, formEncode)
import Data.UUID.V4 (nextRandom)
import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)

-- Identifier of a group.
newtype Group = Group UUID
  deriving (Eq, Ord, Show)

-- Identifier of a group member.
newtype Member = Member Text
  deriving (Eq, Ord, Show)

-- An amount of money in a particular currency (usually, the smallest unit for the currency).
newtype Money = Money Int
  deriving (Eq, Ord, Num, Show)

data Currency = EUR | USD
  deriving (Eq, Show, Read)

data GroupMetadata = GroupMetadata
  { grpName      :: !Text
  , grpCreatedAt :: !UTCTime
  , grpCurrency  :: !Currency
  , grpMembers   :: ![Member]
  }
  deriving (Show)

getMetadata :: Group -> ScriptM (Maybe GroupMetadata)
getMetadata grp = do basePath <- groupPath grp
                     readEncoded (basePath ++ "/metadata")

createGroup :: GroupMetadata -> ScriptM Group
createGroup meta = do grp      <- Group <$> liftIO nextRandom
                      basePath <- groupPath grp
                      liftIO $ createDirectoryIfMissing True basePath
                      writeEncoded (basePath ++ "/metadata") meta
                      pure grp

deleteGroup :: Group -> ScriptM ()
deleteGroup grp = do basePath <- groupPath grp
                     liftIO $ removeDirectoryRecursive basePath

data Expense = Expense
  { expDescription :: !Text
  , expCreatedAt   :: !UTCTime
  , expCreditor    :: !Member
  , expDebtors     :: !(Map Member Money)
  }
  deriving (Show)

expAmount :: Expense -> Money
expAmount expense = M.foldr (+) (Money 0) (expDebtors expense)

expSaldo :: Member -> Expense -> Money
expSaldo mem expense = positiveAmount - negativeAmount
  where positiveAmount = if expCreditor expense == mem
                           then expAmount expense
                           else Money 0
        negativeAmount = fromMaybe (Money 0) (M.lookup mem (expDebtors expense))

getExpenses :: Group -> ScriptM [Expense]
getExpenses = undefined

getSaldo :: Member -> Group -> ScriptM Money
getSaldo mem grp = sum . fmap (expSaldo mem) <$> getExpenses grp

addExpense :: Expense -> Group -> ScriptM ()
addExpense = undefined

groupPath :: Group -> ScriptM FilePath
groupPath (Group grp) = do dataPath <- asks cfgDataPath
                           let dataPath' = fromMaybe dataPath (T.stripSuffix "/" dataPath)
                           pure $ T.unpack (dataPath' <> "/groups/" <> T.pack (show grp))

readEncoded :: Codable c => FilePath -> ScriptM (Maybe c)
readEncoded path = liftIO (try (readFile path) :: IO (Either IOException String)) >>= \case
                     Left _        -> pure Nothing
                     Right content -> Just <$> decode (formDecode content)

writeEncoded :: Codable c => FilePath -> c -> ScriptM ()
writeEncoded path content = liftIO $ writeFile path (formEncode (encode content))

class Codable a where
  encode :: a -> [(String, String)]
  decode :: MonadThrow m => [(String, String)] -> m a

instance Codable GroupMetadata where
  encode grp = [ ("name"     , T.unpack (grpName grp))
               , ("createdAt", show (grpCreatedAt grp))
               , ("currency" , show (grpCurrency grp))
               ] ++
               fmap (\(Member x) -> ("member", T.unpack x)) (grpMembers grp)
  decode rec = do name       <- T.pack    <$> lookupThrow "name"      rec
                  createdAt  <- readThrow =<< lookupThrow "createdAt" rec
                  currency   <- readThrow =<< lookupThrow "currency"  rec
                  let members = (Member . T.pack <$> lookupMany "member" rec)
                  pure $ GroupMetadata { grpName      = name
                                       , grpCreatedAt = createdAt
                                       , grpCurrency  = currency
                                       , grpMembers   = members
                                       }

instance Codable Expense where
  encode expense = [ ("description", T.unpack (expDescription expense))
                   , ("createdAt"  , show (expCreatedAt expense))
                   , ("creditor"   , (\(Member x) -> T.unpack x) (expCreditor expense))
                   ] ++
                   fmap (\(Member x, Money y) -> ("debtor" ++ T.unpack x, show y)) (M.toList (expDebtors expense))
  decode rec = do description <- T.pack          <$> lookupThrow "description" rec
                  createdAt   <- readThrow       =<< lookupThrow "createdAt"   rec
                  creditor    <- Member . T.pack <$> lookupThrow "creditor"    rec
                  let debtorItems = mapMaybe (\(x, y) -> (, y) <$> stripPrefix "debtor" x) rec
                  debtors <- mapM (\(x, y) -> (Member (T.pack x), ) . Money <$> readThrow y) debtorItems
                  pure $ Expense { expDescription = description
                                 , expCreatedAt   = createdAt
                                 , expCreditor    = creditor
                                 , expDebtors     = M.fromList debtors
                                 }

data CodingException
  = CodingInvalidValue !String
  | CodingMissingKey !String

instance Show CodingException where
  show (CodingInvalidValue val) = "Decoding failed: Invalid value \"" ++ val ++ "\""
  show (CodingMissingKey key)   = "Decoding failed: Invalid key \"" ++ key ++ "\""

instance Exception CodingException

readThrow :: MonadThrow m => Read a => String -> m a
readThrow str = case readMaybe str of
                  Just x  -> pure x
                  Nothing -> throwM (CodingInvalidValue str)

lookupThrow :: MonadThrow m => String -> [(String, String)] -> m String
lookupThrow key rec = case find (\(x, _) -> x == key) rec of
                        Just (_, y) -> pure y
                        Nothing     -> throwM (CodingMissingKey key)

lookupMany :: String -> [(String, String)] -> [String]
lookupMany key rec = snd <$> filter (\(x, _) -> x == key) rec