smarter optimizer for batch payments

This commit is contained in:
Stephan D
2026-02-26 23:15:48 +01:00
parent 947cd7f4c9
commit fa9e6f47cf
9 changed files with 972 additions and 27 deletions

View File

@@ -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
}

View File

@@ -6,21 +6,30 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "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/idem"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/opagg" "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/prepo"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" "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/reqval"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"go.uber.org/zap" "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) { func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (resp *orchestrationv2.ExecuteBatchPaymentResponse, err error) {
logger := s.logger logger := s.logger
orgRef := "" orgRef := ""
@@ -72,21 +81,19 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec
if err != nil { if err != nil {
return nil, err return nil, err
} }
group, err := s.buildBatchOperationGroup(aggOutput.Groups)
payments := make([]*agg.Payment, 0, len(aggOutput.Groups)) if err != nil {
for _, group := range aggOutput.Groups { return nil, err
}
payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group) payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group)
if err != nil { if err != nil {
return nil, err return nil, err
} }
payments = append(payments, payment) protoPayment, err := s.mapPayment(payment)
}
protoPayments, err := s.mapPayments(payments)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: protoPayments}, nil return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: []*orchestrationv2.Payment{protoPayment}}, nil
} }
func (s *svc) prepareBatchExecute(req *orchestrationv2.ExecuteBatchPaymentRequest) (*reqval.Ctx, error) { func (s *svc) prepareBatchExecute(req *orchestrationv2.ExecuteBatchPaymentRequest) (*reqval.Ctx, error) {
@@ -260,3 +267,120 @@ func normalizeIntentRefs(values []string) []string {
} }
return out 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
}

View File

@@ -2,10 +2,12 @@ package psvc
import ( import (
"context" "context"
"sort"
"testing" "testing"
"time" "time"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "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/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
pm "github.com/tech/sendico/pkg/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) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
step := req.StepExecution step := req.StepExecution
step.State = agg.StepStateCompleted step.State = agg.StepStateCompleted
@@ -61,8 +63,8 @@ func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("ExecuteBatchPayment returned error: %v", err) t.Fatalf("ExecuteBatchPayment returned error: %v", err)
} }
if got, want := len(resp.GetPayments()), 2; got != want { if got, want := len(resp.GetPayments()), 1; got != want {
t.Fatalf("expected %d payments for different destinations, got=%d", want, got) t.Fatalf("expected %d payment for batched execution, got=%d", want, got)
} }
for i, p := range resp.GetPayments() { for i, p := range resp.GetPayments() {
if got, want := p.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { 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) { func TestExecuteBatchPayment_IdempotentRetry(t *testing.T) {
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
step := req.StepExecution step := req.StepExecution
@@ -183,3 +234,76 @@ func newExecutableBatchQuoteDiffDest(orgRef bson.ObjectID, quoteRef string) *mod
ExpiresAt: now.Add(1 * time.Hour), 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),
}
}

View File

@@ -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)
}
}

View File

@@ -3,6 +3,7 @@ package xplan
import ( import (
"strings" "strings"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
@@ -65,6 +66,13 @@ func (s *svc) applyDefaultBoundary(
case isInternalRail(from.rail) && isExternalRail(to.rail): case isInternalRail(from.rail) && isExternalRail(to.rail):
internalRail := internalRailForBoundary(from, to) 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)) ex.appendMain(makeFundsBlockStep(from, to, internalRail))
observeRef, err := s.ensureExternalObserved(ex, to, intent) observeRef, err := s.ensureExternalObserved(ex, to, intent)
if err != nil { 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) { func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) {
key := observedKey(hop) key := observedKey(hop)
if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" {
@@ -252,7 +294,21 @@ func appendSettlementBranches(
break 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{ successStep := Step{
StepCode: edgeCode(from, to, rail, "debit"), StepCode: edgeCode(from, to, rail, "debit"),
Kind: StepKindFundsDebit, Kind: StepKindFundsDebit,
@@ -264,7 +320,7 @@ func appendSettlementBranches(
Visibility: model.ReportVisibilityHidden, Visibility: model.ReportVisibilityHidden,
CommitPolicy: model.CommitPolicyAfterSuccess, CommitPolicy: model.CommitPolicyAfterSuccess,
CommitAfter: []string{anchorObserveRef}, CommitAfter: []string{anchorObserveRef},
Metadata: map[string]string{"mode": "finalize_debit"}, Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "finalize_debit"}),
} }
ex.appendBranch(successStep) ex.appendBranch(successStep)
@@ -280,7 +336,7 @@ func appendSettlementBranches(
Visibility: model.ReportVisibilityHidden, Visibility: model.ReportVisibilityHidden,
CommitPolicy: model.CommitPolicyAfterFailure, CommitPolicy: model.CommitPolicyAfterFailure,
CommitAfter: []string{anchorSendRef}, CommitAfter: []string{anchorSendRef},
Metadata: map[string]string{"mode": "unlock_hold"}, Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "unlock_hold"}),
} }
ex.appendBranch(sendFailureStep) ex.appendBranch(sendFailureStep)
} }
@@ -296,7 +352,47 @@ func appendSettlementBranches(
Visibility: model.ReportVisibilityHidden, Visibility: model.ReportVisibilityHidden,
CommitPolicy: model.CommitPolicyAfterFailure, CommitPolicy: model.CommitPolicyAfterFailure,
CommitAfter: []string{anchorObserveRef}, CommitAfter: []string{anchorObserveRef},
Metadata: map[string]string{"mode": "unlock_hold"}, Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "unlock_hold"}),
} }
ex.appendBranch(failureStep) 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
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
mntxclient "github.com/tech/sendico/gateway/mntx/client" 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/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/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "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") 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 { if err != nil {
return nil, err return nil, err
} }
amountMinor, currency, err := cardPayoutAmountMinor(req.Payment) amountMinor, currency, err := cardPayoutAmountMinor(req.Payment, req.Step.Metadata)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +60,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
payoutRef := cardPayoutRef(req.Payment, stepToken) payoutRef := cardPayoutRef(req.Payment, stepToken)
idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken)
projectID := cardPayoutProjectID(req.Payment) projectID := cardPayoutProjectID(req.Payment)
customer := cardPayoutCustomerFromPayment(req.Payment, card) customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata)
cardHolder := cardPayoutCardholder(card, customer) cardHolder := cardPayoutCardholder(card, customer)
metadata := cardPayoutMetadata(req.Payment, req.Step) metadata := cardPayoutMetadata(req.Payment, req.Step)
intentRef := strings.TrimSpace(req.Payment.IntentSnapshot.Ref) 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 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 { if payment == nil {
return nil, merrors.InvalidArgument("card payout send: payment is required") 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 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 { if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil {
return payment.QuoteSnapshot.ExpectedSettlementAmount return payment.QuoteSnapshot.ExpectedSettlementAmount
} }
@@ -174,8 +181,8 @@ func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money {
return payment.IntentSnapshot.Amount return payment.IntentSnapshot.Amount
} }
func cardPayoutAmountMinor(payment *agg.Payment) (int64, string, error) { func cardPayoutAmountMinor(payment *agg.Payment, metadata map[string]string) (int64, string, error) {
money := cardPayoutMoney(payment) money := cardPayoutMoney(payment, metadata)
if money == nil { if money == nil {
return 0, "", merrors.InvalidArgument("card payout send: payout amount is required") return 0, "", merrors.InvalidArgument("card payout send: payout amount is required")
} }
@@ -261,7 +268,7 @@ func cardPayoutProjectID(payment *agg.Payment) int64 {
return value 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{} customer := cardPayoutCustomer{}
if payment == nil { if payment == nil {
return customer return customer
@@ -275,7 +282,7 @@ func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoin
cardholderSurname = strings.TrimSpace(card.CardholderSurname) cardholderSurname = strings.TrimSpace(card.CardholderSurname)
cardCountry = strings.ToUpper(strings.TrimSpace(card.Country)) cardCountry = strings.ToUpper(strings.TrimSpace(card.Country))
} }
attrs := payment.IntentSnapshot.Attributes attrs := mergeStringMaps(payment.IntentSnapshot.Attributes, stepMetadata)
intentCustomer := payment.IntentSnapshot.Customer intentCustomer := payment.IntentSnapshot.Customer
if intentCustomer != nil { if intentCustomer != nil {
customer.id = strings.TrimSpace(intentCustomer.ID) 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.city = strings.TrimSpace(intentCustomer.City)
customer.address = strings.TrimSpace(intentCustomer.Address) 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, customer.id = firstNonEmpty(customer.id,
cardPayoutAttribute(attrs, "customer_id", "customerId", "initiator_ref", "initiatorRef"), 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 != "" { if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" {
out[settlementMetadataOutgoingLeg] = 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 { if len(out) == 0 {
return nil return nil
} }

View File

@@ -7,6 +7,7 @@ import (
mntxclient "github.com/tech/sendico/gateway/mntx/client" 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/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/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "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) { func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresMntxClient(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()

View File

@@ -7,6 +7,7 @@ import (
ledgerclient "github.com/tech/sendico/ledger/client" 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/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/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
@@ -123,6 +124,9 @@ func ledgerAmountForStep(
step xplan.Step, step xplan.Step,
action model.RailOperation, action model.RailOperation,
) (*moneyv1.Money, error) { ) (*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) sourceMoney := sourceMoneyForLedger(payment)
settlementMoney := settlementMoneyForLedger(payment, sourceMoney) settlementMoney := settlementMoneyForLedger(payment, sourceMoney)
payoutMoney := payoutMoneyForLedger(payment, settlementMoney) payoutMoney := payoutMoneyForLedger(payment, settlementMoney)

View File

@@ -7,6 +7,7 @@ import (
ledgerclient "github.com/tech/sendico/ledger/client" 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/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/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "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) { func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID) payment := testLedgerExecutorPayment(orgID)