aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoris2016-11-13 00:49:32 +0100
committerJoris2016-11-13 00:49:32 +0100
commit86a96decdb8892b10c5314eb916ef15a64204450 (patch)
tree6f41742d0466f77948680964188144fbff036902 /src
parentbf6a0a0b32a7efb88f75c2e89b84d6907aeb10bc (diff)
downloadbudget-86a96decdb8892b10c5314eb916ef15a64204450.tar.gz
budget-86a96decdb8892b10c5314eb916ef15a64204450.tar.bz2
budget-86a96decdb8892b10c5314eb916ef15a64204450.zip
Send weekly activity at start of week about previous week
Diffstat (limited to 'src')
-rw-r--r--src/server/Conf.hs31
-rw-r--r--src/server/Controller/SignIn.hs2
-rw-r--r--src/server/Job.hs25
-rw-r--r--src/server/Job/Daemon.hs40
-rw-r--r--src/server/Job/Frequency.hs13
-rw-r--r--src/server/Job/Kind.hs (renamed from src/server/Model/JobKind.hs)11
-rw-r--r--src/server/Job/Model.hs (renamed from src/server/Model/Job.hs)18
-rw-r--r--src/server/Job/MonthlyPayment.hs24
-rw-r--r--src/server/Job/WeeklyReport.hs31
-rw-r--r--src/server/Main.hs71
-rw-r--r--src/server/Model/Database.hs17
-rw-r--r--src/server/Model/Income.hs12
-rw-r--r--src/server/Model/Mail.hs3
-rw-r--r--src/server/Model/Message.hs5
-rw-r--r--src/server/Model/Message/Key.hs22
-rw-r--r--src/server/Model/Message/Translations.hs147
-rw-r--r--src/server/Model/Payment.hs11
-rw-r--r--src/server/Model/User.hs5
-rw-r--r--src/server/MonthlyPaymentJob.hs33
-rw-r--r--src/server/Resource.hs50
-rw-r--r--src/server/SendMail.hs7
-rw-r--r--src/server/Utils/Time.hs38
-rw-r--r--src/server/View/Format.hs33
-rw-r--r--src/server/View/Mail/SignIn.hs16
-rw-r--r--src/server/View/Mail/WeeklyReport.hs124
25 files changed, 599 insertions, 190 deletions
diff --git a/src/server/Conf.hs b/src/server/Conf.hs
index 13d5981..a05349d 100644
--- a/src/server/Conf.hs
+++ b/src/server/Conf.hs
@@ -1,11 +1,12 @@
{-# LANGUAGE OverloadedStrings #-}
module Conf
- ( getConf
+ ( get
, Conf(..)
) where
import Data.Text (Text)
+import qualified Data.Text as T
import qualified Data.ConfigManager as Conf
import Data.Time.Clock (NominalDiffTime)
@@ -18,15 +19,19 @@ data Conf = Conf
, https :: Bool
} deriving Show
-getConf :: FilePath -> IO (Either Text Conf)
-getConf path =
- (flip fmap) (Conf.readConfig path) (\configOrError -> do
- conf <- configOrError
- Conf <$>
- Conf.lookup "hostname" conf <*>
- Conf.lookup "port" conf <*>
- Conf.lookup "signInExpiration" conf <*>
- Conf.lookup "currency" conf <*>
- Conf.lookup "noReplyMail" conf <*>
- Conf.lookup "https" conf
- )
+get :: FilePath -> IO Conf
+get path = do
+ conf <-
+ (flip fmap) (Conf.readConfig path) (\configOrError -> do
+ conf <- configOrError
+ Conf <$>
+ Conf.lookup "hostname" conf <*>
+ Conf.lookup "port" conf <*>
+ Conf.lookup "signInExpiration" conf <*>
+ Conf.lookup "currency" conf <*>
+ Conf.lookup "noReplyMail" conf <*>
+ Conf.lookup "https" conf
+ )
+ case conf of
+ Left msg -> error (T.unpack msg)
+ Right c -> return c
diff --git a/src/server/Controller/SignIn.hs b/src/server/Controller/SignIn.hs
index 0fbe7c5..1b8121d 100644
--- a/src/server/Controller/SignIn.hs
+++ b/src/server/Controller/SignIn.hs
@@ -45,7 +45,7 @@ signIn conf login =
"?signInToken=",
token
]
- maybeSentMail <- liftIO . sendMail $ SignIn.getMail conf (entityVal user) url [login]
+ maybeSentMail <- liftIO . sendMail $ SignIn.mail conf (entityVal user) url [login]
case maybeSentMail of
Right _ ->
status ok200
diff --git a/src/server/Job.hs b/src/server/Job.hs
deleted file mode 100644
index bf8f15b..0000000
--- a/src/server/Job.hs
+++ /dev/null
@@ -1,25 +0,0 @@
-module Job
- ( jobListener
- ) where
-
-import Data.Time.Clock
-
-import Control.Concurrent (threadDelay)
-
-import Model.Database
-import Model.JobKind
-import Model.Job
-
-jobListener :: JobKind -> (UTCTime -> IO Bool) -> (() -> Persist ()) -> Int -> IO ()
-jobListener kind lastExecutionTooOld runJob msDelay = do
- mbLastExecution <- runDb $ do
- actualizeLastCheck kind
- getLastExecution kind
- hasToRun <- case mbLastExecution of
- Just lastExecution -> lastExecutionTooOld lastExecution
- Nothing -> return True
- if hasToRun
- then runDb (runJob () >> actualizeLastExecution kind)
- else return ()
- threadDelay msDelay
- jobListener kind lastExecutionTooOld runJob msDelay
diff --git a/src/server/Job/Daemon.hs b/src/server/Job/Daemon.hs
new file mode 100644
index 0000000..8259b18
--- /dev/null
+++ b/src/server/Job/Daemon.hs
@@ -0,0 +1,40 @@
+module Job.Daemon
+ ( runDaemons
+ ) where
+
+import Data.Time.Clock (UTCTime)
+
+import Control.Concurrent (threadDelay, forkIO, ThreadId)
+import Control.Monad (forever)
+
+import Model.Database
+
+import Job.Kind (Kind(..))
+import Job.Frequency (Frequency(..), microSeconds)
+import Job.Model (getLastExecution, actualizeLastCheck, actualizeLastExecution)
+import Job.MonthlyPayment (monthlyPayment)
+import Job.WeeklyReport (weeklyReport)
+
+import Conf (Conf)
+
+import Utils.Time (belongToCurrentMonth, belongToCurrentWeek)
+
+runDaemons :: Conf -> IO ()
+runDaemons conf = do
+ _ <- runDaemon MonthlyPayment EveryHour (fmap not . belongToCurrentMonth) monthlyPayment
+ _ <- runDaemon WeeklyReport EveryHour (fmap not . belongToCurrentWeek) (weeklyReport conf)
+ return ()
+
+runDaemon :: Kind -> Frequency -> (UTCTime -> IO Bool) -> (Maybe UTCTime -> IO UTCTime) -> IO ThreadId
+runDaemon kind frequency isLastExecutionTooOld runJob =
+ forkIO . forever $ do
+ mbLastExecution <- runDb $ do
+ actualizeLastCheck kind
+ getLastExecution kind
+ hasToRun <- case mbLastExecution of
+ Just lastExecution -> isLastExecutionTooOld lastExecution
+ Nothing -> return True
+ if hasToRun
+ then runJob mbLastExecution >>= (runDb . actualizeLastExecution kind)
+ else return ()
+ threadDelay . microSeconds $ frequency
diff --git a/src/server/Job/Frequency.hs b/src/server/Job/Frequency.hs
new file mode 100644
index 0000000..263f6e6
--- /dev/null
+++ b/src/server/Job/Frequency.hs
@@ -0,0 +1,13 @@
+module Job.Frequency
+ ( Frequency(..)
+ , microSeconds
+ ) where
+
+data Frequency =
+ EveryHour
+ | EveryDay
+ deriving (Eq, Read, Show)
+
+microSeconds :: Frequency -> Int
+microSeconds EveryHour = 1000000 * 60 * 60
+microSeconds EveryDay = (microSeconds EveryHour) * 24
diff --git a/src/server/Model/JobKind.hs b/src/server/Job/Kind.hs
index bbe1d45..473b7c4 100644
--- a/src/server/Model/JobKind.hs
+++ b/src/server/Job/Kind.hs
@@ -1,13 +1,14 @@
{-# LANGUAGE TemplateHaskell #-}
-module Model.JobKind
- ( JobKind(..)
+module Job.Kind
+ ( Kind(..)
) where
import Database.Persist.TH
-data JobKind =
- MonthlyPaymentJob
+data Kind =
+ MonthlyPayment
+ | WeeklyReport
deriving (Eq, Show, Read)
-derivePersistField "JobKind"
+derivePersistField "Kind"
diff --git a/src/server/Model/Job.hs b/src/server/Job/Model.hs
index 5b0d89d..cd7297a 100644
--- a/src/server/Model/Job.hs
+++ b/src/server/Job/Model.hs
@@ -1,4 +1,4 @@
-module Model.Job
+module Job.Model
( getLastExecution
, actualizeLastExecution
, actualizeLastCheck
@@ -12,22 +12,22 @@ import Data.Maybe (isJust)
import Database.Persist
import Model.Database
-import Model.JobKind
-getLastExecution :: JobKind -> Persist (Maybe UTCTime)
+import Job.Kind
+
+getLastExecution :: Kind -> Persist (Maybe UTCTime)
getLastExecution kind = do
mbJob <- fmap entityVal <$> selectFirst [JobKind ==. kind] []
return (mbJob >>= jobLastExecution)
-actualizeLastExecution :: JobKind -> Persist ()
-actualizeLastExecution kind = do
- now <- liftIO getCurrentTime
+actualizeLastExecution :: Kind -> UTCTime -> Persist ()
+actualizeLastExecution kind time = do
jobKindDefined <- isJust <$> selectFirst [JobKind ==. kind] []
if jobKindDefined
- then updateWhere [JobKind ==. kind] [JobLastExecution =. Just now]
- else insert (Job kind (Just now) (Just now)) >> return ()
+ then updateWhere [JobKind ==. kind] [JobLastExecution =. Just time]
+ else insert (Job kind (Just time) (Just time)) >> return ()
-actualizeLastCheck :: JobKind -> Persist ()
+actualizeLastCheck :: Kind -> Persist ()
actualizeLastCheck kind = do
now <- liftIO getCurrentTime
updateWhere [JobKind ==. kind] [JobLastCheck =. Just now]
diff --git a/src/server/Job/MonthlyPayment.hs b/src/server/Job/MonthlyPayment.hs
new file mode 100644
index 0000000..bac7062
--- /dev/null
+++ b/src/server/Job/MonthlyPayment.hs
@@ -0,0 +1,24 @@
+module Job.MonthlyPayment
+ ( monthlyPayment
+ ) where
+
+import Control.Monad.IO.Class (liftIO)
+
+import Data.Time.Clock (UTCTime, getCurrentTime)
+
+import Database.Persist (entityVal, insert)
+
+import Model.Database
+import qualified Model.Payment as Payment
+import Model.Frequency
+
+import Utils.Time (timeToDay)
+
+monthlyPayment :: Maybe UTCTime -> IO UTCTime
+monthlyPayment _ = runDb $ do
+ monthlyPayments <- map entityVal <$> Payment.listMonthly
+ now <- liftIO $ getCurrentTime
+ actualDay <- liftIO $ timeToDay now
+ let punctualPayments = map (\p -> p { paymentFrequency = Punctual, paymentDate = actualDay, paymentCreatedAt = now }) monthlyPayments
+ _ <- sequence $ map insert punctualPayments
+ return now
diff --git a/src/server/Job/WeeklyReport.hs b/src/server/Job/WeeklyReport.hs
new file mode 100644
index 0000000..0d1eb35
--- /dev/null
+++ b/src/server/Job/WeeklyReport.hs
@@ -0,0 +1,31 @@
+module Job.WeeklyReport
+ ( weeklyReport
+ ) where
+
+import Data.Time.Clock (UTCTime, getCurrentTime)
+
+import Model.Database (runDb)
+import qualified Model.Payment as Payment
+import qualified Model.Income as Income
+import Model.User (getUsers)
+
+import SendMail
+
+import Conf (Conf)
+
+import View.Mail.WeeklyReport (mail)
+
+weeklyReport :: Conf -> Maybe UTCTime -> IO UTCTime
+weeklyReport conf mbLastExecution = do
+ now <- getCurrentTime
+ case mbLastExecution of
+ Nothing -> return ()
+ Just lastExecution -> do
+ (payments, incomes, users) <- runDb $
+ (,,) <$>
+ Payment.modifiedDuring lastExecution now <*>
+ Income.modifiedDuring lastExecution now <*>
+ getUsers
+ _ <- sendMail (mail conf users payments incomes lastExecution now)
+ return ()
+ return now
diff --git a/src/server/Main.hs b/src/server/Main.hs
index 4636674..2ce8115 100644
--- a/src/server/Main.hs
+++ b/src/server/Main.hs
@@ -3,14 +3,10 @@
import Web.Scotty
import Network.Wai.Middleware.Static
-import Network.HTTP.Types.Status (ok200)
-import Control.Concurrent (forkIO)
+import Job.Daemon (runDaemons)
-import MonthlyPaymentJob (monthlyPaymentJobListener)
-
-import Data.Text (Text)
-import qualified Data.Text.IO as T
+import qualified Data.Text.Lazy as LT
import Controller.Index
import Controller.SignIn
@@ -24,48 +20,41 @@ import qualified Conf
main :: IO ()
main = do
runMigrations
- _ <- forkIO monthlyPaymentJobListener
- confOrError <- Conf.getConf "application.conf"
- case confOrError of
- Left errorMessage ->
- T.putStrLn errorMessage
- Right conf -> do
- scotty (Conf.port conf) $ do
- middleware $
- staticPolicy (noDots >-> addBase "public")
-
- get "/" $
- ( do
- signInToken <- param "signInToken" :: ActionM Text
- status ok200
- getIndex conf (Just signInToken)
- ) `rescue` (\_ -> do
- status ok200
- getIndex conf Nothing
- )
+ conf <- Conf.get "application.conf"
+ _ <- runDaemons conf
+ scotty (Conf.port conf) $ do
+ middleware . staticPolicy $ noDots >-> addBase "public"
- post "/signIn" $ do
- email <- param "email" :: ActionM Text
- signIn conf email
+ get "/" $ do
+ signInToken <- mbParam "signInToken"
+ getIndex conf signInToken
- post "/signOut" (signOut conf)
+ post "/signIn" $ do
+ email <- param "email"
+ signIn conf email
- -- Payments
+ post "/signOut" $
+ signOut conf
- post "/payment" $ jsonData >>= Payment.create
+ post "/payment" $
+ jsonData >>= Payment.create
- put "/payment" $ jsonData >>= Payment.editOwn
+ put "/payment" $
+ jsonData >>= Payment.editOwn
- delete "/payment" $ do
- paymentId <- param "id" :: ActionM Text
- Payment.deleteOwn paymentId
+ delete "/payment" $ do
+ paymentId <- param "id"
+ Payment.deleteOwn paymentId
- -- Incomes
+ post "/income" $
+ jsonData >>= Income.create
- post "/income" $ jsonData >>= Income.create
+ put "/income" $
+ jsonData >>= Income.editOwn
- put "/income" $ jsonData >>= Income.editOwn
+ delete "/income" $ do
+ incomeId <- param "id"
+ Income.deleteOwn incomeId
- delete "/income" $ do
- incomeId <- param "id" :: ActionM Text
- Income.deleteOwn incomeId
+mbParam :: Parsable a => LT.Text -> ActionM (Maybe a)
+mbParam key = (Just <$> param key) `rescue` (const . return $ Nothing)
diff --git a/src/server/Model/Database.hs b/src/server/Model/Database.hs
index 6a2fefe..7f8326e 100644
--- a/src/server/Model/Database.hs
+++ b/src/server/Model/Database.hs
@@ -22,8 +22,11 @@ import Data.Int (Int64)
import Database.Persist.Sqlite
import Database.Persist.TH
+import Resource (Resource, createdAt, editedAt, deletedAt)
+
import Model.Frequency
-import Model.JobKind
+
+import Job.Kind
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
User
@@ -51,7 +54,7 @@ SignIn
UniqSignInToken token
deriving Show
Job
- kind JobKind
+ kind Kind
lastExecution UTCTime Maybe
lastCheck UTCTime Maybe
UniqJobName kind
@@ -66,6 +69,16 @@ Income
deriving Show
|]
+instance Resource Payment where
+ createdAt = paymentCreatedAt
+ editedAt = paymentEditedAt
+ deletedAt = paymentDeletedAt
+
+instance Resource Income where
+ createdAt = incomeCreatedAt
+ editedAt = incomeEditedAt
+ deletedAt = incomeDeletedAt
+
type Persist a = SqlPersistT (ResourceT (NoLoggingT IO)) a
runDb :: Persist a -> IO a
diff --git a/src/server/Model/Income.hs b/src/server/Model/Income.hs
index f389661..b7dd11c 100644
--- a/src/server/Model/Income.hs
+++ b/src/server/Model/Income.hs
@@ -4,9 +4,10 @@ module Model.Income
, create
, editOwn
, deleteOwn
+ , modifiedDuring
) where
-import Data.Time.Clock (getCurrentTime)
+import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.Calendar (Day)
import Control.Monad.IO.Class (liftIO)
@@ -62,3 +63,12 @@ deleteOwn user incomeId = do
return False
Nothing ->
return False
+
+modifiedDuring :: UTCTime -> UTCTime -> Persist [Income]
+modifiedDuring start end =
+ map entityVal <$> selectList
+ ( [IncomeCreatedAt >=. start, IncomeCreatedAt <. end]
+ ||. [IncomeEditedAt >=. Just start, IncomeEditedAt <. Just end]
+ ||. [IncomeDeletedAt >=. Just start, IncomeDeletedAt <. Just end]
+ )
+ []
diff --git a/src/server/Model/Mail.hs b/src/server/Model/Mail.hs
index 7c1a6ed..9a4db73 100644
--- a/src/server/Model/Mail.hs
+++ b/src/server/Model/Mail.hs
@@ -3,11 +3,10 @@ module Model.Mail
) where
import Data.Text (Text)
-import qualified Data.Text.Lazy as LT
data Mail = Mail
{ from :: Text
, to :: [Text]
, subject :: Text
- , plainBody :: LT.Text
+ , plainBody :: Text
} deriving (Eq, Show)
diff --git a/src/server/Model/Message.hs b/src/server/Model/Message.hs
index 0e83e5b..026967f 100644
--- a/src/server/Model/Message.hs
+++ b/src/server/Model/Message.hs
@@ -2,6 +2,7 @@ module Model.Message
( getMessage
, getParamMessage
, getTranslations
+ , plural
) where
import Data.Text (Text)
@@ -28,3 +29,7 @@ getTranslation translationKey =
Translation
(T.pack . show $ translationKey)
(getParts $ getNonFormattedMessage lang translationKey)
+
+plural :: Int -> Key -> Key -> Text
+plural count singularKey pluralKey =
+ getParamMessage [T.pack . show $ count] (if count <= 1 then singularKey else pluralKey)
diff --git a/src/server/Model/Message/Key.hs b/src/server/Model/Message/Key.hs
index 8deca69..e64cfb5 100644
--- a/src/server/Model/Message/Key.hs
+++ b/src/server/Model/Message/Key.hs
@@ -77,7 +77,6 @@ data Key =
| PaymentName
| PaymentCost
- | PaymentDate
| PaymentPunctual
| PaymentMonthly
@@ -101,7 +100,6 @@ data Key =
| CloneIncome
| EditIncome
| IncomeNotDeleted
- | IncomeDate
| IncomeAmount
| ConfirmIncomeDelete
| Add
@@ -130,6 +128,26 @@ data Key =
| Confirm
| Undo
+ -- Weekly report
+
+ | WeeklyReport
+ | PaymentCreated
+ | PaymentsCreated
+ | PaymentEdited
+ | PaymentsEdited
+ | PaymentDeleted
+ | PaymentsDeleted
+ | IncomeCreated
+ | IncomesCreated
+ | IncomeEdited
+ | IncomesEdited
+ | IncomeDeleted
+ | IncomesDeleted
+ | PayedFor
+ | DidNotPayFor
+ | IsPayedFrom
+ | IsNotPayedFrom
+
-- Http error
| Timeout
diff --git a/src/server/Model/Message/Translations.hs b/src/server/Model/Message/Translations.hs
index 994a56c..cf11a5e 100644
--- a/src/server/Model/Message/Translations.hs
+++ b/src/server/Model/Message/Translations.hs
@@ -118,63 +118,63 @@ m l SignInEmailSent =
m l January =
case l of
- English -> "January"
- French -> "Janvier"
+ English -> "january"
+ French -> "janvier"
m l February =
case l of
- English -> "February"
- French -> "Février"
+ English -> "february"
+ French -> "février"
m l March =
case l of
- English -> "March"
- French -> "Mars"
+ English -> "march"
+ French -> "mars"
m l April =
case l of
- English -> "April"
- French -> "Avril"
+ English -> "april"
+ French -> "avril"
m l May =
case l of
- English -> "May"
- French -> "Mai"
+ English -> "may"
+ French -> "mai"
m l June =
case l of
- English -> "June"
- French -> "Juin"
+ English -> "june"
+ French -> "juin"
m l July =
case l of
- English -> "July"
- French -> "Juillet"
+ English -> "july"
+ French -> "juillet"
m l August =
case l of
- English -> "August"
- French -> "Août"
+ English -> "august"
+ French -> "août"
m l September =
case l of
- English -> "September"
- French -> "Septembre"
+ English -> "september"
+ French -> "septembre"
m l October =
case l of
- English -> "October"
- French -> "Octobre"
+ English -> "october"
+ French -> "octobre"
m l November =
case l of
- English -> "November"
- French -> "Novembre"
+ English -> "november"
+ French -> "novembre"
m l December =
case l of
- English -> "December"
- French -> "Décembre"
+ English -> "december"
+ French -> "décembre"
m l ShortDate =
case l of
@@ -322,11 +322,6 @@ m l PaymentCost =
English -> "Cost"
French -> "Coût"
-m l PaymentDate =
- case l of
- English -> "Date"
- French -> "Date"
-
m l PaymentPunctual =
case l of
English -> "Punctual"
@@ -371,7 +366,7 @@ m l Income =
m l MonthlyNetIncomes =
case l of
- English -> "Monthly incomes"
+ English -> "Net monthly incomes"
French -> "Revenus mensuels nets"
m l AddIncome =
@@ -394,11 +389,6 @@ m l IncomeNotDeleted =
English -> "The income could not have been deleted."
French -> "Le revenu n'a pas pu être supprimé."
-m l IncomeDate =
- case l of
- English -> "Date"
- French -> "Date"
-
m l IncomeAmount =
case l of
English -> "Amount"
@@ -495,6 +485,93 @@ m l Undo =
English -> "Undo"
French -> "Annuler"
+-- Weekly report
+
+m l WeeklyReport =
+ case l of
+ English -> "Weekly report"
+ French -> "Rapport hebdomadaire"
+
+m l PaymentCreated =
+ case l of
+ English -> "{1} payment created:"
+ French -> "{1} paiement créé :"
+
+m l PaymentsCreated =
+ case l of
+ English -> "{1} payments created:"
+ French -> "{1} paiements créés :"
+
+m l PaymentEdited =
+ case l of
+ English -> "{1} payment edited:"
+ French -> "{1} paiement modifié :"
+
+m l PaymentsEdited =
+ case l of
+ English -> "{1} payments edited:"
+ French -> "{1} paiements modifiés :"
+
+m l PaymentDeleted =
+ case l of
+ English -> "{1} payment deleted:"
+ French -> "{1} paiement supprimé :"
+
+m l PaymentsDeleted =
+ case l of
+ English -> "{1} payments deleted:"
+ French -> "{1} paiements supprimés :"
+
+m l IncomeCreated =
+ case l of
+ English -> "{1} income created:"
+ French -> "{1} revenu créé :"
+
+m l IncomesCreated =
+ case l of
+ English -> "{1} incomes created:"
+ French -> "{1} revenus créés :"
+
+m l IncomeEdited =
+ case l of
+ English -> "{1} income edited:"
+ French -> "{1} revenu modifié :"
+
+m l IncomesEdited =
+ case l of
+ English -> "{1} incomes edited:"
+ French -> "{1} revenus modifiés :"
+
+m l IncomeDeleted =
+ case l of
+ English -> "{1} income deleted:"
+ French -> "{1} revenu supprimé :"
+
+m l IncomesDeleted =
+ case l of
+ English -> "{1} incomes deleted:"
+ French -> "{1} revenus supprimés :"
+
+m l PayedFor =
+ case l of
+ English -> "{1} payed {2} for “{3}” at {4}"
+ French -> "{1} a payé {2} concernant « {3} » le {4}"
+
+m l DidNotPayFor =
+ case l of
+ English -> "{1} didn't pay {2} for “{3}” at {4}"
+ French -> "{1} n'a pas payé {2} concernant « {3} » le {4}"
+
+m l IsPayedFrom =
+ case l of
+ English -> "{1} is payed {2} of net monthly income from {3}"
+ French -> "{1} est payé {2} net par mois à partir du {3}"
+
+m l IsNotPayedFrom =
+ case l of
+ English -> "{1} isn't payed {2} of net monthly income from {3}"
+ French -> "{1} n'est pas payé {2} net par mois à partir du {3}"
+
-- Http error
m l Timeout =
diff --git a/src/server/Model/Payment.hs b/src/server/Model/Payment.hs
index 0d5e188..ac6cf0a 100644
--- a/src/server/Model/Payment.hs
+++ b/src/server/Model/Payment.hs
@@ -6,9 +6,11 @@ module Model.Payment
, create
, editOwn
, deleteOwn
+ , modifiedDuring
) where
import Data.Text (Text)
+import Data.Time (UTCTime)
import Data.Time.Clock (getCurrentTime)
import Data.Time.Calendar (Day)
@@ -86,3 +88,12 @@ deleteOwn userId paymentId = do
return False
Nothing ->
return False
+
+modifiedDuring :: UTCTime -> UTCTime -> Persist [Payment]
+modifiedDuring start end =
+ map entityVal <$> selectList
+ ( [PaymentFrequency ==. Punctual, PaymentCreatedAt >=. start, PaymentCreatedAt <. end]
+ ||. [PaymentFrequency ==. Punctual, PaymentEditedAt >=. Just start, PaymentEditedAt <. Just end]
+ ||. [PaymentFrequency ==. Punctual, PaymentDeletedAt >=. Just start, PaymentDeletedAt <. Just end]
+ )
+ []
diff --git a/src/server/Model/User.hs b/src/server/Model/User.hs
index 2b52d03..696ef4f 100644
--- a/src/server/Model/User.hs
+++ b/src/server/Model/User.hs
@@ -1,6 +1,7 @@
module Model.User
( getUsers
, getUser
+ , findUser
, getJsonUser
, createUser
, deleteUser
@@ -8,6 +9,7 @@ module Model.User
import Data.Text (Text)
import Data.Time.Clock (getCurrentTime)
+import Data.List (find)
import Control.Monad.IO.Class (liftIO)
@@ -22,6 +24,9 @@ getUsers = selectList [] [Desc UserCreation]
getUser :: Text -> Persist (Maybe (Entity User))
getUser email = selectFirst [UserEmail ==. email] []
+findUser :: UserId -> [Entity User] -> Maybe User
+findUser i = fmap entityVal . find ((==) i . entityKey)
+
getJsonUser :: Entity User -> Json.User
getJsonUser userEntity =
let user = entityVal userEntity
diff --git a/src/server/MonthlyPaymentJob.hs b/src/server/MonthlyPaymentJob.hs
deleted file mode 100644
index c4022c9..0000000
--- a/src/server/MonthlyPaymentJob.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-module MonthlyPaymentJob
- ( monthlyPaymentJobListener
- ) where
-
-import Control.Monad.IO.Class (liftIO)
-
-import Data.Time.Clock
-
-import Database.Persist (entityVal, insert)
-
-import Job (jobListener)
-
-import Model.Database
-import qualified Model.Payment as Payment
-import Model.JobKind
-import Model.Frequency
-
-import Utils.Time (belongToCurrentMonth, timeToDay)
-
-monthlyPaymentJobListener :: IO ()
-monthlyPaymentJobListener =
- let lastExecutionTooOld = fmap not . belongToCurrentMonth
- runJob () = monthlyPaymentJob
- msDelay = 1000000 * 60 * 60
- in jobListener MonthlyPaymentJob lastExecutionTooOld runJob msDelay
-
-monthlyPaymentJob :: Persist ()
-monthlyPaymentJob = do
- monthlyPayments <- map entityVal <$> Payment.listMonthly
- now <- liftIO $ getCurrentTime
- actualDay <- liftIO $ timeToDay now
- let punctualPayments = map (\p -> p { paymentFrequency = Punctual, paymentDate = actualDay, paymentCreatedAt = now }) monthlyPayments
- sequence_ $ map insert punctualPayments
diff --git a/src/server/Resource.hs b/src/server/Resource.hs
new file mode 100644
index 0000000..4dd8615
--- /dev/null
+++ b/src/server/Resource.hs
@@ -0,0 +1,50 @@
+module Resource
+ ( Resource
+ , createdAt
+ , editedAt
+ , deletedAt
+ , Status(..)
+ , groupByStatus
+ , statusDuring
+ ) where
+
+import Data.Maybe (fromMaybe)
+import Data.Map (Map)
+import qualified Data.Map as M
+import Data.Time.Clock (UTCTime)
+
+class Resource a where
+ createdAt :: a -> UTCTime
+ editedAt :: a -> Maybe UTCTime
+ deletedAt :: a -> Maybe UTCTime
+
+data Status =
+ Created
+ | Edited
+ | Deleted
+ deriving (Eq, Show, Read, Ord)
+
+groupByStatus :: Resource a => UTCTime -> UTCTime -> [a] -> Map Status [a]
+groupByStatus start end resources =
+ foldl
+ (\m resource ->
+ case statusDuring start end resource of
+ Just status -> M.insertWith (++) status [resource] m
+ Nothing -> m
+ )
+ M.empty
+ resources
+
+statusDuring :: Resource a => UTCTime -> UTCTime -> a -> Maybe Status
+statusDuring start end resource
+ | created && not deleted = Just Created
+ | not created && edited && not deleted = Just Edited
+ | not created && deleted = Just Deleted
+ | otherwise = Nothing
+ where
+ created = belongs (createdAt resource) start end
+ edited = fromMaybe False (fmap (\t -> belongs t start end) $ editedAt resource)
+ deleted = fromMaybe False (fmap (\t -> belongs t start end) $ deletedAt resource)
+
+belongs :: UTCTime -> UTCTime -> UTCTime -> Bool
+belongs time start end = time >= start && time < end
diff --git a/src/server/SendMail.hs b/src/server/SendMail.hs
index 7d537fc..e434b38 100644
--- a/src/server/SendMail.hs
+++ b/src/server/SendMail.hs
@@ -7,6 +7,8 @@ module SendMail
import Data.Text (Text)
import qualified Data.Text as T
import Data.Either (isLeft)
+import qualified Data.Text.Lazy as LT
+import Data.Text.Lazy.Builder (toLazyText, fromText)
import Control.Exception (SomeException, try)
import Control.Arrow (left)
@@ -28,7 +30,7 @@ getMimeMail (Mail mailFrom mailTo mailSubject mailPlainBody) =
let fromMail = M.emptyMail (address mailFrom)
in fromMail
{ M.mailTo = map address mailTo
- , M.mailParts = [ [ M.plainPart mailPlainBody ] ]
+ , M.mailParts = [ [ M.plainPart . strictToLazy $ mailPlainBody ] ]
, M.mailHeaders = [("Subject", mailSubject)]
}
@@ -38,3 +40,6 @@ address addressEmail =
{ M.addressName = Nothing
, M.addressEmail = addressEmail
}
+
+strictToLazy :: Text -> LT.Text
+strictToLazy = toLazyText . fromText
diff --git a/src/server/Utils/Time.hs b/src/server/Utils/Time.hs
index 170ab36..4a247e9 100644
--- a/src/server/Utils/Time.hs
+++ b/src/server/Utils/Time.hs
@@ -1,22 +1,44 @@
module Utils.Time
( belongToCurrentMonth
+ , belongToCurrentWeek
, timeToDay
+ , monthToKey
) where
-import Data.Time.Clock
+import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.LocalTime
import Data.Time.Calendar
+import Data.Time.Calendar.WeekDate (toWeekDate)
+
+import Model.Message.Key (Key)
+import qualified Model.Message.Key as K
belongToCurrentMonth :: UTCTime -> IO Bool
belongToCurrentMonth time = do
- timeMonth <- dayMonth <$> timeToDay time
- actualMonth <- dayMonth <$> (getCurrentTime >>= timeToDay)
- return (timeMonth == actualMonth)
+ (timeYear, timeMonth, _) <- toGregorian <$> timeToDay time
+ (actualYear, actualMonth, _) <- toGregorian <$> (getCurrentTime >>= timeToDay)
+ return (actualYear == timeYear && actualMonth == timeMonth)
+
+belongToCurrentWeek :: UTCTime -> IO Bool
+belongToCurrentWeek time = do
+ (timeYear, timeWeek, _) <- toWeekDate <$> timeToDay time
+ (actualYear, actualWeek, _) <- toWeekDate <$> (getCurrentTime >>= timeToDay)
+ return (actualYear == timeYear && actualWeek == timeWeek)
timeToDay :: UTCTime -> IO Day
timeToDay time = localDay . (flip utcToLocalTime time) <$> getTimeZone time
-dayMonth :: Day -> Int
-dayMonth day =
- let (_, month, _) = toGregorian day
- in month
+monthToKey :: Int -> Maybe Key
+monthToKey 1 = Just K.January
+monthToKey 2 = Just K.February
+monthToKey 3 = Just K.March
+monthToKey 4 = Just K.April
+monthToKey 5 = Just K.May
+monthToKey 6 = Just K.June
+monthToKey 7 = Just K.July
+monthToKey 8 = Just K.August
+monthToKey 9 = Just K.September
+monthToKey 10 = Just K.October
+monthToKey 11 = Just K.November
+monthToKey 12 = Just K.December
+monthToKey _ = Nothing
diff --git a/src/server/View/Format.hs b/src/server/View/Format.hs
new file mode 100644
index 0000000..354d46a
--- /dev/null
+++ b/src/server/View/Format.hs
@@ -0,0 +1,33 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module View.Format
+ ( price
+ ) where
+
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.List (intersperse)
+
+import Conf (Conf)
+import qualified Conf
+
+price :: Conf -> Int -> Text
+price conf amount = T.concat [number amount, " ", Conf.currency conf]
+
+number :: Int -> Text
+number n =
+ T.pack
+ . (++) (if n < 0 then "-" else "")
+ . reverse
+ . concat
+ . intersperse " "
+ . group 3
+ . reverse
+ . show
+ . abs $ n
+
+group :: Int -> [a] -> [[a]]
+group n xs =
+ if length xs <= n
+ then [xs]
+ else (take n xs) : (group n (drop n xs))
diff --git a/src/server/View/Mail/SignIn.hs b/src/server/View/Mail/SignIn.hs
index f776ddd..8eaa077 100644
--- a/src/server/View/Mail/SignIn.hs
+++ b/src/server/View/Mail/SignIn.hs
@@ -1,12 +1,10 @@
{-# LANGUAGE OverloadedStrings #-}
module View.Mail.SignIn
- ( getMail
+ ( mail
) where
import Data.Text (Text)
-import qualified Data.Text.Lazy as LT
-import Data.Text.Lazy.Builder (toLazyText, fromText)
import Model.Database (User(..))
import qualified Model.Mail as M
@@ -16,17 +14,11 @@ import Model.Message
import Conf (Conf)
import qualified Conf as Conf
-getMail :: Conf -> User -> Text -> [Text] -> M.Mail
-getMail conf user url to =
+mail :: Conf -> User -> Text -> [Text] -> M.Mail
+mail conf user url to =
M.Mail
{ M.from = Conf.noReplyMail conf
, M.to = to
, M.subject = (getMessage SignInMailTitle)
- , M.plainBody = plainBody user url
+ , M.plainBody = getParamMessage [userName user, url] SignInMail
}
-
-plainBody :: User -> Text -> LT.Text
-plainBody user url = strictToLazy (getParamMessage [userName user, url] SignInMail)
-
-strictToLazy :: Text -> LT.Text
-strictToLazy = toLazyText . fromText
diff --git a/src/server/View/Mail/WeeklyReport.hs b/src/server/View/Mail/WeeklyReport.hs
new file mode 100644
index 0000000..b333891
--- /dev/null
+++ b/src/server/View/Mail/WeeklyReport.hs
@@ -0,0 +1,124 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module View.Mail.WeeklyReport
+ ( mail
+ ) where
+
+import Data.Monoid ((<>))
+import Data.Maybe (catMaybes, fromMaybe)
+import Data.Map (Map)
+import qualified Data.Map as M
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Time.Clock (UTCTime)
+import Data.Time.Calendar (Day, toGregorian)
+import Data.List (sortOn)
+
+import Resource (Status(..), groupByStatus)
+
+import Database.Persist (Entity, entityVal)
+
+import Model.Database (Payment, Income, User, UserId)
+import qualified Model.Database as D
+import Model.Mail (Mail(Mail))
+import qualified Model.Mail as M
+import Model.Message (getMessage, getParamMessage, plural)
+import qualified Model.Message.Key as K
+import Model.User (findUser)
+
+import Conf (Conf)
+import qualified Conf as Conf
+
+import qualified View.Format as Format
+
+import Utils.Time (monthToKey)
+
+mail :: Conf -> [Entity User] -> [Payment] -> [Income] -> UTCTime -> UTCTime -> Mail
+mail conf users payments incomes start end =
+ Mail
+ { M.from = Conf.noReplyMail conf
+ , M.to = map (D.userEmail . entityVal) users
+ , M.subject = T.concat [getMessage K.SharedCost, " − ", getMessage K.WeeklyReport]
+ , M.plainBody = body conf users (groupByStatus start end payments) (groupByStatus start end incomes)
+ }
+
+body :: Conf -> [Entity User] -> Map Status [Payment] -> Map Status [Income] -> Text
+body conf users paymentsByStatus incomesByStatus =
+ T.intercalate "\n\n" . catMaybes $
+ [ paymentSection Created conf users <$> M.lookup Created paymentsByStatus
+ , paymentSection Edited conf users <$> M.lookup Edited paymentsByStatus
+ , paymentSection Deleted conf users <$> M.lookup Deleted paymentsByStatus
+ , incomeSection Created conf users <$> M.lookup Created incomesByStatus
+ , incomeSection Edited conf users <$> M.lookup Edited incomesByStatus
+ , incomeSection Deleted conf users <$> M.lookup Deleted incomesByStatus
+ ]
+
+paymentSection :: Status -> Conf -> [Entity User] -> [Payment] -> Text
+paymentSection status conf users payments =
+ section
+ (plural (length payments) singleKey pluralKey)
+ (map (payedFor status conf users) . sortOn D.paymentDate $ payments)
+ where (singleKey, pluralKey) =
+ case status of
+ Created -> (K.PaymentCreated, K.PaymentsCreated)
+ Edited -> (K.PaymentEdited, K.PaymentsEdited)
+ Deleted -> (K.PaymentDeleted, K.PaymentsDeleted)
+
+payedFor :: Status -> Conf -> [Entity User] -> Payment -> Text
+payedFor status conf users payment =
+ getParamMessage
+ [ formatUserName (D.paymentUserId payment) users
+ , Format.price conf . D.paymentCost $ payment
+ , D.paymentName payment
+ , formatDay $ D.paymentDate payment
+ ]
+ ( case status of
+ Created -> K.PayedFor
+ Edited -> K.PayedFor
+ Deleted -> K.DidNotPayFor
+ )
+
+incomeSection :: Status -> Conf -> [Entity User] -> [Income] -> Text
+incomeSection status conf users incomes =
+ section
+ (plural (length incomes) singleKey pluralKey)
+ (map (isPayedFrom status conf users) . sortOn D.incomeDate $ incomes)
+ where (singleKey, pluralKey) =
+ case status of
+ Created -> (K.IncomeCreated, K.IncomesCreated)
+ Edited -> (K.IncomeEdited, K.IncomesEdited)
+ Deleted -> (K.IncomeDeleted, K.IncomesDeleted)
+
+isPayedFrom :: Status -> Conf -> [Entity User] -> Income -> Text
+isPayedFrom status conf users income =
+ getParamMessage
+ [ formatUserName (D.incomeUserId income) users
+ , Format.price conf . D.incomeAmount $ income
+ , formatDay $ D.incomeDate income
+ ]
+ ( case status of
+ Created -> K.IsPayedFrom
+ Edited -> K.IsPayedFrom
+ Deleted -> K.IsNotPayedFrom
+ )
+
+formatUserName :: UserId -> [Entity User] -> Text
+formatUserName userId = fromMaybe "−" . fmap D.userName . findUser userId
+
+formatDay :: Day -> Text
+formatDay d =
+ let (year, month, day) = toGregorian d
+ in getParamMessage
+ [ T.pack . show $ day
+ , fromMaybe "−" . fmap getMessage . monthToKey $ month
+ , T.pack . show $ year
+ ]
+ K.LongDate
+
+section :: Text -> [Text] -> Text
+section title items =
+ T.concat
+ [ title
+ , "\n"
+ , T.unlines . map (" - " <>) $ items
+ ]