-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathCart.hs
More file actions
309 lines (278 loc) · 9.06 KB
/
Copy pathCart.hs
File metadata and controls
309 lines (278 loc) · 9.06 KB
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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
{-# LANGUAGE QuasiQuotes #-}
module App.Cart
( CartException (CartException),
CartId (CartId, unCartId),
BookingId (BookingId, unBookingId),
PaymentId (PaymentId, unPaymentId),
HasCartConfig
( getBookingUrl,
getBookingDelay,
getPaymentUrl,
getPaymentDelay
),
CartStatus (CartStatusOpen, CartStatusLocked, CartStatusPurchased),
getCartStatus,
markCartAsPurchased,
withCart,
processBooking,
processPayment,
)
where
import App.Db (HasDbPool, withConn)
import App.Json (defaultParseJSON, defaultToJSON)
import App.Req (isStatusCodeException')
import Blammo.Logging (Message ((:#)), MonadLogger, logInfo, logWarn, (.=))
import Control.Concurrent (threadDelay)
import Control.Monad (void, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Reader (MonadReader, asks)
import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON))
import Data.Maybe (fromJust)
import Data.String.Conversions (cs)
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8)
import Database.PostgreSQL.Simple
( Only (Only),
ResultError (ConversionFailed, UnexpectedNull),
execute,
query,
)
import Database.PostgreSQL.Simple.FromField (FromField (fromField), returnError)
import Database.PostgreSQL.Simple.SqlQQ (sql)
import Database.PostgreSQL.Simple.ToField (ToField (toField))
import GHC.Generics (Generic)
import Network.HTTP.Req
( HttpException,
JsonResponse,
MonadHttp,
POST (POST),
ReqBodyJson (ReqBodyJson),
jsonResponse,
req,
responseBody,
responseStatusCode,
useHttpURI,
)
import Text.URI (mkURI)
import UnliftIO (Exception, MonadUnliftIO, Typeable)
import UnliftIO.Exception (bracketOnError_, catch, throwIO)
newtype CartException = CartException Text
deriving (Show, Typeable)
instance Exception CartException
newtype CartId = CartId {unCartId :: Text}
deriving (Eq, Read, Show, Generic, ToJSON, ToField)
class HasCartConfig env where
getBookingUrl :: env -> Text
getBookingDelay :: env -> Int
getPaymentUrl :: env -> Text
getPaymentDelay :: env -> Int
newtype BookingId = BookingId {unBookingId :: Text}
deriving (FromJSON, ToJSON)
newtype PaymentId = PaymentId {unPaymentId :: Text}
deriving (FromJSON, ToJSON)
data BookingRequest = BookingRequest
{ bookingRequestVenue :: Text,
bookingRequestSeats :: [Text]
}
deriving (Generic)
instance ToJSON BookingRequest where
toJSON = defaultToJSON "bookingRequest"
data BookingResponse = BookingResponse
{ bookingResponseBookingId :: BookingId,
bookingResponseSeatsReserved :: Int
}
deriving (Generic)
instance FromJSON BookingResponse where
parseJSON = defaultParseJSON "bookingResponse"
data PaymentRequest = PaymentRequest
{ paymentRequestCardholderName :: Text,
paymentRequestCardNumber :: Text
}
deriving (Generic)
instance ToJSON PaymentRequest where
toJSON = defaultToJSON "paymentRequest"
data PaymentResponse = PaymentResponse
{ paymentResponsePaymentId :: PaymentId,
paymentResponseAmountCharged :: Int
}
deriving (Generic)
instance FromJSON PaymentResponse where
parseJSON = defaultParseJSON "paymentResponse"
data CartStatus
= CartStatusOpen
| CartStatusLocked
| CartStatusPurchased
deriving (Eq, Generic)
cartStatusFromText :: Text -> Maybe CartStatus
cartStatusFromText v = case v of
"open" -> Just CartStatusOpen
"locked" -> Just CartStatusLocked
"purchased" -> Just CartStatusPurchased
_ -> Nothing
cartStatusToText :: CartStatus -> Text
cartStatusToText v = case v of
CartStatusOpen -> "open"
CartStatusLocked -> "locked"
CartStatusPurchased -> "purchased"
instance FromJSON CartStatus where
parseJSON = defaultParseJSON "CartStatus"
instance ToJSON CartStatus where
toJSON = defaultToJSON "CartStatus"
cartStatusSqlType :: Text
cartStatusSqlType = "cart_status"
instance FromField CartStatus where
fromField f Nothing =
returnError UnexpectedNull f err
where
err = "Expected SQL type " <> cs cartStatusSqlType <> ", but got null"
fromField f (Just bs) =
case cartStatusFromText $ decodeUtf8 bs of
Just v -> pure v
Nothing -> returnError ConversionFailed f err
where
err = "Expected SQL type " <> cs cartStatusSqlType <> ", but got invalid value " <> cs bs
instance ToField CartStatus where
toField v = toField $ cartStatusToText v
getCartStatus ::
(MonadReader env m, HasDbPool env, MonadUnliftIO m) =>
CartId ->
m (Maybe CartStatus)
getCartStatus cartId = do
result <- withConn $ \conn -> query conn qry args
case result of
[Only cartStatus] -> pure $ Just cartStatus
_ -> pure Nothing
where
qry =
[sql|
select status
from carts
where id = ? limit 1
|]
args = Only cartId
lockCart ::
(MonadReader env m, HasDbPool env, MonadUnliftIO m) =>
CartId ->
m ()
lockCart cartId = do
n <- withConn $ \conn -> execute conn qry args
when (n == 0) $
throwIO $
CartException ("Cannot lock cart whose status is not " <> cartStatusToText CartStatusOpen)
where
qry =
[sql|
update carts
set status = ?
where id = ? and status = ?
|]
args = (CartStatusLocked, cartId, CartStatusOpen)
unlockCart ::
(MonadReader env m, HasDbPool env, MonadUnliftIO m) =>
CartId ->
m ()
unlockCart cartId =
void . withConn $ \conn -> execute conn qry args
where
qry =
[sql|
update carts
set status = ?
where id = ? and status = ?
|]
args = (CartStatusOpen, cartId, CartStatusLocked)
markCartAsPurchased ::
(MonadReader env m, HasDbPool env, MonadUnliftIO m) =>
CartId ->
m ()
markCartAsPurchased cartId =
-- No-op: we don't actually change the cart status to purchased
-- so we don't have to reset the db everytime we test, we just unlock it
unlockCart cartId
withCart ::
(MonadReader env m, HasDbPool env, MonadUnliftIO m) =>
CartId ->
m a ->
m a
withCart cartId action =
bracketOnError_ (lockCart cartId) (unlockCart cartId) action
processBooking ::
forall env m.
(MonadReader env m, HasCartConfig env, MonadLogger m, MonadHttp m, MonadUnliftIO m) =>
CartId ->
m BookingId
processBooking cartId = do
bookingUrl <- asks getBookingUrl
logInfo $ "Booking starting" :# ["cart_id" .= cartId, "booking_url" .= bookingUrl]
bookingDelay <- asks getBookingDelay
liftIO $ threadDelay bookingDelay
uri <- liftIO $ mkURI bookingUrl
let (url, options) = fromJust (useHttpURI uri)
venue = if cartId == CartId "ghi789" then "TDE8751" else "HRT3974"
bookingRequest =
BookingRequest
{ bookingRequestVenue = venue,
bookingRequestSeats = ["D31", "D32", "D33"]
}
handleFailure :: HttpException -> m (JsonResponse BookingResponse)
handleFailure e = do
case isStatusCodeException' e of
Just (r, b) -> do
let statusCode = responseStatusCode r
msg = cs b
logWarn $
"Booking failed"
:# [ "cart_id" .= cartId,
"status_code" .= statusCode,
"response" .= msg
]
throwIO $ CartException ("Booking failed: " <> msg)
Nothing -> throwIO e
response <-
req POST url (ReqBodyJson bookingRequest) jsonResponse options
`catch` handleFailure
let bookingResponse :: BookingResponse
bookingResponse = responseBody response
bookingId = bookingResponseBookingId bookingResponse
logInfo $ "Booking successful" :# ["cart_id" .= cartId, "booking_id" .= bookingId]
pure bookingId
processPayment ::
forall env m.
(MonadReader env m, HasCartConfig env, MonadLogger m, MonadHttp m, MonadUnliftIO m) =>
CartId ->
m PaymentId
processPayment cartId = do
paymentUrl <- asks getPaymentUrl
logInfo $ "Payment starting" :# ["cart_id" .= cartId, "payment_url" .= paymentUrl]
paymentDelay <- asks getPaymentDelay
liftIO $ threadDelay paymentDelay
uri <- liftIO $ mkURI paymentUrl
let (url, options) = fromJust (useHttpURI uri)
cardNumber = if cartId == CartId "ghi789" then "a192901463306478" else "5192901463306478"
paymentRequest =
PaymentRequest
{ paymentRequestCardholderName = "John Doe",
paymentRequestCardNumber = cardNumber
}
handleFailure :: HttpException -> m (JsonResponse PaymentResponse)
handleFailure e = do
case isStatusCodeException' e of
Just (r, b) -> do
let statusCode = responseStatusCode r
msg = cs b
logWarn $
"Payment failed"
:# [ "cart_id" .= cartId,
"status_code" .= statusCode,
"response" .= msg
]
throwIO $ CartException ("Payment failed: " <> msg)
Nothing -> throwIO e
response <-
req POST url (ReqBodyJson paymentRequest) jsonResponse options
`catch` handleFailure
let paymentResponse :: PaymentResponse
paymentResponse = responseBody response
paymentId = paymentResponsePaymentId paymentResponse
logInfo $ "Payment successful" :# ["cart_id" .= cartId, "payment_id" .= paymentId]
pure paymentId