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 {