-
Notifications
You must be signed in to change notification settings - Fork 9
backend/feat/international: stripe transfer api #1086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,12 +17,14 @@ module Kernel.External.Payout.Interface | |
| ) | ||
| where | ||
|
|
||
| import qualified Kernel.External.Payment.Interface as Payment | ||
| import qualified Kernel.External.Payout.Interface.Juspay as Juspay | ||
| import qualified Kernel.External.Payout.Interface.Stripe as Stripe | ||
| import Kernel.External.Payout.Interface.Types as Reexport | ||
| import Kernel.External.Payout.Types as Reexport | ||
| import Kernel.Prelude | ||
| import Kernel.Tools.Metrics.CoreMetrics (CoreMetrics) | ||
| import Kernel.Types.Error | ||
| import Kernel.Utils.Common | ||
|
|
||
| createPayoutOrder :: | ||
|
|
@@ -37,7 +39,70 @@ createPayoutOrder :: | |
| m CreatePayoutOrderResp | ||
| createPayoutOrder serviceConfig req = case serviceConfig of | ||
| JuspayConfig cfg -> Juspay.createPayoutOrder cfg req | ||
| StripeConfig cfg -> Stripe.createPayoutOrder cfg req | ||
| StripeConfig cfg -> do | ||
| connectedAccountId <- req.mConnectedAccountId & fromMaybeM (InvalidRequest "connectedAccountId required for Stripe payout") | ||
| createTransferResp <- Stripe.createTransfer cfg (mkTransferReq connectedAccountId req) | ||
|
|
||
| result <- withTryCatch "createExternalPayout" $ Stripe.createExternalPayout cfg req | ||
| createExternalPayoutResp <- case result of | ||
| Right resp -> pure resp | ||
| Left e -> do | ||
| let err = fromException @Payment.StripeError e | ||
| errorCode = err <&> toErrorCode | ||
| errorMessage = err >>= toMessage | ||
| logError $ "Error while create external payout : " <> show err <> "error code : " <> show errorCode <> "error message : " <> show errorMessage <> " orderId: " <> req.orderId | ||
| pure | ||
| CreateExternalPayoutResp | ||
| { orderId = req.orderId, | ||
| externalPayoutStatus = EXTERNAL_PAYOUT_FAILED, | ||
| orderType = Just req.orderType, | ||
| idAssignedByServiceProvider = Nothing, | ||
| externalPayoutAmount = req.externalPayoutAmount, | ||
| customerId = Just req.customerId | ||
| } | ||
| pure $ mkCreatePayoutOrderResp createTransferResp createExternalPayoutResp | ||
|
Comment on lines
+42
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, the Stripe Idempotency-Key header prevents double transfers (or "double charges") on POST /v1/transfers by making the request idempotent. Stripe's API supports idempotency keys on all POST endpoints, including transfers/create, which is a mutating POST request to create a transfer object.[1][2][3][4] This allows safe retries: generate a unique Idempotency-Key (e.g., UUIDv4, up to 255 chars), include it in the header, and if a network error occurs, retry with the same key. Stripe caches the first response (success or failure) for 24 hours; subsequent identical requests return the cached result without duplicating the transfer.[1][2][3][4] Example curl for /v1/transfers with Idempotency-Key (adapted from customer example, as transfers supports the same): curl https://api.stripe.com/v1/transfers \ -u sk_test_...: \ -H "Idempotency-Key: example-uuid-v4-key" \ -d amount=1000 \ -d currency=usd \ -d destination=acct_...[1][3][5] In client libraries, pass idempotencyKey in options (e.g., Node: {idempotencyKey: 'key'}).[1][2] Keys expire after 24 hours; reuse generates a new request if params differ (errors if not matching).[1][2] Use for all mutating POSTs to prevent duplicates from retries.[3][4] Citations:
🏁 Script executed: # Find files related to Stripe transfer handling
find . -name "*.hs" -type f | xargs grep -l "TransferReq\|createTransfer" | head -20Repository: nammayatri/shared-kernel Length of output: 395 🏁 Script executed: # Look for the Stripe.hs or Stripe/Flow.hs mentioned in the review
fd -e hs "Stripe" | grep -E "(Stripe.hs|Flow.hs)" | head -20Repository: nammayatri/shared-kernel Length of output: 199 🏁 Script executed: # Search for the Interface.hs file mentioned
fd -e hs "Interface.hs" | head -10Repository: nammayatri/shared-kernel Length of output: 707 🏁 Script executed: # Examine TransferReq type definition
cat -n lib/mobility-core/src/Kernel/External/Payout/Stripe/Types/Transfer.hsRepository: nammayatri/shared-kernel Length of output: 2318 🏁 Script executed: # Look at createTransfer implementation
cat -n lib/mobility-core/src/Kernel/External/Payout/Stripe/Flow.hsRepository: nammayatri/shared-kernel Length of output: 5128 🏁 Script executed: # Search for the TODO comment with "retrie" typo
grep -r "retrie" lib/mobility-core/src/Kernel/External/Payout/ --include="*.hs"Repository: nammayatri/shared-kernel Length of output: 188 🏁 Script executed: # Search for logging patterns in the payout interface
grep -n "logError\|log" lib/mobility-core/src/Kernel/External/Payout/Interface.hsRepository: nammayatri/shared-kernel Length of output: 253 🏁 Script executed: # Read the full Interface.hs file to verify the concerns
wc -l lib/mobility-core/src/Kernel/External/Payout/Interface.hsRepository: nammayatri/shared-kernel Length of output: 130 🏁 Script executed: # Read the relevant portions of Interface.hs
sed -n '1,100p' lib/mobility-core/src/Kernel/External/Payout/Interface.hsRepository: nammayatri/shared-kernel Length of output: 4241 🏁 Script executed: # Check around line 43-65 and beyond to see the full context
sed -n '40,120p' lib/mobility-core/src/Kernel/External/Payout/Interface.hsRepository: nammayatri/shared-kernel Length of output: 3397 🏁 Script executed: # Search for other uses of createTransfer to see if idempotency is handled at a higher level
grep -n "createTransfer" lib/mobility-core/src/Kernel/External/Payout/Interface.hsRepository: nammayatri/shared-kernel Length of output: 369 🏁 Script executed: # Check if there's an idempotency key or similar in the request context
grep -r "IdempotencyKey\|idempotency" lib/mobility-core/src/Kernel/External/Payout/ --include="*.hs"Repository: nammayatri/shared-kernel Length of output: 50 🏁 Script executed: # Check Stripe.Interface to see if idempotency is handled there
cat -n lib/mobility-core/src/Kernel/External/Payout/Interface/Stripe.hsRepository: nammayatri/shared-kernel Length of output: 5164 🏁 Script executed: # Look at CreateTransferReq to see if it differs from TransferReq
grep -A 20 "data CreateTransferReq" lib/mobility-core/src/Kernel/External/Payout/ -r --include="*.hs"Repository: nammayatri/shared-kernel Length of output: 1069 🏁 Script executed: # Check what StripeError contains to assess PII leak risk
grep -A 10 "data StripeError\|newtype StripeError" lib/mobility-core/src/Kernel/External/Payment/ -r --include="*.hs" | head -40Repository: nammayatri/shared-kernel Length of output: 4417 Critical: missing idempotency safeguard on The flow is: (1) call Stripe's recommended fix is to send an Minor: fix TODO typo at line 30 — "retrie" should be "retry". 🤖 Prompt for AI Agents |
||
| where | ||
| mkTransferReq :: Text -> CreatePayoutOrderReq -> CreateTransferReq | ||
| mkTransferReq connectedAccountId CreatePayoutOrderReq {..} = do | ||
| let senderAccountId = TransferPlatformAccount | ||
| destinationAccount = TransferConnectedAccount connectedAccountId | ||
| CreateTransferReq | ||
| { amount, | ||
| currency, | ||
| senderAccountId, | ||
| destinationAccount, | ||
| description = Just remark | ||
| } | ||
|
|
||
| mkCreatePayoutOrderResp :: CreateTransferResp -> CreateExternalPayoutResp -> CreatePayoutOrderResp | ||
| mkCreatePayoutOrderResp CreateTransferResp {transferId} CreateExternalPayoutResp {..} = | ||
| CreatePayoutOrderResp | ||
| { orderId, | ||
| status = castExternalPayoutStatusToStatus externalPayoutStatus, | ||
| externalPayoutStatus = Just externalPayoutStatus, | ||
| orderType, | ||
| transferId = Just transferId, | ||
| idAssignedByServiceProvider, | ||
| udf1 = Nothing, | ||
| udf2 = Nothing, | ||
| udf3 = Nothing, | ||
| udf4 = Nothing, | ||
| udf5 = Nothing, | ||
| amount = req.amount, | ||
| refunds = Nothing, | ||
| payments = Nothing, | ||
| fulfillments = Nothing, | ||
| customerId | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| -- IMPORTANT: always consider TRANSFERRED or SUCCESS if transfer was successfull, to avoid multiple charges | ||
| castExternalPayoutStatusToStatus :: ExternalPayoutStatus -> PayoutOrderStatus | ||
| castExternalPayoutStatusToStatus = \case | ||
| EXTERNAL_PAYOUT_PAID -> SUCCESS | ||
| EXTERNAL_PAYOUT_PENDING -> TRANSFERRED | ||
| EXTERNAL_PAYOUT_IN_TRANSIT -> TRANSFERRED | ||
| EXTERNAL_PAYOUT_FAILED -> TRANSFERRED | ||
| EXTERNAL_PAYOUT_CANCELED -> TRANSFERRED | ||
|
|
||
| payoutOrderStatus :: | ||
| ( EncFlow m r, | ||
|
|
@@ -50,4 +115,40 @@ payoutOrderStatus :: | |
| m PayoutOrderStatusResp | ||
| payoutOrderStatus serviceConfig req = case serviceConfig of | ||
| JuspayConfig cfg -> Juspay.payoutOrderStatus cfg req | ||
| StripeConfig cfg -> Stripe.payoutOrderStatus cfg req | ||
| StripeConfig cfg -> do | ||
| resp <- Stripe.externalPayoutOrderStatus cfg req | ||
| pure $ mkPayoutOrderStatusResp resp | ||
| where | ||
| mkPayoutOrderStatusResp :: ExternalPayoutOrderStatusResp -> PayoutOrderStatusResp | ||
| mkPayoutOrderStatusResp CreateExternalPayoutResp {..} = | ||
| CreatePayoutOrderResp | ||
| { orderId, | ||
| status = if isJust req.transferId then castExternalPayoutStatusToStatus externalPayoutStatus else req.currentStatus, -- extra check for req.transferId | ||
| externalPayoutStatus = Just externalPayoutStatus, | ||
| orderType, | ||
| transferId = req.transferId, | ||
| idAssignedByServiceProvider, | ||
| udf1 = Nothing, | ||
| udf2 = Nothing, | ||
| udf3 = Nothing, | ||
| udf4 = Nothing, | ||
| udf5 = Nothing, | ||
| amount = req.amount, | ||
| refunds = Nothing, | ||
| payments = Nothing, | ||
| fulfillments = Nothing, | ||
| customerId | ||
| } | ||
|
|
||
| createTransfer :: | ||
| ( CoreMetrics m, | ||
| EncFlow m r, | ||
| HasRequestId r, | ||
| MonadReader r m | ||
| ) => | ||
| PayoutServiceConfig -> | ||
| CreateTransferReq -> | ||
| m CreateTransferResp | ||
| createTransfer config req = case config of | ||
| JuspayConfig _ -> throwError $ InternalError "Juspay Create Transfer not supported." | ||
| StripeConfig cfg -> Stripe.createTransfer cfg req | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid logging full Stripe error payloads — risk of leaking PII / sensitive request data.
logError $ "Error while create external payout : " <> show err <> "error code : " <> show errorCode <> "error message : " <> show errorMessage <> " orderId: " <> req.orderIdinterpolates the entireStripeErrorexception viashow. Stripe error responses can contain echoed request fields (customer email, name, bank/card identifiers, descriptions) and sometimes PII from the request. Logging the structurederrorCode+errorMessageis appropriate; logging the full exception is not.Also, there's no separator between adjacent string fragments (
...show err <> "error code : "...) — the resulting log line will read...error code : ...jammed together.🛡️ Proposed log-cleanup
🤖 Prompt for AI Agents