+ledger ops
This commit is contained in:
@@ -36,7 +36,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
|
||||
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
|
||||
@@ -51,7 +51,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
|
||||
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/erecon"
|
||||
"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/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
type gatewayCardPayoutExecutor struct {
|
||||
mntxClient mntxclient.Client
|
||||
}
|
||||
|
||||
type cardPayoutCustomer struct {
|
||||
id string
|
||||
firstName string
|
||||
middleName string
|
||||
lastName string
|
||||
ip string
|
||||
zip string
|
||||
country string
|
||||
state string
|
||||
city string
|
||||
address string
|
||||
}
|
||||
|
||||
func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
if req.Payment == nil {
|
||||
return nil, merrors.InvalidArgument("card payout send: payment is required")
|
||||
}
|
||||
if e == nil || e.mntxClient == nil {
|
||||
return nil, merrors.InvalidArgument("card payout send: mntx client is required")
|
||||
}
|
||||
if model.ParseRailOperation(string(req.Step.Action)) != model.RailOperationSend {
|
||||
return nil, merrors.InvalidArgument("card payout send: unsupported action")
|
||||
}
|
||||
|
||||
card, err := payoutDestinationCard(req.Payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amountMinor, currency, err := cardPayoutAmountMinor(req.Payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stepToken := cardPayoutStepToken(req.Step)
|
||||
operationRef := cardPayoutOperationRef(req.Payment, stepToken)
|
||||
payoutRef := cardPayoutRef(req.Payment, stepToken)
|
||||
idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken)
|
||||
projectID := cardPayoutProjectID(req.Payment)
|
||||
customer := cardPayoutCustomerFromPayment(req.Payment, card)
|
||||
cardHolder := cardPayoutCardholder(card, customer)
|
||||
metadata := cardPayoutMetadata(req.Payment, req.Step)
|
||||
intentRef := strings.TrimSpace(req.Payment.IntentSnapshot.Ref)
|
||||
|
||||
var responsePayout *mntxv1.CardPayoutState
|
||||
if token := strings.TrimSpace(card.Token); token != "" {
|
||||
resp, createErr := e.mntxClient.CreateCardTokenPayout(ctx, &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: payoutRef,
|
||||
ProjectId: projectID,
|
||||
CustomerId: customer.id,
|
||||
CustomerFirstName: customer.firstName,
|
||||
CustomerMiddleName: customer.middleName,
|
||||
CustomerLastName: customer.lastName,
|
||||
CustomerIp: customer.ip,
|
||||
CustomerZip: customer.zip,
|
||||
CustomerCountry: customer.country,
|
||||
CustomerState: customer.state,
|
||||
CustomerCity: customer.city,
|
||||
CustomerAddress: customer.address,
|
||||
AmountMinor: amountMinor,
|
||||
Currency: currency,
|
||||
CardToken: token,
|
||||
CardHolder: cardHolder,
|
||||
MaskedPan: strings.TrimSpace(card.MaskedPan),
|
||||
Metadata: metadata,
|
||||
OperationRef: operationRef,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
IntentRef: intentRef,
|
||||
})
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, merrors.Internal("card payout send: card token payout response is missing")
|
||||
}
|
||||
responsePayout = resp.GetPayout()
|
||||
} else {
|
||||
pan := strings.TrimSpace(card.Pan)
|
||||
if pan == "" {
|
||||
return nil, merrors.InvalidArgument("card payout send: card pan is required")
|
||||
}
|
||||
if card.ExpMonth == 0 || card.ExpYear == 0 {
|
||||
return nil, merrors.InvalidArgument("card payout send: card expiry is required")
|
||||
}
|
||||
resp, createErr := e.mntxClient.CreateCardPayout(ctx, &mntxv1.CardPayoutRequest{
|
||||
PayoutId: payoutRef,
|
||||
ProjectId: projectID,
|
||||
CustomerId: customer.id,
|
||||
CustomerFirstName: customer.firstName,
|
||||
CustomerMiddleName: customer.middleName,
|
||||
CustomerLastName: customer.lastName,
|
||||
CustomerIp: customer.ip,
|
||||
CustomerZip: customer.zip,
|
||||
CustomerCountry: customer.country,
|
||||
CustomerState: customer.state,
|
||||
CustomerCity: customer.city,
|
||||
CustomerAddress: customer.address,
|
||||
AmountMinor: amountMinor,
|
||||
Currency: currency,
|
||||
CardPan: pan,
|
||||
CardExpYear: card.ExpYear,
|
||||
CardExpMonth: card.ExpMonth,
|
||||
CardHolder: cardHolder,
|
||||
Metadata: metadata,
|
||||
OperationRef: operationRef,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
IntentRef: intentRef,
|
||||
})
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, merrors.Internal("card payout send: card payout response is missing")
|
||||
}
|
||||
responsePayout = resp.GetPayout()
|
||||
}
|
||||
|
||||
resolvedPayoutRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetPayoutId()), payoutRef)
|
||||
resolvedOperationRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetOperationRef()), operationRef)
|
||||
gatewayInstanceID := firstNonEmpty(strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(req.Step.Gateway))
|
||||
externalRefs, refsErr := cardPayoutExternalRefs(resolvedPayoutRef, resolvedOperationRef, gatewayInstanceID)
|
||||
if refsErr != nil {
|
||||
return nil, refsErr
|
||||
}
|
||||
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
step.ExternalRefs = externalRefs
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
}
|
||||
|
||||
func payoutDestinationCard(payment *agg.Payment) (*model.CardEndpoint, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("card payout send: payment is required")
|
||||
}
|
||||
destination := payment.IntentSnapshot.Destination
|
||||
if destination.Type != model.EndpointTypeCard || destination.Card == nil {
|
||||
return nil, merrors.InvalidArgument("card payout send: destination card is required")
|
||||
}
|
||||
return destination.Card, nil
|
||||
}
|
||||
|
||||
func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money {
|
||||
if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil {
|
||||
return payment.QuoteSnapshot.ExpectedSettlementAmount
|
||||
}
|
||||
if payment == nil {
|
||||
return nil
|
||||
}
|
||||
return payment.IntentSnapshot.Amount
|
||||
}
|
||||
|
||||
func cardPayoutAmountMinor(payment *agg.Payment) (int64, string, error) {
|
||||
money := cardPayoutMoney(payment)
|
||||
if money == nil {
|
||||
return 0, "", merrors.InvalidArgument("card payout send: payout amount is required")
|
||||
}
|
||||
amountText := strings.TrimSpace(money.GetAmount())
|
||||
currency := strings.ToUpper(strings.TrimSpace(money.GetCurrency()))
|
||||
if idx := strings.Index(currency, "-"); idx > 0 {
|
||||
currency = currency[:idx]
|
||||
}
|
||||
if amountText == "" || currency == "" {
|
||||
return 0, "", merrors.InvalidArgument("card payout send: payout amount is invalid")
|
||||
}
|
||||
|
||||
value, err := decimal.NewFromString(amountText)
|
||||
if err != nil || !value.IsPositive() {
|
||||
return 0, "", merrors.InvalidArgument("card payout send: payout amount is invalid")
|
||||
}
|
||||
minor := value.Mul(decimal.NewFromInt(100))
|
||||
if !minor.Equal(minor.Truncate(0)) {
|
||||
return 0, "", merrors.InvalidArgument("card payout send: payout amount supports at most 2 fractional digits")
|
||||
}
|
||||
return minor.IntPart(), currency, nil
|
||||
}
|
||||
|
||||
func cardPayoutStepToken(step xplan.Step) string {
|
||||
return firstNonEmpty(strings.TrimSpace(step.StepRef), strings.TrimSpace(step.StepCode), "card_payout")
|
||||
}
|
||||
|
||||
func cardPayoutOperationRef(payment *agg.Payment, stepToken string) string {
|
||||
base := ""
|
||||
if payment != nil {
|
||||
base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey))
|
||||
}
|
||||
return joinRef(base, stepToken)
|
||||
}
|
||||
|
||||
func cardPayoutRef(payment *agg.Payment, stepToken string) string {
|
||||
base := ""
|
||||
if payment != nil {
|
||||
base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey), "card_payout")
|
||||
}
|
||||
return joinRef(base, stepToken)
|
||||
}
|
||||
|
||||
func cardPayoutIdempotencyKey(payment *agg.Payment, stepToken string) string {
|
||||
base := ""
|
||||
if payment != nil {
|
||||
base = strings.TrimSpace(payment.IdempotencyKey)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
base = "card_payout"
|
||||
}
|
||||
return joinRef(base, stepToken)
|
||||
}
|
||||
|
||||
func joinRef(base, suffix string) string {
|
||||
base = strings.TrimSpace(base)
|
||||
suffix = strings.TrimSpace(suffix)
|
||||
switch {
|
||||
case base == "":
|
||||
return suffix
|
||||
case suffix == "":
|
||||
return base
|
||||
default:
|
||||
return base + ":" + suffix
|
||||
}
|
||||
}
|
||||
|
||||
func cardPayoutProjectID(payment *agg.Payment) int64 {
|
||||
if payment == nil {
|
||||
return 0
|
||||
}
|
||||
raw := cardPayoutAttribute(payment.IntentSnapshot.Attributes, "project_id", "projectId")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoint) cardPayoutCustomer {
|
||||
customer := cardPayoutCustomer{}
|
||||
if payment == nil {
|
||||
return customer
|
||||
}
|
||||
|
||||
cardholder := ""
|
||||
cardholderSurname := ""
|
||||
cardCountry := ""
|
||||
if card != nil {
|
||||
cardholder = strings.TrimSpace(card.Cardholder)
|
||||
cardholderSurname = strings.TrimSpace(card.CardholderSurname)
|
||||
cardCountry = strings.ToUpper(strings.TrimSpace(card.Country))
|
||||
}
|
||||
attrs := payment.IntentSnapshot.Attributes
|
||||
intentCustomer := payment.IntentSnapshot.Customer
|
||||
if intentCustomer != nil {
|
||||
customer.id = strings.TrimSpace(intentCustomer.ID)
|
||||
customer.firstName = strings.TrimSpace(intentCustomer.FirstName)
|
||||
customer.middleName = strings.TrimSpace(intentCustomer.MiddleName)
|
||||
customer.lastName = strings.TrimSpace(intentCustomer.LastName)
|
||||
customer.ip = strings.TrimSpace(intentCustomer.IP)
|
||||
customer.zip = strings.TrimSpace(intentCustomer.Zip)
|
||||
customer.country = strings.ToUpper(strings.TrimSpace(intentCustomer.Country))
|
||||
customer.state = strings.TrimSpace(intentCustomer.State)
|
||||
customer.city = strings.TrimSpace(intentCustomer.City)
|
||||
customer.address = strings.TrimSpace(intentCustomer.Address)
|
||||
}
|
||||
|
||||
customer.id = firstNonEmpty(customer.id,
|
||||
cardPayoutAttribute(attrs, "customer_id", "customerId", "initiator_ref", "initiatorRef"),
|
||||
strings.TrimSpace(payment.PaymentRef),
|
||||
strings.TrimSpace(payment.IdempotencyKey),
|
||||
"unknown_customer")
|
||||
customer.firstName = firstNonEmpty(customer.firstName, cardholder, "UNKNOWN")
|
||||
customer.middleName = firstNonEmpty(customer.middleName, cardPayoutAttribute(attrs, "customer_middle_name", "customerMiddleName"))
|
||||
customer.lastName = firstNonEmpty(customer.lastName, cardholderSurname, "UNKNOWN")
|
||||
customer.ip = firstNonEmpty(customer.ip,
|
||||
cardPayoutAttribute(attrs, "customer_ip", "customerIp", "ip", "ip_address", "ipAddress"),
|
||||
"0.0.0.0")
|
||||
customer.zip = firstNonEmpty(customer.zip, cardPayoutAttribute(attrs, "customer_zip", "customerZip"))
|
||||
customer.country = firstNonEmpty(customer.country, cardCountry, cardPayoutAttribute(attrs, "customer_country", "customerCountry"))
|
||||
customer.state = firstNonEmpty(customer.state, cardPayoutAttribute(attrs, "customer_state", "customerState"))
|
||||
customer.city = firstNonEmpty(customer.city, cardPayoutAttribute(attrs, "customer_city", "customerCity"))
|
||||
customer.address = firstNonEmpty(customer.address, cardPayoutAttribute(attrs, "customer_address", "customerAddress"))
|
||||
|
||||
return customer
|
||||
}
|
||||
|
||||
func cardPayoutCardholder(card *model.CardEndpoint, customer cardPayoutCustomer) string {
|
||||
holder := ""
|
||||
if card != nil {
|
||||
holder = strings.TrimSpace(card.Cardholder)
|
||||
surname := strings.TrimSpace(card.CardholderSurname)
|
||||
if holder != "" && surname != "" && !strings.Contains(strings.ToLower(holder), strings.ToLower(surname)) {
|
||||
holder = holder + " " + surname
|
||||
}
|
||||
}
|
||||
if holder == "" {
|
||||
holder = strings.TrimSpace(firstNonEmpty(spaceJoin(customer.firstName, customer.lastName), customer.firstName, customer.lastName))
|
||||
}
|
||||
if holder == "" {
|
||||
holder = "UNKNOWN"
|
||||
}
|
||||
return holder
|
||||
}
|
||||
|
||||
func spaceJoin(values ...string) string {
|
||||
parts := make([]string, 0, len(values))
|
||||
for i := range values {
|
||||
item := strings.TrimSpace(values[i])
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, item)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func cardPayoutAttribute(attrs map[string]string, keys ...string) string {
|
||||
if len(attrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i := range keys {
|
||||
key := strings.TrimSpace(keys[i])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if value, ok := attrs[key]; ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
for attrKey, value := range attrs {
|
||||
if !strings.EqualFold(strings.TrimSpace(attrKey), key) {
|
||||
continue
|
||||
}
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string {
|
||||
out := transferMetadata(step)
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
}
|
||||
if payment != nil {
|
||||
if quoteRef := firstNonEmpty(
|
||||
strings.TrimSpace(payment.QuotationRef),
|
||||
strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)),
|
||||
); quoteRef != "" {
|
||||
out[settlementMetadataQuoteRef] = quoteRef
|
||||
}
|
||||
}
|
||||
if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" {
|
||||
out[settlementMetadataOutgoingLeg] = outgoingLeg
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cardPayoutExternalRefs(payoutRef, operationRef, gatewayInstanceID string) ([]agg.ExternalRef, error) {
|
||||
gatewayInstanceID = strings.TrimSpace(gatewayInstanceID)
|
||||
refs := make([]agg.ExternalRef, 0, 3)
|
||||
if operationRef = strings.TrimSpace(operationRef); operationRef != "" {
|
||||
refs = append(refs, agg.ExternalRef{
|
||||
GatewayInstanceID: gatewayInstanceID,
|
||||
Kind: erecon.ExternalRefKindOperation,
|
||||
Ref: operationRef,
|
||||
})
|
||||
}
|
||||
if payoutRef = strings.TrimSpace(payoutRef); payoutRef != "" {
|
||||
refs = append(refs, agg.ExternalRef{
|
||||
GatewayInstanceID: gatewayInstanceID,
|
||||
Kind: erecon.ExternalRefKindTransfer,
|
||||
Ref: payoutRef,
|
||||
})
|
||||
refs = append(refs, agg.ExternalRef{
|
||||
GatewayInstanceID: gatewayInstanceID,
|
||||
Kind: erecon.ExternalRefKindCardPayout,
|
||||
Ref: payoutRef,
|
||||
})
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil, merrors.Internal("card payout send: payout response does not contain references")
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
var _ sexec.CardPayoutExecutor = (*gatewayCardPayoutExecutor)(nil)
|
||||
@@ -0,0 +1,181 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/erecon"
|
||||
"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/storage/model"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(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-1",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
Cardholder: "Stephan",
|
||||
CardholderSurname: "Deshevikh",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
},
|
||||
},
|
||||
Customer: &model.Customer{
|
||||
ID: "cust-1",
|
||||
FirstName: "Stephan",
|
||||
LastName: "Deshevikh",
|
||||
IP: "198.51.100.10",
|
||||
},
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: "1.000000",
|
||||
Currency: "USDT",
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
"initiator_ref": "user-123",
|
||||
},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
ExpectedSettlementAmount: &paymenttypes.Money{
|
||||
Amount: "76.50",
|
||||
Currency: "RUB",
|
||||
},
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_4_card_payout_send",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
Action: model.RailOperationSend,
|
||||
Rail: model.RailCardPayout,
|
||||
Gateway: "monetix",
|
||||
InstanceID: "monetix",
|
||||
},
|
||||
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 out.StepExecution.State != agg.StepStateCompleted {
|
||||
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
|
||||
}
|
||||
if payoutReq == nil {
|
||||
t.Fatal("expected payout request to be submitted")
|
||||
}
|
||||
if got, want := payoutReq.GetPayoutId(), "payment-1:hop_4_card_payout_send"; got != want {
|
||||
t.Fatalf("payout_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payoutReq.GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want {
|
||||
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payoutReq.GetIdempotencyKey(), "idem-1:hop_4_card_payout_send"; got != want {
|
||||
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payoutReq.GetAmountMinor(), int64(7650); 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.GetMetadata()[settlementMetadataQuoteRef], "quote-1"; got != want {
|
||||
t.Fatalf("quote_ref metadata mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payoutReq.GetMetadata()[settlementMetadataOutgoingLeg], string(model.RailCardPayout); got != want {
|
||||
t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if len(out.StepExecution.ExternalRefs) != 3 {
|
||||
t.Fatalf("expected 3 external refs, got=%d", len(out.StepExecution.ExternalRefs))
|
||||
}
|
||||
if out.StepExecution.ExternalRefs[0].Kind != erecon.ExternalRefKindOperation {
|
||||
t.Fatalf("expected first external ref to be operation, got=%q", out.StepExecution.ExternalRefs[0].Kind)
|
||||
}
|
||||
if out.StepExecution.ExternalRefs[1].Kind != erecon.ExternalRefKindTransfer {
|
||||
t.Fatalf("expected second external ref to be transfer, got=%q", out.StepExecution.ExternalRefs[1].Kind)
|
||||
}
|
||||
if out.StepExecution.ExternalRefs[2].Kind != erecon.ExternalRefKindCardPayout {
|
||||
t.Fatalf("expected third external ref to be card payout, got=%q", out.StepExecution.ExternalRefs[2].Kind)
|
||||
}
|
||||
if got, want := out.StepExecution.ExternalRefs[1].Ref, "payout-remote-1"; got != want {
|
||||
t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresMntxClient(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
executor := &gatewayCardPayoutExecutor{}
|
||||
_, err := executor.ExecuteCardPayout(context.Background(), sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-2",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
Pan: "4111111111111111",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "RUB"},
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_4_card_payout_send",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
Action: model.RailOperationSend,
|
||||
Rail: model.RailCardPayout,
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_4_card_payout_send",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
Attempt: 1,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mntx client is required") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,9 @@ func matchExecutionStep(payment *agg.Payment, msg *pmodel.PaymentGatewayExecutio
|
||||
if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindTransfer, transferRef); ok {
|
||||
return stepRef, gatewayInstanceID
|
||||
}
|
||||
if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindCardPayout, transferRef); ok {
|
||||
return stepRef, gatewayInstanceID
|
||||
}
|
||||
}
|
||||
|
||||
operationRef := strings.TrimSpace(msg.OperationRef)
|
||||
@@ -361,6 +364,11 @@ func buildObserveCandidate(step agg.StepExecution) (runningObserveCandidate, boo
|
||||
candidate.transferRef = value
|
||||
candidate.gatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
|
||||
}
|
||||
case strings.EqualFold(kind, erecon.ExternalRefKindCardPayout):
|
||||
if candidate.transferRef == "" {
|
||||
candidate.transferRef = value
|
||||
candidate.gatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
|
||||
}
|
||||
case strings.EqualFold(kind, erecon.ExternalRefKindOperation):
|
||||
if candidate.operationRef == "" {
|
||||
candidate.operationRef = value
|
||||
|
||||
@@ -96,6 +96,43 @@ func TestBuildGatewayExecutionEvent_FailedSetsTerminalNeedsAttentionHint(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
payment := &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-card-1",
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "hop_4_card_payout_observe",
|
||||
StepCode: "hop.4.card_payout.observe",
|
||||
State: agg.StepStateRunning,
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{
|
||||
GatewayInstanceID: "monetix",
|
||||
Kind: erecon.ExternalRefKindCardPayout,
|
||||
Ref: "payout-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Status: rail.OperationResultSuccess,
|
||||
TransferRef: "payout-1",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected gateway execution event to be accepted")
|
||||
}
|
||||
if got, want := event.StepRef, "hop_4_card_payout_observe"; got != want {
|
||||
t.Fatalf("step_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := event.GatewayInstanceID, "monetix"; got != want {
|
||||
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnPaymentGatewayExecution_ReconcilesUsingGlobalPaymentLookup(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
payment := &agg.Payment{
|
||||
@@ -273,6 +310,36 @@ func TestRunningObserveCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) {
|
||||
payment := &agg.Payment{
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "hop_4_card_payout_observe",
|
||||
StepCode: "hop.4.card_payout.observe",
|
||||
State: agg.StepStateRunning,
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{
|
||||
GatewayInstanceID: "monetix",
|
||||
Kind: erecon.ExternalRefKindCardPayout,
|
||||
Ref: "payout-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
candidates := runningObserveCandidates(payment)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates))
|
||||
}
|
||||
if got, want := candidates[0].transferRef, "payout-2"; got != want {
|
||||
t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := candidates[0].gatewayInstanceID, "monetix"; got != want {
|
||||
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *testing.T) {
|
||||
svc := &Service{
|
||||
gatewayRegistry: &fakeGatewayRegistry{
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
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/erecon"
|
||||
"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/storage/model"
|
||||
"github.com/tech/sendico/pkg/ledgerconv"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
ledgerMetadataMode = "mode"
|
||||
)
|
||||
|
||||
type gatewayLedgerExecutor struct {
|
||||
ledgerClient ledgerclient.Client
|
||||
}
|
||||
|
||||
type ledgerRoles struct {
|
||||
from account_role.AccountRole
|
||||
to account_role.AccountRole
|
||||
}
|
||||
|
||||
func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
if req.Payment == nil {
|
||||
return nil, merrors.InvalidArgument("ledger step: payment is required")
|
||||
}
|
||||
if e == nil || e.ledgerClient == nil {
|
||||
return nil, merrors.InvalidArgument("ledger step: ledger client is required")
|
||||
}
|
||||
|
||||
action := model.ParseRailOperation(string(req.Step.Action))
|
||||
switch action {
|
||||
case model.RailOperationDebit,
|
||||
model.RailOperationCredit,
|
||||
model.RailOperationExternalDebit,
|
||||
model.RailOperationExternalCredit,
|
||||
model.RailOperationMove,
|
||||
model.RailOperationBlock,
|
||||
model.RailOperationRelease:
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("ledger step: unsupported action")
|
||||
}
|
||||
|
||||
amount, err := ledgerAmountForStep(req.Payment, req.Step, action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles, err := ledgerRolesForStep(req.Step, action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transferReq := &ledgerv1.TransferRequest{
|
||||
IdempotencyKey: ledgerStepIdempotencyKey(req.Payment, req.Step),
|
||||
OrganizationRef: req.Payment.OrganizationRef.Hex(),
|
||||
Money: amount,
|
||||
Description: ledgerDescription(req.Step),
|
||||
Metadata: ledgerTransferMetadata(req.Payment, req.Step, roles),
|
||||
FromRole: ledgerRoleToProto(roles.from),
|
||||
ToRole: ledgerRoleToProto(roles.to),
|
||||
}
|
||||
|
||||
resp, err := e.ledgerClient.TransferInternal(ctx, transferReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil || strings.TrimSpace(resp.GetJournalEntryRef()) == "" {
|
||||
return nil, merrors.Internal("ledger step: journal entry reference is missing")
|
||||
}
|
||||
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
step.ExternalRefs = appendLedgerExternalRef(step.ExternalRefs, agg.ExternalRef{
|
||||
GatewayInstanceID: firstNonEmpty(strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(req.Step.Gateway)),
|
||||
Kind: erecon.ExternalRefKindLedger,
|
||||
Ref: strings.TrimSpace(resp.GetJournalEntryRef()),
|
||||
})
|
||||
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
}
|
||||
|
||||
func ledgerAmountForStep(
|
||||
payment *agg.Payment,
|
||||
step xplan.Step,
|
||||
action model.RailOperation,
|
||||
) (*moneyv1.Money, error) {
|
||||
sourceMoney := sourceMoneyForLedger(payment)
|
||||
settlementMoney := settlementMoneyForLedger(payment, sourceMoney)
|
||||
payoutMoney := payoutMoneyForLedger(payment, settlementMoney)
|
||||
|
||||
if fromRail, toRail, ok := ledgerBoundaryRails(payment, step); ok {
|
||||
switch {
|
||||
case isLedgerExternalRail(fromRail) && isLedgerExternalRail(toRail):
|
||||
return protoMoneyRequired(sourceMoney, "ledger step: source amount is required")
|
||||
case isLedgerExternalRail(fromRail) && isLedgerInternalRail(toRail):
|
||||
return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required")
|
||||
case isLedgerInternalRail(fromRail) && isLedgerExternalRail(toRail):
|
||||
if toRail == model.RailCardPayout {
|
||||
return protoMoneyRequired(payoutMoney, "ledger step: payout amount is required")
|
||||
}
|
||||
return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required")
|
||||
case isLedgerInternalRail(fromRail) && isLedgerInternalRail(toRail):
|
||||
return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required")
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case model.RailOperationCredit, model.RailOperationExternalCredit:
|
||||
return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required")
|
||||
case model.RailOperationDebit, model.RailOperationExternalDebit:
|
||||
if sourceMoney != nil {
|
||||
return protoMoneyRequired(sourceMoney, "ledger step: source amount is required")
|
||||
}
|
||||
return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required")
|
||||
case model.RailOperationMove, model.RailOperationBlock, model.RailOperationRelease:
|
||||
if settlementMoney != nil {
|
||||
return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required")
|
||||
}
|
||||
return protoMoneyRequired(sourceMoney, "ledger step: source amount is required")
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("ledger step: unsupported action")
|
||||
}
|
||||
}
|
||||
|
||||
func sourceMoneyForLedger(payment *agg.Payment) *paymenttypes.Money {
|
||||
if payment == nil {
|
||||
return nil
|
||||
}
|
||||
if payment.QuoteSnapshot != nil && payment.QuoteSnapshot.DebitAmount != nil {
|
||||
return payment.QuoteSnapshot.DebitAmount
|
||||
}
|
||||
return payment.IntentSnapshot.Amount
|
||||
}
|
||||
|
||||
func settlementMoneyForLedger(payment *agg.Payment, source *paymenttypes.Money) *paymenttypes.Money {
|
||||
if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil {
|
||||
return payment.QuoteSnapshot.ExpectedSettlementAmount
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func payoutMoneyForLedger(_ *agg.Payment, settlement *paymenttypes.Money) *paymenttypes.Money {
|
||||
return settlement
|
||||
}
|
||||
|
||||
func protoMoneyRequired(m *paymenttypes.Money, errMsg string) (*moneyv1.Money, error) {
|
||||
if m == nil {
|
||||
return nil, merrors.InvalidArgument(errMsg)
|
||||
}
|
||||
amount := strings.TrimSpace(m.GetAmount())
|
||||
currency := strings.TrimSpace(m.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil, merrors.InvalidArgument(errMsg)
|
||||
}
|
||||
return &moneyv1.Money{Amount: amount, Currency: currency}, nil
|
||||
}
|
||||
|
||||
func ledgerRolesForStep(step xplan.Step, action model.RailOperation) (ledgerRoles, error) {
|
||||
roles, ok, err := ledgerRolesFromMetadata(step.Metadata)
|
||||
if err != nil {
|
||||
return ledgerRoles{}, err
|
||||
}
|
||||
if ok {
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(step.Metadata[ledgerMetadataMode]))
|
||||
switch action {
|
||||
case model.RailOperationBlock:
|
||||
return ledgerRoles{from: account_role.AccountRoleOperating, to: account_role.AccountRoleHold}, nil
|
||||
case model.RailOperationRelease:
|
||||
return ledgerRoles{from: account_role.AccountRoleHold, to: account_role.AccountRoleOperating}, nil
|
||||
case model.RailOperationCredit, model.RailOperationExternalCredit:
|
||||
return ledgerRoles{from: account_role.AccountRolePending, to: account_role.AccountRoleOperating}, nil
|
||||
case model.RailOperationDebit, model.RailOperationExternalDebit:
|
||||
if mode == "finalize_debit" {
|
||||
return ledgerRoles{from: account_role.AccountRoleHold, to: account_role.AccountRoleTransit}, nil
|
||||
}
|
||||
return ledgerRoles{from: account_role.AccountRoleOperating, to: account_role.AccountRoleTransit}, nil
|
||||
case model.RailOperationMove:
|
||||
return ledgerRoles{from: account_role.AccountRoleOperating, to: account_role.AccountRoleTransit}, nil
|
||||
default:
|
||||
return ledgerRoles{}, merrors.InvalidArgument("ledger step: unsupported action")
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerRolesFromMetadata(metadata map[string]string) (ledgerRoles, bool, error) {
|
||||
fromValue, fromFound := metadataLookup(metadata, "from_role", "fromRole")
|
||||
toValue, toFound := metadataLookup(metadata, "to_role", "toRole")
|
||||
if !fromFound && !toFound {
|
||||
return ledgerRoles{}, false, nil
|
||||
}
|
||||
if strings.TrimSpace(fromValue) == "" || strings.TrimSpace(toValue) == "" {
|
||||
return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: from_role and to_role must both be provided")
|
||||
}
|
||||
|
||||
fromRole, ok := account_role.Parse(fromValue)
|
||||
if !ok || fromRole == "" {
|
||||
return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: invalid from_role")
|
||||
}
|
||||
toRole, ok := account_role.Parse(toValue)
|
||||
if !ok || toRole == "" {
|
||||
return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: invalid to_role")
|
||||
}
|
||||
if fromRole == toRole {
|
||||
return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: from_role and to_role must differ")
|
||||
}
|
||||
return ledgerRoles{from: fromRole, to: toRole}, true, nil
|
||||
}
|
||||
|
||||
func metadataLookup(metadata map[string]string, keys ...string) (string, bool) {
|
||||
if len(metadata) == 0 {
|
||||
return "", false
|
||||
}
|
||||
for i := range keys {
|
||||
key := strings.TrimSpace(keys[i])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
value, ok := metadata[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ledgerRoleToProto(role account_role.AccountRole) ledgerv1.AccountRole {
|
||||
parsed, _ := ledgerconv.ParseAccountRole(string(role))
|
||||
return parsed
|
||||
}
|
||||
|
||||
func ledgerTransferMetadata(payment *agg.Payment, step xplan.Step, roles ledgerRoles) map[string]string {
|
||||
out := transferMetadata(step)
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
}
|
||||
if quoteRef := firstNonEmpty(strings.TrimSpace(payment.QuotationRef), strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot))); quoteRef != "" {
|
||||
out[settlementMetadataQuoteRef] = quoteRef
|
||||
}
|
||||
if roles.from != "" {
|
||||
out[account_role.MetadataKeyFromRole] = string(roles.from)
|
||||
}
|
||||
if roles.to != "" {
|
||||
out[account_role.MetadataKeyToRole] = string(roles.to)
|
||||
}
|
||||
if mode := strings.TrimSpace(step.Metadata[ledgerMetadataMode]); mode != "" {
|
||||
out[ledgerMetadataMode] = mode
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ledgerStepIdempotencyKey(payment *agg.Payment, step xplan.Step) string {
|
||||
base := strings.TrimSpace(payment.IdempotencyKey)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
stepToken := firstNonEmpty(strings.TrimSpace(step.StepRef), strings.TrimSpace(step.StepCode), "ledger")
|
||||
if base == "" {
|
||||
return "ledger:" + stepToken
|
||||
}
|
||||
return base + ":" + stepToken
|
||||
}
|
||||
|
||||
func ledgerDescription(step xplan.Step) string {
|
||||
code := strings.TrimSpace(step.StepCode)
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(step.StepRef)
|
||||
}
|
||||
action := strings.ToLower(strings.TrimSpace(string(step.Action)))
|
||||
if code == "" {
|
||||
return "orchestration ledger " + action
|
||||
}
|
||||
return fmt.Sprintf("orchestration ledger %s %s", action, code)
|
||||
}
|
||||
|
||||
func appendLedgerExternalRef(existing []agg.ExternalRef, ref agg.ExternalRef) []agg.ExternalRef {
|
||||
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
|
||||
ref.Kind = strings.TrimSpace(ref.Kind)
|
||||
ref.Ref = strings.TrimSpace(ref.Ref)
|
||||
if ref.Kind == "" || ref.Ref == "" {
|
||||
return existing
|
||||
}
|
||||
for i := range existing {
|
||||
item := existing[i]
|
||||
if strings.EqualFold(strings.TrimSpace(item.GatewayInstanceID), ref.GatewayInstanceID) &&
|
||||
strings.EqualFold(strings.TrimSpace(item.Kind), ref.Kind) &&
|
||||
strings.EqualFold(strings.TrimSpace(item.Ref), ref.Ref) {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
return append(existing, ref)
|
||||
}
|
||||
|
||||
func ledgerBoundaryRails(payment *agg.Payment, step xplan.Step) (model.Rail, model.Rail, bool) {
|
||||
fromIndex, toIndex, ok := parseLedgerEdgeStepCode(step.StepCode)
|
||||
if !ok || payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil {
|
||||
return model.RailUnspecified, model.RailUnspecified, false
|
||||
}
|
||||
|
||||
fromRail := model.RailUnspecified
|
||||
toRail := model.RailUnspecified
|
||||
for i := range payment.QuoteSnapshot.Route.Hops {
|
||||
hop := payment.QuoteSnapshot.Route.Hops[i]
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
if hop.Index == fromIndex {
|
||||
fromRail = model.ParseRail(hop.Rail)
|
||||
}
|
||||
if hop.Index == toIndex {
|
||||
toRail = model.ParseRail(hop.Rail)
|
||||
}
|
||||
}
|
||||
if fromRail == model.RailUnspecified || toRail == model.RailUnspecified {
|
||||
return model.RailUnspecified, model.RailUnspecified, false
|
||||
}
|
||||
return fromRail, toRail, true
|
||||
}
|
||||
|
||||
func parseLedgerEdgeStepCode(stepCode string) (uint32, uint32, bool) {
|
||||
code := strings.ToLower(strings.TrimSpace(stepCode))
|
||||
if !strings.HasPrefix(code, "edge.") || !strings.Contains(code, ".ledger.") {
|
||||
return 0, 0, false
|
||||
}
|
||||
var (
|
||||
from uint32
|
||||
to uint32
|
||||
op string
|
||||
)
|
||||
if _, err := fmt.Sscanf(code, "edge.%d_%d.ledger.%s", &from, &to, &op); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if strings.TrimSpace(op) == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
return from, to, true
|
||||
}
|
||||
|
||||
func isLedgerInternalRail(rail model.Rail) bool {
|
||||
return rail == model.RailLedger
|
||||
}
|
||||
|
||||
func isLedgerExternalRail(rail model.Rail) bool {
|
||||
switch rail {
|
||||
case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var _ sexec.LedgerExecutor = (*gatewayLedgerExecutor)(nil)
|
||||
@@ -0,0 +1,275 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/erecon"
|
||||
"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/storage/model"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRoles(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-1"}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
|
||||
Payment: payment,
|
||||
Step: xplan.Step{
|
||||
StepRef: "edge_1_2_ledger_credit",
|
||||
StepCode: "edge.1_2.ledger.credit",
|
||||
Action: model.RailOperationCredit,
|
||||
Rail: model.RailLedger,
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "edge_1_2_ledger_credit",
|
||||
StepCode: "edge.1_2.ledger.credit",
|
||||
Attempt: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteLedger returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if transferReq == nil {
|
||||
t.Fatal("expected ledger transfer request")
|
||||
}
|
||||
if got, want := transferReq.GetMoney().GetAmount(), "1.000000"; got != want {
|
||||
t.Fatalf("money.amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := transferReq.GetMoney().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("money.currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING; got != want {
|
||||
t.Fatalf("from_role mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want {
|
||||
t.Fatalf("to_role mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want {
|
||||
t.Fatalf("state mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if len(out.StepExecution.ExternalRefs) != 1 {
|
||||
t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs))
|
||||
}
|
||||
if got, want := out.StepExecution.ExternalRefs[0].Kind, erecon.ExternalRefKindLedger; got != want {
|
||||
t.Fatalf("external ref kind mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ExternalRefs[0].Ref, "entry-1"; got != want {
|
||||
t.Fatalf("external ref value mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(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-2"}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
|
||||
Payment: payment,
|
||||
Step: xplan.Step{
|
||||
StepRef: "edge_3_4_ledger_debit",
|
||||
StepCode: "edge.3_4.ledger.debit",
|
||||
Action: model.RailOperationDebit,
|
||||
Rail: model.RailLedger,
|
||||
Metadata: map[string]string{"mode": "finalize_debit"},
|
||||
HopIndex: 4,
|
||||
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
DependsOn: []string{"hop_4_card_payout_observe"},
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "edge_3_4_ledger_debit",
|
||||
StepCode: "edge.3_4.ledger.debit",
|
||||
Attempt: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteLedger returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if transferReq == nil {
|
||||
t.Fatal("expected ledger transfer request")
|
||||
}
|
||||
if got, want := transferReq.GetMoney().GetAmount(), "76.5"; 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)
|
||||
}
|
||||
if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want {
|
||||
t.Fatalf("from_role mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT; got != want {
|
||||
t.Fatalf("to_role mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := transferReq.GetMetadata()[ledgerMetadataMode], "finalize_debit"; got != want {
|
||||
t.Fatalf("mode metadata mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(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-3"}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
|
||||
Payment: payment,
|
||||
Step: xplan.Step{
|
||||
StepRef: "edge_2_3_ledger_credit",
|
||||
StepCode: "edge.2_3.ledger.credit",
|
||||
Action: model.RailOperationCredit,
|
||||
Rail: model.RailLedger,
|
||||
Metadata: map[string]string{
|
||||
"from_role": "reserve",
|
||||
"to_role": "liquidity",
|
||||
},
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "edge_2_3_ledger_credit",
|
||||
StepCode: "edge.2_3.ledger.credit",
|
||||
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.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE; got != want {
|
||||
t.Fatalf("from_role mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY; got != want {
|
||||
t.Fatalf("to_role mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLedgerExecutor_ExecuteLedger_ValidatesMetadataRoles(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
payment := testLedgerExecutorPayment(orgID)
|
||||
|
||||
executor := &gatewayLedgerExecutor{
|
||||
ledgerClient: &ledgerclient.Fake{},
|
||||
}
|
||||
|
||||
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
|
||||
Payment: payment,
|
||||
Step: xplan.Step{
|
||||
StepRef: "edge_2_3_ledger_credit",
|
||||
StepCode: "edge.2_3.ledger.credit",
|
||||
Action: model.RailOperationCredit,
|
||||
Rail: model.RailLedger,
|
||||
Metadata: map[string]string{
|
||||
"from_role": "bad_role",
|
||||
"to_role": "operating",
|
||||
},
|
||||
},
|
||||
StepExecution: agg.StepExecution{StepRef: "edge_2_3_ledger_credit", StepCode: "edge.2_3.ledger.credit", Attempt: 1},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid from_role") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLedgerExecutor_ExecuteLedger_RequiresLedgerClient(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
payment := testLedgerExecutorPayment(orgID)
|
||||
|
||||
executor := &gatewayLedgerExecutor{}
|
||||
|
||||
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
|
||||
Payment: payment,
|
||||
Step: xplan.Step{
|
||||
StepRef: "edge_1_2_ledger_credit",
|
||||
StepCode: "edge.1_2.ledger.credit",
|
||||
Action: model.RailOperationCredit,
|
||||
Rail: model.RailLedger,
|
||||
},
|
||||
StepExecution: agg.StepExecution{StepRef: "edge_1_2_ledger_credit", StepCode: "edge.1_2.ledger.credit", Attempt: 1},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ledger client is required") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testLedgerExecutorPayment(orgID bson.ObjectID) *agg.Payment {
|
||||
return &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-ledger-1",
|
||||
IdempotencyKey: "idem-ledger-1",
|
||||
QuotationRef: "quote-ledger-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-ledger-1",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{Pan: "4111111111111111"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
|
||||
ExpectedSettlementAmount: &paymenttypes.Money{Amount: "76.5", Currency: "RUB"},
|
||||
QuoteRef: "quote-ledger-1",
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "SETTLEMENT", Role: paymenttypes.QuoteRouteHopRoleTransit},
|
||||
{Index: 3, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit},
|
||||
{Index: 4, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,24 @@ func WithFeeEngine(_ feesv1.FeeEngineClient, _ time.Duration) Option {
|
||||
return func(*Service) {}
|
||||
}
|
||||
|
||||
// WithLedgerClient is retained for backward-compatible wiring and is currently a no-op.
|
||||
func WithLedgerClient(_ ledgerclient.Client) Option {
|
||||
return func(*Service) {}
|
||||
// WithLedgerClient configures internal ledger execution for ledger-bound steps.
|
||||
func WithLedgerClient(client ledgerclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.ledgerClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithMntxGateway is retained for backward-compatible wiring and is currently a no-op.
|
||||
func WithMntxGateway(_ mntxclient.Client) Option {
|
||||
return func(*Service) {}
|
||||
// WithMntxGateway configures card payout execution for card-bound steps.
|
||||
func WithMntxGateway(client mntxclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mntxClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithPaymentGatewayBroker wires broker subscription for payment gateway execution events.
|
||||
|
||||
@@ -3,6 +3,8 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
@@ -22,6 +24,8 @@ type Service struct {
|
||||
v2 psvc.Service
|
||||
paymentRepo prepo.Repository
|
||||
|
||||
ledgerClient ledgerclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
gatewayInvokeResolver GatewayInvokeResolver
|
||||
gatewayRegistry GatewayRegistry
|
||||
cardGatewayRoutes map[string]CardGatewayRoute
|
||||
@@ -49,6 +53,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
|
||||
|
||||
var err error
|
||||
svc.v2, svc.paymentRepo, err = newOrchestrationV2Service(svc.logger, repo, v2RuntimeDeps{
|
||||
LedgerClient: svc.ledgerClient,
|
||||
MntxClient: svc.mntxClient,
|
||||
GatewayInvokeResolver: svc.gatewayInvokeResolver,
|
||||
GatewayRegistry: svc.gatewayRegistry,
|
||||
CardGatewayRoutes: svc.cardGatewayRoutes,
|
||||
|
||||
@@ -3,6 +3,8 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
@@ -22,6 +24,8 @@ type v2MongoDBProvider interface {
|
||||
}
|
||||
|
||||
type v2RuntimeDeps struct {
|
||||
LedgerClient ledgerclient.Client
|
||||
MntxClient mntxclient.Client
|
||||
GatewayInvokeResolver GatewayInvokeResolver
|
||||
GatewayRegistry GatewayRegistry
|
||||
CardGatewayRoutes map[string]CardGatewayRoute
|
||||
@@ -34,6 +38,14 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r
|
||||
if repo == nil {
|
||||
return nil, nil, merrors.Internal("No repo for orchestrator v2 provided")
|
||||
}
|
||||
if runtimeDeps.LedgerClient == nil {
|
||||
logger.Error("Orchestration v2 disabled: ledger client is missing")
|
||||
return nil, nil, merrors.Internal("ledger client is required")
|
||||
}
|
||||
if checker, ok := runtimeDeps.LedgerClient.(interface{ Available() bool }); ok && !checker.Available() {
|
||||
logger.Error("Orchestration v2 disabled: ledger client is unavailable")
|
||||
return nil, nil, merrors.Internal("ledger client is unavailable")
|
||||
}
|
||||
|
||||
paymentRepo, err := buildPaymentRepositoryV2(repo, logger)
|
||||
if paymentRepo == nil || err != nil {
|
||||
@@ -72,27 +84,42 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r
|
||||
}
|
||||
|
||||
func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeDeps) sexec.Registry {
|
||||
if runtimeDeps.GatewayInvokeResolver == nil || runtimeDeps.GatewayRegistry == nil {
|
||||
return nil
|
||||
}
|
||||
execLogger := logger.Named("v2")
|
||||
cryptoExecutor := &gatewayCryptoExecutor{
|
||||
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
|
||||
gatewayRegistry: runtimeDeps.GatewayRegistry,
|
||||
cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes),
|
||||
|
||||
var cryptoExecutor sexec.CryptoExecutor
|
||||
var providerSettlementExecutor sexec.ProviderSettlementExecutor
|
||||
var guardExecutor sexec.GuardExecutor
|
||||
if runtimeDeps.GatewayInvokeResolver != nil && runtimeDeps.GatewayRegistry != nil {
|
||||
cryptoExecutor = &gatewayCryptoExecutor{
|
||||
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
|
||||
gatewayRegistry: runtimeDeps.GatewayRegistry,
|
||||
cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes),
|
||||
}
|
||||
providerSettlementExecutor = &gatewayProviderSettlementExecutor{
|
||||
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
|
||||
gatewayRegistry: runtimeDeps.GatewayRegistry,
|
||||
}
|
||||
guardExecutor = &gatewayGuardExecutor{
|
||||
logger: execLogger.Named("guard"),
|
||||
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
|
||||
gatewayRegistry: runtimeDeps.GatewayRegistry,
|
||||
}
|
||||
}
|
||||
providerSettlementExecutor := &gatewayProviderSettlementExecutor{
|
||||
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
|
||||
gatewayRegistry: runtimeDeps.GatewayRegistry,
|
||||
|
||||
ledgerExecutor := &gatewayLedgerExecutor{
|
||||
ledgerClient: runtimeDeps.LedgerClient,
|
||||
}
|
||||
guardExecutor := &gatewayGuardExecutor{
|
||||
logger: execLogger.Named("guard"),
|
||||
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
|
||||
gatewayRegistry: runtimeDeps.GatewayRegistry,
|
||||
var cardPayoutExecutor sexec.CardPayoutExecutor
|
||||
if runtimeDeps.MntxClient != nil {
|
||||
cardPayoutExecutor = &gatewayCardPayoutExecutor{
|
||||
mntxClient: runtimeDeps.MntxClient,
|
||||
}
|
||||
}
|
||||
return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{
|
||||
Ledger: ledgerExecutor,
|
||||
Crypto: cryptoExecutor,
|
||||
ProviderSettlement: providerSettlementExecutor,
|
||||
CardPayout: cardPayoutExecutor,
|
||||
Guard: guardExecutor,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestNewOrchestrationV2Service_FailsWhenLedgerClientMissing(t *testing.T) {
|
||||
svc, repo, err := newOrchestrationV2Service(zap.NewNop(), fakeStorageRepo{}, v2RuntimeDeps{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ledger client is required") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if svc != nil {
|
||||
t.Fatal("expected nil service")
|
||||
}
|
||||
if repo != nil {
|
||||
t.Fatal("expected nil payment repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOrchestrationV2Service_FailsWhenLedgerClientUnavailable(t *testing.T) {
|
||||
ledger := unavailableLedgerClient{Fake: &ledgerclient.Fake{}}
|
||||
svc, repo, err := newOrchestrationV2Service(zap.NewNop(), fakeStorageRepo{}, v2RuntimeDeps{
|
||||
LedgerClient: ledger,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ledger client is unavailable") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if svc != nil {
|
||||
t.Fatal("expected nil service")
|
||||
}
|
||||
if repo != nil {
|
||||
t.Fatal("expected nil payment repo")
|
||||
}
|
||||
}
|
||||
|
||||
type unavailableLedgerClient struct {
|
||||
*ledgerclient.Fake
|
||||
}
|
||||
|
||||
func (u unavailableLedgerClient) Available() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeStorageRepo struct{}
|
||||
|
||||
func (fakeStorageRepo) Ping(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeStorageRepo) Payments() storage.PaymentsStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeStorageRepo) PaymentMethods() storage.PaymentMethodsStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeStorageRepo) Quotes() quotestorage.QuotesStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeStorageRepo) Routes() storage.RoutesStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeStorageRepo) PlanTemplates() storage.PlanTemplatesStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ storage.Repository = fakeStorageRepo{}
|
||||
@@ -50,7 +50,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
|
||||
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
|
||||
Reference in New Issue
Block a user