From 8377b6b2afdd04423f5c3b577470077dfce4c520 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 4 Mar 2026 02:27:12 +0100 Subject: [PATCH 1/2] fixed operations idempotency --- api/gateway/mntx/client/client.go | 71 +++++++-- .../mntx/internal/service/gateway/callback.go | 1 + .../service/gateway/card_payout_handlers.go | 12 +- .../service/gateway/card_payout_store_test.go | 45 ++++-- .../service/gateway/card_payout_validation.go | 17 +- .../gateway/card_payout_validation_test.go | 12 +- .../service/gateway/card_processor.go | 148 +++++++++++++++--- .../service/gateway/card_token_validation.go | 4 +- .../gateway/card_token_validation_test.go | 14 +- .../internal/service/gateway/connector.go | 59 +++++-- .../mntx/internal/service/gateway/helpers.go | 2 +- .../mntx/storage/mongo/store/payouts.go | 15 ++ api/gateway/mntx/storage/storage.go | 1 + .../orchestrator/card_payout_executor.go | 16 +- .../orchestrator/card_payout_executor_test.go | 11 +- .../orchestrator/settlement_executor.go | 5 +- 16 files changed, 353 insertions(+), 80 deletions(-) diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index ee04cd5a..dead2fc9 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -39,6 +39,8 @@ type gatewayClient struct { logger mlogger.Logger } +const parentPaymentRefMetadataKey = "parent_payment_ref" + // New dials the Monetix gateway. func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { cfg.setDefaults() @@ -104,7 +106,7 @@ func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPa if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { return nil, connectorError(resp.GetReceipt().GetError()) } - return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil + return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), resp.GetReceipt())}, nil } func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { @@ -121,7 +123,7 @@ func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.C if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { return nil, connectorError(resp.GetReceipt().GetError()) } - return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil + return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), resp.GetReceipt())}, nil } func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { @@ -147,10 +149,12 @@ func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operat } params := payoutParamsFromCard(req) money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) + operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId()) + idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef) op := &connectorv1.Operation{ Type: connectorv1.OperationType_PAYOUT, - IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), - OperationRef: strings.TrimSpace(req.GetOperationRef()), + IdempotencyKey: idempotencyKey, + OperationRef: operationRef, IntentRef: strings.TrimSpace(req.GetIntentRef()), Money: money, Params: structFromMap(params), @@ -165,9 +169,13 @@ func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1. } params := payoutParamsFromToken(req) money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) + operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId()) + idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef) op := &connectorv1.Operation{ Type: connectorv1.OperationType_PAYOUT, - IdempotencyKey: strings.TrimSpace(req.GetPayoutId()), + IdempotencyKey: idempotencyKey, + OperationRef: operationRef, + IntentRef: strings.TrimSpace(req.GetIntentRef()), Money: money, Params: structFromMap(params), } @@ -192,8 +200,8 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin } func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} { + metadata := metadataWithParentPaymentRef(req.GetMetadata(), req.GetPayoutId()) params := map[string]interface{}{ - "payout_id": strings.TrimSpace(req.GetPayoutId()), "project_id": req.GetProjectId(), "customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), @@ -212,15 +220,15 @@ func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} "card_exp_month": req.GetCardExpMonth(), "card_holder": strings.TrimSpace(req.GetCardHolder()), } - if len(req.GetMetadata()) > 0 { - params["metadata"] = mapStringToInterface(req.GetMetadata()) + if len(metadata) > 0 { + params["metadata"] = mapStringToInterface(metadata) } return params } func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} { + metadata := metadataWithParentPaymentRef(req.GetMetadata(), req.GetPayoutId()) params := map[string]interface{}{ - "payout_id": strings.TrimSpace(req.GetPayoutId()), "project_id": req.GetProjectId(), "customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), @@ -238,8 +246,8 @@ func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interf "card_holder": strings.TrimSpace(req.GetCardHolder()), "masked_pan": strings.TrimSpace(req.GetMaskedPan()), } - if len(req.GetMetadata()) > 0 { - params["metadata"] = mapStringToInterface(req.GetMetadata()) + if len(metadata) > 0 { + params["metadata"] = mapStringToInterface(metadata) } return params } @@ -255,16 +263,53 @@ func moneyFromMinor(amount int64, currency string) *moneyv1.Money { } } -func payoutFromReceipt(payoutID string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { - state := &mntxv1.CardPayoutState{PayoutId: strings.TrimSpace(payoutID)} +func payoutFromReceipt(payoutID, operationRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { + state := &mntxv1.CardPayoutState{ + PayoutId: fallbackNonEmpty(operationRef, payoutID), + } if receipt == nil { return state } + if opID := strings.TrimSpace(receipt.GetOperationId()); opID != "" { + state.PayoutId = opID + } state.Status = payoutStatusFromOperation(receipt.GetStatus()) state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef()) return state } +func fallbackNonEmpty(values ...string) string { + for _, value := range values { + clean := strings.TrimSpace(value) + if clean != "" { + return clean + } + } + return "" +} + +func metadataWithParentPaymentRef(source map[string]string, parentPaymentRef string) map[string]string { + parentPaymentRef = strings.TrimSpace(parentPaymentRef) + if len(source) == 0 && parentPaymentRef == "" { + return nil + } + out := map[string]string{} + for key, value := range source { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + if parentPaymentRef != "" && strings.TrimSpace(out[parentPaymentRefMetadataKey]) == "" { + out[parentPaymentRefMetadataKey] = parentPaymentRef + } + if len(out) == 0 { + return nil + } + return out +} + func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState { if op == nil { return nil diff --git a/api/gateway/mntx/internal/service/gateway/callback.go b/api/gateway/mntx/internal/service/gateway/callback.go index ed2211f7..f277e58d 100644 --- a/api/gateway/mntx/internal/service/gateway/callback.go +++ b/api/gateway/mntx/internal/service/gateway/callback.go @@ -110,6 +110,7 @@ func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCall ProviderCode: cb.Operation.Code, ProviderMessage: cb.Operation.Message, ProviderPaymentId: fallbackProviderPaymentID(cb), + OperationRef: strings.TrimSpace(cb.Payment.ID), UpdatedAt: now, CreatedAt: now, } diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go index 80b56ffc..9fdfa51d 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go @@ -21,6 +21,7 @@ func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPa log := s.logger.Named("card_payout") log.Info("Create card payout request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), @@ -47,6 +48,7 @@ func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.C log := s.logger.Named("card_token_payout") log.Info("Create card token payout request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), @@ -133,6 +135,9 @@ func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayout r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency())) r.CardPan = strings.TrimSpace(r.GetCardPan()) r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + r.OperationRef = strings.TrimSpace(r.GetOperationRef()) + r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey()) + r.IntentRef = strings.TrimSpace(r.GetIntentRef()) return r } @@ -160,6 +165,9 @@ func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1. r.CardToken = strings.TrimSpace(r.GetCardToken()) r.CardHolder = strings.TrimSpace(r.GetCardHolder()) r.MaskedPan = strings.TrimSpace(r.GetMaskedPan()) + r.OperationRef = strings.TrimSpace(r.GetOperationRef()) + r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey()) + r.IntentRef = strings.TrimSpace(r.GetIntentRef()) return r } @@ -206,7 +214,7 @@ func buildCardPayoutRequest(projectID int64, req *mntxv1.CardPayoutRequest) mone return monetix.CardPayoutRequest{ General: monetix.General{ ProjectID: projectID, - PaymentID: req.GetPayoutId(), + PaymentID: findOperationRef(req.GetOperationRef(), req.GetPayoutId()), }, Customer: monetix.Customer{ ID: req.GetCustomerId(), @@ -232,7 +240,7 @@ func buildCardTokenPayoutRequest(projectID int64, req *mntxv1.CardTokenPayoutReq return monetix.CardTokenPayoutRequest{ General: monetix.General{ ProjectID: projectID, - PaymentID: req.GetPayoutId(), + PaymentID: findOperationRef(req.GetOperationRef(), req.GetPayoutId()), }, Customer: monetix.Customer{ ID: req.GetCustomerId(), diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go b/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go index e9254ca9..4e5c9234 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go @@ -27,6 +27,16 @@ type cardPayoutStore struct { data map[string]*model.CardPayout } +func payoutStoreKey(state *model.CardPayout) string { + if state == nil { + return "" + } + if ref := state.OperationRef; ref != "" { + return ref + } + return state.PaymentRef +} + func newCardPayoutStore() *cardPayoutStore { return &cardPayoutStore{ data: make(map[string]*model.CardPayout), @@ -42,26 +52,43 @@ func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (* return nil, nil } -func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) { - v, ok := s.data[id] - if !ok { - return nil, nil +func (s *cardPayoutStore) FindByOperationRef(_ context.Context, ref string) (*model.CardPayout, error) { + for _, v := range s.data { + if v.OperationRef == ref { + return v, nil + } } - return v, nil + return nil, nil +} + +func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) { + for _, v := range s.data { + if v.PaymentRef == id { + return v, nil + } + } + return nil, nil } func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error { - s.data[record.PaymentRef] = record + s.data[payoutStoreKey(record)] = record return nil } // Save is a helper for tests to pre-populate data. func (s *cardPayoutStore) Save(state *model.CardPayout) { - s.data[state.PaymentRef] = state + s.data[payoutStoreKey(state)] = state } // Get is a helper for tests to retrieve data. func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) { - v, ok := s.data[id] - return v, ok + if v, ok := s.data[id]; ok { + return v, true + } + for _, v := range s.data { + if v.PaymentRef == id || v.OperationRef == id { + return v, true + } + } + return nil, false } diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_validation.go b/api/gateway/mntx/internal/service/gateway/card_payout_validation.go index 0fc47955..232d9b29 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_validation.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_validation.go @@ -14,8 +14,8 @@ func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) } - if strings.TrimSpace(req.GetPayoutId()) == "" { - return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id")) + if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { + return err } if strings.TrimSpace(req.GetCustomerId()) == "" { return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) @@ -81,3 +81,16 @@ func validateCardExpiryFields(month uint32, year uint32) error { } return nil } + +func validateOperationIdentity(payoutID, operationRef string) error { + payoutID = strings.TrimSpace(payoutID) + operationRef = strings.TrimSpace(operationRef) + switch { + case payoutID == "" && operationRef == "": + return newPayoutError("missing_operation_ref", merrors.InvalidArgument("operation_ref or payout_id is required", "operation_ref")) + case payoutID != "" && operationRef != "": + return newPayoutError("ambiguous_operation_ref", merrors.InvalidArgument("provide either operation_ref or payout_id, not both")) + default: + return nil + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go index 642b128d..e05640f7 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go @@ -24,9 +24,17 @@ func TestValidateCardPayoutRequest_Errors(t *testing.T) { expected string }{ { - name: "missing_payout_id", + name: "missing_operation_identity", mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" }, - expected: "missing_payout_id", + expected: "missing_operation_ref", + }, + { + name: "both_operation_and_payout_identity", + mutate: func(r *mntxv1.CardPayoutRequest) { + r.PayoutId = "parent-1" + r.OperationRef = "parent-1:hop_1_card_payout_send" + }, + expected: "ambiguous_operation_ref", }, { name: "missing_customer_id", diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index dbf10cc1..8b846fe1 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -39,6 +39,8 @@ type cardPayoutProcessor struct { perTxMinAmountMinorByCurrency map[string]int64 } +const parentPaymentRefMetadataKey = "parent_payment_ref" + func mergePayoutStateWithExisting(state, existing *model.CardPayout) { if state == nil || existing == nil { return @@ -57,13 +59,102 @@ func mergePayoutStateWithExisting(state, existing *model.CardPayout) { if state.IntentRef == "" { state.IntentRef = existing.IntentRef } + if state.PaymentRef == "" { + state.PaymentRef = existing.PaymentRef + } +} + +func findOperationRef(operationRef, payoutID string) string { + ref := strings.TrimSpace(operationRef) + if ref != "" { + return ref + } + return strings.TrimSpace(payoutID) +} + +func parentPaymentRefFromOperationRef(operationRef string) string { + ref := strings.TrimSpace(operationRef) + if ref == "" { + return "" + } + if idx := strings.Index(ref, ":hop_"); idx > 0 { + return ref[:idx] + } + if idx := strings.Index(ref, ":"); idx > 0 { + return ref[:idx] + } + return "" +} + +func parentPaymentRefFromMetadata(metadata map[string]string) string { + if len(metadata) == 0 { + return "" + } + return strings.TrimSpace(metadata[parentPaymentRefMetadataKey]) +} + +func resolveParentPaymentRef(payoutID, operationRef string, metadata map[string]string) string { + if parent := parentPaymentRefFromMetadata(metadata); parent != "" { + return parent + } + payoutRef := strings.TrimSpace(payoutID) + opRef := strings.TrimSpace(operationRef) + + // Legacy callers may pass parent payment ref via payout_id and a distinct operation_ref. + if payoutRef != "" && (opRef == "" || payoutRef != opRef) { + return payoutRef + } + if parent := parentPaymentRefFromOperationRef(opRef); parent != "" { + return parent + } + if payoutRef != "" { + return payoutRef + } + return opRef +} + +func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) { + if p == nil || state == nil { + return nil, nil + } + if opRef := strings.TrimSpace(state.OperationRef); opRef != "" { + existing, err := p.store.Payouts().FindByOperationRef(ctx, opRef) + if err == nil { + if existing != nil { + return existing, nil + } + } + if !errors.Is(err, merrors.ErrNoData) { + if err != nil { + return nil, err + } + } + // Legacy mode may still map operation ref to payout/payment ref. + if paymentRef := strings.TrimSpace(state.PaymentRef); paymentRef == "" || paymentRef != opRef { + return nil, nil + } + } + if paymentRef := strings.TrimSpace(state.PaymentRef); paymentRef != "" { + existing, err := p.store.Payouts().FindByPaymentID(ctx, paymentRef) + if err == nil { + if existing != nil { + return existing, nil + } + } + if !errors.Is(err, merrors.ErrNoData) { + if err != nil { + return nil, err + } + } + } + return nil, nil } func (p *cardPayoutProcessor) findAndMergePayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) { if p == nil || state == nil { return nil, nil } - existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PaymentRef) + existing, err := p.findExistingPayoutState(ctx, state) if err != nil { return nil, err } @@ -218,13 +309,15 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout } req = sanitizeCardPayoutRequest(req) + operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + parentPaymentRef := resolveParentPaymentRef(req.GetPayoutId(), operationRef, req.GetMetadata()) p.logger.Info("Submitting card payout", - zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("parent_payment_ref", parentPaymentRef), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), - zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("operation_ref", operationRef), zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), ) @@ -235,7 +328,8 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout if err := validateCardPayoutRequest(req, p.config); err != nil { p.logger.Warn("Card payout validation failed", - zap.String("payout_id", req.GetPayoutId()), + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), ) @@ -243,7 +337,8 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout } if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil { p.logger.Warn("Card payout amount below configured minimum", - zap.String("payout_id", req.GetPayoutId()), + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), zap.String("customer_id", req.GetCustomerId()), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), @@ -253,7 +348,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout return nil, err } - projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId()) + projectID, err := p.resolveProjectID(req.GetProjectId(), "operation_ref", operationRef) if err != nil { return nil, err } @@ -264,8 +359,8 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout Base: storable.Base{ ID: bson.NilObjectID, }, - PaymentRef: strings.TrimSpace(req.GetPayoutId()), - OperationRef: strings.TrimSpace(req.GetOperationRef()), + PaymentRef: parentPaymentRef, + OperationRef: operationRef, IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), IntentRef: strings.TrimSpace(req.GetIntentRef()), ProjectID: projectID, @@ -339,13 +434,15 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT } req = sanitizeCardTokenPayoutRequest(req) + operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + parentPaymentRef := resolveParentPaymentRef(req.GetPayoutId(), operationRef, req.GetMetadata()) p.logger.Info("Submitting card token payout", - zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("parent_payment_ref", parentPaymentRef), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), - zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("operation_ref", operationRef), zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), ) @@ -356,7 +453,8 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT if err := validateCardTokenPayoutRequest(req, p.config); err != nil { p.logger.Warn("Card token payout validation failed", - zap.String("payout_id", req.GetPayoutId()), + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), zap.String("customer_id", req.GetCustomerId()), zap.Error(err), ) @@ -364,7 +462,8 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT } if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil { p.logger.Warn("Card token payout amount below configured minimum", - zap.String("payout_id", req.GetPayoutId()), + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), zap.String("customer_id", req.GetCustomerId()), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), @@ -374,16 +473,17 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT return nil, err } - projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId()) + projectID, err := p.resolveProjectID(req.GetProjectId(), "operation_ref", operationRef) if err != nil { return nil, err } now := p.clock.Now() state := &model.CardPayout{ - PaymentRef: strings.TrimSpace(req.GetPayoutId()), - OperationRef: strings.TrimSpace(req.GetOperationRef()), + PaymentRef: parentPaymentRef, + OperationRef: operationRef, IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + IntentRef: strings.TrimSpace(req.GetIntentRef()), ProjectID: projectID, CustomerID: strings.TrimSpace(req.GetCustomerId()), AmountMinor: req.GetAmountMinor(), @@ -509,21 +609,29 @@ func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mnt } id := strings.TrimSpace(payoutID) - p.logger.Info("Card payout status requested", zap.String("payout_id", id)) + p.logger.Info("Card payout status requested", zap.String("operation_or_payout_id", id)) if id == "" { p.logger.Warn("Payout status requested with empty payout_id") return nil, merrors.InvalidArgument("payout_id is required", "payout_id") } - state, err := p.store.Payouts().FindByPaymentID(ctx, id) - if err != nil || state == nil { - p.logger.Warn("Payout status not found", zap.String("payout_id", id), zap.Error(err)) - return nil, merrors.NoData("payout not found") + state, err := p.store.Payouts().FindByOperationRef(ctx, id) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + p.logger.Warn("Payout status lookup by operation ref failed", zap.String("operation_ref", id), zap.Error(err)) + return nil, err + } + if state == nil || errors.Is(err, merrors.ErrNoData) { + state, err = p.store.Payouts().FindByPaymentID(ctx, id) + if err != nil || state == nil { + p.logger.Warn("Payout status not found", zap.String("operation_or_payout_id", id), zap.Error(err)) + return nil, merrors.NoData("payout not found") + } } p.logger.Info("Card payout status resolved", zap.String("payment_ref", state.PaymentRef), + zap.String("operation_ref", state.OperationRef), zap.String("status", string(state.Status)), ) diff --git a/api/gateway/mntx/internal/service/gateway/card_token_validation.go b/api/gateway/mntx/internal/service/gateway/card_token_validation.go index 9392490c..08e2f2a6 100644 --- a/api/gateway/mntx/internal/service/gateway/card_token_validation.go +++ b/api/gateway/mntx/internal/service/gateway/card_token_validation.go @@ -13,8 +13,8 @@ func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg mone return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) } - if strings.TrimSpace(req.GetPayoutId()) == "" { - return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id")) + if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { + return err } if strings.TrimSpace(req.GetCustomerId()) == "" { return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) diff --git a/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go index 156097b2..086c16dc 100644 --- a/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go @@ -24,9 +24,17 @@ func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) { expected string }{ { - name: "missing_payout_id", + name: "missing_operation_identity", mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" }, - expected: "missing_payout_id", + expected: "missing_operation_ref", + }, + { + name: "both_operation_and_payout_identity", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { + r.PayoutId = "parent-1" + r.OperationRef = "parent-1:hop_1_card_payout_send" + }, + expected: "ambiguous_operation_ref", }, { name: "missing_customer_id", @@ -63,7 +71,7 @@ func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) { expected: "missing_card_token", }, { - name: "missing_customer_city_when_required", + name: "missing_customer_city_when_required", mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerCountry = "US" r.CustomerCity = "" diff --git a/api/gateway/mntx/internal/service/gateway/connector.go b/api/gateway/mntx/internal/service/gateway/connector.go index a87e39ba..9993b85d 100644 --- a/api/gateway/mntx/internal/service/gateway/connector.go +++ b/api/gateway/mntx/internal/service/gateway/connector.go @@ -71,12 +71,10 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp } payoutID := strings.TrimSpace(reader.String("payout_id")) - if payoutID == "" { - payoutID = strings.TrimSpace(op.GetIdempotencyKey()) - } + payoutID = operationIDForRequest(payoutID, operationRef, idempotencyKey) if strings.TrimSpace(reader.String("card_token")) != "" { - resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, amountMinor, currency)) + resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, idempotencyKey, operationRef, intentRef, amountMinor, currency)) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } @@ -169,7 +167,38 @@ func currencyFromOperation(op *connectorv1.Operation) string { return strings.ToUpper(currency) } -func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { +func operationIDForRequest(payoutID, operationRef, idempotencyKey string) string { + if ref := strings.TrimSpace(operationRef); ref != "" { + return ref + } + if ref := strings.TrimSpace(payoutID); ref != "" { + return ref + } + return strings.TrimSpace(idempotencyKey) +} + +func metadataFromReader(reader params.Reader) map[string]string { + metadata := reader.StringMap("metadata") + if metadata == nil { + metadata = map[string]string{} + } + if parentRef := strings.TrimSpace(reader.String("payout_id")); parentRef != "" && strings.TrimSpace(metadata[parentPaymentRefMetadataKey]) == "" { + metadata[parentPaymentRefMetadataKey] = parentRef + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +func buildCardTokenPayoutRequestFromParams(reader params.Reader, + payoutID, idempotencyKey, operationRef, intentRef string, + amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { + operationRef = strings.TrimSpace(operationRef) + payoutID = strings.TrimSpace(payoutID) + if operationRef != "" { + payoutID = "" + } req := &mntxv1.CardTokenPayoutRequest{ PayoutId: payoutID, ProjectId: readerInt64(reader, "project_id"), @@ -188,7 +217,10 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string CardToken: strings.TrimSpace(reader.String("card_token")), CardHolder: strings.TrimSpace(reader.String("card_holder")), MaskedPan: strings.TrimSpace(reader.String("masked_pan")), - Metadata: reader.StringMap("metadata"), + Metadata: metadataFromReader(reader), + OperationRef: operationRef, + IdempotencyKey: strings.TrimSpace(idempotencyKey), + IntentRef: strings.TrimSpace(intentRef), } return req } @@ -196,6 +228,11 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string func buildCardPayoutRequestFromParams(reader params.Reader, payoutID, idempotencyKey, operationRef, intentRef string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { + operationRef = strings.TrimSpace(operationRef) + payoutID = strings.TrimSpace(payoutID) + if operationRef != "" { + payoutID = "" + } return &mntxv1.CardPayoutRequest{ PayoutId: payoutID, ProjectId: readerInt64(reader, "project_id"), @@ -215,10 +252,10 @@ func buildCardPayoutRequestFromParams(reader params.Reader, CardExpYear: uint32(readerInt64(reader, "card_exp_year")), CardExpMonth: uint32(readerInt64(reader, "card_exp_month")), CardHolder: strings.TrimSpace(reader.String("card_holder")), - Metadata: reader.StringMap("metadata"), + Metadata: metadataFromReader(reader), OperationRef: operationRef, - IdempotencyKey: idempotencyKey, - IntentRef: intentRef, + IdempotencyKey: strings.TrimSpace(idempotencyKey), + IntentRef: strings.TrimSpace(intentRef), } } @@ -236,7 +273,7 @@ func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt } } return &connectorv1.OperationReceipt{ - OperationId: strings.TrimSpace(state.GetPayoutId()), + OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), Status: payoutStatusToOperation(state.GetStatus()), ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), } @@ -247,7 +284,7 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation { return nil } return &connectorv1.Operation{ - OperationId: strings.TrimSpace(state.GetPayoutId()), + OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), Type: connectorv1.OperationType_PAYOUT, Status: payoutStatusToOperation(state.GetStatus()), Money: &moneyv1.Money{ diff --git a/api/gateway/mntx/internal/service/gateway/helpers.go b/api/gateway/mntx/internal/service/gateway/helpers.go index 13311b52..3a81099b 100644 --- a/api/gateway/mntx/internal/service/gateway/helpers.go +++ b/api/gateway/mntx/internal/service/gateway/helpers.go @@ -41,7 +41,7 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) * func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState { return &mntxv1.CardPayoutState{ - PayoutId: m.PaymentRef, + PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef), ProjectId: m.ProjectID, CustomerId: m.CustomerID, AmountMinor: m.AmountMinor, diff --git a/api/gateway/mntx/storage/mongo/store/payouts.go b/api/gateway/mntx/storage/mongo/store/payouts.go index 183d9d9f..51c249f0 100644 --- a/api/gateway/mntx/storage/mongo/store/payouts.go +++ b/api/gateway/mntx/storage/mongo/store/payouts.go @@ -19,6 +19,7 @@ const ( payoutsCollection = "card_payouts" payoutIdemField = "idempotencyKey" payoutIdField = "paymentRef" + payoutOpField = "operationRef" ) type Payouts struct { @@ -36,6 +37,16 @@ func NewPayouts(logger mlogger.Logger, db *mongo.Database) (*Payouts, error) { logger = logger.Named("payouts").With(zap.String("collection", payoutsCollection)) repo := repository.CreateMongoRepository(db, payoutsCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: payoutOpField, Sort: ri.Asc}}, + Unique: true, + Sparse: true, + }); err != nil { + logger.Error("Failed to create payouts operation index", + zap.Error(err), + zap.String("index_field", payoutOpField)) + return nil, err + } if err := repo.CreateIndex(&ri.Definition{ Keys: []ri.Key{{Field: payoutIdemField, Sort: ri.Asc}}, Unique: true, @@ -63,6 +74,10 @@ func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model. return p.findOneByField(ctx, payoutIdemField, key) } +func (p *Payouts) FindByOperationRef(ctx context.Context, operationRef string) (*model.CardPayout, error) { + return p.findOneByField(ctx, payoutOpField, operationRef) +} + func (p *Payouts) FindByPaymentID(ctx context.Context, paymentID string) (*model.CardPayout, error) { return p.findOneByField(ctx, payoutIdField, paymentID) } diff --git a/api/gateway/mntx/storage/storage.go b/api/gateway/mntx/storage/storage.go index f330a62a..61374857 100644 --- a/api/gateway/mntx/storage/storage.go +++ b/api/gateway/mntx/storage/storage.go @@ -15,6 +15,7 @@ type Repository interface { type PayoutsStore interface { FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) + FindByOperationRef(ctx context.Context, key string) (*model.CardPayout, error) FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error) Upsert(ctx context.Context, record *model.CardPayout) error } diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go index 85639ca5..cbed86c0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go @@ -74,7 +74,6 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s stepToken := cardPayoutStepToken(req.Step) operationRef := cardPayoutOperationRef(req.Payment, stepToken) - payoutRef := cardPayoutRef(req.Payment) idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) projectID := cardPayoutProjectID(req.Payment) customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata) @@ -85,7 +84,6 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s var responsePayout *mntxv1.CardPayoutState if token := strings.TrimSpace(card.Token); token != "" { resp, createErr := client.CreateCardTokenPayout(ctx, &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutRef, ProjectId: projectID, CustomerId: customer.id, CustomerFirstName: customer.firstName, @@ -123,7 +121,6 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s return nil, merrors.InvalidArgument("card payout send: card expiry is required") } resp, createErr := client.CreateCardPayout(ctx, &mntxv1.CardPayoutRequest{ - PayoutId: payoutRef, ProjectId: projectID, CustomerId: customer.id, CustomerFirstName: customer.firstName, @@ -155,8 +152,8 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s responsePayout = resp.GetPayout() } - resolvedPayoutRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetPayoutId()), payoutRef) resolvedOperationRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetOperationRef()), operationRef) + resolvedPayoutRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetPayoutId()), resolvedOperationRef) gatewayInstanceID := firstNonEmpty( strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(gateway.InstanceID), @@ -356,14 +353,6 @@ func cardPayoutOperationRef(payment *agg.Payment, stepToken string) string { return joinRef(base, stepToken) } -func cardPayoutRef(payment *agg.Payment) string { - base := "" - if payment != nil { - base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey), "card_payout") - } - return base -} - func cardPayoutIdempotencyKey(payment *agg.Payment, stepToken string) string { base := "" if payment != nil { @@ -549,6 +538,9 @@ func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string out = map[string]string{} } if payment != nil { + if parentPaymentRef := strings.TrimSpace(payment.PaymentRef); parentPaymentRef != "" { + out[settlementMetadataParentPaymentRef] = parentPaymentRef + } if quoteRef := firstNonEmpty( strings.TrimSpace(payment.QuotationRef), strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)), diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go index bcaecfd6..8cb86bfd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -122,7 +122,7 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin if got, want := dialAddress, "mntx-gateway:50051"; got != want { t.Fatalf("dial address mismatch: got=%q want=%q", got, want) } - if got, want := payoutReq.GetPayoutId(), "payment-1"; got != want { + if got, want := payoutReq.GetPayoutId(), ""; got != want { t.Fatalf("payout_id mismatch: got=%q want=%q", got, want) } if got, want := payoutReq.GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want { @@ -143,6 +143,9 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin if got, want := payoutReq.GetMetadata()[settlementMetadataOutgoingLeg], string(discovery.RailCardPayout); got != want { t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want) } + if got, want := payoutReq.GetMetadata()[settlementMetadataParentPaymentRef], "payment-1"; got != want { + t.Fatalf("parent_payment_ref metadata mismatch: got=%q want=%q", got, want) + } if len(out.StepExecution.ExternalRefs) != 3 { t.Fatalf("expected 3 external refs, got=%d", len(out.StepExecution.ExternalRefs)) } @@ -263,12 +266,18 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesStepMetadataOverrides(t if got, want := payoutReq.GetCurrency(), "RUB"; got != want { t.Fatalf("currency mismatch: got=%q want=%q", got, want) } + if got, want := payoutReq.GetPayoutId(), ""; got != want { + t.Fatalf("payout_id mismatch: got=%q want=%q", got, want) + } if got, want := payoutReq.GetCardPan(), "2200700142860162"; got != want { t.Fatalf("card pan mismatch: got=%q want=%q", got, want) } if got, want := payoutReq.GetMetadata()[batchmeta.MetaPayoutTargetRef], "recipient-2"; got != want { t.Fatalf("target_ref metadata mismatch: got=%q want=%q", got, want) } + if got, want := payoutReq.GetMetadata()[settlementMetadataParentPaymentRef], "payment-2"; got != want { + t.Fatalf("parent_payment_ref metadata mismatch: got=%q want=%q", got, want) + } } func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresGatewayRegistry(t *testing.T) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go index 6d1835b8..2433c606 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go @@ -15,8 +15,9 @@ import ( ) const ( - settlementMetadataQuoteRef = "quote_ref" - settlementMetadataOutgoingLeg = "outgoing_leg" + settlementMetadataQuoteRef = "quote_ref" + settlementMetadataOutgoingLeg = "outgoing_leg" + settlementMetadataParentPaymentRef = "parent_payment_ref" ) type gatewayProviderSettlementExecutor struct { -- 2.49.1 From 56bf49aa03ee1da7ef57dfadc0dba9732f3696a9 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 4 Mar 2026 02:46:51 +0100 Subject: [PATCH 2/2] fixed mntx payout sequence --- api/gateway/mntx/README.md | 5 +- api/gateway/mntx/client/client.go | 25 ++- .../service/gateway/card_payout_handlers.go | 4 + .../service/gateway/card_payout_validation.go | 3 + .../gateway/card_payout_validation_test.go | 5 + .../service/gateway/card_processor.go | 75 +------- .../service/gateway/card_processor_test.go | 176 +++++++++++++++++- .../service/gateway/card_token_validation.go | 3 + .../gateway/card_token_validation_test.go | 5 + .../internal/service/gateway/connector.go | 31 ++- .../mntx/internal/service/gateway/helpers.go | 4 +- .../service/gateway/testhelpers_test.go | 2 + .../orchestrator/card_payout_executor.go | 5 +- .../orchestrator/card_payout_executor_test.go | 145 ++++++++++++++- .../orchestrator/settlement_executor.go | 5 +- api/proto/gateway/mntx/v1/mntx.proto | 11 +- .../payment/quotation/intent_builder.dart | 1 + .../provider/payment/quotation/quotation.dart | 1 + t.log | 0 19 files changed, 385 insertions(+), 121 deletions(-) create mode 100644 t.log diff --git a/api/gateway/mntx/README.md b/api/gateway/mntx/README.md index 0dc2a9e6..bda3af63 100644 --- a/api/gateway/mntx/README.md +++ b/api/gateway/mntx/README.md @@ -20,12 +20,15 @@ This service now supports Monetix “payout by card”. Payload is built per Monetix spec: ``` { - "general": { "project_id": , "payment_id": "", "signature": "" }, + "general": { "project_id": , "payment_id": "", "signature": "" }, "customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? }, "payment": { amount: , currency: "" }, "card": { pan, year?, month?, card_holder } } ``` +Gateway request contract additionally requires `parent_payment_ref` as a first-class field +(separate from Monetix `payment_id`). + Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`. ## Callback handling diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index dead2fc9..4cbd2e55 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -39,8 +39,6 @@ type gatewayClient struct { logger mlogger.Logger } -const parentPaymentRefMetadataKey = "parent_payment_ref" - // New dials the Monetix gateway. func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { cfg.setDefaults() @@ -106,7 +104,7 @@ func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPa if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { return nil, connectorError(resp.GetReceipt().GetError()) } - return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), resp.GetReceipt())}, nil + return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil } func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { @@ -123,7 +121,7 @@ func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.C if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { return nil, connectorError(resp.GetReceipt().GetError()) } - return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), resp.GetReceipt())}, nil + return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil } func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { @@ -200,9 +198,10 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin } func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} { - metadata := metadataWithParentPaymentRef(req.GetMetadata(), req.GetPayoutId()) + metadata := sanitizeMetadata(req.GetMetadata()) params := map[string]interface{}{ "project_id": req.GetProjectId(), + "parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()), "customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), @@ -227,9 +226,10 @@ func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} } func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} { - metadata := metadataWithParentPaymentRef(req.GetMetadata(), req.GetPayoutId()) + metadata := sanitizeMetadata(req.GetMetadata()) params := map[string]interface{}{ "project_id": req.GetProjectId(), + "parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()), "customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), @@ -263,9 +263,10 @@ func moneyFromMinor(amount int64, currency string) *moneyv1.Money { } } -func payoutFromReceipt(payoutID, operationRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { +func payoutFromReceipt(payoutID, operationRef, parentPaymentRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { state := &mntxv1.CardPayoutState{ - PayoutId: fallbackNonEmpty(operationRef, payoutID), + PayoutId: fallbackNonEmpty(operationRef, payoutID), + ParentPaymentRef: strings.TrimSpace(parentPaymentRef), } if receipt == nil { return state @@ -288,9 +289,8 @@ func fallbackNonEmpty(values ...string) string { return "" } -func metadataWithParentPaymentRef(source map[string]string, parentPaymentRef string) map[string]string { - parentPaymentRef = strings.TrimSpace(parentPaymentRef) - if len(source) == 0 && parentPaymentRef == "" { +func sanitizeMetadata(source map[string]string) map[string]string { + if len(source) == 0 { return nil } out := map[string]string{} @@ -301,9 +301,6 @@ func metadataWithParentPaymentRef(source map[string]string, parentPaymentRef str } out[k] = strings.TrimSpace(value) } - if parentPaymentRef != "" && strings.TrimSpace(out[parentPaymentRefMetadataKey]) == "" { - out[parentPaymentRefMetadataKey] = parentPaymentRef - } if len(out) == 0 { return nil } diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go index 9fdfa51d..3b1ded96 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go @@ -22,6 +22,7 @@ func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPa log.Info("Create card payout request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), @@ -49,6 +50,7 @@ func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.C log.Info("Create card token payout request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), @@ -122,6 +124,7 @@ func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayout return req } r.PayoutId = strings.TrimSpace(r.GetPayoutId()) + r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef()) r.CustomerId = strings.TrimSpace(r.GetCustomerId()) r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) @@ -151,6 +154,7 @@ func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1. return req } r.PayoutId = strings.TrimSpace(r.GetPayoutId()) + r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef()) r.CustomerId = strings.TrimSpace(r.GetCustomerId()) r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_validation.go b/api/gateway/mntx/internal/service/gateway/card_payout_validation.go index 232d9b29..bfa22e22 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_validation.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_validation.go @@ -17,6 +17,9 @@ func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { return err } + if strings.TrimSpace(req.GetParentPaymentRef()) == "" { + return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref")) + } if strings.TrimSpace(req.GetCustomerId()) == "" { return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) } diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go index e05640f7..a35c2463 100644 --- a/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_payout_validation_test.go @@ -28,6 +28,11 @@ func TestValidateCardPayoutRequest_Errors(t *testing.T) { mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" }, expected: "missing_operation_ref", }, + { + name: "missing_parent_payment_ref", + mutate: func(r *mntxv1.CardPayoutRequest) { r.ParentPaymentRef = "" }, + expected: "missing_parent_payment_ref", + }, { name: "both_operation_and_payout_identity", mutate: func(r *mntxv1.CardPayoutRequest) { diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index 8b846fe1..d0a9630a 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -39,8 +39,6 @@ type cardPayoutProcessor struct { perTxMinAmountMinorByCurrency map[string]int64 } -const parentPaymentRefMetadataKey = "parent_payment_ref" - func mergePayoutStateWithExisting(state, existing *model.CardPayout) { if state == nil || existing == nil { return @@ -59,7 +57,7 @@ func mergePayoutStateWithExisting(state, existing *model.CardPayout) { if state.IntentRef == "" { state.IntentRef = existing.IntentRef } - if state.PaymentRef == "" { + if existing.PaymentRef != "" { state.PaymentRef = existing.PaymentRef } } @@ -72,47 +70,6 @@ func findOperationRef(operationRef, payoutID string) string { return strings.TrimSpace(payoutID) } -func parentPaymentRefFromOperationRef(operationRef string) string { - ref := strings.TrimSpace(operationRef) - if ref == "" { - return "" - } - if idx := strings.Index(ref, ":hop_"); idx > 0 { - return ref[:idx] - } - if idx := strings.Index(ref, ":"); idx > 0 { - return ref[:idx] - } - return "" -} - -func parentPaymentRefFromMetadata(metadata map[string]string) string { - if len(metadata) == 0 { - return "" - } - return strings.TrimSpace(metadata[parentPaymentRefMetadataKey]) -} - -func resolveParentPaymentRef(payoutID, operationRef string, metadata map[string]string) string { - if parent := parentPaymentRefFromMetadata(metadata); parent != "" { - return parent - } - payoutRef := strings.TrimSpace(payoutID) - opRef := strings.TrimSpace(operationRef) - - // Legacy callers may pass parent payment ref via payout_id and a distinct operation_ref. - if payoutRef != "" && (opRef == "" || payoutRef != opRef) { - return payoutRef - } - if parent := parentPaymentRefFromOperationRef(opRef); parent != "" { - return parent - } - if payoutRef != "" { - return payoutRef - } - return opRef -} - func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) { if p == nil || state == nil { return nil, nil @@ -129,23 +86,6 @@ func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state return nil, err } } - // Legacy mode may still map operation ref to payout/payment ref. - if paymentRef := strings.TrimSpace(state.PaymentRef); paymentRef == "" || paymentRef != opRef { - return nil, nil - } - } - if paymentRef := strings.TrimSpace(state.PaymentRef); paymentRef != "" { - existing, err := p.store.Payouts().FindByPaymentID(ctx, paymentRef) - if err == nil { - if existing != nil { - return existing, nil - } - } - if !errors.Is(err, merrors.ErrNoData) { - if err != nil { - return nil, err - } - } } return nil, nil } @@ -310,7 +250,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout req = sanitizeCardPayoutRequest(req) operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) - parentPaymentRef := resolveParentPaymentRef(req.GetPayoutId(), operationRef, req.GetMetadata()) + parentPaymentRef := strings.TrimSpace(req.GetParentPaymentRef()) p.logger.Info("Submitting card payout", zap.String("parent_payment_ref", parentPaymentRef), @@ -435,7 +375,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT req = sanitizeCardTokenPayoutRequest(req) operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) - parentPaymentRef := resolveParentPaymentRef(req.GetPayoutId(), operationRef, req.GetMetadata()) + parentPaymentRef := strings.TrimSpace(req.GetParentPaymentRef()) p.logger.Info("Submitting card token payout", zap.String("parent_payment_ref", parentPaymentRef), @@ -609,7 +549,7 @@ func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mnt } id := strings.TrimSpace(payoutID) - p.logger.Info("Card payout status requested", zap.String("operation_or_payout_id", id)) + p.logger.Info("Card payout status requested", zap.String("operation_ref", id)) if id == "" { p.logger.Warn("Payout status requested with empty payout_id") @@ -622,11 +562,8 @@ func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mnt return nil, err } if state == nil || errors.Is(err, merrors.ErrNoData) { - state, err = p.store.Payouts().FindByPaymentID(ctx, id) - if err != nil || state == nil { - p.logger.Warn("Payout status not found", zap.String("operation_or_payout_id", id), zap.Error(err)) - return nil, merrors.NoData("payout not found") - } + p.logger.Warn("Payout status not found", zap.String("operation_ref", id)) + return nil, merrors.NoData("payout not found") } p.logger.Info("Card payout status resolved", diff --git a/api/gateway/mntx/internal/service/gateway/card_processor_test.go b/api/gateway/mntx/internal/service/gateway/card_processor_test.go index 5879f4cf..80f8b7c6 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "testing" @@ -45,8 +46,9 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) { repo := newMockRepository() repo.payouts.Save(&model.CardPayout{ - PaymentRef: "payout-1", - CreatedAt: existingCreated, + PaymentRef: "payment-parent-1", + OperationRef: "payout-1", + CreatedAt: existingCreated, }) httpClient := &http.Client{ @@ -227,3 +229,173 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) { t.Fatalf("expected success status in model, got %v", state.Status) } } + +func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparately(t *testing.T) { + cfg := monetix.Config{ + BaseURL: "https://monetix.test", + SecretKey: "secret", + ProjectID: 99, + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + var callN int + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + callN++ + resp := monetix.APIResponse{} + resp.Operation.RequestID = fmt.Sprintf("req-%d", callN) + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)}, + repo, + httpClient, + nil, + ) + + parentPaymentRef := "payment-parent-1" + op1 := parentPaymentRef + ":hop_4_card_payout_send" + op2 := parentPaymentRef + ":hop_4_card_payout_send_2" + + req1 := validCardPayoutRequest() + req1.PayoutId = "" + req1.OperationRef = op1 + req1.IdempotencyKey = "idem-1" + req1.ParentPaymentRef = parentPaymentRef + req1.CardPan = "2204310000002456" + + req2 := validCardPayoutRequest() + req2.PayoutId = "" + req2.OperationRef = op2 + req2.IdempotencyKey = "idem-2" + req2.ParentPaymentRef = parentPaymentRef + req2.CardPan = "2204320000009754" + + if _, err := processor.Submit(context.Background(), req1); err != nil { + t.Fatalf("first submit failed: %v", err) + } + if _, err := processor.Submit(context.Background(), req2); err != nil { + t.Fatalf("second submit failed: %v", err) + } + + first, err := repo.payouts.FindByOperationRef(context.Background(), op1) + if err != nil || first == nil { + t.Fatalf("expected first operation stored, err=%v", err) + } + second, err := repo.payouts.FindByOperationRef(context.Background(), op2) + if err != nil || second == nil { + t.Fatalf("expected second operation stored, err=%v", err) + } + + if got, want := first.PaymentRef, parentPaymentRef; got != want { + t.Fatalf("first parent payment ref mismatch: got=%q want=%q", got, want) + } + if got, want := second.PaymentRef, parentPaymentRef; got != want { + t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want) + } + if got, want := first.OperationRef, op1; got != want { + t.Fatalf("first operation ref mismatch: got=%q want=%q", got, want) + } + if got, want := second.OperationRef, op2; got != want { + t.Fatalf("second operation ref mismatch: got=%q want=%q", got, want) + } + if first.ProviderPaymentID == "" || second.ProviderPaymentID == "" { + t.Fatalf("expected provider payment ids for both operations") + } + if first.ProviderPaymentID == second.ProviderPaymentID { + t.Fatalf("expected different provider payment ids, got=%q", first.ProviderPaymentID) + } +} + +func TestCardPayoutProcessor_ProcessCallback_UpdatesMatchingOperationWithinSameParent(t *testing.T) { + cfg := monetix.Config{ + SecretKey: "secret", + StatusSuccess: "success", + StatusProcessing: "processing", + AllowedCurrencies: []string{"RUB"}, + } + + parentPaymentRef := "payment-parent-1" + op1 := parentPaymentRef + ":hop_4_card_payout_send" + op2 := parentPaymentRef + ":hop_4_card_payout_send_2" + now := time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC) + + repo := newMockRepository() + repo.payouts.Save(&model.CardPayout{ + PaymentRef: parentPaymentRef, + OperationRef: op1, + Status: model.PayoutStatusWaiting, + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + }) + repo.payouts.Save(&model.CardPayout{ + PaymentRef: parentPaymentRef, + OperationRef: op2, + Status: model.PayoutStatusWaiting, + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + }) + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: now}, + repo, + &http.Client{}, + nil, + ) + + cb := baseCallback() + cb.Payment.ID = op2 + cb.Payment.Status = "success" + cb.Operation.Status = "success" + cb.Operation.Code = "0" + cb.Operation.Provider.PaymentID = "provider-op-2" + cb.Payment.Sum.Currency = "RUB" + + sig, err := monetix.SignPayload(cb, cfg.SecretKey) + if err != nil { + t.Fatalf("failed to sign callback: %v", err) + } + cb.Signature = sig + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + + status, err := processor.ProcessCallback(context.Background(), payload) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected status ok, got %d", status) + } + + first, err := repo.payouts.FindByOperationRef(context.Background(), op1) + if err != nil || first == nil { + t.Fatalf("expected first operation present, err=%v", err) + } + second, err := repo.payouts.FindByOperationRef(context.Background(), op2) + if err != nil || second == nil { + t.Fatalf("expected second operation present, err=%v", err) + } + + if got, want := first.Status, model.PayoutStatusWaiting; got != want { + t.Fatalf("first operation status mismatch: got=%v want=%v", got, want) + } + if got, want := second.Status, model.PayoutStatusSuccess; got != want { + t.Fatalf("second operation status mismatch: got=%v want=%v", got, want) + } + if got, want := second.PaymentRef, parentPaymentRef; got != want { + t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_token_validation.go b/api/gateway/mntx/internal/service/gateway/card_token_validation.go index 08e2f2a6..550f29f8 100644 --- a/api/gateway/mntx/internal/service/gateway/card_token_validation.go +++ b/api/gateway/mntx/internal/service/gateway/card_token_validation.go @@ -16,6 +16,9 @@ func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg mone if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { return err } + if strings.TrimSpace(req.GetParentPaymentRef()) == "" { + return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref")) + } if strings.TrimSpace(req.GetCustomerId()) == "" { return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) } diff --git a/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go b/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go index 086c16dc..30526b66 100644 --- a/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_token_validation_test.go @@ -28,6 +28,11 @@ func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) { mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" }, expected: "missing_operation_ref", }, + { + name: "missing_parent_payment_ref", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.ParentPaymentRef = "" }, + expected: "missing_parent_payment_ref", + }, { name: "both_operation_and_payout_identity", mutate: func(r *mntxv1.CardTokenPayoutRequest) { diff --git a/api/gateway/mntx/internal/service/gateway/connector.go b/api/gateway/mntx/internal/service/gateway/connector.go index 9993b85d..819a7f2f 100644 --- a/api/gateway/mntx/internal/service/gateway/connector.go +++ b/api/gateway/mntx/internal/service/gateway/connector.go @@ -69,18 +69,18 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil } + parentPaymentRef := strings.TrimSpace(reader.String("parent_payment_ref")) - payoutID := strings.TrimSpace(reader.String("payout_id")) - payoutID = operationIDForRequest(payoutID, operationRef, idempotencyKey) + payoutID := operationIDForRequest(operationRef) if strings.TrimSpace(reader.String("card_token")) != "" { - resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, idempotencyKey, operationRef, intentRef, amountMinor, currency)) + resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency)) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil } - cr := buildCardPayoutRequestFromParams(reader, payoutID, idempotencyKey, operationRef, intentRef, amountMinor, currency) + cr := buildCardPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency) resp, err := s.CreateCardPayout(ctx, cr) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil @@ -117,6 +117,7 @@ func mntxOperationParams() []*connectorv1.OperationParamSpec { {Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false}, {Key: "project_id", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "parent_payment_ref", Type: connectorv1.ParamType_STRING, Required: true}, {Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false}, @@ -167,24 +168,12 @@ func currencyFromOperation(op *connectorv1.Operation) string { return strings.ToUpper(currency) } -func operationIDForRequest(payoutID, operationRef, idempotencyKey string) string { - if ref := strings.TrimSpace(operationRef); ref != "" { - return ref - } - if ref := strings.TrimSpace(payoutID); ref != "" { - return ref - } - return strings.TrimSpace(idempotencyKey) +func operationIDForRequest(operationRef string) string { + return strings.TrimSpace(operationRef) } func metadataFromReader(reader params.Reader) map[string]string { metadata := reader.StringMap("metadata") - if metadata == nil { - metadata = map[string]string{} - } - if parentRef := strings.TrimSpace(reader.String("payout_id")); parentRef != "" && strings.TrimSpace(metadata[parentPaymentRefMetadataKey]) == "" { - metadata[parentPaymentRefMetadataKey] = parentRef - } if len(metadata) == 0 { return nil } @@ -192,7 +181,7 @@ func metadataFromReader(reader params.Reader) map[string]string { } func buildCardTokenPayoutRequestFromParams(reader params.Reader, - payoutID, idempotencyKey, operationRef, intentRef string, + payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { operationRef = strings.TrimSpace(operationRef) payoutID = strings.TrimSpace(payoutID) @@ -201,6 +190,7 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader, } req := &mntxv1.CardTokenPayoutRequest{ PayoutId: payoutID, + ParentPaymentRef: strings.TrimSpace(parentPaymentRef), ProjectId: readerInt64(reader, "project_id"), CustomerId: strings.TrimSpace(reader.String("customer_id")), CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), @@ -226,7 +216,7 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader, } func buildCardPayoutRequestFromParams(reader params.Reader, - payoutID, idempotencyKey, operationRef, intentRef string, + payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { operationRef = strings.TrimSpace(operationRef) payoutID = strings.TrimSpace(payoutID) @@ -235,6 +225,7 @@ func buildCardPayoutRequestFromParams(reader params.Reader, } return &mntxv1.CardPayoutRequest{ PayoutId: payoutID, + ParentPaymentRef: strings.TrimSpace(parentPaymentRef), ProjectId: readerInt64(reader, "project_id"), CustomerId: strings.TrimSpace(reader.String("customer_id")), CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), diff --git a/api/gateway/mntx/internal/service/gateway/helpers.go b/api/gateway/mntx/internal/service/gateway/helpers.go index 3a81099b..629b4ff2 100644 --- a/api/gateway/mntx/internal/service/gateway/helpers.go +++ b/api/gateway/mntx/internal/service/gateway/helpers.go @@ -1,6 +1,7 @@ package gateway import ( + "strings" "time" "github.com/tech/sendico/gateway/mntx/storage/model" @@ -22,7 +23,7 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) * } return &model.CardPayout{ - PaymentRef: p.PayoutId, + PaymentRef: strings.TrimSpace(p.GetParentPaymentRef()), OperationRef: p.GetOperationRef(), IntentRef: p.GetIntentRef(), IdempotencyKey: p.GetIdempotencyKey(), @@ -42,6 +43,7 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) * func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState { return &mntxv1.CardPayoutState{ PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef), + ParentPaymentRef: m.PaymentRef, ProjectId: m.ProjectID, CustomerId: m.CustomerID, AmountMinor: m.AmountMinor, diff --git a/api/gateway/mntx/internal/service/gateway/testhelpers_test.go b/api/gateway/mntx/internal/service/gateway/testhelpers_test.go index 0284a2d1..089ada48 100644 --- a/api/gateway/mntx/internal/service/gateway/testhelpers_test.go +++ b/api/gateway/mntx/internal/service/gateway/testhelpers_test.go @@ -36,6 +36,7 @@ func testMonetixConfig() monetix.Config { func validCardPayoutRequest() *mntxv1.CardPayoutRequest { return &mntxv1.CardPayoutRequest{ PayoutId: "payout-1", + ParentPaymentRef: "payment-parent-1", CustomerId: "cust-1", CustomerFirstName: "Jane", CustomerLastName: "Doe", @@ -52,6 +53,7 @@ func validCardPayoutRequest() *mntxv1.CardPayoutRequest { func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest { return &mntxv1.CardTokenPayoutRequest{ PayoutId: "payout-1", + ParentPaymentRef: "payment-parent-1", CustomerId: "cust-1", CustomerFirstName: "Jane", CustomerLastName: "Doe", diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go index cbed86c0..c5334ec9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go @@ -85,6 +85,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s if token := strings.TrimSpace(card.Token); token != "" { resp, createErr := client.CreateCardTokenPayout(ctx, &mntxv1.CardTokenPayoutRequest{ ProjectId: projectID, + ParentPaymentRef: strings.TrimSpace(req.Payment.PaymentRef), CustomerId: customer.id, CustomerFirstName: customer.firstName, CustomerMiddleName: customer.middleName, @@ -122,6 +123,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s } resp, createErr := client.CreateCardPayout(ctx, &mntxv1.CardPayoutRequest{ ProjectId: projectID, + ParentPaymentRef: strings.TrimSpace(req.Payment.PaymentRef), CustomerId: customer.id, CustomerFirstName: customer.firstName, CustomerMiddleName: customer.middleName, @@ -538,9 +540,6 @@ func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string out = map[string]string{} } if payment != nil { - if parentPaymentRef := strings.TrimSpace(payment.PaymentRef); parentPaymentRef != "" { - out[settlementMetadataParentPaymentRef] = parentPaymentRef - } if quoteRef := firstNonEmpty( strings.TrimSpace(payment.QuotationRef), strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)), diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go index 8cb86bfd..9f8bfd7a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -143,8 +143,8 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin if got, want := payoutReq.GetMetadata()[settlementMetadataOutgoingLeg], string(discovery.RailCardPayout); got != want { t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want) } - if got, want := payoutReq.GetMetadata()[settlementMetadataParentPaymentRef], "payment-1"; got != want { - t.Fatalf("parent_payment_ref metadata mismatch: got=%q want=%q", got, want) + if got, want := payoutReq.GetParentPaymentRef(), "payment-1"; got != want { + t.Fatalf("parent_payment_ref mismatch: got=%q want=%q", got, want) } if len(out.StepExecution.ExternalRefs) != 3 { t.Fatalf("expected 3 external refs, got=%d", len(out.StepExecution.ExternalRefs)) @@ -275,8 +275,8 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesStepMetadataOverrides(t if got, want := payoutReq.GetMetadata()[batchmeta.MetaPayoutTargetRef], "recipient-2"; got != want { t.Fatalf("target_ref metadata mismatch: got=%q want=%q", got, want) } - if got, want := payoutReq.GetMetadata()[settlementMetadataParentPaymentRef], "payment-2"; got != want { - t.Fatalf("parent_payment_ref metadata mismatch: got=%q want=%q", got, want) + if got, want := payoutReq.GetParentPaymentRef(), "payment-2"; got != want { + t.Fatalf("parent_payment_ref mismatch: got=%q want=%q", got, want) } } @@ -319,3 +319,140 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresGatewayRegistry(t * t.Fatalf("unexpected error: %v", err) } } + +func TestGatewayCardPayoutExecutor_ExecuteCardPayout_BatchChildrenUseDistinctOperationRefsAndSharedParent(t *testing.T) { + orgID := bson.NewObjectID() + + var payoutReqs []*mntxv1.CardPayoutRequest + executor := &gatewayCardPayoutExecutor{ + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, + Rail: discovery.RailCardPayout, + InvokeURI: "grpc://mntx-gateway:50051", + IsEnabled: true, + }, + }, + }, + dialClient: func(_ context.Context, _ string) (mntxclient.Client, error) { + return &mntxclient.Fake{ + CreateCardPayoutFn: func(_ context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + payoutReqs = append(payoutReqs, req) + return &mntxv1.CardPayoutResponse{ + Payout: &mntxv1.CardPayoutState{ + PayoutId: req.GetOperationRef(), + OperationRef: req.GetOperationRef(), + }, + }, nil + }, + }, nil + }, + } + + payment := &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-3", + IdempotencyKey: "idem-3", + QuotationRef: "quote-3", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-3", + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860161", + ExpMonth: 3, + ExpYear: 2030, + }, + }, + Customer: &model.Customer{ + ID: "cust-3", + FirstName: "Stephan", + LastName: "Deshevikh", + IP: "198.51.100.10", + }, + Amount: &paymenttypes.Money{ + Amount: "1.000000", + Currency: "USDT", + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: "76.50", + Currency: "RUB", + }, + QuoteRef: "quote-3", + }, + } + + firstReq := sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Action: discovery.RailOperationSend, + Rail: discovery.RailCardPayout, + Gateway: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Attempt: 1, + }, + } + secondReq := sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "hop_4_card_payout_send_2", + StepCode: "hop.4.card_payout.send", + Action: discovery.RailOperationSend, + Rail: discovery.RailCardPayout, + Gateway: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, + Metadata: map[string]string{ + batchmeta.MetaPayoutTargetRef: "recipient-2", + batchmeta.MetaAmount: "150", + batchmeta.MetaCurrency: "RUB", + batchmeta.MetaCardPan: "2200700142860162", + batchmeta.MetaCardExpMonth: "4", + batchmeta.MetaCardExpYear: "2030", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_4_card_payout_send_2", + StepCode: "hop.4.card_payout.send", + Attempt: 1, + }, + } + + if _, err := executor.ExecuteCardPayout(context.Background(), firstReq); err != nil { + t.Fatalf("first ExecuteCardPayout returned error: %v", err) + } + if _, err := executor.ExecuteCardPayout(context.Background(), secondReq); err != nil { + t.Fatalf("second ExecuteCardPayout returned error: %v", err) + } + + if got, want := len(payoutReqs), 2; got != want { + t.Fatalf("submitted request count mismatch: got=%d want=%d", got, want) + } + if got, want := payoutReqs[0].GetOperationRef(), "payment-3:hop_4_card_payout_send"; got != want { + t.Fatalf("first operation_ref mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReqs[1].GetOperationRef(), "payment-3:hop_4_card_payout_send_2"; got != want { + t.Fatalf("second operation_ref mismatch: got=%q want=%q", got, want) + } + if payoutReqs[0].GetPayoutId() != "" || payoutReqs[1].GetPayoutId() != "" { + t.Fatalf("expected empty payout_id for both child operations") + } + if got, want := payoutReqs[0].GetParentPaymentRef(), "payment-3"; got != want { + t.Fatalf("first parent_payment_ref mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReqs[1].GetParentPaymentRef(), "payment-3"; got != want { + t.Fatalf("second parent_payment_ref mismatch: got=%q want=%q", got, want) + } + if payoutReqs[0].GetCardPan() == payoutReqs[1].GetCardPan() { + t.Fatalf("expected different destination cards across child operations") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go index 2433c606..6d1835b8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go @@ -15,9 +15,8 @@ import ( ) const ( - settlementMetadataQuoteRef = "quote_ref" - settlementMetadataOutgoingLeg = "outgoing_leg" - settlementMetadataParentPaymentRef = "parent_payment_ref" + settlementMetadataQuoteRef = "quote_ref" + settlementMetadataOutgoingLeg = "outgoing_leg" ) type gatewayProviderSettlementExecutor struct { diff --git a/api/proto/gateway/mntx/v1/mntx.proto b/api/proto/gateway/mntx/v1/mntx.proto index 5eb090e2..cc5e5a4c 100644 --- a/api/proto/gateway/mntx/v1/mntx.proto +++ b/api/proto/gateway/mntx/v1/mntx.proto @@ -21,7 +21,7 @@ enum PayoutStatus { // Request to initiate a Monetix card payout. message CardPayoutRequest { - string payout_id = 1; // internal payout id, mapped to Monetix payment_id + string payout_id = 1; // alternate operation id mapped to Monetix payment_id int64 project_id = 2; // optional override; defaults to configured project id string customer_id = 3; string customer_first_name = 4; @@ -40,9 +40,10 @@ message CardPayoutRequest { uint32 card_exp_month = 17; string card_holder = 18; map metadata = 30; - string operation_ref = 31; + string operation_ref = 31; // preferred operation id mapped to Monetix payment_id string idempotency_key = 32; string intent_ref = 33; + string parent_payment_ref = 34; } // Persisted payout state for retrieval and status updates. @@ -61,6 +62,7 @@ message CardPayoutState { string operation_ref = 12; string idempotency_key = 13; string intent_ref = 14; + string parent_payment_ref = 15; } // Response returned immediately after submitting a payout to Monetix. @@ -97,7 +99,7 @@ message ListGatewayInstancesResponse { // Request to initiate a token-based card payout. message CardTokenPayoutRequest { - string payout_id = 1; + string payout_id = 1; // alternate operation id int64 project_id = 2; string customer_id = 3; @@ -119,9 +121,10 @@ message CardTokenPayoutRequest { string card_holder = 16; string masked_pan = 17; map metadata = 30; - string operation_ref = 31; + string operation_ref = 31; // preferred operation id string idempotency_key = 32; string intent_ref = 33; + string parent_payment_ref = 34; } // Response returned immediately after submitting a token payout to Monetix. diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index 50e57ae0..d3f14800 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -24,6 +24,7 @@ import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/utils/payment/fx_helpers.dart'; + class QuotationIntentBuilder { static const String _settlementCurrency = 'RUB'; diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart index c48e4828..acb7a853 100644 --- a/frontend/pshared/lib/provider/payment/quotation/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation/quotation.dart @@ -26,6 +26,7 @@ import 'package:pshared/service/payment/quotation.dart'; import 'package:pshared/utils/payment/quote_helpers.dart'; import 'package:pshared/utils/exception.dart'; + class QuotationProvider extends ChangeNotifier { static final _logger = Logger('provider.payment.quotation'); Resource _quotation = Resource( diff --git a/t.log b/t.log new file mode 100644 index 00000000..e69de29b -- 2.49.1