diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go new file mode 100644 index 0000000..e0d967a --- /dev/null +++ b/api/gateway/mntx/client/client.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// Client wraps the Monetix gateway gRPC API. +type Client interface { + CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) + CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) + GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) + Close() error +} + +type gatewayClient struct { + conn *grpc.ClientConn + client mntxv1.MntxGatewayServiceClient + cfg Config +} + +// New dials the Monetix gateway. +func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { + cfg.setDefaults() + if strings.TrimSpace(cfg.Address) == "" { + return nil, merrors.InvalidArgument("mntx: address is required") + } + dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) + defer cancel() + + dialOpts := make([]grpc.DialOption, 0, len(opts)+1) + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + dialOpts = append(dialOpts, opts...) + + conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...) + if err != nil { + return nil, merrors.Internal("mntx: dial failed: " + err.Error()) + } + + return &gatewayClient{ + conn: conn, + client: mntxv1.NewMntxGatewayServiceClient(conn), + cfg: cfg, + }, nil +} + +func (g *gatewayClient) Close() error { + if g.conn != nil { + return g.conn.Close() + } + return nil +} + +func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { + timeout := g.cfg.CallTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + return context.WithTimeout(ctx, timeout) +} + +func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + ctx, cancel := g.callContext(ctx) + defer cancel() + return g.client.CreateCardPayout(ctx, req) +} + +func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + ctx, cancel := g.callContext(ctx) + defer cancel() + return g.client.CreateCardTokenPayout(ctx, req) +} + +func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { + ctx, cancel := g.callContext(ctx) + defer cancel() + return g.client.GetCardPayoutStatus(ctx, req) +} diff --git a/api/gateway/mntx/client/config.go b/api/gateway/mntx/client/config.go new file mode 100644 index 0000000..08a4c7e --- /dev/null +++ b/api/gateway/mntx/client/config.go @@ -0,0 +1,19 @@ +package client + +import "time" + +// Config holds Monetix gateway client settings. +type Config struct { + Address string + DialTimeout time.Duration + CallTimeout time.Duration +} + +func (c *Config) setDefaults() { + if c.DialTimeout <= 0 { + c.DialTimeout = 5 * time.Second + } + if c.CallTimeout <= 0 { + c.CallTimeout = 10 * time.Second + } +} diff --git a/api/gateway/mntx/client/fake.go b/api/gateway/mntx/client/fake.go new file mode 100644 index 0000000..f4d9023 --- /dev/null +++ b/api/gateway/mntx/client/fake.go @@ -0,0 +1,37 @@ +package client + +import ( + "context" + + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +// Fake implements Client for tests. +type Fake struct { + CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) + CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) + GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) +} + +func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + if f.CreateCardPayoutFn != nil { + return f.CreateCardPayoutFn(ctx, req) + } + return &mntxv1.CardPayoutResponse{}, nil +} + +func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + if f.CreateCardTokenPayoutFn != nil { + return f.CreateCardTokenPayoutFn(ctx, req) + } + return &mntxv1.CardTokenPayoutResponse{}, nil +} + +func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { + if f.GetCardPayoutStatusFn != nil { + return f.GetCardPayoutStatusFn(ctx, req) + } + return &mntxv1.GetCardPayoutStatusResponse{}, nil +} + +func (f *Fake) Close() error { return nil } diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index 645d3cc..06ae682 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -56,3 +56,8 @@ oracle: dial_timeout_seconds: 5 call_timeout_seconds: 3 insecure: true + +card_gateways: + monetix: + funding_address: "wallet_funding_monetix" + fee_address: "wallet_fee_monetix" diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 8e1bfc1..1ef185d 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -8,6 +8,8 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees replace github.com/tech/sendico/gateway/chain => ../../gateway/chain +replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx + replace github.com/tech/sendico/fx/oracle => ../../fx/oracle replace github.com/tech/sendico/ledger => ../../ledger @@ -17,6 +19,7 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 + github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver v1.17.6 diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index e8c89e3..3a65c68 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 439733a..54ae625 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -7,8 +7,8 @@ import ( "strings" "time" - chainclient "github.com/tech/sendico/gateway/chain/client" oracleclient "github.com/tech/sendico/fx/oracle/client" + chainclient "github.com/tech/sendico/gateway/chain/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/storage" @@ -41,10 +41,11 @@ type Imp struct { type config struct { *grpcapp.Config `yaml:",inline"` - Fees clientConfig `yaml:"fees"` - Ledger clientConfig `yaml:"ledger"` - Gateway clientConfig `yaml:"gateway"` - Oracle clientConfig `yaml:"oracle"` + Fees clientConfig `yaml:"fees"` + Ledger clientConfig `yaml:"ledger"` + Gateway clientConfig `yaml:"gateway"` + Oracle clientConfig `yaml:"oracle"` + CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` } type clientConfig struct { @@ -54,6 +55,11 @@ type clientConfig struct { InsecureTransport bool `yaml:"insecure"` } +type cardGatewayRouteConfig struct { + FundingAddress string `yaml:"funding_address"` + FeeAddress string `yaml:"fee_address"` +} + func (c clientConfig) address() string { return strings.TrimSpace(c.Address) } @@ -107,7 +113,7 @@ func (i *Imp) Shutdown() { func (i *Imp) Start() error { cfg, err := i.loadConfig() - if err != nil { + if err != nil { return err } i.config = cfg @@ -150,6 +156,9 @@ func (i *Imp) Start() error { if oracleClient != nil { opts = append(opts, orchestrator.WithOracleClient(oracleClient)) } + if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { + opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) + } return orchestrator.NewService(logger, repo, opts...), nil } @@ -296,3 +305,21 @@ func (i *Imp) loadConfig() (*config, error) { return cfg, nil } + +func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute { + if len(src) == 0 { + return nil + } + result := make(map[string]orchestrator.CardGatewayRoute, len(src)) + for key, route := range src { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + continue + } + result[trimmedKey] = orchestrator.CardGatewayRoute{ + FundingAddress: strings.TrimSpace(route.FundingAddress), + FeeAddress: strings.TrimSpace(route.FeeAddress), + } + } + return result +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go new file mode 100644 index 0000000..65bf7ee --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go @@ -0,0 +1,252 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" +) + +const defaultCardGateway = "monetix" + +func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { + if len(s.deps.cardRoutes) == 0 { + s.logger.Warn("card routing not configured", zap.String("gateway", gateway)) + return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") + } + key := strings.ToLower(strings.TrimSpace(gateway)) + if key == "" { + key = defaultCardGateway + } + route, ok := s.deps.cardRoutes[key] + if !ok { + s.logger.Warn("card routing missing for gateway", zap.String("gateway", key)) + return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) + } + if strings.TrimSpace(route.FundingAddress) == "" { + s.logger.Warn("card routing missing funding address", zap.String("gateway", key)) + return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) + } + return route, nil +} + +func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + intent := payment.Intent + source := intent.Source.ManagedWallet + if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { + return merrors.InvalidArgument("card funding: source managed wallet is required") + } + if !s.deps.gateway.available() { + s.logger.Warn("card funding aborted: chain gateway unavailable") + return merrors.InvalidArgument("card funding: chain gateway unavailable") + } + + route, err := s.cardRoute(defaultCardGateway) + if err != nil { + return err + } + + amount := cloneMoney(intent.Amount) + if amount == nil { + return merrors.InvalidArgument("card funding: amount is required") + } + + exec := payment.Execution + if exec == nil { + exec = &model.ExecutionRefs{} + } + + // Transfer payout amount to funding wallet. + fundReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:fund", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef), + Destination: &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)}, + }, + Amount: amount, + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + } + fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq) + if err != nil { + s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + return err + } + if fundResp != nil && fundResp.GetTransfer() != nil { + exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) + } + s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef)) + + feeMoney := quote.GetExpectedFeeTotal() + if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" { + if strings.TrimSpace(route.FeeAddress) == "" { + return merrors.InvalidArgument("card funding: fee address is required when fee exists") + } + feeDecimal, err := decimalFromMoney(feeMoney) + if err != nil { + return err + } + if feeDecimal.IsPositive() { + feeReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:fee", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef), + Destination: &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)}, + }, + Amount: feeMoney, + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + } + feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq) + if feeErr != nil { + s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef)) + return feeErr + } + if feeResp != nil && feeResp.GetTransfer() != nil { + exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) + } + s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) + } + } + + payment.Execution = exec + return nil +} + +func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error { + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + intent := payment.Intent + card := intent.Destination.Card + if card == nil { + return merrors.InvalidArgument("card payout: card endpoint is required") + } + amount := cloneMoney(intent.Amount) + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return merrors.InvalidArgument("card payout: amount is required") + } + amtDec, err := decimalFromMoney(amount) + if err != nil { + return err + } + minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() + + payoutID := payment.PaymentRef + currency := strings.TrimSpace(amount.GetCurrency()) + holder := strings.TrimSpace(card.Cardholder) + meta := cloneMetadata(payment.Metadata) + + var ( + state *mntxv1.CardPayoutState + ) + + if token := strings.TrimSpace(card.Token); token != "" { + req := &mntxv1.CardTokenPayoutRequest{ + PayoutId: payoutID, + AmountMinor: minor, + Currency: currency, + CardToken: token, + CardHolder: holder, + MaskedPan: strings.TrimSpace(card.MaskedPan), + Metadata: meta, + } + resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) + if err != nil { + s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + return err + } + state = resp.GetPayout() + } else if pan := strings.TrimSpace(card.Pan); pan != "" { + req := &mntxv1.CardPayoutRequest{ + PayoutId: payoutID, + AmountMinor: minor, + Currency: currency, + CardPan: pan, + CardExpYear: card.ExpYear, + CardExpMonth: card.ExpMonth, + CardHolder: holder, + Metadata: meta, + } + resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) + if err != nil { + s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + return err + } + state = resp.GetPayout() + } else { + return merrors.InvalidArgument("card payout: either token or pan must be provided") + } + + if state == nil { + return merrors.Internal("card payout: missing payout state") + } + recordCardPayoutState(payment, state) + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + if payment.Execution.CardPayoutRef == "" { + payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) + } + s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef)) + + return nil +} + +func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) { + if payment == nil || state == nil { + return + } + if payment.CardPayout == nil { + payment.CardPayout = &model.CardPayout{} + } + payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId()) + payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId()) + payment.CardPayout.Status = state.GetStatus().String() + payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage()) + payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode()) + if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil { + payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country) + } + if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil { + payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan) + } + payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId()) +} + +func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) { + if payment == nil || payout == nil { + return + } + recordCardPayoutState(payment, payout) + + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + if payment.Execution.CardPayoutRef == "" { + payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId()) + } + + payment.State = mapMntxStatusToState(payout.GetStatus()) + switch payout.GetStatus() { + case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + payment.FailureCode = model.PaymentFailureCodeUnspecified + payment.FailureReason = "" + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) + default: + // leave as-is for pending/unspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go new file mode 100644 index 0000000..e8cf6c7 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go @@ -0,0 +1,83 @@ +package orchestrator + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/mlogger" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +type paymentEngine interface { + EnsureRepository(ctx context.Context) error + BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) + ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) + ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error + Repository() storage.Repository +} + +type defaultPaymentEngine struct { + svc *Service +} + +func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error { + return e.svc.ensureRepository(ctx) +} + +func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { + return e.svc.buildPaymentQuote(ctx, orgRef, req) +} + +func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) { + return e.svc.resolvePaymentQuote(ctx, in) +} + +func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { + return e.svc.executePayment(ctx, store, payment, quote) +} + +func (e defaultPaymentEngine) Repository() storage.Repository { + return e.svc.storage +} + +type paymentCommandFactory struct { + engine paymentEngine + logger mlogger.Logger +} + +func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory { + return &paymentCommandFactory{ + engine: engine, + logger: logger.Named("commands"), + } +} + +func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand { + return "ePaymentCommand{ + engine: f.engine, + logger: f.logger.Named("quote_payment"), + } +} + +func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand { + return &initiatePaymentCommand{ + engine: f.engine, + logger: f.logger.Named("initiate_payment"), + } +} + +func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand { + return &cancelPaymentCommand{ + engine: f.engine, + logger: f.logger.Named("cancel_payment"), + } +} + +func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand { + return &initiateConversionCommand{ + engine: f.engine, + logger: f.logger.Named("initiate_conversion"), + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 5cbe63b..218eada 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -67,6 +67,19 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin } return result } + if card := src.GetCard(); card != nil { + result.Type = model.EndpointTypeCard + result.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(card.GetPan()), + Token: strings.TrimSpace(card.GetToken()), + Cardholder: strings.TrimSpace(card.GetCardholderName()), + ExpMonth: card.GetExpMonth(), + ExpYear: card.GetExpYear(), + Country: strings.TrimSpace(card.GetCountry()), + MaskedPan: strings.TrimSpace(card.GetMaskedPan()), + } + return result + } return result } @@ -116,6 +129,18 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { Execution: protoExecutionFromModel(src.Execution), Metadata: cloneMetadata(src.Metadata), } + if src.CardPayout != nil { + payment.CardPayout = &orchestratorv1.CardPayout{ + PayoutRef: src.CardPayout.PayoutRef, + ProviderPaymentId: src.CardPayout.ProviderPaymentID, + Status: src.CardPayout.Status, + FailureReason: src.CardPayout.FailureReason, + CardCountry: src.CardPayout.CardCountry, + MaskedPan: src.CardPayout.MaskedPan, + ProviderCode: src.CardPayout.ProviderCode, + GatewayReference: src.CardPayout.GatewayReference, + } + } if src.CreatedAt.IsZero() { payment.CreatedAt = timestamppb.New(time.Now().UTC()) } else { @@ -176,6 +201,23 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn }, } } + case model.EndpointTypeCard: + if src.Card != nil { + card := &orchestratorv1.CardEndpoint{ + CardholderName: src.Card.Cardholder, + ExpMonth: src.Card.ExpMonth, + ExpYear: src.Card.ExpYear, + Country: src.Card.Country, + MaskedPan: src.Card.MaskedPan, + } + if pan := strings.TrimSpace(src.Card.Pan); pan != "" { + card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan} + } + if token := strings.TrimSpace(src.Card.Token); token != "" { + card.Card = &orchestratorv1.CardEndpoint_Token{Token: token} + } + endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card} + } default: // leave unspecified } @@ -205,6 +247,8 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution CreditEntryRef: src.CreditEntryRef, FxEntryRef: src.FXEntryRef, ChainTransferRef: src.ChainTransferRef, + CardPayoutRef: src.CardPayoutRef, + FeeTransferRef: src.FeeTransferRef, } } @@ -402,6 +446,18 @@ func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) e dst.Metadata = cloneMetadata(src.GetMetadata()) dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote()) dst.Execution = executionFromProto(src.GetExecution()) + if src.GetCardPayout() != nil { + dst.CardPayout = &model.CardPayout{ + PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()), + ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()), + Status: strings.TrimSpace(src.GetCardPayout().GetStatus()), + FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()), + CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()), + MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()), + ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()), + GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()), + } + } return nil } @@ -414,6 +470,8 @@ func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()), FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()), ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()), + CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()), + FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()), } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go new file mode 100644 index 0000000..b90a638 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go @@ -0,0 +1,69 @@ +package orchestrator + +import ( + "testing" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func TestEndpointFromProtoCard(t *testing.T) { + protoEndpoint := &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Card{ + Card: &orchestratorv1.CardEndpoint{ + Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "}, + CardholderName: " Jane Doe ", + ExpMonth: 12, + ExpYear: 2030, + Country: " US ", + MaskedPan: " ****1111 ", + }, + }, + Metadata: map[string]string{"k": "v"}, + } + + modelEndpoint := endpointFromProto(protoEndpoint) + if modelEndpoint.Type != model.EndpointTypeCard { + t.Fatalf("expected card type, got %s", modelEndpoint.Type) + } + if modelEndpoint.Card == nil { + t.Fatalf("card payload missing") + } + if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" { + t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card) + } + if modelEndpoint.Metadata["k"] != "v" { + t.Fatalf("metadata not preserved") + } +} + +func TestProtoEndpointFromModelCard(t *testing.T) { + modelEndpoint := model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Token: "tok_123", + Cardholder: "Jane", + ExpMonth: 1, + ExpYear: 2028, + Country: "GB", + MaskedPan: "****1234", + }, + Metadata: map[string]string{"k": "v"}, + } + + protoEndpoint := protoEndpointFromModel(modelEndpoint) + card := protoEndpoint.GetCard() + if card == nil { + t.Fatalf("card payload missing in proto") + } + token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token) + if !ok || token.Token != "tok_123" { + t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card) + } + if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" { + t.Fatalf("card details mismatch: %#v", card) + } + if protoEndpoint.GetMetadata()["k"] != "v" { + t.Fatalf("metadata not preserved in proto endpoint") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go new file mode 100644 index 0000000..2a9806f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -0,0 +1,275 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type quotePaymentCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if err := requireNonNilIntent(req.GetIntent()); err != nil { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + intent := req.GetIntent() + + quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req) + if err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if !req.GetPreviewOnly() { + quotesStore, err := ensureQuotesStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + quoteRef := primitive.NewObjectID().Hex() + quote.QuoteRef = quoteRef + record := &model.PaymentQuoteRecord{ + QuoteRef: quoteRef, + Intent: intentFromProto(intent), + Quote: quoteSnapshotToModel(quote), + ExpiresAt: expiresAt, + } + record.SetID(primitive.NewObjectID()) + record.SetOrganizationRef(orgID) + if err := quotesStore.Create(ctx, record); err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex())) + } + + return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote}) +} + +type initiatePaymentCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] { + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + intent := req.GetIntent() + if err := requireNonNilIntent(intent); err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + store, err := ensurePaymentsStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { + h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex())) + return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)}) + } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ + OrgRef: orgRef, + OrgID: orgID, + Meta: req.GetMeta(), + Intent: intent, + QuoteRef: req.GetQuoteRef(), + FeeQuoteToken: req.GetFeeQuoteToken(), + IdempotencyKey: req.GetIdempotencyKey(), + }) + if err != nil { + if qerr, ok := err.(quoteResolutionError); ok { + switch qerr.code { + case "quote_not_found": + return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) + case "quote_expired": + return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) + case "quote_intent_mismatch": + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) + default: + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) + } + } + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if quoteSnapshot == nil { + quoteSnapshot = &orchestratorv1.PaymentQuote{} + } + + entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot) + + if err = store.Create(ctx, entity); err != nil { + if errors.Is(err, storage.ErrDuplicatePayment) { + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) + } + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil { + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String())) + return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ + Payment: toProtoPayment(entity), + }) +} + +type cancelPaymentCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] { + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + paymentRef, err := requirePaymentRef(req.GetPaymentRef()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + store, err := ensurePaymentsStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + payment, err := store.GetByPaymentRef(ctx, paymentRef) + if err != nil { + return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) + } + if payment.State != model.PaymentStateAccepted { + reason := merrors.InvalidArgument("payment cannot be cancelled in current state") + return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason) + } + payment.State = model.PaymentStateCancelled + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = strings.TrimSpace(req.GetReason()) + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex())) + return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)}) +} + +type initiateConversionCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] { + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req.GetSource() == nil || req.GetSource().GetLedger() == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required")) + } + if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required")) + } + fxIntent := req.GetFx() + if fxIntent == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required")) + } + + store, err := ensurePaymentsStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { + h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex())) + return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)}) + } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { + return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + intentProto := &orchestratorv1.PaymentIntent{ + Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, + Source: req.GetSource(), + Destination: req.GetDestination(), + Amount: amount, + RequiresFx: true, + Fx: fxIntent, + FeePolicy: req.GetFeePolicy(), + } + + quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ + Meta: req.GetMeta(), + IdempotencyKey: req.GetIdempotencyKey(), + Intent: intentProto, + }) + if err != nil { + return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote) + + if err = store.Create(ctx, entity); err != nil { + if errors.Is(err, storage.ErrDuplicatePayment) { + return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) + } + return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil { + return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex())) + return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{ + Conversion: toProtoPayment(entity), + }) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go new file mode 100644 index 0000000..98b8c19 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -0,0 +1,139 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" +) + +type paymentEventHandler struct { + repo storage.Repository + ensureRepo func(ctx context.Context) error + logger mlogger.Logger +} + +func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler { + return &paymentEventHandler{ + repo: repo, + ensureRepo: ensure, + logger: logger, + } +} + +func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] { + if err := h.ensureRepo(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil { + return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required")) + } + transfer := req.GetEvent().GetTransfer() + transferRef := strings.TrimSpace(transfer.GetTransferRef()) + if transferRef == "" { + return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required")) + } + store := h.repo.Payments() + if store == nil { + return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) + } + payment, err := store.GetByChainTransferRef(ctx, transferRef) + if err != nil { + return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) + } + applyTransferStatus(req.GetEvent(), payment) + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) +} + +func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] { + if err := h.ensureRepo(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil || req.GetEvent() == nil { + return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required")) + } + event := req.GetEvent() + walletRef := strings.TrimSpace(event.GetWalletRef()) + if walletRef == "" { + return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required")) + } + store := h.repo.Payments() + if store == nil { + return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) + } + filter := &model.PaymentFilter{ + States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved}, + DestinationRef: walletRef, + } + result, err := store.List(ctx, filter) + if err != nil { + return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) + } + for _, payment := range result.Items { + if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet { + continue + } + if !moneyEquals(payment.Intent.Amount, event.GetAmount()) { + continue + } + payment.State = model.PaymentStateSettled + payment.FailureCode = model.PaymentFailureCodeUnspecified + payment.FailureReason = "" + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + if payment.Execution.ChainTransferRef == "" { + payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash()) + } + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) + } + h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef)) + return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)}) + } + return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{}) +} + +func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] { + if err := h.ensureRepo(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil { + return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required")) + } + payout := req.GetEvent().GetPayout() + paymentRef := strings.TrimSpace(payout.GetPayoutId()) + if paymentRef == "" { + return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required")) + } + + store := h.repo.Payments() + if store == nil { + return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) + } + payment, err := store.GetByPaymentRef(ctx, paymentRef) + if err != nil { + return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) + } + + applyCardPayoutUpdate(payment, payout) + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State)) + return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{ + Payment: toProtoPayment(payment), + }) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go new file mode 100644 index 0000000..c41e8a4 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go @@ -0,0 +1,80 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" +) + +type paymentQueryHandler struct { + repo storage.Repository + ensureRepo func(ctx context.Context) error + logger mlogger.Logger +} + +func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler { + return &paymentQueryHandler{ + repo: repo, + ensureRepo: ensure, + logger: logger, + } +} + +func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] { + if err := h.ensureRepo(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + paymentRef, err := requirePaymentRef(req.GetPaymentRef()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + store, err := ensurePaymentsStore(h.repo) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + entity, err := store.GetByPaymentRef(ctx, paymentRef) + if err != nil { + return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) + } + h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef)) + return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)}) +} + +func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] { + if err := h.ensureRepo(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + store, err := ensurePaymentsStore(h.repo) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + filter := filterFromProto(req) + result, err := store.List(ctx, filter) + if err != nil { + return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + resp := &orchestratorv1.ListPaymentsResponse{ + Page: &paginationv1.CursorPageResponse{ + NextCursor: result.NextCursor, + }, + } + resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items)) + for _, item := range result.Items { + resp.Payments = append(resp.Payments, toProtoPayment(item)) + } + h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor())) + return gsresponse.Success(resp) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go index 3401eb9..26a819a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go @@ -4,9 +4,11 @@ import ( "context" "time" + "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -51,10 +53,17 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool { if intent == nil { return false } + dest := intent.GetDestination() + if dest == nil { + return false + } + if dest.GetCard() != nil { + return false + } if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT { return true } - if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil { + if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil { return true } return false @@ -69,3 +78,16 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool { } return intent.GetFx() != nil && intent.GetFx().GetPair() != nil } + +func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { + switch status { + case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + return model.PaymentStateSettled + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + return model.PaymentStateFailed + case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: + return model.PaymentStateSubmitted + default: + return model.PaymentStateUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go new file mode 100644 index 0000000..0f9ace2 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go @@ -0,0 +1,51 @@ +package orchestrator + +import ( + "testing" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) { + intent := &orchestratorv1.PaymentIntent{ + Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Card{ + Card: &orchestratorv1.CardEndpoint{}, + }, + }, + } + if shouldEstimateNetworkFee(intent) { + t.Fatalf("expected network fee estimation to be skipped for card payouts") + } +} + +func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) { + intent := &orchestratorv1.PaymentIntent{ + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{ + ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"}, + }, + }, + } + if !shouldEstimateNetworkFee(intent) { + t.Fatalf("expected network fee estimation when destination is managed wallet") + } +} + +func TestMapMntxStatusToState(t *testing.T) { + if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled { + t.Fatalf("processed should map to settled") + } + if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed { + t.Fatalf("failed should map to failed") + } + if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted { + t.Fatalf("pending should map to submitted") + } + if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified { + t.Fatalf("unspecified should map to unspecified") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index 80c49b8..4b4d0da 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -1,10 +1,12 @@ package orchestrator import ( + "strings" "time" oracleclient "github.com/tech/sendico/fx/oracle/client" chainclient "github.com/tech/sendico/gateway/chain/client" + mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" clockpkg "github.com/tech/sendico/pkg/clock" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" @@ -46,10 +48,24 @@ func (o oracleDependency) available() bool { return o.client != nil } +type mntxDependency struct { + client mntxclient.Client +} + +func (m mntxDependency) available() bool { + return m.client != nil +} + +// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses). +type CardGatewayRoute struct { + FundingAddress string + FeeAddress string +} + // WithFeeEngine wires the fee engine client. func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { return func(s *Service) { - s.fees = feesDependency{ + s.deps.fees = feesDependency{ client: client, timeout: timeout, } @@ -59,21 +75,41 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option // WithLedgerClient wires the ledger client. func WithLedgerClient(client ledgerclient.Client) Option { return func(s *Service) { - s.ledger = ledgerDependency{client: client} + s.deps.ledger = ledgerDependency{client: client} } } // WithChainGatewayClient wires the chain gateway client. func WithChainGatewayClient(client chainclient.Client) Option { return func(s *Service) { - s.gateway = gatewayDependency{client: client} + s.deps.gateway = gatewayDependency{client: client} } } // WithOracleClient wires the FX oracle client. func WithOracleClient(client oracleclient.Client) Option { return func(s *Service) { - s.oracle = oracleDependency{client: client} + s.deps.oracle = oracleDependency{client: client} + } +} + +// WithMntxGateway wires the Monetix gateway client. +func WithMntxGateway(client mntxclient.Client) Option { + return func(s *Service) { + s.deps.mntx = mntxDependency{client: client} + } +} + +// WithCardGatewayRoutes configures funding/fee wallet routing per gateway. +func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { + return func(s *Service) { + if len(routes) == 0 { + return + } + s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes)) + for k, v := range routes { + s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v + } } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go similarity index 55% rename from api/payments/orchestrator/internal/service/orchestrator/execution.go rename to api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index 422dd23..382b5ae 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/execution.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -3,215 +3,30 @@ package orchestrator import ( "context" "strings" - "time" - oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + "github.com/tech/sendico/pkg/mlogger" 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" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" - "google.golang.org/protobuf/types/known/timestamppb" ) -func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { - intent := req.GetIntent() - amount := intent.GetAmount() - fxSide := fxv1.Side_SIDE_UNSPECIFIED - if intent.GetFx() != nil { - fxSide = intent.GetFx().GetSide() - } - - var fxQuote *oraclev1.Quote - var err error - if shouldRequestFX(intent) { - fxQuote, err = s.requestFXQuote(ctx, orgRef, req) - if err != nil { - return nil, time.Time{}, err - } - } - - payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide) - - feeBaseAmount := payAmount - if feeBaseAmount == nil { - feeBaseAmount = cloneMoney(amount) - } - - feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount) - if err != nil { - return nil, time.Time{}, err - } - feeCurrency := "" - if feeBaseAmount != nil { - feeCurrency = feeBaseAmount.GetCurrency() - } else if amount != nil { - feeCurrency = amount.GetCurrency() - } - feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency) - - var networkFee *chainv1.EstimateTransferFeeResponse - if shouldEstimateNetworkFee(intent) { - networkFee, err = s.estimateNetworkFee(ctx, intent) - if err != nil { - return nil, time.Time{}, err - } - } - - debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote) - - quote := &orchestratorv1.PaymentQuote{ - DebitAmount: debitAmount, - ExpectedSettlementAmount: settlementAmount, - ExpectedFeeTotal: feeTotal, - FeeLines: cloneFeeLines(feeQuote.GetLines()), - FeeRules: cloneFeeRules(feeQuote.GetApplied()), - FxQuote: fxQuote, - NetworkFee: networkFee, - FeeQuoteToken: feeQuote.GetFeeQuoteToken(), - } - - expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote) - - return quote, expiresAt, nil +type paymentExecutor struct { + deps *serviceDependencies + logger mlogger.Logger + svc *Service } -func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { - if !s.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil - } - intent := req.GetIntent() - amount := cloneMoney(baseAmount) - if amount == nil { - amount = cloneMoney(intent.GetAmount()) - } - feeIntent := &feesv1.Intent{ - Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()), - BaseAmount: amount, - BookedAt: timestamppb.New(s.clock.Now()), - OriginType: "payments.orchestrator.quote", - OriginRef: strings.TrimSpace(req.GetIdempotencyKey()), - Attributes: cloneMetadata(intent.GetAttributes()), - } - timeout := req.GetMeta().GetTrace() - ctxTimeout, cancel := s.withTimeout(ctx, s.fees.timeout) - defer cancel() - resp, err := s.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{ - Meta: &feesv1.RequestMeta{ - OrganizationRef: orgRef, - Trace: timeout, - }, - Intent: feeIntent, - TtlMs: defaultFeeQuoteTTLMillis, - }) - if err != nil { - s.logger.Error("fees precompute failed", zap.Error(err)) - return nil, merrors.Internal("fees_precompute_failed") - } - return resp, nil +func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor { + return &paymentExecutor{deps: deps, logger: logger, svc: svc} } -func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) { - if !s.gateway.available() { - return nil, nil - } - - req := &chainv1.EstimateTransferFeeRequest{ - Amount: cloneMoney(intent.GetAmount()), - } - if src := intent.GetSource().GetManagedWallet(); src != nil { - req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef()) - } - if dst := intent.GetDestination().GetManagedWallet(); dst != nil { - req.Destination = &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())}, - } - } - if dst := intent.GetDestination().GetExternalChain(); dst != nil { - req.Destination = &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())}, - Memo: strings.TrimSpace(dst.GetMemo()), - } - req.Asset = dst.GetAsset() - } - if req.Asset == nil { - if src := intent.GetSource().GetManagedWallet(); src != nil { - req.Asset = src.GetAsset() - } - } - - resp, err := s.gateway.client.EstimateTransferFee(ctx, req) - if err != nil { - s.logger.Error("chain gateway fee estimation failed", zap.Error(err)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - return resp, nil -} - -func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) { - if !s.oracle.available() { - return nil, nil - } - intent := req.GetIntent() - meta := req.GetMeta() - fxIntent := intent.GetFx() - if fxIntent == nil { - return nil, nil - } - - ttl := fxIntent.GetTtlMs() - if ttl <= 0 { - ttl = defaultOracleTTLMillis - } - - params := oracleclient.GetQuoteParams{ - Meta: oracleclient.RequestMeta{ - OrganizationRef: orgRef, - Trace: meta.GetTrace(), - }, - Pair: fxIntent.GetPair(), - Side: fxIntent.GetSide(), - Firm: fxIntent.GetFirm(), - TTL: time.Duration(ttl) * time.Millisecond, - PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()), - } - - if fxIntent.GetMaxAgeMs() > 0 { - params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond - } - - if amount := intent.GetAmount(); amount != nil { - pair := fxIntent.GetPair() - if pair != nil { - switch { - case strings.EqualFold(amount.GetCurrency(), pair.GetBase()): - params.BaseAmount = cloneMoney(amount) - case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()): - params.QuoteAmount = cloneMoney(amount) - default: - params.BaseAmount = cloneMoney(amount) - } - } else { - params.BaseAmount = cloneMoney(amount) - } - } - - quote, err := s.oracle.client.GetQuote(ctx, params) - if err != nil { - s.logger.Error("fx oracle quote failed", zap.Error(err)) - return nil, merrors.Internal("fx_quote_failed") - } - return quoteToProto(quote), nil -} - -func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { +func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { if store == nil { return errStorageUnavailable } @@ -219,6 +34,7 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor charges := ledgerChargesFromFeeLines(quote.GetFeeLines()) ledgerNeeded := requiresLedger(payment) chainNeeded := requiresChain(payment) + cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard exec := payment.Execution if exec == nil { @@ -226,25 +42,26 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor } if ledgerNeeded { - if !s.ledger.available() { - return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable")) + if !p.deps.ledger.available() { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable")) } - if err := s.performLedgerOperation(ctx, payment, quote, charges); err != nil { - return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err) + if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err) } payment.State = model.PaymentStateFundsReserved - if err := s.persistPayment(ctx, store, payment); err != nil { + if err := p.persistPayment(ctx, store, payment); err != nil { return err } + p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef)) } if chainNeeded { - if !s.gateway.available() { - return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable")) + if !p.deps.gateway.available() { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable")) } - resp, err := s.submitChainTransfer(ctx, payment, quote) + resp, err := p.submitChainTransfer(ctx, payment, quote) if err != nil { - return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) + return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) } exec = payment.Execution if exec == nil { @@ -255,17 +72,42 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor } payment.Execution = exec payment.State = model.PaymentStateSubmitted - if err := s.persistPayment(ctx, store, payment); err != nil { + if err := p.persistPayment(ctx, store, payment); err != nil { return err } + p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef)) + if !cardNeeded { + return nil + } + } + + if cardNeeded { + if !p.deps.mntx.available() { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable")) + } + if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) + } + if err := p.svc.submitCardPayout(ctx, payment); err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + payment.State = model.PaymentStateSubmitted + if err := p.persistPayment(ctx, store, payment); err != nil { + return err + } + p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef)) return nil } payment.State = model.PaymentStateSettled - return s.persistPayment(ctx, store, payment) + if err := p.persistPayment(ctx, store, payment); err != nil { + return err + } + p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef)) + return nil } -func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error { +func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error { intent := payment.Intent if payment.OrganizationRef == primitive.NilObjectID { return merrors.InvalidArgument("ledger: organization_ref is required") @@ -285,7 +127,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay switch intent.Kind { case model.PaymentKindFXConversion: - if err := s.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil { + if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil { return err } case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified: @@ -303,7 +145,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay Charges: charges, Metadata: metadata, } - resp, err := s.ledger.client.TransferInternal(ctx, req) + resp, err := p.deps.ledger.client.TransferInternal(ctx, req) if err != nil { return err } @@ -316,7 +158,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay return nil } -func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { +func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { intent := payment.Intent source := intent.Source.Ledger destination := intent.Destination.Ledger @@ -354,7 +196,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or Charges: charges, Metadata: metadata, } - resp, err := s.ledger.client.ApplyFXWithCharges(ctx, req) + resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req) if err != nil { return err } @@ -363,7 +205,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or return nil } -func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) { +func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) { intent := payment.Intent source := intent.Source.ManagedWallet destination := intent.Destination @@ -389,23 +231,23 @@ func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Paymen Metadata: cloneMetadata(payment.Metadata), ClientReference: payment.PaymentRef, } - return s.gateway.client.SubmitTransfer(ctx, req) + return p.deps.gateway.client.SubmitTransfer(ctx, req) } -func (s *Service) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { +func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { if store == nil { return errStorageUnavailable } return store.Update(ctx, payment) } -func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { +func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { payment.State = model.PaymentStateFailed payment.FailureCode = code payment.FailureReason = strings.TrimSpace(reason) if store != nil { if updateErr := store.Update(ctx, payment); updateErr != nil { - s.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) + p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) } } if err != nil { @@ -414,6 +256,21 @@ func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore, return merrors.Internal(reason) } +func paymentDescription(payment *model.Payment) string { + if payment == nil { + return "" + } + if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" { + return val + } + if payment.Metadata != nil { + if val := strings.TrimSpace(payment.Metadata["description"]); val != "" { + return val + } + } + return payment.PaymentRef +} + func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) { source := intent.Source.Ledger destination := intent.Destination.Ledger @@ -432,21 +289,6 @@ func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) { return strings.TrimSpace(source.LedgerAccountRef), to, nil } -func paymentDescription(payment *model.Payment) string { - if payment == nil { - return "" - } - if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" { - return val - } - if payment.Metadata != nil { - if val := strings.TrimSpace(payment.Metadata["description"]); val != "" { - return val - } - } - return payment.PaymentRef -} - func requiresLedger(payment *model.Payment) bool { if payment == nil { return false diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go new file mode 100644 index 0000000..b9de283 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -0,0 +1,210 @@ +package orchestrator + +import ( + "context" + "strings" + "time" + + oracleclient "github.com/tech/sendico/fx/oracle/client" + "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" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { + intent := req.GetIntent() + amount := intent.GetAmount() + fxSide := fxv1.Side_SIDE_UNSPECIFIED + if intent.GetFx() != nil { + fxSide = intent.GetFx().GetSide() + } + + var fxQuote *oraclev1.Quote + var err error + if shouldRequestFX(intent) { + fxQuote, err = s.requestFXQuote(ctx, orgRef, req) + if err != nil { + return nil, time.Time{}, err + } + s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef)) + } + + payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide) + + feeBaseAmount := payAmount + if feeBaseAmount == nil { + feeBaseAmount = cloneMoney(amount) + } + + feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount) + if err != nil { + return nil, time.Time{}, err + } + feeCurrency := "" + if feeBaseAmount != nil { + feeCurrency = feeBaseAmount.GetCurrency() + } else if amount != nil { + feeCurrency = amount.GetCurrency() + } + feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency) + + var networkFee *chainv1.EstimateTransferFeeResponse + if shouldEstimateNetworkFee(intent) { + networkFee, err = s.estimateNetworkFee(ctx, intent) + if err != nil { + return nil, time.Time{}, err + } + s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef)) + } + + debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote) + + quote := &orchestratorv1.PaymentQuote{ + DebitAmount: debitAmount, + ExpectedSettlementAmount: settlementAmount, + ExpectedFeeTotal: feeTotal, + FeeLines: cloneFeeLines(feeQuote.GetLines()), + FeeRules: cloneFeeRules(feeQuote.GetApplied()), + FxQuote: fxQuote, + NetworkFee: networkFee, + FeeQuoteToken: feeQuote.GetFeeQuoteToken(), + } + + expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote) + + return quote, expiresAt, nil +} + +func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { + if !s.deps.fees.available() { + return &feesv1.PrecomputeFeesResponse{}, nil + } + intent := req.GetIntent() + amount := cloneMoney(baseAmount) + if amount == nil { + amount = cloneMoney(intent.GetAmount()) + } + feeIntent := &feesv1.Intent{ + Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()), + BaseAmount: amount, + BookedAt: timestamppb.New(s.clock.Now()), + OriginType: "payments.orchestrator.quote", + OriginRef: strings.TrimSpace(req.GetIdempotencyKey()), + Attributes: cloneMetadata(intent.GetAttributes()), + } + timeout := req.GetMeta().GetTrace() + ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout) + defer cancel() + resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{ + Meta: &feesv1.RequestMeta{ + OrganizationRef: orgRef, + Trace: timeout, + }, + Intent: feeIntent, + TtlMs: defaultFeeQuoteTTLMillis, + }) + if err != nil { + s.logger.Warn("fees precompute failed", zap.Error(err)) + return nil, merrors.Internal("fees_precompute_failed") + } + return resp, nil +} + +func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) { + if !s.deps.gateway.available() { + return nil, nil + } + + req := &chainv1.EstimateTransferFeeRequest{ + Amount: cloneMoney(intent.GetAmount()), + } + if src := intent.GetSource().GetManagedWallet(); src != nil { + req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef()) + } + if dst := intent.GetDestination().GetManagedWallet(); dst != nil { + req.Destination = &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())}, + } + } + if dst := intent.GetDestination().GetExternalChain(); dst != nil { + req.Destination = &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())}, + Memo: strings.TrimSpace(dst.GetMemo()), + } + req.Asset = dst.GetAsset() + } + if req.Asset == nil { + if src := intent.GetSource().GetManagedWallet(); src != nil { + req.Asset = src.GetAsset() + } + } + + resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, req) + if err != nil { + s.logger.Warn("chain gateway fee estimation failed", zap.Error(err)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + return resp, nil +} + +func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) { + if !s.deps.oracle.available() { + return nil, nil + } + intent := req.GetIntent() + meta := req.GetMeta() + fxIntent := intent.GetFx() + if fxIntent == nil { + return nil, nil + } + + ttl := fxIntent.GetTtlMs() + if ttl <= 0 { + ttl = defaultOracleTTLMillis + } + + params := oracleclient.GetQuoteParams{ + Meta: oracleclient.RequestMeta{ + OrganizationRef: orgRef, + Trace: meta.GetTrace(), + }, + Pair: fxIntent.GetPair(), + Side: fxIntent.GetSide(), + Firm: fxIntent.GetFirm(), + TTL: time.Duration(ttl) * time.Millisecond, + PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()), + } + + if fxIntent.GetMaxAgeMs() > 0 { + params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond + } + + if amount := intent.GetAmount(); amount != nil { + pair := fxIntent.GetPair() + if pair != nil { + switch { + case strings.EqualFold(amount.GetCurrency(), pair.GetBase()): + params.BaseAmount = cloneMoney(amount) + case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()): + params.QuoteAmount = cloneMoney(amount) + default: + params.BaseAmount = cloneMoney(amount) + } + } else { + params.BaseAmount = cloneMoney(amount) + } + } + + quote, err := s.deps.oracle.client.GetQuote(ctx, params) + if err != nil { + s.logger.Warn("fx oracle quote failed", zap.Error(err)) + return nil, merrors.Internal("fx_quote_failed") + } + return quoteToProto(quote), nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go index 674a2bd..397e463 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go @@ -19,19 +19,21 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) { svc := &Service{ logger: zap.NewNop(), clock: testClock{now: time.Now()}, - oracle: oracleDependency{ - client: &oracleclient.Fake{ - GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { - captured = params - return &oracleclient.Quote{ - QuoteRef: "q", - Pair: params.Pair, - Side: params.Side, - Price: "1.1", - BaseAmount: params.BaseAmount, - QuoteAmount: params.QuoteAmount, - ExpiresAt: time.Now(), - }, nil + deps: serviceDependencies{ + oracle: oracleDependency{ + client: &oracleclient.Fake{ + GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { + captured = params + return &oracleclient.Quote{ + QuoteRef: "q", + Pair: params.Pair, + Side: params.Side, + Price: "1.1", + BaseAmount: params.BaseAmount, + QuoteAmount: params.QuoteAmount, + ExpiresAt: time.Now(), + }, nil + }, }, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index be105a2..9d29218 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -2,21 +2,14 @@ package orchestrator import ( "context" - "strings" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers" - "github.com/tech/sendico/pkg/api/routers/gsresponse" clockpkg "github.com/tech/sendico/pkg/clock" - "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.mongodb.org/mongo-driver/bson/primitive" "google.golang.org/grpc" - "google.golang.org/protobuf/proto" ) type serviceError string @@ -40,14 +33,32 @@ type Service struct { storage storage.Repository clock clockpkg.Clock - fees feesDependency - ledger ledgerDependency - gateway gatewayDependency - oracle oracleDependency + deps serviceDependencies + h handlerSet + comp componentSet orchestratorv1.UnimplementedPaymentOrchestratorServer } +type serviceDependencies struct { + fees feesDependency + ledger ledgerDependency + gateway gatewayDependency + oracle oracleDependency + mntx mntxDependency + cardRoutes map[string]CardGatewayRoute +} + +type handlerSet struct { + commands *paymentCommandFactory + queries *paymentQueryHandler + events *paymentEventHandler +} + +type componentSet struct { + executor *paymentExecutor +} + // NewService constructs a payment orchestrator service. func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { svc := &Service{ @@ -68,9 +79,30 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) svc.clock = clockpkg.NewSystem() } + engine := defaultPaymentEngine{svc: svc} + svc.h.commands = newPaymentCommandFactory(engine, svc.logger) + svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries")) + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events")) + svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc) + return svc } +func (s *Service) ensureHandlers() { + if s.h.commands == nil { + s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger) + } + if s.h.queries == nil { + s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries")) + } + if s.h.events == nil { + s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events")) + } + if s.comp.executor == nil { + s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s) + } +} + // Register attaches the service to the supplied gRPC router. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { @@ -80,474 +112,59 @@ func (s *Service) Register(router routers.GRPC) error { // QuotePayment aggregates downstream quotes. func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { - return executeUnary(ctx, s, "QuotePayment", s.quotePaymentHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req) } // InitiatePayment captures a payment intent and reserves funds orchestration. func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { - return executeUnary(ctx, s, "InitiatePayment", s.initiatePaymentHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req) } // CancelPayment attempts to cancel an in-flight payment. func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { - return executeUnary(ctx, s, "CancelPayment", s.cancelPaymentHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req) } // GetPayment returns a stored payment record. func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { - return executeUnary(ctx, s, "GetPayment", s.getPaymentHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req) } // ListPayments lists stored payment records. func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { - return executeUnary(ctx, s, "ListPayments", s.listPaymentsHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req) } // InitiateConversion orchestrates standalone FX conversions. func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { - return executeUnary(ctx, s, "InitiateConversion", s.initiateConversionHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req) } // ProcessTransferUpdate reconciles chain events back into payment state. func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { - return executeUnary(ctx, s, "ProcessTransferUpdate", s.processTransferUpdateHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req) } // ProcessDepositObserved reconciles deposit events to ledger. func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { - return executeUnary(ctx, s, "ProcessDepositObserved", s.processDepositObservedHandler, req) + s.ensureHandlers() + return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req) } -func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - meta := req.GetMeta() - if meta == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required")) - } - orgRef := strings.TrimSpace(meta.GetOrganizationRef()) - if orgRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required")) - } - orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef) - if parseErr != nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID")) - } - intent := req.GetIntent() - if intent == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required")) - } - if intent.GetAmount() == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required")) - } - - quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - if !req.GetPreviewOnly() { - quotesStore := s.storage.Quotes() - if quotesStore == nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - quoteRef := primitive.NewObjectID().Hex() - quote.QuoteRef = quoteRef - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - Intent: intentFromProto(intent), - Quote: quoteSnapshotToModel(quote), - ExpiresAt: expiresAt, - } - record.SetID(primitive.NewObjectID()) - record.SetOrganizationRef(orgObjectID) - if err := quotesStore.Create(ctx, record); err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - } - - return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote}) +// ProcessCardPayoutUpdate reconciles card payout events back into payment state. +func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) { + s.ensureHandlers() + return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req) } -func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - meta := req.GetMeta() - if meta == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required")) - } - orgRef := strings.TrimSpace(meta.GetOrganizationRef()) - if orgRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required")) - } - orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef) - if parseErr != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID")) - } - intent := req.GetIntent() - if intent == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required")) - } - if intent.GetAmount() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required")) - } - idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) - if idempotencyKey == "" { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required")) - } - - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - - existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey) - if err == nil && existing != nil { - return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ - Payment: toProtoPayment(existing), - }) - } - if err != nil && err != storage.ErrPaymentNotFound { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - quoteRef := strings.TrimSpace(req.GetQuoteRef()) - quote := strings.TrimSpace(req.GetFeeQuoteToken()) - var quoteSnapshot *orchestratorv1.PaymentQuote - if quoteRef != "" { - quotesStore := s.storage.Quotes() - if quotesStore == nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef) - if err != nil { - if err == storage.ErrQuoteNotFound { - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired")) - } - if !proto.Equal(protoIntentFromModel(record.Intent), intent) { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent")) - } - quoteSnapshot = modelQuoteToProto(record.Quote) - if quoteSnapshot == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty")) - } - quoteSnapshot.QuoteRef = quoteRef - } else if quote == "" { - quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ - Meta: req.GetMeta(), - IdempotencyKey: req.GetIdempotencyKey(), - Intent: req.GetIntent(), - PreviewOnly: false, - }) - if err != nil { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - } else { - quoteSnapshot = &orchestratorv1.PaymentQuote{FeeQuoteToken: quote} - } - - entity := &model.Payment{} - entity.SetID(primitive.NewObjectID()) - entity.SetOrganizationRef(orgObjectID) - entity.PaymentRef = entity.GetID().Hex() - entity.IdempotencyKey = idempotencyKey - entity.State = model.PaymentStateAccepted - entity.Intent = intentFromProto(intent) - entity.Metadata = cloneMetadata(req.GetMetadata()) - entity.LastQuote = quoteSnapshotToModel(quoteSnapshot) - entity.Normalize() - - if err = store.Create(ctx, entity); err != nil { - if err == storage.ErrDuplicatePayment { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - if quoteSnapshot == nil { - quoteSnapshot = &orchestratorv1.PaymentQuote{} - } - - if err := s.executePayment(ctx, store, entity, quoteSnapshot); err != nil { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ - Payment: toProtoPayment(entity), - }) -} - -func (s *Service) cancelPaymentHandler(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - paymentRef := strings.TrimSpace(req.GetPaymentRef()) - if paymentRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required")) - } - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - if err == storage.ErrPaymentNotFound { - return gsresponse.NotFound[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if payment.State != model.PaymentStateAccepted { - reason := merrors.InvalidArgument("payment cannot be cancelled in current state") - return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason) - } - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(req.GetReason()) - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)}) -} - -func (s *Service) getPaymentHandler(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - paymentRef := strings.TrimSpace(req.GetPaymentRef()) - if paymentRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required")) - } - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - entity, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - if err == storage.ErrPaymentNotFound { - return gsresponse.NotFound[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)}) -} - -func (s *Service) listPaymentsHandler(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - filter := filterFromProto(req) - result, err := store.List(ctx, filter) - if err != nil { - return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err) - } - resp := &orchestratorv1.ListPaymentsResponse{ - Page: &paginationv1.CursorPageResponse{ - NextCursor: result.NextCursor, - }, - } - resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items)) - for _, item := range result.Items { - resp.Payments = append(resp.Payments, toProtoPayment(item)) - } - return gsresponse.Success(resp) -} - -func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - meta := req.GetMeta() - if meta == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required")) - } - orgRef := strings.TrimSpace(meta.GetOrganizationRef()) - if orgRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required")) - } - orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef) - if parseErr != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID")) - } - idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) - if idempotencyKey == "" { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required")) - } - if req.GetSource() == nil || req.GetSource().GetLedger() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required")) - } - if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required")) - } - fxIntent := req.GetFx() - if fxIntent == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required")) - } - - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - - if existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey); err == nil && existing != nil { - return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)}) - } else if err != nil && err != storage.ErrPaymentNotFound { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - intentProto := &orchestratorv1.PaymentIntent{ - Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, - Source: req.GetSource(), - Destination: req.GetDestination(), - Amount: amount, - RequiresFx: true, - Fx: fxIntent, - FeePolicy: req.GetFeePolicy(), - } - - quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ - Meta: req.GetMeta(), - IdempotencyKey: req.GetIdempotencyKey(), - Intent: intentProto, - }) - if err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - entity := &model.Payment{} - entity.SetID(primitive.NewObjectID()) - entity.SetOrganizationRef(orgObjectID) - entity.PaymentRef = entity.GetID().Hex() - entity.IdempotencyKey = idempotencyKey - entity.State = model.PaymentStateAccepted - entity.Intent = intentFromProto(intentProto) - entity.Metadata = cloneMetadata(req.GetMetadata()) - entity.LastQuote = quoteSnapshotToModel(quote) - entity.Normalize() - - if err = store.Create(ctx, entity); err != nil { - if err == storage.ErrDuplicatePayment { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - if err := s.executePayment(ctx, store, entity, quote); err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err) - } - - return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{ - Conversion: toProtoPayment(entity), - }) -} - -func (s *Service) processTransferUpdateHandler(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required")) - } - transfer := req.GetEvent().GetTransfer() - transferRef := strings.TrimSpace(transfer.GetTransferRef()) - if transferRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required")) - } - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - payment, err := store.GetByChainTransferRef(ctx, transferRef) - if err != nil { - if err == storage.ErrPaymentNotFound { - return gsresponse.NotFound[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err) - } - applyTransferStatus(req.GetEvent(), payment) - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) -} - -func (s *Service) processDepositObservedHandler(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] { - if err := s.ensureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required")) - } - event := req.GetEvent() - walletRef := strings.TrimSpace(event.GetWalletRef()) - if walletRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required")) - } - store := s.storage.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - filter := &model.PaymentFilter{ - States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved}, - DestinationRef: walletRef, - } - result, err := store.List(ctx, filter) - if err != nil { - return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err) - } - for _, payment := range result.Items { - if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet { - continue - } - if !moneyEquals(payment.Intent.Amount, event.GetAmount()) { - continue - } - payment.State = model.PaymentStateSettled - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash()) - } - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)}) - } - return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{}) +func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { + s.ensureHandlers() + return s.comp.executor.executePayment(ctx, store, payment, quote) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go new file mode 100644 index 0000000..0246a16 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -0,0 +1,170 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/protobuf/proto" +) + +func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, primitive.ObjectID, error) { + if meta == nil { + return "", primitive.NilObjectID, merrors.InvalidArgument("meta is required") + } + orgRef := strings.TrimSpace(meta.GetOrganizationRef()) + if orgRef == "" { + return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref is required") + } + orgID, err := primitive.ObjectIDFromHex(orgRef) + if err != nil { + return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID") + } + return orgRef, orgID, nil +} + +func requireIdempotencyKey(k string) (string, error) { + key := strings.TrimSpace(k) + if key == "" { + return "", merrors.InvalidArgument("idempotency_key is required") + } + return key, nil +} + +func requirePaymentRef(ref string) (string, error) { + val := strings.TrimSpace(ref) + if val == "" { + return "", merrors.InvalidArgument("payment_ref is required") + } + return val, nil +} + +func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error { + if intent == nil { + return merrors.InvalidArgument("intent is required") + } + if intent.GetAmount() == nil { + return merrors.InvalidArgument("intent.amount is required") + } + return nil +} + +func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) { + if repo == nil { + return nil, errStorageUnavailable + } + store := repo.Payments() + if store == nil { + return nil, errStorageUnavailable + } + return store, nil +} + +func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) { + if repo == nil { + return nil, errStorageUnavailable + } + store := repo.Quotes() + if store == nil { + return nil, errStorageUnavailable + } + return store, nil +} + +func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID primitive.ObjectID, key string) (*model.Payment, error) { + payment, err := store.GetByIdempotencyKey(ctx, orgID, key) + if err != nil { + return nil, err + } + return payment, nil +} + +type quoteResolutionInput struct { + OrgRef string + OrgID primitive.ObjectID + Meta *orchestratorv1.RequestMeta + Intent *orchestratorv1.PaymentIntent + QuoteRef string + FeeQuoteToken string + IdempotencyKey string +} + +type quoteResolutionError struct { + code string + err error +} + +func (e quoteResolutionError) Error() string { return e.err.Error() } + +func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) { + if ref := strings.TrimSpace(in.QuoteRef); ref != "" { + quotesStore, err := ensureQuotesStore(s.storage) + if err != nil { + return nil, err + } + record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) + if err != nil { + if errors.Is(err, storage.ErrQuoteNotFound) { + return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} + } + return nil, err + } + if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { + return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} + } + if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) { + return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} + } + quote := modelQuoteToProto(record.Quote) + if quote == nil { + return nil, merrors.InvalidArgument("stored quote is empty") + } + quote.QuoteRef = ref + return quote, nil + } + + if token := strings.TrimSpace(in.FeeQuoteToken); token != "" { + return &orchestratorv1.PaymentQuote{FeeQuoteToken: token}, nil + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: in.Meta, + IdempotencyKey: in.IdempotencyKey, + Intent: in.Intent, + PreviewOnly: false, + } + quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) + if err != nil { + return nil, err + } + return quote, nil +} + +func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment { + entity := &model.Payment{} + entity.SetID(primitive.NewObjectID()) + entity.SetOrganizationRef(orgID) + entity.PaymentRef = entity.GetID().Hex() + entity.IdempotencyKey = idempotencyKey + entity.State = model.PaymentStateAccepted + entity.Intent = intentFromProto(intent) + entity.Metadata = cloneMetadata(metadata) + entity.LastQuote = quoteSnapshotToModel(quote) + entity.Normalize() + return entity +} + +func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] { + if errors.Is(err, storage.ErrPaymentNotFound) { + return gsresponse.NotFound[T](logger, svc, err) + } + return gsresponse.Auto[T](logger, svc, err) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go new file mode 100644 index 0000000..3384c86 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -0,0 +1,252 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + clockpkg "github.com/tech/sendico/pkg/clock" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestValidateMetaAndOrgRef(t *testing.T) { + org := primitive.NewObjectID() + meta := &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()} + ref, id, err := validateMetaAndOrgRef(meta) + if err != nil { + t.Fatalf("expected nil error: %v", err) + } + if ref != org.Hex() || id != org { + t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex()) + } + if _, _, err := validateMetaAndOrgRef(nil); err == nil { + t.Fatalf("expected error on nil meta") + } + if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: ""}); err == nil { + t.Fatalf("expected error on empty orgRef") + } + if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: "bad"}); err == nil { + t.Fatalf("expected error on invalid orgRef") + } +} + +func TestRequireIdempotencyKey(t *testing.T) { + if _, err := requireIdempotencyKey(" "); err == nil { + t.Fatalf("expected error for empty key") + } + val, err := requireIdempotencyKey(" key ") + if err != nil || val != "key" { + t.Fatalf("unexpected result %s err %v", val, err) + } +} + +func TestNewPayment(t *testing.T) { + org := primitive.NewObjectID() + intent := &orchestratorv1.PaymentIntent{ + Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, + } + quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"} + p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote) + if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted { + t.Fatalf("unexpected payment fields: %+v", p) + } + if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" { + t.Fatalf("intent not copied") + } + if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" { + t.Fatalf("quote not copied") + } +} + +func TestResolvePaymentQuote_NotFound(t *testing.T) { + org := primitive.NewObjectID() + svc := &Service{ + storage: stubRepo{quotes: &helperQuotesStore{}}, + clock: clockpkg.NewSystem(), + } + _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + OrgRef: org.Hex(), + OrgID: org, + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}, + QuoteRef: "missing", + }) + if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" { + t.Fatalf("expected quote_not_found, got %v", err) + } +} + +func TestResolvePaymentQuote_Expired(t *testing.T) { + org := primitive.NewObjectID() + intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + record := &model.PaymentQuoteRecord{ + QuoteRef: "q1", + Intent: intentFromProto(intent), + Quote: &model.PaymentQuoteSnapshot{}, + ExpiresAt: time.Now().Add(-time.Minute), + } + svc := &Service{ + storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, + clock: clockpkg.NewSystem(), + } + _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + OrgRef: org.Hex(), + OrgID: org, + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + Intent: intent, + QuoteRef: "q1", + }) + if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" { + t.Fatalf("expected quote_expired, got %v", err) + } +} + +func TestResolvePaymentQuote_FeeToken(t *testing.T) { + org := primitive.NewObjectID() + intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + svc := &Service{clock: clockpkg.NewSystem()} + quote, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + OrgRef: org.Hex(), + OrgID: org, + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + Intent: intent, + FeeQuoteToken: "token", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if quote.GetFeeQuoteToken() != "token" { + t.Fatalf("expected fee token preserved") + } +} + +func TestInitiatePaymentIdempotency(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + org := primitive.NewObjectID() + store := newHelperPaymentStore() + svc := NewService(logger, stubRepo{ + payments: store, + }, WithClock(clockpkg.NewSystem())) + svc.ensureHandlers() + + intent := &orchestratorv1.PaymentIntent{ + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + } + req := &orchestratorv1.InitiatePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + Intent: intent, + IdempotencyKey: "k1", + FeeQuoteToken: "fq", + } + resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) + if err != nil { + t.Fatalf("first call failed: %v", err) + } + resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) + if err != nil { + t.Fatalf("second call failed: %v", err) + } + if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() { + t.Fatalf("idempotent call returned different payments") + } +} + +// --- test doubles --- + +type stubRepo struct { + payments storage.PaymentsStore + quotes storage.QuotesStore + pingErr error +} + +func (s stubRepo) Ping(context.Context) error { return s.pingErr } +func (s stubRepo) Payments() storage.PaymentsStore { return s.payments } +func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes } + +type helperPaymentStore struct { + byRef map[string]*model.Payment + byIdem map[string]*model.Payment + byChain map[string]*model.Payment +} + +func newHelperPaymentStore() *helperPaymentStore { + return &helperPaymentStore{ + byRef: make(map[string]*model.Payment), + byIdem: make(map[string]*model.Payment), + byChain: make(map[string]*model.Payment), + } +} + +func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error { + if _, ok := s.byRef[p.PaymentRef]; ok { + return storage.ErrDuplicatePayment + } + s.byRef[p.PaymentRef] = p + if p.IdempotencyKey != "" { + s.byIdem[p.IdempotencyKey] = p + } + if p.Execution != nil && p.Execution.ChainTransferRef != "" { + s.byChain[p.Execution.ChainTransferRef] = p + } + return nil +} + +func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error { + if p == nil { + return storage.ErrPaymentNotFound + } + if _, ok := s.byRef[p.PaymentRef]; !ok { + return storage.ErrPaymentNotFound + } + s.byRef[p.PaymentRef] = p + if p.IdempotencyKey != "" { + s.byIdem[p.IdempotencyKey] = p + } + return nil +} + +func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) { + if p, ok := s.byRef[ref]; ok { + return p, nil + } + return nil, storage.ErrPaymentNotFound +} + +func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ primitive.ObjectID, key string) (*model.Payment, error) { + if p, ok := s.byIdem[key]; ok { + return p, nil + } + return nil, storage.ErrPaymentNotFound +} + +func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) { + if p, ok := s.byChain[ref]; ok { + return p, nil + } + return nil, storage.ErrPaymentNotFound +} + +func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) { + return &model.PaymentList{}, nil +} + +type helperQuotesStore struct { + records map[string]*model.PaymentQuoteRecord +} + +func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil } + +func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { + if s.records == nil { + return nil, storage.ErrQuoteNotFound + } + if rec, ok := s.records[ref]; ok { + return rec, nil + } + return nil, storage.ErrQuoteNotFound +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index 1d33b98..f2f6e0c 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -32,11 +32,13 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { logger: zap.NewNop(), clock: testClock{now: time.Now()}, storage: repo, - ledger: ledgerDependency{client: &ledgerclient.Fake{ - ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil - }, - }}, + deps: serviceDependencies{ + ledger: ledgerDependency{client: &ledgerclient.Fake{ + ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { + return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil + }, + }}, + }, } payment := &model.Payment{ @@ -88,11 +90,13 @@ func TestExecutePayment_ChainFailure(t *testing.T) { logger: zap.NewNop(), clock: testClock{now: time.Now()}, storage: repo, - gateway: gatewayDependency{client: &chainclient.Fake{ - SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { - return nil, errors.New("chain failure") - }, - }}, + deps: serviceDependencies{ + gateway: gatewayDependency{client: &chainclient.Fake{ + SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + return nil, errors.New("chain failure") + }, + }}, + }, } payment := &model.Payment{ @@ -146,6 +150,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) { clock: testClock{now: time.Now()}, storage: &stubRepository{store: store}, } + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger) req := &orchestratorv1.ProcessTransferUpdateRequest{ Event: &chainv1.TransferStatusChangedEvent{ @@ -156,7 +161,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) { }, } - reSP, err := gsresponse.Execute(ctx, svc.processTransferUpdateHandler(ctx, req)) + reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) if err != nil { t.Fatalf("handler returned error: %v", err) } @@ -189,6 +194,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { clock: testClock{now: time.Now()}, storage: &stubRepository{store: store}, } + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger) req := &orchestratorv1.ProcessDepositObservedRequest{ Event: &chainv1.WalletDepositObservedEvent{ @@ -197,7 +203,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { }, } - reSP, err := gsresponse.Execute(ctx, svc.processDepositObservedHandler(ctx, req)) + reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req)) if err != nil { t.Fatalf("handler returned error: %v", err) } diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 53915ea..9b9d8ca 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -57,6 +57,7 @@ const ( EndpointTypeLedger PaymentEndpointType = "ledger" EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet" EndpointTypeExternalChain PaymentEndpointType = "external_chain" + EndpointTypeCard PaymentEndpointType = "card" ) // LedgerEndpoint describes ledger routing. @@ -78,12 +79,36 @@ type ExternalChainEndpoint struct { Memo string `bson:"memo,omitempty" json:"memo,omitempty"` } +// CardEndpoint describes a card payout destination. +type CardEndpoint struct { + Pan string `bson:"pan,omitempty" json:"pan,omitempty"` + Token string `bson:"token,omitempty" json:"token,omitempty"` + Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"` + ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"` + ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` + MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"` +} + +// CardPayout stores gateway payout tracking info. +type CardPayout struct { + PayoutRef string `bson:"payoutRef,omitempty" json:"payoutRef,omitempty"` + ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"providerPaymentId,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` + FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"` + CardCountry string `bson:"cardCountry,omitempty" json:"cardCountry,omitempty"` + MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"` + ProviderCode string `bson:"providerCode,omitempty" json:"providerCode,omitempty"` + GatewayReference string `bson:"gatewayReference,omitempty" json:"gatewayReference,omitempty"` +} + // PaymentEndpoint is a polymorphic payment destination/source. type PaymentEndpoint struct { Type PaymentEndpointType `bson:"type" json:"type"` Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"` ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"` ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"` + Card *CardEndpoint `bson:"card,omitempty" json:"card,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } @@ -128,6 +153,8 @@ type ExecutionRefs struct { CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"` FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"` ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"` + CardPayoutRef string `bson:"cardPayoutRef,omitempty" json:"cardPayoutRef,omitempty"` + FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"` } // Payment persists orchestrated payment lifecycle. @@ -144,6 +171,7 @@ type Payment struct { LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"` } // Collection implements storable.Storable. @@ -223,5 +251,13 @@ func normalizeEndpoint(ep *PaymentEndpoint) { ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress)) } } + case EndpointTypeCard: + if ep.Card != nil { + ep.Card.Pan = strings.TrimSpace(ep.Card.Pan) + ep.Card.Token = strings.TrimSpace(ep.Card.Token) + ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder) + ep.Card.Country = strings.TrimSpace(ep.Card.Country) + ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan) + } } } diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 49ca5ed..8949c99 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -11,6 +11,7 @@ import "common/trace/v1/trace.proto"; import "common/pagination/v1/cursor.proto"; import "billing/fees/v1/fees.proto"; import "gateway/chain/v1/chain.proto"; +import "gateway/mntx/v1/mntx.proto"; import "oracle/v1/oracle.proto"; enum PaymentKind { @@ -20,6 +21,13 @@ enum PaymentKind { PAYMENT_KIND_FX_CONVERSION = 3; } +// SettlementMode defines how to treat fees/FX variance for payouts. +enum SettlementMode { + SETTLEMENT_MODE_UNSPECIFIED = 0; + SETTLEMENT_MODE_FIX_SOURCE = 1; // customer pays fees; sent amount fixed + SETTLEMENT_MODE_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes +} + enum PaymentState { PAYMENT_STATE_UNSPECIFIED = 0; PAYMENT_STATE_ACCEPTED = 1; @@ -61,11 +69,25 @@ message ExternalChainEndpoint { string memo = 3; } +// Card payout destination. +message CardEndpoint { + oneof card { + string pan = 1; // raw PAN + string token = 2; // network or gateway-issued token + } + string cardholder_name = 3; + uint32 exp_month = 4; + uint32 exp_year = 5; + string country = 6; + string masked_pan = 7; +} + message PaymentEndpoint { oneof endpoint { LedgerEndpoint ledger = 1; ManagedWalletEndpoint managed_wallet = 2; ExternalChainEndpoint external_chain = 3; + CardEndpoint card = 4; } map metadata = 10; } @@ -88,6 +110,7 @@ message PaymentIntent { FXIntent fx = 6; fees.v1.PolicyOverrides fee_policy = 7; map attributes = 8; + SettlementMode settlement_mode = 9; } message PaymentQuote { @@ -107,6 +130,20 @@ message ExecutionRefs { string credit_entry_ref = 2; string fx_entry_ref = 3; string chain_transfer_ref = 4; + string card_payout_ref = 5; + string fee_transfer_ref = 6; +} + +// Card payout gateway tracking info. +message CardPayout { + string payout_ref = 1; + string provider_payment_id = 2; + string status = 3; + string failure_reason = 4; + string card_country = 5; + string masked_pan = 6; + string provider_code = 7; + string gateway_reference = 8; } message Payment { @@ -121,6 +158,7 @@ message Payment { map metadata = 9; google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp updated_at = 11; + CardPayout card_payout = 12; } message QuotePaymentRequest { @@ -138,10 +176,8 @@ message InitiatePaymentRequest { RequestMeta meta = 1; string idempotency_key = 2; PaymentIntent intent = 3; - string fee_quote_token = 4; - string fx_quote_ref = 5; - map metadata = 6; - string quote_ref = 7; + map metadata = 4; + string quote_ref = 5; } message InitiatePaymentResponse { @@ -198,6 +234,15 @@ message ProcessDepositObservedResponse { Payment payment = 1; } +message ProcessCardPayoutUpdateRequest { + RequestMeta meta = 1; + mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2; +} + +message ProcessCardPayoutUpdateResponse { + Payment payment = 1; +} + message InitiateConversionRequest { RequestMeta meta = 1; string idempotency_key = 2; @@ -221,4 +266,5 @@ service PaymentOrchestrator { rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); + rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse); } diff --git a/api/server/go.mod b/api/server/go.mod index 6161452..677ffe3 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -12,9 +12,9 @@ replace github.com/tech/sendico/gateway/chain => ../gateway/chain require ( github.com/aws/aws-sdk-go-v2 v1.41.0 - github.com/aws/aws-sdk-go-v2/config v1.32.4 - github.com/aws/aws-sdk-go-v2/credentials v1.19.4 - github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 + github.com/aws/aws-sdk-go-v2/config v1.32.5 + github.com/aws/aws-sdk-go-v2/credentials v1.19.5 + github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -22,7 +22,7 @@ require ( github.com/google/uuid v1.6.0 github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.11.1 - github.com/tech/sendico/gateway/chain v0.1.0 + github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000 github.com/tech/sendico/pkg v0.1.0 @@ -59,7 +59,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect diff --git a/api/server/go.sum b/api/server/go.sum index 7d3b2c4..698c293 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.4 h1:gl+DxVuadpkYoaDcWllZqLkhGEbvwyqgNVRTmlaf5PI= -github.com/aws/aws-sdk-go-v2/config v1.32.4/go.mod h1:MBUp9Og/bzMmQHjMwace4aJfyvJeadzXjoTcR/SxLV0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.4 h1:KeIZxHVbGWRLhPvhdPbbi/DtFBHNKm6OsVDuiuFefdQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.4/go.mod h1:Smw5n0nCZE9PeFEguofdXyt8kUC4JNrkDTfBOioPhFA= +github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= +github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= @@ -32,16 +32,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= -github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 h1:5FhzzN6JmlGQF6c04kDIb5KNGm6KnNdLISNrfivIhHg= -github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 h1:YCu/iAhQer8WZ66lldyKkpvMyv+HkPufMa4dyT6wils= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.4/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= diff --git a/api/server/interface/api/srequest/endpoint_payloads.go b/api/server/interface/api/srequest/endpoint_payloads.go new file mode 100644 index 0000000..f9e46fd --- /dev/null +++ b/api/server/interface/api/srequest/endpoint_payloads.go @@ -0,0 +1,47 @@ +package srequest + +// Asset represents a chain/token pair for blockchain endpoints. +type Asset struct { + Chain ChainNetwork `json:"chain"` + TokenSymbol string `json:"token_symbol"` + ContractAddress string `json:"contract_address,omitempty"` +} + +// LedgerEndpoint represents a ledger account payload. +type LedgerEndpoint struct { + LedgerAccountRef string `json:"ledger_account_ref"` + ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"` +} + +// ManagedWalletEndpoint represents a managed wallet payload. +type ManagedWalletEndpoint struct { + ManagedWalletRef string `json:"managed_wallet_ref"` + Asset *Asset `json:"asset,omitempty"` +} + +// ExternalChainEndpoint represents an external chain address payload. +type ExternalChainEndpoint struct { + Asset *Asset `json:"asset,omitempty"` + Address string `json:"address"` + Memo string `json:"memo,omitempty"` +} + +// CardEndpoint represents a card payout payload. +type CardEndpoint struct { + Pan string `json:"pan,omitempty"` + Token string `json:"token,omitempty"` + Cardholder string `json:"cardholder,omitempty"` + ExpMonth uint32 `json:"exp_month,omitempty"` + ExpYear uint32 `json:"exp_year,omitempty"` + Country string `json:"country,omitempty"` + MaskedPan string `json:"masked_pan,omitempty"` +} + +// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility. +type LegacyPaymentEndpoint struct { + Ledger *LedgerEndpoint `json:"ledger,omitempty"` + ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"` + ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"` + Card *CardEndpoint `json:"card,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} diff --git a/api/server/interface/api/srequest/endpoint_union.go b/api/server/interface/api/srequest/endpoint_union.go new file mode 100644 index 0000000..16e167f --- /dev/null +++ b/api/server/interface/api/srequest/endpoint_union.go @@ -0,0 +1,211 @@ +package srequest + +import ( + "encoding/json" + + "github.com/tech/sendico/pkg/merrors" +) + +type EndpointType string + +const ( + EndpointTypeLedger EndpointType = "ledger" + EndpointTypeManagedWallet EndpointType = "managed_wallet" + EndpointTypeExternalChain EndpointType = "external_chain" + EndpointTypeCard EndpointType = "card" +) + +// Endpoint is a discriminated union for payment endpoints. +type Endpoint struct { + Type EndpointType `json:"type"` + Data json.RawMessage `json:"data"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]string) (Endpoint, error) { + data, err := json.Marshal(payload) + if err != nil { + return Endpoint{}, merrors.Internal("marshal endpoint payload failed") + } + return Endpoint{ + Type: kind, + Data: data, + Metadata: cloneStringMap(metadata), + }, nil +} + +func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error { + if e.Type == "" { + return merrors.InvalidArgument("endpoint type is required") + } + if e.Type != expected { + return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type)) + } + if len(e.Data) == 0 { + return merrors.InvalidArgument("endpoint data is required for type " + string(expected)) + } + if err := json.Unmarshal(e.Data, dst); err != nil { + return merrors.InvalidArgument("decode " + string(expected) + " endpoint: " + err.Error()) + } + return nil +} + +func (e *Endpoint) UnmarshalJSON(data []byte) error { + var envelope struct { + Type EndpointType `json:"type"` + Data json.RawMessage `json:"data"` + Metadata map[string]string `json:"metadata"` + } + if err := json.Unmarshal(data, &envelope); err == nil { + if envelope.Type != "" || len(envelope.Data) > 0 { + if envelope.Type == "" { + return merrors.InvalidArgument("endpoint type is required") + } + *e = Endpoint{ + Type: envelope.Type, + Data: envelope.Data, + Metadata: cloneStringMap(envelope.Metadata), + } + return nil + } + } + + var legacy LegacyPaymentEndpoint + if err := json.Unmarshal(data, &legacy); err != nil { + return err + } + endpoint, err := LegacyPaymentEndpointToEndpointDTO(&legacy) + if err != nil { + return err + } + if endpoint == nil { + return merrors.InvalidArgument("endpoint payload is empty") + } + *e = *endpoint + return nil +} + +func NewLedgerEndpointDTO(payload LedgerEndpoint, metadata map[string]string) (Endpoint, error) { + return newEndpoint(EndpointTypeLedger, payload, metadata) +} + +func NewManagedWalletEndpointDTO(payload ManagedWalletEndpoint, metadata map[string]string) (Endpoint, error) { + return newEndpoint(EndpointTypeManagedWallet, payload, metadata) +} + +func NewExternalChainEndpointDTO(payload ExternalChainEndpoint, metadata map[string]string) (Endpoint, error) { + return newEndpoint(EndpointTypeExternalChain, payload, metadata) +} + +func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpoint, error) { + return newEndpoint(EndpointTypeCard, payload, metadata) +} + +func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) { + var payload LedgerEndpoint + return payload, e.decodePayload(EndpointTypeLedger, &payload) +} + +func (e Endpoint) DecodeManagedWallet() (ManagedWalletEndpoint, error) { + var payload ManagedWalletEndpoint + return payload, e.decodePayload(EndpointTypeManagedWallet, &payload) +} + +func (e Endpoint) DecodeExternalChain() (ExternalChainEndpoint, error) { + var payload ExternalChainEndpoint + return payload, e.decodePayload(EndpointTypeExternalChain, &payload) +} + +func (e Endpoint) DecodeCard() (CardEndpoint, error) { + var payload CardEndpoint + return payload, e.decodePayload(EndpointTypeCard, &payload) +} + +func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) { + if old == nil { + return nil, nil + } + + count := 0 + var endpoint Endpoint + var err error + + if old.Ledger != nil { + count++ + endpoint, err = NewLedgerEndpointDTO(*old.Ledger, old.Metadata) + } + if old.ManagedWallet != nil { + count++ + endpoint, err = NewManagedWalletEndpointDTO(*old.ManagedWallet, old.Metadata) + } + if old.ExternalChain != nil { + count++ + endpoint, err = NewExternalChainEndpointDTO(*old.ExternalChain, old.Metadata) + } + if old.Card != nil { + count++ + endpoint, err = NewCardEndpointDTO(*old.Card, old.Metadata) + } + + if err != nil { + return nil, err + } + if count == 0 { + return nil, merrors.InvalidArgument("exactly one endpoint must be set") + } + if count > 1 { + return nil, merrors.InvalidArgument("only one endpoint can be set") + } + return &endpoint, nil +} + +func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint, error) { + if new == nil { + return nil, nil + } + + legacy := &LegacyPaymentEndpoint{ + Metadata: cloneStringMap(new.Metadata), + } + + switch new.Type { + case EndpointTypeLedger: + payload, err := new.DecodeLedger() + if err != nil { + return nil, err + } + legacy.Ledger = &payload + case EndpointTypeManagedWallet: + payload, err := new.DecodeManagedWallet() + if err != nil { + return nil, err + } + legacy.ManagedWallet = &payload + case EndpointTypeExternalChain: + payload, err := new.DecodeExternalChain() + if err != nil { + return nil, err + } + legacy.ExternalChain = &payload + case EndpointTypeCard: + payload, err := new.DecodeCard() + if err != nil { + return nil, err + } + legacy.Card = &payload + default: + return nil, merrors.InvalidArgument("unsupported endpoint type: " + string(new.Type)) + } + return legacy, nil +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index 366edc7..5ad13fb 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -1,17 +1,60 @@ package srequest +import ( + "github.com/tech/sendico/pkg/merrors" +) + type QuotePayment struct { IdempotencyKey string `json:"idempotencyKey"` - Intent *PaymentIntent `json:"intent"` + Intent PaymentIntent `json:"intent"` PreviewOnly bool `json:"previewOnly"` Metadata map[string]string `json:"metadata,omitempty"` } type InitiatePayment struct { IdempotencyKey string `json:"idempotencyKey"` - Intent *PaymentIntent `json:"intent"` Metadata map[string]string `json:"metadata,omitempty"` - FeeQuoteToken string `json:"feeQuoteToken,omitempty"` - FxQuoteRef string `json:"fxQuoteRef,omitempty"` + Intent *PaymentIntent `json:"intent,omitempty"` QuoteRef string `json:"quoteRef,omitempty"` } + +func (r QuotePayment) Validate() error { + if r.IdempotencyKey == "" { + return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey") + } + + if validator, ok := any(r.Intent).(interface{ Validate() error }); ok { + if err := validator.Validate(); err != nil { + return err + } + } + + return nil +} + +// Validate проверяет базовые инварианты запроса на инициацию платежа. +func (r InitiatePayment) Validate() error { + if r.IdempotencyKey == "" { + return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey") + } + + hasIntent := r.Intent != nil + hasQuote := r.QuoteRef != "" + + switch { + case !hasIntent && !hasQuote: + return merrors.NoData("either intent or quoteRef must be provided") + case hasIntent && hasQuote: + return merrors.DataConflict("intent and quoteRef are mutually exclusive") + } + + if hasIntent { + if validator, ok := any(*r.Intent).(interface{ Validate() error }); ok { + if err := validator.Validate(); err != nil { + return err + } + } + } + + return nil +} diff --git a/api/server/interface/api/srequest/payment_enums.go b/api/server/interface/api/srequest/payment_enums.go new file mode 100644 index 0000000..bc7aaf6 --- /dev/null +++ b/api/server/interface/api/srequest/payment_enums.go @@ -0,0 +1,50 @@ +package srequest + +// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types. +// Strings keep JSON readable; conversion helpers map these to proto enums. +type PaymentKind string + +const ( + PaymentKindUnspecified PaymentKind = "unspecified" + PaymentKindPayout PaymentKind = "payout" + PaymentKindInternalTransfer PaymentKind = "internal_transfer" + PaymentKindFxConversion PaymentKind = "fx_conversion" +) + +// SettlementMode matches orchestrator settlement behavior. +type SettlementMode string + +const ( + SettlementModeUnspecified SettlementMode = "unspecified" + SettlementModeFixSource SettlementMode = "fix_source" + SettlementModeFixReceived SettlementMode = "fix_received" +) + +// FXSide mirrors the common FX side enum. +type FXSide string + +const ( + FXSideUnspecified FXSide = "unspecified" + FXSideBuyBaseSellQuote FXSide = "buy_base_sell_quote" + FXSideSellBaseBuyQuote FXSide = "sell_base_buy_quote" +) + +// ChainNetwork mirrors the chain network enum used by managed wallets. +type ChainNetwork string + +const ( + ChainNetworkUnspecified ChainNetwork = "unspecified" + ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet" + ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one" + ChainNetworkOtherEVM ChainNetwork = "other_evm" +) + +// InsufficientNetPolicy mirrors the fee engine policy override. +type InsufficientNetPolicy string + +const ( + InsufficientNetPolicyUnspecified InsufficientNetPolicy = "unspecified" + InsufficientNetPolicyBlockPosting InsufficientNetPolicy = "block_posting" + InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = "sweep_org_cash" + InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = "invoice_later" +) diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go new file mode 100644 index 0000000..af652bc --- /dev/null +++ b/api/server/interface/api/srequest/payment_intent.go @@ -0,0 +1,12 @@ +package srequest + +type PaymentIntent struct { + Kind PaymentKind `json:"kind,omitempty"` + Source *Endpoint `json:"source,omitempty"` + Destination *Endpoint `json:"destination,omitempty"` + Amount *Money `json:"amount,omitempty"` + RequiresFX bool `json:"requires_fx,omitempty"` + FX *FXIntent `json:"fx,omitempty"` + SettlementMode SettlementMode `json:"settlement_mode,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` +} diff --git a/api/server/interface/api/srequest/payment_types.go b/api/server/interface/api/srequest/payment_types.go deleted file mode 100644 index f82744b..0000000 --- a/api/server/interface/api/srequest/payment_types.go +++ /dev/null @@ -1,103 +0,0 @@ -package srequest - -// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types. -type PaymentKind int32 - -const ( - PaymentKindUnspecified PaymentKind = 0 - PaymentKindPayout PaymentKind = 1 - PaymentKindInternalTransfer PaymentKind = 2 - PaymentKindFxConversion PaymentKind = 3 -) - -// FXSide mirrors the common FX side enum. -type FXSide int32 - -const ( - FXSideUnspecified FXSide = 0 - FXSideBuyBaseSellQuote FXSide = 1 - FXSideSellBaseBuyQuote FXSide = 2 -) - -// ChainNetwork mirrors the chain network enum used by managed wallets. -type ChainNetwork int32 - -const ( - ChainNetworkUnspecified ChainNetwork = 0 - ChainNetworkEthereumMainnet ChainNetwork = 1 - ChainNetworkArbitrumOne ChainNetwork = 2 - ChainNetworkOtherEVM ChainNetwork = 3 -) - -// InsufficientNetPolicy mirrors the fee engine policy override. -type InsufficientNetPolicy int32 - -const ( - InsufficientNetPolicyUnspecified InsufficientNetPolicy = 0 - InsufficientNetPolicyBlockPosting InsufficientNetPolicy = 1 - InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = 2 - InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = 3 -) - -type PaymentIntent struct { - Kind PaymentKind `json:"kind,omitempty"` - Source *PaymentEndpoint `json:"source,omitempty"` - Destination *PaymentEndpoint `json:"destination,omitempty"` - Amount *Money `json:"amount,omitempty"` - RequiresFX bool `json:"requires_fx,omitempty"` - FX *FXIntent `json:"fx,omitempty"` - FeePolicy *PolicyOverrides `json:"fee_policy,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` -} - -type PaymentEndpoint struct { - Ledger *LedgerEndpoint `json:"ledger,omitempty"` - ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"` - ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -type LedgerEndpoint struct { - LedgerAccountRef string `json:"ledger_account_ref"` - ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"` -} - -type ManagedWalletEndpoint struct { - ManagedWalletRef string `json:"managed_wallet_ref"` - Asset *Asset `json:"asset,omitempty"` -} - -type ExternalChainEndpoint struct { - Asset *Asset `json:"asset,omitempty"` - Address string `json:"address"` - Memo string `json:"memo,omitempty"` -} - -type Asset struct { - Chain ChainNetwork `json:"chain"` - TokenSymbol string `json:"token_symbol"` - ContractAddress string `json:"contract_address,omitempty"` -} - -type Money struct { - Amount string `json:"amount"` - Currency string `json:"currency"` -} - -type CurrencyPair struct { - Base string `json:"base"` - Quote string `json:"quote"` -} - -type FXIntent struct { - Pair *CurrencyPair `json:"pair,omitempty"` - Side FXSide `json:"side,omitempty"` - Firm bool `json:"firm,omitempty"` - TTLms int64 `json:"ttl_ms,omitempty"` - PreferredProvider string `json:"preferred_provider,omitempty"` - MaxAgeMs int32 `json:"max_age_ms,omitempty"` -} - -type PolicyOverrides struct { - InsufficientNet InsufficientNetPolicy `json:"insufficient_net,omitempty"` -} diff --git a/api/server/interface/api/srequest/payment_types_test.go b/api/server/interface/api/srequest/payment_types_test.go new file mode 100644 index 0000000..fa7fd4f --- /dev/null +++ b/api/server/interface/api/srequest/payment_types_test.go @@ -0,0 +1,313 @@ +package srequest + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestEndpointDTOBuildersAndDecoders(t *testing.T) { + meta := map[string]string{"note": "meta"} + + t.Run("ledger", func(t *testing.T) { + payload := LedgerEndpoint{LedgerAccountRef: "acc-1", ContraLedgerAccountRef: "contra-1"} + endpoint, err := NewLedgerEndpointDTO(payload, meta) + if err != nil { + t.Fatalf("build ledger endpoint: %v", err) + } + if endpoint.Type != EndpointTypeLedger { + t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type) + } + if string(endpoint.Data) != `{"ledger_account_ref":"acc-1","contra_ledger_account_ref":"contra-1"}` { + t.Fatalf("unexpected data: %s", endpoint.Data) + } + decoded, err := endpoint.DecodeLedger() + if err != nil { + t.Fatalf("decode ledger: %v", err) + } + if decoded != payload { + t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload) + } + meta["note"] = "changed" + if endpoint.Metadata["note"] != "meta" { + t.Fatalf("metadata should be copied, got %s", endpoint.Metadata["note"]) + } + }) + + t.Run("managed wallet", func(t *testing.T) { + payload := ManagedWalletEndpoint{ + ManagedWalletRef: "mw-1", + Asset: &Asset{ + Chain: ChainNetworkArbitrumOne, + TokenSymbol: "USDC", + ContractAddress: "0xabc", + }, + } + endpoint, err := NewManagedWalletEndpointDTO(payload, nil) + if err != nil { + t.Fatalf("build managed wallet endpoint: %v", err) + } + if endpoint.Type != EndpointTypeManagedWallet { + t.Fatalf("expected type %s got %s", EndpointTypeManagedWallet, endpoint.Type) + } + decoded, err := endpoint.DecodeManagedWallet() + if err != nil { + t.Fatalf("decode managed wallet: %v", err) + } + if !reflect.DeepEqual(decoded, payload) { + t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload) + } + }) + + t.Run("external chain", func(t *testing.T) { + payload := ExternalChainEndpoint{ + Asset: &Asset{ + Chain: ChainNetworkOtherEVM, + TokenSymbol: "ETH", + }, + Address: "0x123", + Memo: "memo", + } + endpoint, err := NewExternalChainEndpointDTO(payload, nil) + if err != nil { + t.Fatalf("build external chain endpoint: %v", err) + } + if endpoint.Type != EndpointTypeExternalChain { + t.Fatalf("expected type %s got %s", EndpointTypeExternalChain, endpoint.Type) + } + decoded, err := endpoint.DecodeExternalChain() + if err != nil { + t.Fatalf("decode external chain: %v", err) + } + if !reflect.DeepEqual(decoded, payload) { + t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload) + } + }) + + t.Run("card", func(t *testing.T) { + payload := CardEndpoint{Pan: "pan", Token: "token", Cardholder: "Jane", ExpMonth: 12, ExpYear: 2030, Country: "US", MaskedPan: "****"} + endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"}) + if err != nil { + t.Fatalf("build card endpoint: %v", err) + } + if endpoint.Type != EndpointTypeCard { + t.Fatalf("expected type %s got %s", EndpointTypeCard, endpoint.Type) + } + decoded, err := endpoint.DecodeCard() + if err != nil { + t.Fatalf("decode card: %v", err) + } + if !reflect.DeepEqual(decoded, payload) { + t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload) + } + if endpoint.Metadata["k"] != "v" { + t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["k"]) + } + }) + + t.Run("type mismatch", func(t *testing.T) { + endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil) + if err != nil { + t.Fatalf("build ledger endpoint: %v", err) + } + if _, err := endpoint.DecodeCard(); err == nil || !strings.Contains(err.Error(), "expected endpoint type") { + t.Fatalf("expected type mismatch error, got %v", err) + } + }) + + t.Run("invalid json data", func(t *testing.T) { + endpoint := Endpoint{Type: EndpointTypeLedger, Data: json.RawMessage("not-json")} + if _, err := endpoint.DecodeLedger(); err == nil { + t.Fatalf("expected decode error") + } + }) +} + +func TestPaymentIntentJSONRoundTrip(t *testing.T) { + sourcePayload := LedgerEndpoint{LedgerAccountRef: "source"} + source, err := NewLedgerEndpointDTO(sourcePayload, map[string]string{"src": "meta"}) + if err != nil { + t.Fatalf("build source endpoint: %v", err) + } + destPayload := ExternalChainEndpoint{Address: "0xabc", Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "USDC"}} + dest, err := NewExternalChainEndpointDTO(destPayload, nil) + if err != nil { + t.Fatalf("build destination endpoint: %v", err) + } + + intent := &PaymentIntent{ + Kind: PaymentKindPayout, + Source: &source, + Destination: &dest, + Amount: &Money{Amount: "10", Currency: "USD"}, + RequiresFX: true, + FX: &FXIntent{ + Pair: &CurrencyPair{Base: "USD", Quote: "EUR"}, + Side: FXSideBuyBaseSellQuote, + Firm: true, + TTLms: 5000, + PreferredProvider: "provider", + MaxAgeMs: 10, + }, + SettlementMode: SettlementModeFixReceived, + Attributes: map[string]string{"k": "v"}, + } + + data, err := json.Marshal(intent) + if err != nil { + t.Fatalf("marshal intent: %v", err) + } + var decoded PaymentIntent + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal intent: %v", err) + } + + if decoded.Kind != intent.Kind || decoded.RequiresFX != intent.RequiresFX || decoded.SettlementMode != intent.SettlementMode { + t.Fatalf("scalar fields changed after round trip") + } + if decoded.Amount == nil || *decoded.Amount != *intent.Amount { + t.Fatalf("amount mismatch after round trip") + } + if decoded.FX == nil || decoded.FX.PreferredProvider != intent.FX.PreferredProvider { + t.Fatalf("fx mismatch after round trip") + } + if decoded.Source == nil || decoded.Destination == nil { + t.Fatalf("source/destination missing after round trip") + } + sourceDecoded, err := decoded.Source.DecodeLedger() + if err != nil { + t.Fatalf("decode source after round trip: %v", err) + } + if sourceDecoded != sourcePayload { + t.Fatalf("source payload mismatch after round trip: %#v vs %#v", sourceDecoded, sourcePayload) + } + destDecoded, err := decoded.Destination.DecodeExternalChain() + if err != nil { + t.Fatalf("decode destination after round trip: %v", err) + } + if !reflect.DeepEqual(destDecoded, destPayload) { + t.Fatalf("destination payload mismatch after round trip: %#v vs %#v", destDecoded, destPayload) + } + if decoded.Attributes["k"] != "v" { + t.Fatalf("attributes mismatch after round trip") + } +} + +func TestPaymentIntentMinimalRoundTrip(t *testing.T) { + sourcePayload := ManagedWalletEndpoint{ManagedWalletRef: "mw"} + source, err := NewManagedWalletEndpointDTO(sourcePayload, nil) + if err != nil { + t.Fatalf("build source endpoint: %v", err) + } + destPayload := LedgerEndpoint{LedgerAccountRef: "dest-ledger"} + dest, err := NewLedgerEndpointDTO(destPayload, nil) + if err != nil { + t.Fatalf("build destination endpoint: %v", err) + } + + intent := &PaymentIntent{ + Kind: PaymentKindInternalTransfer, + Source: &source, + Destination: &dest, + Amount: &Money{Amount: "1", Currency: "USD"}, + } + + data, err := json.Marshal(intent) + if err != nil { + t.Fatalf("marshal intent: %v", err) + } + var decoded PaymentIntent + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal intent: %v", err) + } + + if decoded.Kind != intent.Kind || decoded.RequiresFX || decoded.FX != nil { + t.Fatalf("unexpected fx data in minimal intent: %#v", decoded) + } + if decoded.Amount == nil || *decoded.Amount != *intent.Amount { + t.Fatalf("amount mismatch after round trip") + } + if decoded.Source == nil || decoded.Destination == nil { + t.Fatalf("endpoints missing after round trip") + } + sourceDecoded, err := decoded.Source.DecodeManagedWallet() + if err != nil { + t.Fatalf("decode source: %v", err) + } + if !reflect.DeepEqual(sourceDecoded, sourcePayload) { + t.Fatalf("source payload mismatch: %#v vs %#v", sourceDecoded, sourcePayload) + } + destDecoded, err := decoded.Destination.DecodeLedger() + if err != nil { + t.Fatalf("decode destination: %v", err) + } + if destDecoded != destPayload { + t.Fatalf("destination payload mismatch: %#v vs %#v", destDecoded, destPayload) + } +} + +func TestLegacyEndpointRoundTrip(t *testing.T) { + legacy := &LegacyPaymentEndpoint{ + ExternalChain: &ExternalChainEndpoint{ + Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"}, + Address: "0x123", + Memo: "memo", + }, + Metadata: map[string]string{"note": "legacy"}, + } + + endpoint, err := LegacyPaymentEndpointToEndpointDTO(legacy) + if err != nil { + t.Fatalf("convert legacy to dto: %v", err) + } + if endpoint == nil || endpoint.Type != EndpointTypeExternalChain { + t.Fatalf("unexpected endpoint result: %#v", endpoint) + } + legacy.Metadata["note"] = "changed" + if endpoint.Metadata["note"] != "legacy" { + t.Fatalf("metadata should be copied from legacy") + } + + roundTrip, err := EndpointDTOToLegacyPaymentEndpoint(endpoint) + if err != nil { + t.Fatalf("convert dto back to legacy: %v", err) + } + if roundTrip == nil || roundTrip.ExternalChain == nil { + t.Fatalf("round trip legacy missing payload: %#v", roundTrip) + } + if !reflect.DeepEqual(roundTrip.ExternalChain, legacy.ExternalChain) { + t.Fatalf("round trip payload mismatch: %#v vs %#v", roundTrip.ExternalChain, legacy.ExternalChain) + } + if roundTrip.Metadata["note"] != "legacy" { + t.Fatalf("metadata mismatch after round trip: %v", roundTrip.Metadata) + } +} + +func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) { + _, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{ + Ledger: &LedgerEndpoint{LedgerAccountRef: "a"}, + Card: &CardEndpoint{Token: "t"}, + }) + if err == nil { + t.Fatalf("expected error when multiple legacy endpoints are set") + } +} + +func TestEndpointUnmarshalLegacyShape(t *testing.T) { + raw := []byte(`{"ledger":{"ledger_account_ref":"abc"}}`) + var endpoint Endpoint + if err := json.Unmarshal(raw, &endpoint); err != nil { + t.Fatalf("unmarshal legacy shape: %v", err) + } + if endpoint.Type != EndpointTypeLedger { + t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type) + } + payload, err := endpoint.DecodeLedger() + if err != nil { + t.Fatalf("decode ledger from legacy shape: %v", err) + } + if payload.LedgerAccountRef != "abc" { + t.Fatalf("unexpected payload from legacy shape: %#v", payload) + } +} diff --git a/api/server/interface/api/srequest/payment_value_objects.go b/api/server/interface/api/srequest/payment_value_objects.go new file mode 100644 index 0000000..e0029a3 --- /dev/null +++ b/api/server/interface/api/srequest/payment_value_objects.go @@ -0,0 +1,20 @@ +package srequest + +type Money struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +type CurrencyPair struct { + Base string `json:"base"` + Quote string `json:"quote"` +} + +type FXIntent struct { + Pair *CurrencyPair `json:"pair,omitempty"` + Side FXSide `json:"side,omitempty"` + Firm bool `json:"firm,omitempty"` + TTLms int64 `json:"ttl_ms,omitempty"` + PreferredProvider string `json:"preferred_provider,omitempty"` + MaxAgeMs int32 `json:"max_age_ms,omitempty"` +} diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 676e726..eec145c 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -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)) } } diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index c39ae84..3ed91d1 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -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 } diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go index a854eaf..b2ad152 100644 --- a/api/server/internal/server/paymentapiimp/quote.go +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -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 }