Orchestration / payments v2 #554
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user