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