From fa9e6f47cf30fa67afe8df396a6e2b410c67f815 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 23:15:48 +0100 Subject: [PATCH] smarter optimizer for batch payments --- .../orchestrationv2/batchmeta/module.go | 296 ++++++++++++++++++ .../orchestrationv2/psvc/execute_batch.go | 148 ++++++++- .../psvc/execute_batch_test.go | 130 +++++++- .../xplan/compile_batch_targets_test.go | 90 ++++++ .../xplan/service_boundaries.go | 102 +++++- .../orchestrator/card_payout_executor.go | 88 +++++- .../orchestrator/card_payout_executor_test.go | 94 ++++++ .../service/orchestrator/ledger_executor.go | 4 + .../orchestrator/ledger_executor_test.go | 47 +++ 9 files changed, 972 insertions(+), 27 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go new file mode 100644 index 00000000..3ed7be3d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go @@ -0,0 +1,296 @@ +package batchmeta + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +const ( + // AttrPayoutTargets stores serialized payout target descriptors in intent attributes. + AttrPayoutTargets = "orchestrator.v2.batch_payout_targets" + + MetaPayoutTargetRef = "orchestrator.v2.payout_target_ref" + MetaPayoutTargetIndex = "orchestrator.v2.payout_target_index" + MetaAmount = "orchestrator.v2.amount" + MetaCurrency = "orchestrator.v2.currency" + + MetaCardPan = "orchestrator.v2.card_pan" + MetaCardToken = "orchestrator.v2.card_token" + MetaCardholder = "orchestrator.v2.cardholder" + MetaCardholderSurname = "orchestrator.v2.cardholder_surname" + MetaCardExpMonth = "orchestrator.v2.card_exp_month" + MetaCardExpYear = "orchestrator.v2.card_exp_year" + MetaCardCountry = "orchestrator.v2.card_country" + MetaCardMaskedPan = "orchestrator.v2.card_masked_pan" + MetaCustomerID = "orchestrator.v2.customer_id" + MetaCustomerFirstName = "orchestrator.v2.customer_first_name" + MetaCustomerMiddleName = "orchestrator.v2.customer_middle_name" + MetaCustomerLastName = "orchestrator.v2.customer_last_name" + MetaCustomerIP = "orchestrator.v2.customer_ip" + MetaCustomerZip = "orchestrator.v2.customer_zip" + MetaCustomerCountry = "orchestrator.v2.customer_country" + MetaCustomerState = "orchestrator.v2.customer_state" + MetaCustomerCity = "orchestrator.v2.customer_city" + MetaCustomerAddress = "orchestrator.v2.customer_address" +) + +// PayoutTarget carries one destination-level payout branch payload for batch execution. +type PayoutTarget struct { + TargetRef string `json:"target_ref,omitempty"` + IntentRef []string `json:"intent_refs,omitempty"` + Amount *paymenttypes.Money `json:"amount,omitempty"` + Card *model.CardEndpoint `json:"card,omitempty"` + Customer *model.Customer `json:"customer,omitempty"` +} + +func EncodePayoutTargets(targets []PayoutTarget) (string, error) { + norm := normalizeTargets(targets) + if len(norm) == 0 { + return "", nil + } + data, err := json.Marshal(norm) + if err != nil { + return "", err + } + return string(data), nil +} + +func DecodePayoutTargets(raw string) ([]PayoutTarget, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var targets []PayoutTarget + if err := json.Unmarshal([]byte(raw), &targets); err != nil { + return nil, err + } + return normalizeTargets(targets), nil +} + +func StepMetadataForTarget(target PayoutTarget, index int) map[string]string { + target = normalizeTarget(target, index) + out := map[string]string{} + + if ref := strings.TrimSpace(target.TargetRef); ref != "" { + out[MetaPayoutTargetRef] = ref + } + if index >= 0 { + out[MetaPayoutTargetIndex] = strconv.Itoa(index + 1) + } + if target.Amount != nil { + if amount := strings.TrimSpace(target.Amount.Amount); amount != "" { + out[MetaAmount] = amount + } + if currency := strings.ToUpper(strings.TrimSpace(target.Amount.Currency)); currency != "" { + out[MetaCurrency] = currency + } + } + if target.Card != nil { + appendIfNotEmpty(out, MetaCardPan, target.Card.Pan) + appendIfNotEmpty(out, MetaCardToken, target.Card.Token) + appendIfNotEmpty(out, MetaCardholder, target.Card.Cardholder) + appendIfNotEmpty(out, MetaCardholderSurname, target.Card.CardholderSurname) + appendIfNotEmpty(out, MetaCardCountry, target.Card.Country) + appendIfNotEmpty(out, MetaCardMaskedPan, target.Card.MaskedPan) + if target.Card.ExpMonth != 0 { + out[MetaCardExpMonth] = strconv.FormatUint(uint64(target.Card.ExpMonth), 10) + } + if target.Card.ExpYear != 0 { + out[MetaCardExpYear] = strconv.FormatUint(uint64(target.Card.ExpYear), 10) + } + } + if target.Customer != nil { + appendIfNotEmpty(out, MetaCustomerID, target.Customer.ID) + appendIfNotEmpty(out, MetaCustomerFirstName, target.Customer.FirstName) + appendIfNotEmpty(out, MetaCustomerMiddleName, target.Customer.MiddleName) + appendIfNotEmpty(out, MetaCustomerLastName, target.Customer.LastName) + appendIfNotEmpty(out, MetaCustomerIP, target.Customer.IP) + appendIfNotEmpty(out, MetaCustomerZip, target.Customer.Zip) + appendIfNotEmpty(out, MetaCustomerCountry, target.Customer.Country) + appendIfNotEmpty(out, MetaCustomerState, target.Customer.State) + appendIfNotEmpty(out, MetaCustomerCity, target.Customer.City) + appendIfNotEmpty(out, MetaCustomerAddress, target.Customer.Address) + } + if len(out) == 0 { + return nil + } + return out +} + +func AmountFromMetadata(metadata map[string]string) (*paymenttypes.Money, bool) { + if len(metadata) == 0 { + return nil, false + } + amount := strings.TrimSpace(metadata[MetaAmount]) + currency := strings.ToUpper(strings.TrimSpace(metadata[MetaCurrency])) + if amount == "" || currency == "" { + return nil, false + } + return &paymenttypes.Money{Amount: amount, Currency: currency}, true +} + +func CardFromMetadata(metadata map[string]string) (*model.CardEndpoint, bool) { + if len(metadata) == 0 { + return nil, false + } + card := &model.CardEndpoint{ + Pan: strings.TrimSpace(metadata[MetaCardPan]), + Token: strings.TrimSpace(metadata[MetaCardToken]), + Cardholder: strings.TrimSpace(metadata[MetaCardholder]), + CardholderSurname: strings.TrimSpace(metadata[MetaCardholderSurname]), + Country: strings.ToUpper(strings.TrimSpace(metadata[MetaCardCountry])), + MaskedPan: strings.TrimSpace(metadata[MetaCardMaskedPan]), + } + if parsed, ok := parseUint32(metadata[MetaCardExpMonth]); ok { + card.ExpMonth = parsed + } + if parsed, ok := parseUint32(metadata[MetaCardExpYear]); ok { + card.ExpYear = parsed + } + if card.Pan == "" && card.Token == "" && card.Cardholder == "" && + card.CardholderSurname == "" && card.ExpMonth == 0 && card.ExpYear == 0 && + card.Country == "" && card.MaskedPan == "" { + return nil, false + } + return card, true +} + +func CustomerFromMetadata(metadata map[string]string) (*model.Customer, bool) { + if len(metadata) == 0 { + return nil, false + } + customer := &model.Customer{ + ID: strings.TrimSpace(metadata[MetaCustomerID]), + FirstName: strings.TrimSpace(metadata[MetaCustomerFirstName]), + MiddleName: strings.TrimSpace(metadata[MetaCustomerMiddleName]), + LastName: strings.TrimSpace(metadata[MetaCustomerLastName]), + IP: strings.TrimSpace(metadata[MetaCustomerIP]), + Zip: strings.TrimSpace(metadata[MetaCustomerZip]), + Country: strings.ToUpper(strings.TrimSpace(metadata[MetaCustomerCountry])), + State: strings.TrimSpace(metadata[MetaCustomerState]), + City: strings.TrimSpace(metadata[MetaCustomerCity]), + Address: strings.TrimSpace(metadata[MetaCustomerAddress]), + } + if customer.ID == "" && customer.FirstName == "" && customer.MiddleName == "" && + customer.LastName == "" && customer.IP == "" && customer.Zip == "" && + customer.Country == "" && customer.State == "" && customer.City == "" && + customer.Address == "" { + return nil, false + } + return customer, true +} + +func normalizeTargets(targets []PayoutTarget) []PayoutTarget { + if len(targets) == 0 { + return nil + } + out := make([]PayoutTarget, 0, len(targets)) + for i := range targets { + target := normalizeTarget(targets[i], i) + if target.TargetRef == "" && target.Amount == nil && target.Card == nil && target.Customer == nil { + continue + } + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeTarget(target PayoutTarget, index int) PayoutTarget { + target.TargetRef = strings.TrimSpace(target.TargetRef) + if target.TargetRef == "" { + target.TargetRef = "target-" + strconv.Itoa(index+1) + } + target.IntentRef = normalizeStringSlice(target.IntentRef) + if target.Amount != nil { + amount := strings.TrimSpace(target.Amount.Amount) + currency := strings.ToUpper(strings.TrimSpace(target.Amount.Currency)) + if amount == "" || currency == "" { + target.Amount = nil + } else { + target.Amount = &paymenttypes.Money{ + Amount: amount, + Currency: currency, + } + } + } + if target.Card != nil { + target.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(target.Card.Pan), + Token: strings.TrimSpace(target.Card.Token), + Cardholder: strings.TrimSpace(target.Card.Cardholder), + CardholderSurname: strings.TrimSpace(target.Card.CardholderSurname), + ExpMonth: target.Card.ExpMonth, + ExpYear: target.Card.ExpYear, + Country: strings.ToUpper(strings.TrimSpace(target.Card.Country)), + MaskedPan: strings.TrimSpace(target.Card.MaskedPan), + } + } + if target.Customer != nil { + target.Customer = &model.Customer{ + ID: strings.TrimSpace(target.Customer.ID), + FirstName: strings.TrimSpace(target.Customer.FirstName), + MiddleName: strings.TrimSpace(target.Customer.MiddleName), + LastName: strings.TrimSpace(target.Customer.LastName), + IP: strings.TrimSpace(target.Customer.IP), + Zip: strings.TrimSpace(target.Customer.Zip), + Country: strings.ToUpper(strings.TrimSpace(target.Customer.Country)), + State: strings.TrimSpace(target.Customer.State), + City: strings.TrimSpace(target.Customer.City), + Address: strings.TrimSpace(target.Customer.Address), + } + } + return target +} + +func normalizeStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for i := range values { + token := strings.TrimSpace(values[i]) + if token == "" { + continue + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + out = append(out, token) + } + if len(out) == 0 { + return nil + } + return out +} + +func appendIfNotEmpty(dst map[string]string, key string, value string) { + if dst == nil { + return + } + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + dst[key] = trimmed +} + +func parseUint32(raw string) (uint32, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return 0, false + } + parsed, err := strconv.ParseUint(trimmed, 10, 32) + if err != nil { + return 0, false + } + return uint32(parsed), true +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go index 22cc2e9d..e480dbef 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go @@ -6,21 +6,30 @@ import ( "encoding/hex" "errors" "sort" + "strconv" "strings" "time" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/opagg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.uber.org/zap" ) +const ( + attrAggregatedByRecipient = "orchestrator.v2.aggregated_by_recipient" + attrAggregatedItems = "orchestrator.v2.aggregated_items" +) + func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (resp *orchestrationv2.ExecuteBatchPaymentResponse, err error) { logger := s.logger orgRef := "" @@ -72,21 +81,19 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec if err != nil { return nil, err } - - payments := make([]*agg.Payment, 0, len(aggOutput.Groups)) - for _, group := range aggOutput.Groups { - payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group) - if err != nil { - return nil, err - } - payments = append(payments, payment) - } - - protoPayments, err := s.mapPayments(payments) + group, err := s.buildBatchOperationGroup(aggOutput.Groups) if err != nil { return nil, err } - return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: protoPayments}, nil + payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group) + if err != nil { + return nil, err + } + protoPayment, err := s.mapPayment(payment) + if err != nil { + return nil, err + } + return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: []*orchestrationv2.Payment{protoPayment}}, nil } func (s *svc) prepareBatchExecute(req *orchestrationv2.ExecuteBatchPaymentRequest) (*reqval.Ctx, error) { @@ -260,3 +267,120 @@ func normalizeIntentRefs(values []string) []string { } return out } + +func (s *svc) buildBatchOperationGroup(groups []opagg.Group) (opagg.Group, error) { + if len(groups) == 0 { + return opagg.Group{}, merrors.InvalidArgument("aggregation produced no groups") + } + + anchorDestination := groups[0].IntentSnapshot.Destination + synthetic := make([]opagg.Item, 0, len(groups)) + allIntentRefs := make([]string, 0, len(groups)) + for i := range groups { + group := groups[i] + intent := group.IntentSnapshot + intent.Destination = anchorDestination + synthetic = append(synthetic, opagg.Item{ + IntentRef: strings.Join(normalizeIntentRefs(group.IntentRefs), ","), + IntentSnapshot: intent, + QuoteSnapshot: group.QuoteSnapshot, + }) + allIntentRefs = append(allIntentRefs, group.IntentRefs...) + } + + collapsed, err := s.aggregator.Aggregate(opagg.Input{Items: synthetic}) + if err != nil { + return opagg.Group{}, err + } + if collapsed == nil || len(collapsed.Groups) != 1 { + return opagg.Group{}, merrors.InvalidArgument("batch quotation contains incompatible operation groups") + } + + out := collapsed.Groups[0] + out.IntentRefs = normalizeIntentRefs(allIntentRefs) + if len(out.IntentRefs) == 0 { + return opagg.Group{}, merrors.InvalidArgument("aggregated group has no intent refs") + } + if out.IntentSnapshot.Attributes == nil { + out.IntentSnapshot.Attributes = map[string]string{} + } + if len(groups) > 1 { + out.IntentSnapshot.Attributes[attrAggregatedByRecipient] = "true" + } + out.IntentSnapshot.Attributes[attrAggregatedItems] = strconv.Itoa(len(out.IntentRefs)) + + targets := buildBatchPayoutTargets(groups) + if routeContainsCardPayout(out.QuoteSnapshot) && len(targets) > 0 { + raw, err := batchmeta.EncodePayoutTargets(targets) + if err != nil { + return opagg.Group{}, err + } + if strings.TrimSpace(raw) != "" { + out.IntentSnapshot.Attributes[batchmeta.AttrPayoutTargets] = raw + } + } + return out, nil +} + +func buildBatchPayoutTargets(groups []opagg.Group) []batchmeta.PayoutTarget { + if len(groups) == 0 { + return nil + } + out := make([]batchmeta.PayoutTarget, 0, len(groups)) + for i := range groups { + group := groups[i] + target := batchmeta.PayoutTarget{ + TargetRef: firstNonEmpty(strings.TrimSpace(group.RecipientKey), "recipient-"+strconv.Itoa(i+1)), + IntentRef: normalizeIntentRefs(group.IntentRefs), + Amount: batchPayoutAmount(group), + } + if group.IntentSnapshot.Destination.Type == model.EndpointTypeCard && group.IntentSnapshot.Destination.Card != nil { + card := *group.IntentSnapshot.Destination.Card + target.Card = &card + } + if group.IntentSnapshot.Customer != nil { + customer := *group.IntentSnapshot.Customer + target.Customer = &customer + } + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func batchPayoutAmount(group opagg.Group) *paymenttypes.Money { + if group.QuoteSnapshot != nil && group.QuoteSnapshot.ExpectedSettlementAmount != nil { + return &paymenttypes.Money{ + Amount: strings.TrimSpace(group.QuoteSnapshot.ExpectedSettlementAmount.Amount), + Currency: strings.TrimSpace(group.QuoteSnapshot.ExpectedSettlementAmount.Currency), + } + } + if group.IntentSnapshot.Amount == nil { + return nil + } + return &paymenttypes.Money{ + Amount: strings.TrimSpace(group.IntentSnapshot.Amount.Amount), + Currency: strings.TrimSpace(group.IntentSnapshot.Amount.Currency), + } +} + +func routeContainsCardPayout(snapshot *model.PaymentQuoteSnapshot) bool { + if snapshot == nil || snapshot.Route == nil { + return false + } + for i := range snapshot.Route.Hops { + hop := snapshot.Route.Hops[i] + if hop == nil { + continue + } + if model.ParseRail(hop.Rail) == model.RailCardPayout { + return true + } + } + if model.ParseRail(snapshot.Route.Rail) == model.RailCardPayout { + return true + } + return false +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go index 70abf825..4be95db9 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go @@ -2,10 +2,12 @@ package psvc import ( "context" + "sort" "testing" "time" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/storage/model" pm "github.com/tech/sendico/pkg/model" @@ -43,7 +45,7 @@ func TestExecuteBatchPayment_SameDestinationMerges(t *testing.T) { } } -func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) { +func TestExecuteBatchPayment_DifferentDestinationsCompactsIntoSinglePayment(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution step.State = agg.StepStateCompleted @@ -61,8 +63,8 @@ func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) if err != nil { t.Fatalf("ExecuteBatchPayment returned error: %v", err) } - if got, want := len(resp.GetPayments()), 2; got != want { - t.Fatalf("expected %d payments for different destinations, got=%d", want, got) + if got, want := len(resp.GetPayments()), 1; got != want { + t.Fatalf("expected %d payment for batched execution, got=%d", want, got) } for i, p := range resp.GetPayments() { if got, want := p.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { @@ -71,6 +73,55 @@ func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) } } +func TestExecuteBatchPayment_CardBatchCreatesPerTargetPayoutStepsInSinglePayment(t *testing.T) { + var ( + targetRefs []string + amounts []string + ) + env := newTestEnv(t, func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if kind == "card_payout" { + targetRefs = append(targetRefs, req.Step.Metadata[batchmeta.MetaPayoutTargetRef]) + amounts = append(amounts, req.Step.Metadata[batchmeta.MetaAmount]) + } + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + quote := newExecutableBatchCardQuoteDiffDest(env.orgID, "quote-batch-card-diff") + env.quotes.Put(quote) + + resp, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-batch-card-diff"), + QuotationRef: "quote-batch-card-diff", + ClientPaymentRef: "client-batch-card-diff", + }) + if err != nil { + t.Fatalf("ExecuteBatchPayment returned error: %v", err) + } + if got, want := len(resp.GetPayments()), 1; got != want { + t.Fatalf("expected %d payment for card batch, got=%d", want, got) + } + if got, want := resp.GetPayments()[0].GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } + if got, want := len(targetRefs), 2; got != want { + t.Fatalf("expected %d card payout send calls, got=%d", want, got) + } + if got, want := len(amounts), 2; got != want { + t.Fatalf("expected %d card payout amounts, got=%d", want, got) + } + + sort.Strings(targetRefs) + sort.Strings(amounts) + if targetRefs[0] == targetRefs[1] { + t.Fatalf("expected distinct target refs, got=%v", targetRefs) + } + if got, want := amounts, []string{"100", "150"}; got[0] != want[0] || got[1] != want[1] { + t.Fatalf("amount overrides mismatch: got=%v want=%v", got, want) + } +} + func TestExecuteBatchPayment_IdempotentRetry(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution @@ -183,3 +234,76 @@ func newExecutableBatchQuoteDiffDest(orgRef bson.ObjectID, quoteRef string) *mod ExpiresAt: now.Add(1 * time.Hour), } } + +func newExecutableBatchCardQuoteDiffDest(orgRef bson.ObjectID, quoteRef string) *model.PaymentQuoteRecord { + now := time.Now().UTC() + return &model.PaymentQuoteRecord{ + Base: modelBase(now), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + QuoteRef: quoteRef, + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{ + Ref: "intent-a", + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860161", + ExpMonth: 3, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{Amount: "100", Currency: "RUB"}, + SettlementCurrency: "RUB", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "1.2", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "100", Currency: "RUB"}, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{ + Ref: "intent-b", + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860162", + ExpMonth: 4, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{Amount: "150", Currency: "RUB"}, + SettlementCurrency: "RUB", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "1.8", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "150", Currency: "RUB"}, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + ExpiresAt: now.Add(1 * time.Hour), + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go new file mode 100644 index 00000000..f00a8daa --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go @@ -0,0 +1,90 @@ +package xplan + +import ( + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestCompile_InternalToCard_WithBatchTargetsExpandsPerRecipientPayoutBranches(t *testing.T) { + compiler := New() + + targetsRaw, err := batchmeta.EncodePayoutTargets([]batchmeta.PayoutTarget{ + { + TargetRef: "recipient-1", + Amount: &paymenttypes.Money{Amount: "100", Currency: "RUB"}, + Card: &model.CardEndpoint{Pan: "2200700142860161", ExpMonth: 3, ExpYear: 2030}, + }, + { + TargetRef: "recipient-2", + Amount: &paymenttypes.Money{Amount: "150", Currency: "RUB"}, + Card: &model.CardEndpoint{Pan: "2200700142860162", ExpMonth: 4, ExpYear: 2030}, + }, + }) + if err != nil { + t.Fatalf("EncodePayoutTargets returned error: %v", err) + } + + intent := testIntent(model.PaymentKindPayout) + intent.Attributes = map[string]string{ + batchmeta.AttrPayoutTargets: targetsRaw, + } + + graph, err := compiler.Compile(Input{ + IntentSnapshot: intent, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + if graph == nil { + t.Fatal("expected graph") + } + if got, want := len(graph.Steps), 11; got != want { + t.Fatalf("steps count mismatch: got=%d want=%d", got, want) + } + if got, want := graph.Steps[0].StepCode, "edge.10_20.ledger.block"; got != want { + t.Fatalf("first step mismatch: got=%q want=%q", got, want) + } + + sendSteps := make([]Step, 0, 2) + for i := range graph.Steps { + step := graph.Steps[i] + if step.StepCode != "hop.20.card_payout.send" { + continue + } + sendSteps = append(sendSteps, step) + } + if got, want := len(sendSteps), 2; got != want { + t.Fatalf("send steps mismatch: got=%d want=%d", got, want) + } + + seenTargets := map[string]string{} + for i := range sendSteps { + step := sendSteps[i] + if got, want := step.DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) { + t.Fatalf("send step deps mismatch: got=%v want=%v", got, want) + } + targetRef := step.Metadata[batchmeta.MetaPayoutTargetRef] + amount := step.Metadata[batchmeta.MetaAmount] + if targetRef == "" || amount == "" { + t.Fatalf("expected target metadata on send step, got=%v", step.Metadata) + } + seenTargets[targetRef] = amount + } + if got, want := seenTargets["recipient-1"], "100"; got != want { + t.Fatalf("recipient-1 amount mismatch: got=%q want=%q", got, want) + } + if got, want := seenTargets["recipient-2"], "150"; got != want { + t.Fatalf("recipient-2 amount mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index bc2221b5..27543f36 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -3,6 +3,7 @@ package xplan import ( "strings" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" @@ -65,6 +66,13 @@ func (s *svc) applyDefaultBoundary( case isInternalRail(from.rail) && isExternalRail(to.rail): internalRail := internalRailForBoundary(from, to) + targets, err := payoutTargetsFromIntent(intent) + if err != nil { + return err + } + if to.rail == model.RailCardPayout && len(targets) > 0 { + return s.applyBatchCardPayoutBoundary(ex, from, to, internalRail, intent, targets) + } ex.appendMain(makeFundsBlockStep(from, to, internalRail)) observeRef, err := s.ensureExternalObserved(ex, to, intent) if err != nil { @@ -96,6 +104,40 @@ func (s *svc) applyDefaultBoundary( } } +func (s *svc) applyBatchCardPayoutBoundary( + ex *expansion, + from normalizedHop, + to normalizedHop, + internalRail model.Rail, + intent model.PaymentIntent, + targets []batchmeta.PayoutTarget, +) error { + blockRef := ex.appendMain(makeFundsBlockStep(from, to, internalRail)) + for i := range targets { + target := targets[i] + if target.Amount == nil { + return merrors.InvalidArgument("intent_snapshot.attributes[" + batchmeta.AttrPayoutTargets + "]: amount is required") + } + if len(targets) > 1 && target.Card == nil { + return merrors.InvalidArgument("intent_snapshot.attributes[" + batchmeta.AttrPayoutTargets + "]: card is required for each target") + } + targetMeta := batchmeta.StepMetadataForTarget(target, i) + + sendStep := makeRailSendStep(to, intent) + sendStep.DependsOn = []string{blockRef} + sendStep.Metadata = cloneMetadata(targetMeta) + sendRef := ex.appendBranch(sendStep) + + observeStep := makeRailObserveStep(to, intent) + observeStep.DependsOn = []string{sendRef} + observeStep.Metadata = cloneMetadata(targetMeta) + observeRef := ex.appendBranch(observeStep) + + appendSettlementBranchesWithMetadata(ex, from, to, internalRail, sendRef, observeRef, targetMeta) + } + return nil +} + func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) { key := observedKey(hop) if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { @@ -252,7 +294,21 @@ func appendSettlementBranches( break } } + appendSettlementBranchesWithMetadata(ex, from, to, rail, anchorSendRef, anchorObserveRef, nil) +} +func appendSettlementBranchesWithMetadata( + ex *expansion, + from normalizedHop, + to normalizedHop, + rail model.Rail, + anchorSendRef string, + anchorObserveRef string, + metadata map[string]string, +) { + if strings.TrimSpace(anchorObserveRef) == "" { + return + } successStep := Step{ StepCode: edgeCode(from, to, rail, "debit"), Kind: StepKindFundsDebit, @@ -264,7 +320,7 @@ func appendSettlementBranches( Visibility: model.ReportVisibilityHidden, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "finalize_debit"}, + Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "finalize_debit"}), } ex.appendBranch(successStep) @@ -280,7 +336,7 @@ func appendSettlementBranches( Visibility: model.ReportVisibilityHidden, CommitPolicy: model.CommitPolicyAfterFailure, CommitAfter: []string{anchorSendRef}, - Metadata: map[string]string{"mode": "unlock_hold"}, + Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "unlock_hold"}), } ex.appendBranch(sendFailureStep) } @@ -296,7 +352,47 @@ func appendSettlementBranches( Visibility: model.ReportVisibilityHidden, CommitPolicy: model.CommitPolicyAfterFailure, CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "unlock_hold"}, + Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "unlock_hold"}), } ex.appendBranch(failureStep) } + +func mergeStepMetadata(left, right map[string]string) map[string]string { + if len(left) == 0 && len(right) == 0 { + return nil + } + out := cloneMetadata(left) + if out == nil { + out = map[string]string{} + } + for key, value := range right { + k := strings.TrimSpace(key) + if k == "" { + continue + } + v := strings.TrimSpace(value) + if v == "" { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + +func payoutTargetsFromIntent(intent model.PaymentIntent) ([]batchmeta.PayoutTarget, error) { + if len(intent.Attributes) == 0 { + return nil, nil + } + raw := strings.TrimSpace(intent.Attributes[batchmeta.AttrPayoutTargets]) + if raw == "" { + return nil, nil + } + targets, err := batchmeta.DecodePayoutTargets(raw) + if err != nil { + return nil, merrors.InvalidArgument("intent_snapshot.attributes[" + batchmeta.AttrPayoutTargets + "] is invalid") + } + return targets, nil +} 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 5315039c..67c2948f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go @@ -8,6 +8,7 @@ import ( "github.com/shopspring/decimal" mntxclient "github.com/tech/sendico/gateway/mntx/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -45,11 +46,11 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s return nil, merrors.InvalidArgument("card payout send: unsupported action") } - card, err := payoutDestinationCard(req.Payment) + card, err := payoutDestinationCard(req.Payment, req.Step.Metadata) if err != nil { return nil, err } - amountMinor, currency, err := cardPayoutAmountMinor(req.Payment) + amountMinor, currency, err := cardPayoutAmountMinor(req.Payment, req.Step.Metadata) if err != nil { return nil, err } @@ -59,7 +60,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s payoutRef := cardPayoutRef(req.Payment, stepToken) idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) projectID := cardPayoutProjectID(req.Payment) - customer := cardPayoutCustomerFromPayment(req.Payment, card) + customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata) cardHolder := cardPayoutCardholder(card, customer) metadata := cardPayoutMetadata(req.Payment, req.Step) intentRef := strings.TrimSpace(req.Payment.IntentSnapshot.Ref) @@ -153,7 +154,10 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s return &sexec.ExecuteOutput{StepExecution: step}, nil } -func payoutDestinationCard(payment *agg.Payment) (*model.CardEndpoint, error) { +func payoutDestinationCard(payment *agg.Payment, metadata map[string]string) (*model.CardEndpoint, error) { + if card, ok := batchmeta.CardFromMetadata(metadata); ok && card != nil { + return card, nil + } if payment == nil { return nil, merrors.InvalidArgument("card payout send: payment is required") } @@ -164,7 +168,10 @@ func payoutDestinationCard(payment *agg.Payment) (*model.CardEndpoint, error) { return destination.Card, nil } -func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money { +func cardPayoutMoney(payment *agg.Payment, metadata map[string]string) *paymenttypes.Money { + if override, ok := batchmeta.AmountFromMetadata(metadata); ok && override != nil { + return override + } if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil { return payment.QuoteSnapshot.ExpectedSettlementAmount } @@ -174,8 +181,8 @@ func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money { return payment.IntentSnapshot.Amount } -func cardPayoutAmountMinor(payment *agg.Payment) (int64, string, error) { - money := cardPayoutMoney(payment) +func cardPayoutAmountMinor(payment *agg.Payment, metadata map[string]string) (int64, string, error) { + money := cardPayoutMoney(payment, metadata) if money == nil { return 0, "", merrors.InvalidArgument("card payout send: payout amount is required") } @@ -261,7 +268,7 @@ func cardPayoutProjectID(payment *agg.Payment) int64 { return value } -func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoint) cardPayoutCustomer { +func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoint, stepMetadata map[string]string) cardPayoutCustomer { customer := cardPayoutCustomer{} if payment == nil { return customer @@ -275,7 +282,7 @@ func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoin cardholderSurname = strings.TrimSpace(card.CardholderSurname) cardCountry = strings.ToUpper(strings.TrimSpace(card.Country)) } - attrs := payment.IntentSnapshot.Attributes + attrs := mergeStringMaps(payment.IntentSnapshot.Attributes, stepMetadata) intentCustomer := payment.IntentSnapshot.Customer if intentCustomer != nil { customer.id = strings.TrimSpace(intentCustomer.ID) @@ -289,6 +296,38 @@ func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoin customer.city = strings.TrimSpace(intentCustomer.City) customer.address = strings.TrimSpace(intentCustomer.Address) } + if override, ok := batchmeta.CustomerFromMetadata(stepMetadata); ok && override != nil { + if customer.id == "" { + customer.id = strings.TrimSpace(override.ID) + } + if customer.firstName == "" { + customer.firstName = strings.TrimSpace(override.FirstName) + } + if customer.middleName == "" { + customer.middleName = strings.TrimSpace(override.MiddleName) + } + if customer.lastName == "" { + customer.lastName = strings.TrimSpace(override.LastName) + } + if customer.ip == "" { + customer.ip = strings.TrimSpace(override.IP) + } + if customer.zip == "" { + customer.zip = strings.TrimSpace(override.Zip) + } + if customer.country == "" { + customer.country = strings.ToUpper(strings.TrimSpace(override.Country)) + } + if customer.state == "" { + customer.state = strings.TrimSpace(override.State) + } + if customer.city == "" { + customer.city = strings.TrimSpace(override.City) + } + if customer.address == "" { + customer.address = strings.TrimSpace(override.Address) + } + } customer.id = firstNonEmpty(customer.id, cardPayoutAttribute(attrs, "customer_id", "customerId", "initiator_ref", "initiatorRef"), @@ -382,6 +421,37 @@ func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" { out[settlementMetadataOutgoingLeg] = outgoingLeg } + if targetRef := strings.TrimSpace(step.Metadata[batchmeta.MetaPayoutTargetRef]); targetRef != "" { + out[batchmeta.MetaPayoutTargetRef] = targetRef + } + if targetIndex := strings.TrimSpace(step.Metadata[batchmeta.MetaPayoutTargetIndex]); targetIndex != "" { + out[batchmeta.MetaPayoutTargetIndex] = targetIndex + } + if len(out) == 0 { + return nil + } + return out +} + +func mergeStringMaps(left, right map[string]string) map[string]string { + if len(left) == 0 && len(right) == 0 { + return nil + } + out := map[string]string{} + for key, value := range left { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + for key, value := range right { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } if len(out) == 0 { return nil } 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 8bfce504..d7bc7899 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 @@ -7,6 +7,7 @@ import ( mntxclient "github.com/tech/sendico/gateway/mntx/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -140,6 +141,99 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin } } +func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesStepMetadataOverrides(t *testing.T) { + orgID := bson.NewObjectID() + + var payoutReq *mntxv1.CardPayoutRequest + executor := &gatewayCardPayoutExecutor{ + mntxClient: &mntxclient.Fake{ + CreateCardPayoutFn: func(_ context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + payoutReq = req + return &mntxv1.CardPayoutResponse{ + Payout: &mntxv1.CardPayoutState{ + PayoutId: "payout-remote-2", + }, + }, nil + }, + }, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + IdempotencyKey: "idem-2", + QuotationRef: "quote-2", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-2", + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860999", + ExpMonth: 1, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{ + Amount: "1.000000", + Currency: "USDT", + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: "76.50", + Currency: "RUB", + }, + QuoteRef: "quote-2", + }, + }, + Step: xplan.Step{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Action: model.RailOperationSend, + Rail: model.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", + StepCode: "hop.4.card_payout.send", + Attempt: 1, + }, + } + + out, err := executor.ExecuteCardPayout(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteCardPayout returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if payoutReq == nil { + t.Fatal("expected payout request to be submitted") + } + if got, want := payoutReq.GetAmountMinor(), int64(15000); got != want { + t.Fatalf("amount_minor mismatch: got=%d want=%d", got, want) + } + if got, want := payoutReq.GetCurrency(), "RUB"; got != want { + t.Fatalf("currency 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) + } +} + func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresMntxClient(t *testing.T) { orgID := bson.NewObjectID() diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go index f511c74e..1fd8ce43 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go @@ -7,6 +7,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -123,6 +124,9 @@ func ledgerAmountForStep( step xplan.Step, action model.RailOperation, ) (*moneyv1.Money, error) { + if override, ok := batchmeta.AmountFromMetadata(step.Metadata); ok && override != nil { + return protoMoneyRequired(override, "ledger step: override amount is invalid") + } sourceMoney := sourceMoneyForLedger(payment) settlementMoney := settlementMoneyForLedger(payment, sourceMoney) payoutMoney := payoutMoneyForLedger(payment, settlementMoney) diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index 87d12a35..8df29768 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -7,6 +7,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -380,6 +381,52 @@ func TestGatewayLedgerExecutor_ExecuteLedger_ReleaseUsesHoldToOperatingAndPayout } } +func TestGatewayLedgerExecutor_ExecuteLedger_UsesStepAmountOverride(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-override"}, nil + }, + }, + } + + _, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_block", + StepCode: "edge.3_4.ledger.block", + Action: model.RailOperationBlock, + Rail: model.RailLedger, + Metadata: map[string]string{ + batchmeta.MetaAmount: "40", + batchmeta.MetaCurrency: "RUB", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_block", + StepCode: "edge.3_4.ledger.block", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetMoney().GetAmount(), "40"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } +} + func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(t *testing.T) { orgID := bson.NewObjectID() payment := testLedgerExecutorPayment(orgID)