aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2019-08-10 14:53:41 +0200
committerJoris2019-08-10 14:53:41 +0200
commitfb8f0fe577e28dae69903413b761da50586e0099 (patch)
tree91149151facf24348ce1f9798edd5c70be795d11
parent3943c50d5320f7137bd5acec4485dd56a2aa52b3 (diff)
Remove payment category if unused after a payment is deleted
-rw-r--r--README.md11
-rw-r--r--client/src/View/Payment.hs49
-rw-r--r--client/src/View/Payment/Add.hs3
-rw-r--r--client/src/View/Payment/Delete.hs35
-rw-r--r--client/src/View/Payment/Table.hs12
-rw-r--r--common/src/Common/Message/Key.hs1
-rw-r--r--common/src/Common/Message/Translation.hs5
-rw-r--r--server/migrations/2.sql23
-rw-r--r--server/src/Controller/Income.hs33
-rw-r--r--server/src/Controller/Payment.hs37
-rw-r--r--server/src/Main.hs8
-rw-r--r--server/src/Persistence/Income.hs34
-rw-r--r--server/src/Persistence/Payment.hs34
-rw-r--r--server/src/Persistence/PaymentCategory.hs14
14 files changed, 163 insertions, 136 deletions
diff --git a/README.md b/README.md
index 39dd428..dbffc41 100644
--- a/README.md
+++ b/README.md
@@ -60,10 +60,9 @@ See [application.conf](application.conf).
### Payment view
-- When removing / modifying a payment, remove the payment category if there is
- no other payment with the same name
- Check monthly payment UX
- Edit a payment.
+- Possibly remove payment category after payment edit
- Clone a payment.
### Income view
@@ -89,11 +88,15 @@ See [application.conf](application.conf).
### Code
+- Move the CSS out from the index page
- Add tests about exceedingPayers
-- Move up element ids security (editOwn is actually at db level).
- try DuplicateRecordFields (https://ghc.haskell.org/trac/ghc/wiki/Records/OverloadedRecordFields/DuplicateRecordFields)
+### DB
+
+- Add DB indexes
+
### Tooling
-- use Obelisk
+- deploy command
- migration diff (use flyway?).
diff --git a/client/src/View/Payment.hs b/client/src/View/Payment.hs
index 915cc18..46ab642 100644
--- a/client/src/View/Payment.hs
+++ b/client/src/View/Payment.hs
@@ -45,18 +45,14 @@ widget paymentIn = do
paymentCategories <- getPaymentCategories
(_init_paymentCategories init)
(_createdPayment_paymentCategory <$> _headerOut_addPayment header)
+ payments
+ (_tableOut_deletePayment table)
- let searchPayments =
- getSearchPayments
- debouncedSearchName
- (_headerOut_searchFrequency header)
- payments
-
- debouncedSearchNameEvt <-
- R.debounce (0.5 :: NominalDiffTime) (R.updated $ _headerOut_searchName header)
+ (searchNameEvent, searchName) <-
+ debounceSearchName (_headerOut_searchName header)
- debouncedSearchName <-
- R.holdDyn "" debouncedSearchNameEvt
+ let searchPayments =
+ getSearchPayments searchName (_headerOut_searchFrequency header) payments
header <- Header.widget $ HeaderIn
{ _headerIn_init = init
@@ -77,34 +73,57 @@ widget paymentIn = do
{ _pagesIn_total = length <$> searchPayments
, _pagesIn_perPage = paymentsPerPage
, _pagesIn_reset = R.leftmost $
- [ const () <$> debouncedSearchNameEvt
+ [ const () <$> searchNameEvent
, const () <$> _headerOut_addPayment header
]
}
pure $ PaymentOut {}
+debounceSearchName
+ :: forall t m. MonadWidget t m
+ => Dynamic t Text
+ -> m (Event t Text, Dynamic t Text)
+debounceSearchName searchName = do
+ event <- R.debounce (0.5 :: NominalDiffTime) (R.updated searchName)
+ dynamic <- R.holdDyn "" event
+ return (event, dynamic)
+
getPayments
:: forall t m. MonadWidget t m
=> [Payment]
-> Event t Payment
- -> Event t PaymentId
+ -> Event t Payment
-> m (Dynamic t [Payment])
getPayments initPayments addPayment deletePayment =
R.foldDyn id initPayments $ R.leftmost
[ (:) <$> addPayment
- , flip fmap deletePayment (\paymentId -> filter ((/= paymentId) . _payment_id))
+ , R.ffor deletePayment (\p -> filter ((/= (_payment_id p)) . _payment_id))
]
getPaymentCategories
:: forall t m. MonadWidget t m
=> [PaymentCategory]
- -> Event t PaymentCategory
+ -> Event t PaymentCategory -- add payment category
+ -> Dynamic t [Payment] -- payments
+ -> Event t Payment -- delete payment
-> m (Dynamic t [PaymentCategory])
-getPaymentCategories initPaymentCategories addPaymentCategory =
+getPaymentCategories initPaymentCategories addPaymentCategory payments deletePayment =
R.foldDyn id initPaymentCategories $ R.leftmost
[ (:) <$> addPaymentCategory
+ , R.ffor deletePaymentName (\name -> filter ((/=) (T.toLower name) . _paymentCategory_name))
]
+ where
+ deletePaymentName =
+ R.attachWithMaybe
+ (\ps p ->
+ if any (\p2 -> _payment_id p2 /= _payment_id p && lowerName p2 == lowerName p) ps then
+ Nothing
+ else
+ Just (_payment_name p))
+ (R.current payments)
+ deletePayment
+ lowerName = T.toLower . _payment_name
getSearchPayments
:: forall t. Reflex t
diff --git a/client/src/View/Payment/Add.hs b/client/src/View/Payment/Add.hs
index e0772f7..bd10e5a 100644
--- a/client/src/View/Payment/Add.hs
+++ b/client/src/View/Payment/Add.hs
@@ -23,7 +23,6 @@ import Common.Model (Category (..), CategoryId,
CreatedPayment (..), Frequency (..),
Payment (..), PaymentCategory (..))
import qualified Common.Msg as Msg
-import qualified Common.Util.Text as Text
import qualified Common.Util.Time as Time
import qualified Common.Validation.Payment as PaymentValidation
import Component (ButtonIn (..), InputIn (..),
@@ -168,4 +167,4 @@ view addIn = do
findCategory :: Text -> [PaymentCategory] -> Maybe CategoryId
findCategory paymentName =
fmap _paymentCategory_category
- . L.find ((==) (Text.formatSearch paymentName) . _paymentCategory_name)
+ . L.find ((==) (T.toLower paymentName) . _paymentCategory_name)
diff --git a/client/src/View/Payment/Delete.hs b/client/src/View/Payment/Delete.hs
index 4aa10f3..65ce660 100644
--- a/client/src/View/Payment/Delete.hs
+++ b/client/src/View/Payment/Delete.hs
@@ -4,26 +4,26 @@ module View.Payment.Delete
, DeleteOut(..)
) where
-import Data.Text (Text)
-import qualified Data.Text as T
-import Reflex.Dom (Dynamic, Event, MonadWidget)
-import qualified Reflex.Dom as R
-
-import Common.Model.Payment (PaymentId)
-import qualified Common.Msg as Msg
-import Component (ButtonIn (..), ButtonOut (..))
-import qualified Component as Component
-import qualified Util.Ajax as Ajax
-import qualified Util.Either as EitherUtil
-import qualified Util.WaitFor as WaitFor
+import Data.Text (Text)
+import qualified Data.Text as T
+import Reflex.Dom (Dynamic, Event, MonadWidget)
+import qualified Reflex.Dom as R
+
+import Common.Model (Payment (..))
+import qualified Common.Msg as Msg
+import Component (ButtonIn (..), ButtonOut (..))
+import qualified Component as Component
+import qualified Util.Ajax as Ajax
+import qualified Util.Either as EitherUtil
+import qualified Util.WaitFor as WaitFor
data DeleteIn t = DeleteIn
- { _deleteIn_id :: Dynamic t PaymentId
+ { _deleteIn_payment :: Dynamic t Payment
}
data DeleteOut t = DeleteOut
{ _deleteOut_cancel :: Event t ()
- , _deleteOut_validate :: Event t PaymentId
+ , _deleteOut_validate :: Event t Payment
}
view :: forall t m. MonadWidget t m => (DeleteIn t) -> m (DeleteOut t)
@@ -47,8 +47,9 @@ view deleteIn =
, _buttonIn_waiting = waiting
})
- let url = flip fmap (_deleteIn_id deleteIn) (\id ->
- T.concat ["/payment/", T.pack . show $ id]
+ let url =
+ R.ffor (_deleteIn_payment deleteIn) (\id ->
+ T.concat ["/payment/", T.pack . show $ _payment_id id]
)
(result, waiting) <- WaitFor.waitFor
@@ -59,5 +60,5 @@ view deleteIn =
return DeleteOut
{ _deleteOut_cancel = cancel
- , _deleteOut_validate = R.tag (R.current $ _deleteIn_id deleteIn) deletedPayment
+ , _deleteOut_validate = R.tag (R.current $ _deleteIn_payment deleteIn) deletedPayment
}
diff --git a/client/src/View/Payment/Table.hs b/client/src/View/Payment/Table.hs
index cdc4bb3..b09f30f 100644
--- a/client/src/View/Payment/Table.hs
+++ b/client/src/View/Payment/Table.hs
@@ -13,11 +13,9 @@ import Reflex.Dom (Dynamic, Event, MonadWidget)
import qualified Reflex.Dom as R
import Common.Model (Category (..), Init (..), Payment (..),
- PaymentCategory (..), PaymentId,
- User (..))
+ PaymentCategory (..), User (..))
import qualified Common.Model as CM
import qualified Common.Msg as Msg
-import qualified Common.Util.Text as T
import qualified Common.View.Format as Format
import Component (ButtonIn (..), ButtonOut (..),
ModalIn (..), ModalOut (..))
@@ -37,7 +35,7 @@ data TableIn t = TableIn
}
data TableOut t = TableOut
- { _tableOut_deletePayment :: Event t PaymentId
+ { _tableOut_deletePayment :: Event t Payment
}
widget :: forall t m. MonadWidget t m => TableIn t -> m (TableOut t)
@@ -84,7 +82,7 @@ paymentRow
=> Init
-> Dynamic t [PaymentCategory]
-> Dynamic t Payment
- -> m (Event t PaymentId)
+ -> m (Event t Payment)
paymentRow init paymentCategories payment =
R.divClass "row" $ do
@@ -151,13 +149,13 @@ paymentRow init paymentCategories payment =
[ _deleteOut_cancel . _modalOut_content $ modalOut
, fmap (const ()) . _deleteOut_validate . _modalOut_content $ modalOut
]
- , _modalIn_content = Delete.view (DeleteIn { _deleteIn_id = fmap _payment_id payment })
+ , _modalIn_content = Delete.view (DeleteIn { _deleteIn_payment = payment })
}
return (_deleteOut_validate . _modalOut_content $ modalOut)
findCategory :: [Category] -> [PaymentCategory] -> Text -> Maybe Category
findCategory categories paymentCategories paymentName = do
paymentCategory <- L.find
- ((== T.formatSearch paymentName) . _paymentCategory_name)
+ ((== T.toLower paymentName) . _paymentCategory_name)
paymentCategories
L.find ((== (_paymentCategory_category paymentCategory)) . _category_id) categories
diff --git a/common/src/Common/Message/Key.hs b/common/src/Common/Message/Key.hs
index 4acba93..e460d3e 100644
--- a/common/src/Common/Message/Key.hs
+++ b/common/src/Common/Message/Key.hs
@@ -64,7 +64,6 @@ data Key =
| Income_Edit
| Income_Empty
| Income_MonthlyNet
- | Income_NotDeleted
| Income_Title
| Month_January
diff --git a/common/src/Common/Message/Translation.hs b/common/src/Common/Message/Translation.hs
index 4eb0523..6b9e7be 100644
--- a/common/src/Common/Message/Translation.hs
+++ b/common/src/Common/Message/Translation.hs
@@ -277,11 +277,6 @@ m l Income_MonthlyNet =
English -> "Net monthly incomes"
French -> "Revenus mensuels nets"
-m l Income_NotDeleted =
- case l of
- English -> "The income could not have been deleted."
- French -> "Le revenu n’a pas pu être supprimé."
-
m l Income_Title =
case l of
English -> "Income"
diff --git a/server/migrations/2.sql b/server/migrations/2.sql
new file mode 100644
index 0000000..1c829ec
--- /dev/null
+++ b/server/migrations/2.sql
@@ -0,0 +1,23 @@
+-- Add payment categories with accents from payment with accents
+
+INSERT INTO
+ payment_category (name, category, created_at)
+SELECT
+ DISTINCT lower(payment.name), payment_category.category, datetime('now')
+FROM
+ payment
+INNER JOIN
+ payment_category
+ON
+ replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower(payment.name), 'é', 'e'), 'è', 'e'), 'à', 'a'), 'û', 'u'), 'â', 'a'), 'ê', 'e'), 'â', 'a'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ë', 'e') = payment_category.name
+WHERE
+ payment.name
+IN
+ (SELECT DISTINCT payment.name FROM payment WHERE lower(payment.name) NOT IN (SELECT payment_category.name FROM payment_category) AND payment.deleted_at IS NULL);
+
+-- Remove unused payment categories
+
+DELETE FROM
+ payment_category
+WHERE
+ name NOT IN (SELECT DISTINCT lower(name) FROM payment);
diff --git a/server/src/Controller/Income.hs b/server/src/Controller/Income.hs
index 3f623e5..ed58ac8 100644
--- a/server/src/Controller/Income.hs
+++ b/server/src/Controller/Income.hs
@@ -1,17 +1,15 @@
module Controller.Income
( create
- , editOwn
- , deleteOwn
+ , edit
+ , delete
) where
import Control.Monad.IO.Class (liftIO)
-import qualified Data.Text.Lazy as TL
-import Network.HTTP.Types.Status (badRequest400, ok200)
-import Web.Scotty
+import qualified Network.HTTP.Types.Status as Status
+import Web.Scotty hiding (delete)
import Common.Model (CreateIncome (..), EditIncome (..),
IncomeId, User (..))
-import qualified Common.Msg as Msg
import Json (jsonId)
import qualified Model.Query as Query
@@ -24,23 +22,18 @@ create (CreateIncome date amount) =
(liftIO . Query.run $ IncomePersistence.create (_user_id user) date amount) >>= jsonId
)
-editOwn :: EditIncome -> ActionM ()
-editOwn (EditIncome incomeId date amount) =
+edit :: EditIncome -> ActionM ()
+edit (EditIncome incomeId date amount) =
Secure.loggedAction (\user -> do
- updated <- liftIO . Query.run $ IncomePersistence.editOwn (_user_id user) incomeId date amount
+ updated <- liftIO . Query.run $ IncomePersistence.edit (_user_id user) incomeId date amount
if updated
- then status ok200
- else status badRequest400
+ then status Status.ok200
+ else status Status.badRequest400
)
-deleteOwn :: IncomeId -> ActionM ()
-deleteOwn incomeId =
+delete :: IncomeId -> ActionM ()
+delete incomeId =
Secure.loggedAction (\user -> do
- deleted <- liftIO . Query.run $ IncomePersistence.deleteOwn user incomeId
- if deleted
- then
- status ok200
- else do
- status badRequest400
- text . TL.fromStrict $ Msg.get Msg.Income_NotDeleted
+ _ <- liftIO . Query.run $ IncomePersistence.delete (_user_id user) incomeId
+ status Status.ok200
)
diff --git a/server/src/Controller/Payment.hs b/server/src/Controller/Payment.hs
index e82fd49..3d857be 100644
--- a/server/src/Controller/Payment.hs
+++ b/server/src/Controller/Payment.hs
@@ -1,18 +1,18 @@
module Controller.Payment
( list
, create
- , editOwn
- , deleteOwn
+ , edit
+ , delete
) where
import Control.Monad.IO.Class (liftIO)
import qualified Network.HTTP.Types.Status as Status
-import Web.Scotty
+import Web.Scotty hiding (delete)
import Common.Model (CreatePayment (..),
CreatedPayment (..),
- EditPayment (..), PaymentId,
- User (..))
+ EditPayment (..), Payment (..),
+ PaymentId, User (..))
import qualified Model.Query as Query
import qualified Persistence.Payment as PaymentPersistence
import qualified Persistence.PaymentCategory as PaymentCategoryPersistence
@@ -41,11 +41,11 @@ create createPayment@(CreatePayment name cost date category frequency) =
json validationError
)
-editOwn :: EditPayment -> ActionM ()
-editOwn (EditPayment paymentId name cost date category frequency) =
+edit :: EditPayment -> ActionM ()
+edit (EditPayment paymentId name cost date category frequency) =
Secure.loggedAction (\user -> do
updated <- liftIO . Query.run $ do
- edited <- PaymentPersistence.editOwn (_user_id user) paymentId name cost date frequency
+ edited <- PaymentPersistence.edit (_user_id user) paymentId name cost date frequency
_ <- if edited
then PaymentCategoryPersistence.save name category >> return ()
else return ()
@@ -55,11 +55,20 @@ editOwn (EditPayment paymentId name cost date category frequency) =
else status Status.badRequest400
)
-deleteOwn :: PaymentId -> ActionM ()
-deleteOwn paymentId =
+delete :: PaymentId -> ActionM ()
+delete paymentId =
Secure.loggedAction (\user -> do
- deleted <- liftIO . Query.run $ PaymentPersistence.deleteOwn (_user_id user) paymentId
- if deleted
- then status Status.ok200
- else status Status.badRequest400
+ deleted <- liftIO . Query.run $ do
+ payment <- PaymentPersistence.find paymentId
+ case payment of
+ Just p | _payment_user p == _user_id user -> do
+ PaymentPersistence.delete (_user_id user) paymentId
+ PaymentCategoryPersistence.deleteIfUnused (_payment_name p)
+ return True
+ _ ->
+ return False
+ if deleted then
+ status Status.ok200
+ else
+ status Status.badRequest400
)
diff --git a/server/src/Main.hs b/server/src/Main.hs
index 745071c..0ccf5e2 100644
--- a/server/src/Main.hs
+++ b/server/src/Main.hs
@@ -35,21 +35,21 @@ main = do
S.jsonData >>= Payment.create
S.put "/payment" $
- S.jsonData >>= Payment.editOwn
+ S.jsonData >>= Payment.edit
S.delete "/payment/:id" $ do
paymentId <- S.param "id"
- Payment.deleteOwn paymentId
+ Payment.delete paymentId
S.post "/income" $
S.jsonData >>= Income.create
S.put "/income" $
- S.jsonData >>= Income.editOwn
+ S.jsonData >>= Income.edit
S.delete "/income/:id" $ do
incomeId <- S.param "id"
- Income.deleteOwn incomeId
+ Income.delete incomeId
S.post "/category" $
S.jsonData >>= Category.create
diff --git a/server/src/Persistence/Income.hs b/server/src/Persistence/Income.hs
index a863f85..cee9892 100644
--- a/server/src/Persistence/Income.hs
+++ b/server/src/Persistence/Income.hs
@@ -1,8 +1,8 @@
module Persistence.Income
( list
, create
- , editOwn
- , deleteOwn
+ , edit
+ , delete
) where
import Data.Maybe (listToMaybe)
@@ -12,7 +12,7 @@ import Database.SQLite.Simple (FromRow (fromRow), Only (Only))
import qualified Database.SQLite.Simple as SQLite
import Prelude hiding (id)
-import Common.Model (Income (..), IncomeId, User (..),
+import Common.Model (Income (..), IncomeId, PaymentId,
UserId)
import Model.Query (Query (Query))
@@ -47,8 +47,8 @@ create incomeUserId incomeDate incomeAmount =
SQLite.lastInsertRowId conn
)
-editOwn :: UserId -> IncomeId -> Day -> Int -> Query Bool
-editOwn incomeUserId incomeId incomeDate incomeAmount =
+edit :: UserId -> IncomeId -> Day -> Int -> Query Bool
+edit incomeUserId incomeId incomeDate incomeAmount =
Query (\conn -> do
mbIncome <- fmap (\(Row i) -> i) . listToMaybe <$>
SQLite.query conn "SELECT * FROM income WHERE id = ?" (Only incomeId)
@@ -68,21 +68,11 @@ editOwn incomeUserId incomeId incomeDate incomeAmount =
return False
)
-deleteOwn :: User -> IncomeId -> Query Bool
-deleteOwn user incomeId =
- Query (\conn -> do
- mbIncome <-
- fmap (\(Row i) -> i) . listToMaybe <$>
- SQLite.query conn "SELECT * FROM income WHERE id = ?" (Only incomeId)
- case mbIncome of
- Just income ->
- if _income_userId income == _user_id user
- then do
- now <- getCurrentTime
- SQLite.execute conn "UPDATE income SET deleted_at = ? WHERE id = ?" (now, incomeId)
- return True
- else
- return False
- Nothing ->
- return False
+delete :: UserId -> PaymentId -> Query ()
+delete userId paymentId =
+ Query (\conn ->
+ SQLite.execute
+ conn
+ "UPDATE income SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?"
+ (paymentId, userId)
)
diff --git a/server/src/Persistence/Payment.hs b/server/src/Persistence/Payment.hs
index 272cd39..3d8f129 100644
--- a/server/src/Persistence/Payment.hs
+++ b/server/src/Persistence/Payment.hs
@@ -6,8 +6,8 @@ module Persistence.Payment
, listActiveMonthlyOrderedByName
, create
, createMany
- , editOwn
- , deleteOwn
+ , edit
+ , delete
) where
import Data.Maybe (listToMaybe)
@@ -129,8 +129,8 @@ createMany payments =
(map InsertRow payments)
)
-editOwn :: UserId -> PaymentId -> Text -> Int -> Day -> Frequency -> Query Bool
-editOwn userId paymentId paymentName paymentCost paymentDate paymentFrequency =
+edit :: UserId -> PaymentId -> Text -> Int -> Day -> Frequency -> Query Bool
+edit userId paymentId paymentName paymentCost paymentDate paymentFrequency =
Query (\conn -> do
mbPayment <- fmap (\(Row p) -> p) . listToMaybe <$>
SQLite.query conn "SELECT * FROM payment WHERE id = ?" (Only paymentId)
@@ -158,23 +158,11 @@ editOwn userId paymentId paymentName paymentCost paymentDate paymentFrequency =
return False
)
-deleteOwn :: UserId -> PaymentId -> Query Bool
-deleteOwn userId paymentId =
- Query (\conn -> do
- mbPayment <- listToMaybe <$>
- SQLite.query conn "SELECT * FROM payment WHERE id = ?" (Only paymentId)
- case mbPayment of
- Just (Row payment) ->
- if _payment_user payment == userId
- then do
- now <- getCurrentTime
- SQLite.execute
- conn
- "UPDATE payment SET deleted_at = ? WHERE id = ?"
- (now, paymentId)
- return True
- else
- return False
- Nothing ->
- return False
+delete :: UserId -> PaymentId -> Query ()
+delete userId paymentId =
+ Query (\conn ->
+ SQLite.execute
+ conn
+ "UPDATE payment SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?"
+ (paymentId, userId)
)
diff --git a/server/src/Persistence/PaymentCategory.hs b/server/src/Persistence/PaymentCategory.hs
index 5fd035a..7dc363c 100644
--- a/server/src/Persistence/PaymentCategory.hs
+++ b/server/src/Persistence/PaymentCategory.hs
@@ -2,16 +2,17 @@ module Persistence.PaymentCategory
( list
, listByCategory
, save
+ , deleteIfUnused
) where
import qualified Data.Maybe as Maybe
import Data.Text (Text)
+import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Database.SQLite.Simple (FromRow (fromRow), Only (Only))
import qualified Database.SQLite.Simple as SQLite
import Common.Model (CategoryId, PaymentCategory (..))
-import qualified Common.Util.Text as T
import Model.Query (Query (Query))
@@ -76,4 +77,13 @@ save newName categoryId =
Nothing
)
where
- formattedNewName = T.formatSearch newName
+ formattedNewName = T.toLower newName
+
+deleteIfUnused :: Text -> Query ()
+deleteIfUnused name =
+ Query (\conn ->
+ SQLite.execute
+ conn
+ "DELETE FROM payment_category WHERE name = lower(?) AND name IN (SELECT DISTINCT lower(name) FROM payment WHERE name = lower(?) AND deleted_at IS NOT NULL)"
+ (name, name)
+ ) >> return ()