Skip to content

Commit 368f733

Browse files
backend/feat/international: stripe transfer api
1 parent 48aee00 commit 368f733

8 files changed

Lines changed: 187 additions & 13 deletions

File tree

lib/mobility-core/mobility-core.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ library
172172
Kernel.External.Payment.Stripe.Types.PaymentIntent
173173
Kernel.External.Payment.Stripe.Types.Refund
174174
Kernel.External.Payment.Stripe.Types.SetupIntent
175+
Kernel.External.Payment.Stripe.Types.Transfer
175176
Kernel.External.Payment.Stripe.Types.Webhook
176177
Kernel.External.Payment.Stripe.Webhook
177178
Kernel.External.Payment.Types

lib/mobility-core/src/Kernel/External/Payment/Interface.hs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -406,9 +406,9 @@ createRefund ::
406406
PaymentServiceConfig ->
407407
CreateRefundReq ->
408408
m CreateRefundResp
409-
createRefund config paymentIntentId = case config of
409+
createRefund config req = case config of
410410
JuspayConfig _ -> throwError $ InternalError "Juspay Create Refund not supported."
411-
StripeConfig cfg -> Stripe.createRefund cfg paymentIntentId
411+
StripeConfig cfg -> Stripe.createRefund cfg req
412412

413413
getRefund ::
414414
( CoreMetrics m,
@@ -419,9 +419,9 @@ getRefund ::
419419
PaymentServiceConfig ->
420420
GetRefundReq ->
421421
m GetRefundResp
422-
getRefund config paymentIntentId = case config of
422+
getRefund config req = case config of
423423
JuspayConfig _ -> throwError $ InternalError "Juspay Get Refund not supported."
424-
StripeConfig cfg -> Stripe.getRefund cfg paymentIntentId
424+
StripeConfig cfg -> Stripe.getRefund cfg req
425425

426426
cancelRefund ::
427427
( CoreMetrics m,
@@ -432,9 +432,22 @@ cancelRefund ::
432432
PaymentServiceConfig ->
433433
CancelRefundReq ->
434434
m CancelRefundResp
435-
cancelRefund config paymentIntentId = case config of
435+
cancelRefund config req = case config of
436436
JuspayConfig _ -> throwError $ InternalError "Juspay Cancel Refund not supported."
437-
StripeConfig cfg -> Stripe.cancelRefund cfg paymentIntentId
437+
StripeConfig cfg -> Stripe.cancelRefund cfg req
438+
439+
createTransfer ::
440+
( CoreMetrics m,
441+
EncFlow m r,
442+
HasRequestId r,
443+
MonadReader r m
444+
) =>
445+
PaymentServiceConfig ->
446+
CreateTransferReq ->
447+
m CreateTransferResp
448+
createTransfer config req = case config of
449+
JuspayConfig _ -> throwError $ InternalError "Juspay Create Refund not supported."
450+
StripeConfig cfg -> Stripe.createTransfer cfg req
438451

439452
verifyVPA ::
440453
( CoreMetrics m,

lib/mobility-core/src/Kernel/External/Payment/Interface/Stripe.hs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ createPaymentIntent config req = do
253253
ConnectedAccount -> createConnectedAccountCharge url apiKey req
254254
where
255255
-- Platform Charge: No cloning, no on_behalf_of
256-
createPlatformCharge url apiKey CreatePaymentIntentReq {amount = amonutInUsd, ..} = do
256+
createPlatformCharge url apiKey CreatePaymentIntentReq {amount = amountInUsd, ..} = do
257257
let paymentIntentReq = mkPlatformPaymentIntentReq
258258
paymentIntentResp <- Stripe.createPaymentIntent url apiKey paymentIntentReq
259259
let paymentIntentId = paymentIntentResp.id
@@ -264,7 +264,7 @@ createPaymentIntent config req = do
264264
mkPlatformPaymentIntentReq :: Stripe.PaymentIntentReq
265265
mkPlatformPaymentIntentReq =
266266
let application_fee_amount = eurToCents applicationFeeAmount
267-
amountInCents = eurToCents amonutInUsd
267+
amountInCents = eurToCents amountInUsd
268268
payment_method = paymentMethod -- Use original payment method (NO cloning)
269269
receipt_email = receiptEmail
270270
on_behalf_of = Nothing -- OMIT for platform charges
@@ -293,9 +293,9 @@ createPaymentIntent config req = do
293293
return $ CreatePaymentIntentResp {..}
294294
where
295295
mkPaymentIntentReq :: PaymentMethodId -> CreatePaymentIntentReq -> Stripe.PaymentIntentReq
296-
mkPaymentIntentReq clonedPaymentMethodId CreatePaymentIntentReq {amount = amonutInUsd, ..} = do
296+
mkPaymentIntentReq clonedPaymentMethodId CreatePaymentIntentReq {amount = amountInUsd, ..} = do
297297
let application_fee_amount = usdToCents applicationFeeAmount
298-
let amountInCents = usdToCents amonutInUsd
298+
let amountInCents = usdToCents amountInUsd
299299
let payment_method = clonedPaymentMethodId
300300
let receipt_email = receiptEmail
301301
let on_behalf_of = Just driverAccountId
@@ -668,10 +668,10 @@ createRefund config req = do
668668
mkRefundResp <$> Stripe.createRefund url apiKey (Just req.driverAccountId) refundReq
669669

670670
mkRefundReq :: CreateRefundReq -> Maybe Bool -> Stripe.RefundReq
671-
mkRefundReq CreateRefundReq {amount = amonutInUsd, ..} reverse_transfer =
671+
mkRefundReq CreateRefundReq {amount = amountInUsd, ..} reverse_transfer =
672672
let charge = Nothing
673673
payment_intent = Just req.paymentIntentId
674-
amountInCents = eurToCents <$> amonutInUsd
674+
amountInCents = eurToCents <$> amountInUsd
675675
metadata = Metadata {order_short_id = Just orderShortId, order_id = Just orderId, refunds_id = Just refundsId}
676676
refund_application_fee = Just req.refundApplicationFee
677677
instructions_email = req.email
@@ -736,3 +736,30 @@ mkGetRefundResp Stripe.RefundObject {..} =
736736
reverseTransferId = transfer_reversal,
737737
errorCode = failure_reason
738738
}
739+
740+
createTransfer ::
741+
forall m r.
742+
( Metrics.CoreMetrics m,
743+
EncFlow m r,
744+
HasRequestId r,
745+
MonadReader r m
746+
) =>
747+
StripeCfg ->
748+
CreateTransferReq ->
749+
m CreateTransferResp
750+
createTransfer config req = do
751+
let url = config.url
752+
apiKey <- decrypt config.apiKey
753+
transferReq <- buildCreateTransferReq req
754+
mkCreateTransferResp <$> Stripe.createTransfer url apiKey (Just req.senderConnectedAccountId) transferReq
755+
where
756+
buildCreateTransferReq :: CreateTransferReq -> m Stripe.TransferReq
757+
buildCreateTransferReq CreateTransferReq {amount = amountInUsd, ..} = do
758+
let amountInCents = eurToCents amountInUsd
759+
destination <- case destinationAccount of
760+
TransferConnectedAccount accountId -> pure accountId
761+
TransferPlatformAccount -> config.platformAccountId & fromMaybeM (InternalError "STRIPE_PLATFORM_ACCOUNT_ID_NOT_FOUND")
762+
pure Stripe.TransferReq {amount = amountInCents, metadata = Nothing, ..}
763+
764+
mkCreateTransferResp :: Stripe.TransferObject -> CreateTransferResp
765+
mkCreateTransferResp Stripe.TransferObject {..} = CreateTransferResp {transferId = id, status}

lib/mobility-core/src/Kernel/External/Payment/Interface/Types.hs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,3 +783,18 @@ type CancelRefundResp = GetRefundResp
783783
derivePersistField "RefundStatus"
784784

785785
$(mkBeamInstancesForEnum ''RefundStatus)
786+
787+
data TransferAccount = TransferConnectedAccount AccountId | TransferPlatformAccount
788+
789+
data CreateTransferReq = CreateTransferReq
790+
{ amount :: HighPrecMoney,
791+
currency :: Currency,
792+
senderConnectedAccountId :: AccountId,
793+
destinationAccount :: TransferAccount,
794+
description :: Maybe Text
795+
}
796+
797+
data CreateTransferResp = CreateTransferResp
798+
{ transferId :: TransferId,
799+
status :: TransferStatus
800+
}

lib/mobility-core/src/Kernel/External/Payment/Stripe/Config.hs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ where
2222
import Data.Aeson
2323
import Kernel.External.Encryption
2424
import Kernel.External.Payment.Stripe.Types.Accounts as Reexport (BusinessProfile (..))
25+
import Kernel.External.Payment.Stripe.Types.Common (AccountId)
2526
import Kernel.Prelude
2627
import Kernel.Types.Common
2728

@@ -38,7 +39,8 @@ data StripeCfg = StripeCfg
3839
chargeDestination :: ChargeDestination,
3940
webhookEndpointSecret :: Maybe (EncryptedField 'AsEncrypted Text),
4041
webhookToleranceSeconds :: Maybe Seconds,
41-
serviceMode :: Maybe ServiceMode
42+
serviceMode :: Maybe ServiceMode,
43+
platformAccountId :: Maybe AccountId
4244
}
4345
deriving stock (Show, Eq, Generic)
4446
deriving anyclass (FromJSON, ToJSON)

lib/mobility-core/src/Kernel/External/Payment/Stripe/Flow.hs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,3 +618,27 @@ cancelRefund url apiKey connectedAccountId refundId = do
618618
let proxy = Proxy @CancelRefundAPI
619619
eulerClient = Euler.client proxy (mkBasicAuthData apiKey) connectedAccountId refundId
620620
callStripeAPI url eulerClient "cancel-refund" proxy
621+
622+
type CreateTransferAPI =
623+
"v1"
624+
:> "transfers"
625+
:> BasicAuth "secretkey-password" BasicAuthData
626+
:> Header "Stripe-Account" Text
627+
:> ReqBody '[FormUrlEncoded] TransferReq
628+
:> Post '[JSON] TransferObject
629+
630+
createTransfer ::
631+
( Metrics.CoreMetrics m,
632+
MonadFlow m,
633+
HasRequestId r,
634+
MonadReader r m
635+
) =>
636+
BaseUrl ->
637+
Text ->
638+
Maybe Text ->
639+
TransferReq ->
640+
m TransferObject
641+
createTransfer url apiKey connectedAccountId transferReq = do
642+
let proxy = Proxy @CreateTransferAPI
643+
eulerClient = Euler.client proxy (mkBasicAuthData apiKey) connectedAccountId transferReq
644+
callStripeAPI url eulerClient "create-transfer" proxy

lib/mobility-core/src/Kernel/External/Payment/Stripe/Types.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ import Kernel.External.Payment.Stripe.Types.Error as Reexport
2525
import Kernel.External.Payment.Stripe.Types.PaymentIntent as Reexport
2626
import Kernel.External.Payment.Stripe.Types.Refund as Reexport
2727
import Kernel.External.Payment.Stripe.Types.SetupIntent as Reexport
28+
import Kernel.External.Payment.Stripe.Types.Transfer as Reexport
2829
import Kernel.External.Payment.Stripe.Types.Webhook as Reexport
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
{-# LANGUAGE DerivingStrategies #-}
2+
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
3+
{-# LANGUAGE TemplateHaskell #-}
4+
5+
module Kernel.External.Payment.Stripe.Types.Transfer where
6+
7+
import Data.Aeson
8+
import qualified Data.HashMap.Strict as HM
9+
import Kernel.Beam.Lib.UtilsTH (mkBeamInstancesForEnum)
10+
import Kernel.External.Payment.Stripe.Types.Common
11+
import Kernel.Prelude
12+
import Kernel.Storage.Esqueleto (derivePersistField)
13+
import Kernel.Types.Price (Currency)
14+
import Kernel.Utils.JSON
15+
import Web.FormUrlEncoded
16+
import Web.HttpApiData (ToHttpApiData (..))
17+
18+
newtype TransferId = TransferId {getTransferId :: Text}
19+
deriving stock (Generic, Show, Eq)
20+
deriving newtype (FromJSON, ToJSON, ToSchema)
21+
22+
data TransferReq = TransferReq
23+
{ amount :: Int,
24+
currency :: Currency,
25+
destination :: AccountId,
26+
metadata :: Maybe Metadata,
27+
description :: Maybe Text
28+
}
29+
deriving stock (Show, Generic)
30+
31+
instance ToForm TransferReq where
32+
toForm TransferReq {..} =
33+
Form $
34+
HM.fromList $
35+
catMaybes
36+
[ Just . ("amount",) . pure $ toQueryParam amount,
37+
Just . ("currency",) . pure $ toQueryParam currency,
38+
Just . ("destination",) . pure $ toQueryParam destination,
39+
("description",) . pure . toQueryParam <$> description
40+
]
41+
42+
-- TODO webhook transfer.created transfer.failed transfer.reversed transfer.updated
43+
data TransferObject = TransferObject
44+
{ id :: TransferId,
45+
_object :: Text,
46+
amount :: Int,
47+
created :: UTCTime,
48+
currency :: Currency,
49+
destination :: AccountId,
50+
status :: TransferStatus
51+
}
52+
deriving stock (Show, Generic)
53+
54+
instance FromJSON TransferObject where
55+
parseJSON = genericParseJSON stripPrefixUnderscoreIfAny
56+
57+
instance ToJSON TransferObject where
58+
toJSON = genericToJSON stripPrefixUnderscoreIfAny
59+
60+
data TransferStatus
61+
= TRANSFER_PENDING
62+
| TRANSFER_IN_TRANSIT
63+
| TRANSFER_CANCELED
64+
| TRANSFER_FAILED
65+
| TRANSFER_SUCCEEDED
66+
| TRANSFER_REVERSED
67+
deriving stock (Show, Eq, Ord, Generic, Read)
68+
deriving anyclass (ToSchema)
69+
70+
transferStatusJsonOptions :: Options
71+
transferStatusJsonOptions =
72+
defaultOptions
73+
{ constructorTagModifier = \case
74+
"TRANSFER_PENDING" -> "pending"
75+
"TRANSFER_IN_TRANSIT" -> "in_transit"
76+
"TRANSFER_CANCELED" -> "canceled"
77+
"TRANSFER_FAILED" -> "failed"
78+
"TRANSFER_SUCCEEDED" -> "succeeded"
79+
"TRANSFER_REVERSED" -> "reversed"
80+
x -> x
81+
}
82+
83+
instance FromJSON TransferStatus where
84+
parseJSON = genericParseJSON transferStatusJsonOptions
85+
86+
instance ToJSON TransferStatus where
87+
toJSON = genericToJSON transferStatusJsonOptions
88+
89+
derivePersistField "TransferStatus"
90+
91+
$(mkBeamInstancesForEnum ''TransferStatus)

0 commit comments

Comments
 (0)