payment rails

This commit is contained in:
Stephan D
2025-12-10 18:40:55 +01:00
parent 81d2db394b
commit 47899e25d4
39 changed files with 3043 additions and 909 deletions

View File

@@ -1,8 +1,9 @@
package paymentapiimp
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -15,6 +16,16 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
return nil, merrors.InvalidArgument("intent is required")
}
kind, err := mapPaymentKind(intent.Kind)
if err != nil {
return nil, err
}
settlementMode, err := mapSettlementMode(intent.SettlementMode)
if err != nil {
return nil, err
}
source, err := mapPaymentEndpoint(intent.Source, "source")
if err != nil {
return nil, err
@@ -30,48 +41,68 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
}
return &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind(intent.Kind),
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
RequiresFx: intent.RequiresFX,
Fx: fx,
FeePolicy: mapPolicyOverrides(intent.FeePolicy),
Attributes: copyStringMap(intent.Attributes),
Kind: kind,
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
RequiresFx: intent.RequiresFX,
Fx: fx,
SettlementMode: settlementMode,
Attributes: copyStringMap(intent.Attributes),
}, nil
}
func mapPaymentEndpoint(endpoint *srequest.PaymentEndpoint, field string) (*orchestratorv1.PaymentEndpoint, error) {
func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*orchestratorv1.PaymentEndpoint, error) {
if endpoint == nil {
return nil, nil
}
var (
count int
result orchestratorv1.PaymentEndpoint
)
if endpoint.Ledger != nil {
count++
var result orchestratorv1.PaymentEndpoint
switch endpoint.Type {
case srequest.EndpointTypeLedger:
payload, err := endpoint.DecodeLedger()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: mapLedgerEndpoint(endpoint.Ledger),
Ledger: mapLedgerEndpoint(&payload),
}
case srequest.EndpointTypeManagedWallet:
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
mw, err := mapManagedWalletEndpoint(&payload)
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
}
if endpoint.ManagedWallet != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: mapManagedWalletEndpoint(endpoint.ManagedWallet),
ManagedWallet: mw,
}
case srequest.EndpointTypeExternalChain:
payload, err := endpoint.DecodeExternalChain()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
ext, err := mapExternalChainEndpoint(&payload)
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
}
if endpoint.ExternalChain != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
ExternalChain: mapExternalChainEndpoint(endpoint.ExternalChain),
ExternalChain: ext,
}
}
if count > 1 {
return nil, merrors.InvalidArgument(field + " endpoint must set only one of ledger, managed_wallet, external_chain")
case srequest.EndpointTypeCard:
payload, err := endpoint.DecodeCard()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
Card: mapCardEndpoint(&payload),
}
case "":
return nil, merrors.InvalidArgument(field + " endpoint type is required")
default:
return nil, merrors.InvalidArgument(field + " endpoint has unsupported type: " + string(endpoint.Type))
}
result.Metadata = copyStringMap(endpoint.Metadata)
@@ -88,36 +119,48 @@ func mapLedgerEndpoint(endpoint *srequest.LedgerEndpoint) *orchestratorv1.Ledger
}
}
func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) *orchestratorv1.ManagedWalletEndpoint {
func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) (*orchestratorv1.ManagedWalletEndpoint, error) {
if endpoint == nil {
return nil
return nil, nil
}
asset, err := mapAsset(endpoint.Asset)
if err != nil {
return nil, err
}
return &orchestratorv1.ManagedWalletEndpoint{
ManagedWalletRef: endpoint.ManagedWalletRef,
Asset: mapAsset(endpoint.Asset),
}
Asset: asset,
}, nil
}
func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) *orchestratorv1.ExternalChainEndpoint {
func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) (*orchestratorv1.ExternalChainEndpoint, error) {
if endpoint == nil {
return nil
return nil, nil
}
asset, err := mapAsset(endpoint.Asset)
if err != nil {
return nil, err
}
return &orchestratorv1.ExternalChainEndpoint{
Asset: mapAsset(endpoint.Asset),
Asset: asset,
Address: endpoint.Address,
Memo: endpoint.Memo,
}
}, nil
}
func mapAsset(asset *srequest.Asset) *chainv1.Asset {
func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) {
if asset == nil {
return nil
return nil, nil
}
chain, err := mapChainNetwork(asset.Chain)
if err != nil {
return nil, err
}
return &chainv1.Asset{
Chain: chainv1.ChainNetwork(asset.Chain),
Chain: chain,
TokenSymbol: asset.TokenSymbol,
ContractAddress: asset.ContractAddress,
}
}, nil
}
func mapMoney(m *srequest.Money) *moneyv1.Money {
@@ -134,9 +177,13 @@ func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) {
if fx == nil {
return nil, nil
}
side, err := mapFXSide(fx.Side)
if err != nil {
return nil, err
}
return &orchestratorv1.FXIntent{
Pair: mapCurrencyPair(fx.Pair),
Side: fxv1.Side(fx.Side),
Side: side,
Firm: fx.Firm,
TtlMs: fx.TTLms,
PreferredProvider: fx.PreferredProvider,
@@ -154,12 +201,79 @@ func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair {
}
}
func mapPolicyOverrides(policy *srequest.PolicyOverrides) *feesv1.PolicyOverrides {
if policy == nil {
func mapCardEndpoint(card *srequest.CardEndpoint) *orchestratorv1.CardEndpoint {
if card == nil {
return nil
}
return &feesv1.PolicyOverrides{
InsufficientNet: feesv1.InsufficientNetPolicy(policy.InsufficientNet),
result := &orchestratorv1.CardEndpoint{
CardholderName: strings.TrimSpace(card.Cardholder),
ExpMonth: card.ExpMonth,
ExpYear: card.ExpYear,
Country: strings.TrimSpace(card.Country),
MaskedPan: strings.TrimSpace(card.MaskedPan),
}
if pan := strings.TrimSpace(card.Pan); pan != "" {
result.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(card.Token); token != "" {
result.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
return result
}
func mapPaymentKind(kind srequest.PaymentKind) (orchestratorv1.PaymentKind, error) {
switch strings.TrimSpace(string(kind)) {
case "", string(srequest.PaymentKindUnspecified):
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, nil
case string(srequest.PaymentKindPayout):
return orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT, nil
case string(srequest.PaymentKindInternalTransfer):
return orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER, nil
case string(srequest.PaymentKindFxConversion):
return orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, nil
default:
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, merrors.InvalidArgument("unsupported payment kind: " + string(kind))
}
}
func mapSettlementMode(mode srequest.SettlementMode) (orchestratorv1.SettlementMode, error) {
switch strings.TrimSpace(string(mode)) {
case "", string(srequest.SettlementModeUnspecified):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, nil
case string(srequest.SettlementModeFixSource):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE, nil
case string(srequest.SettlementModeFixReceived):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED, nil
default:
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode))
}
}
func mapFXSide(side srequest.FXSide) (fxv1.Side, error) {
switch strings.TrimSpace(string(side)) {
case "", string(srequest.FXSideUnspecified):
return fxv1.Side_SIDE_UNSPECIFIED, nil
case string(srequest.FXSideBuyBaseSellQuote):
return fxv1.Side_BUY_BASE_SELL_QUOTE, nil
case string(srequest.FXSideSellBaseBuyQuote):
return fxv1.Side_SELL_BASE_BUY_QUOTE, nil
default:
return fxv1.Side_SIDE_UNSPECIFIED, merrors.InvalidArgument("unsupported fx side: " + string(side))
}
}
func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) {
switch strings.TrimSpace(string(chain)) {
case "", string(srequest.ChainNetworkUnspecified):
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
case string(srequest.ChainNetworkEthereumMainnet):
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case string(srequest.ChainNetworkArbitrumOne):
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case string(srequest.ChainNetworkOtherEVM):
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
}
}

View File

@@ -39,16 +39,29 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if expectQuote && strings.TrimSpace(payload.QuoteRef) == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quote_ref is required"))
}
if !expectQuote {
payload.QuoteRef = ""
if expectQuote {
if payload.QuoteRef == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quoteRef is required"))
}
if payload.Intent != nil {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be combined with intent"))
}
} else {
if payload.Intent == nil {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("intent is required"))
}
if payload.QuoteRef != "" {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be used when intent is provided"))
}
}
intent, err := mapPaymentIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
var intent *orchestratorv1.PaymentIntent
if payload.Intent != nil {
intent, err = mapPaymentIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
}
req := &orchestratorv1.InitiatePaymentRequest{
@@ -57,8 +70,6 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
},
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
Intent: intent,
FeeQuoteToken: strings.TrimSpace(payload.FeeQuoteToken),
FxQuoteRef: strings.TrimSpace(payload.FxQuoteRef),
QuoteRef: strings.TrimSpace(payload.QuoteRef),
Metadata: payload.Metadata,
}
@@ -80,11 +91,10 @@ func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotencyKey is required")
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -39,7 +39,7 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
return response.BadPayload(a.logger, a.Name(), err)
}
intent, err := mapPaymentIntent(payload.Intent)
intent, err := mapPaymentIntent(&payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
@@ -50,7 +50,6 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
},
IdempotencyKey: payload.IdempotencyKey,
Intent: intent,
PreviewOnly: payload.PreviewOnly,
}
resp, err := a.client.QuotePayment(ctx, req)
@@ -70,11 +69,8 @@ func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotencyKey is required")
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}