+source currency pick fix +fx side propagation

This commit is contained in:
Stephan D
2026-02-26 02:39:48 +01:00
parent 008427483c
commit 70b1c2a9cc
73 changed files with 2123 additions and 656 deletions

View File

@@ -14,19 +14,3 @@ func toMoney(m *moneyv1.Money) *paymenttypes.Money {
Currency: m.GetCurrency(),
}
}
func toMoneyList(list []*moneyv1.Money) []*paymenttypes.Money {
if len(list) == 0 {
return nil
}
result := make([]*paymenttypes.Money, 0, len(list))
for _, item := range list {
if m := toMoney(item); m != nil {
result = append(result, m)
}
}
if len(result) == 0 {
return nil
}
return result
}

View File

@@ -64,14 +64,26 @@ type PaymentQuotes struct {
}
type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
State string `json:"state,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
State string `json:"state,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
Operations []PaymentOperation `json:"operations,omitempty"`
LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type paymentQuoteResponse struct {
@@ -269,12 +281,14 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
failureCode, failureReason := firstFailure(p.GetStepExecutions())
operations := toUserVisibleOperations(p.GetStepExecutions())
failureCode, failureReason := firstFailure(operations)
return &Payment{
PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()),
FailureCode: failureCode,
FailureReason: failureReason,
Operations: operations,
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
CreatedAt: timestampAsTime(p.GetCreatedAt()),
Meta: paymentMeta(p),
@@ -282,21 +296,65 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
}
}
func firstFailure(steps []*orchestrationv2.StepExecution) (string, string) {
for _, step := range steps {
if step == nil || step.GetFailure() == nil {
func firstFailure(operations []PaymentOperation) (string, string) {
for _, op := range operations {
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {
continue
}
failure := step.GetFailure()
message := strings.TrimSpace(failure.GetMessage())
if message == "" {
message = strings.TrimSpace(failure.GetCode())
}
return enumJSONName(failure.GetCategory().String()), message
return strings.TrimSpace(op.FailureCode), strings.TrimSpace(op.FailureReason)
}
return "", ""
}
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
if len(steps) == 0 {
return nil
}
ops := make([]PaymentOperation, 0, len(steps))
for _, step := range steps {
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue
}
ops = append(ops, toPaymentOperation(step))
}
if len(ops) == 0 {
return nil
}
return ops
}
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
op := PaymentOperation{
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
}
failure := step.GetFailure()
if failure == nil {
return op
}
op.FailureCode = enumJSONName(failure.GetCategory().String())
op.FailureReason = strings.TrimSpace(failure.GetMessage())
if op.FailureReason == "" {
op.FailureReason = strings.TrimSpace(failure.GetCode())
}
return op
}
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
switch visibility {
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT:
return false
default:
return true
}
}
func paymentMeta(p *orchestrationv2.Payment) map[string]string {
if p == nil {
return nil

View File

@@ -0,0 +1,119 @@
package sresponse
import (
"testing"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
)
func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
steps := []*orchestrationv2.StepExecution{
{
StepRef: "hidden",
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
},
{
StepRef: "user",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
},
{
StepRef: "unspecified",
StepCode: "hop.4.card_payout.observe",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED,
},
{
StepRef: "backoffice",
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
},
}
ops := toUserVisibleOperations(steps)
if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
}
if got, want := ops[0].StepRef, "user"; got != want {
t.Fatalf("first operation step_ref mismatch: got=%q want=%q", got, want)
}
if got, want := ops[1].StepRef, "unspecified"; got != want {
t.Fatalf("second operation step_ref mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentFailureUsesVisibleOperationsOnly(t *testing.T) {
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-1",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
StepExecutions: []*orchestrationv2.StepExecution{
{
StepRef: "hidden_failed",
StepCode: "edge.1_2.ledger.debit",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
Failure: &orchestrationv2.Failure{
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
Message: "internal hold release failure",
},
},
{
StepRef: "user_failed",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
Failure: &orchestrationv2.Failure{
Category: sharedv1.PaymentFailureCode_FAILURE_CHAIN,
Message: "card declined",
},
},
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if got, want := dto.FailureCode, "failure_chain"; got != want {
t.Fatalf("failure_code mismatch: got=%q want=%q", got, want)
}
if got, want := dto.FailureReason, "card declined"; got != want {
t.Fatalf("failure_reason mismatch: got=%q want=%q", got, want)
}
if len(dto.Operations) != 1 {
t.Fatalf("operations count mismatch: got=%d want=1", len(dto.Operations))
}
if got, want := dto.Operations[0].StepRef, "user_failed"; got != want {
t.Fatalf("visible operation mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-2",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
StepExecutions: []*orchestrationv2.StepExecution{
{
StepRef: "hidden_failed",
StepCode: "edge.1_2.ledger.release",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
Failure: &orchestrationv2.Failure{
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
Message: "backoffice only failure",
},
},
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if got := dto.FailureCode; got != "" {
t.Fatalf("expected empty failure_code, got=%q", got)
}
if got := dto.FailureReason; got != "" {
t.Fatalf("expected empty failure_reason, got=%q", got)
}
if len(dto.Operations) != 0 {
t.Fatalf("expected no visible operations, got=%d", len(dto.Operations))
}
}

View File

@@ -8,6 +8,7 @@ import (
pkgmodel "github.com/tech/sendico/pkg/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -58,6 +59,7 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
SettlementMode: resolvedSettlementMode,
FeeTreatment: resolvedFeeTreatment,
SettlementCurrency: settlementCurrency,
FxSide: mapFXSide(intent),
}
if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" {
quoteIntent.Comment = comment
@@ -65,6 +67,20 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
return quoteIntent, nil
}
func mapFXSide(intent *srequest.PaymentIntent) fxv1.Side {
if intent == nil || intent.FX == nil {
return fxv1.Side_SIDE_UNSPECIFIED
}
switch strings.TrimSpace(string(intent.FX.Side)) {
case string(srequest.FXSideBuyBaseSellQuote):
return fxv1.Side_BUY_BASE_SELL_QUOTE
case string(srequest.FXSideSellBaseBuyQuote):
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func validatePaymentKind(kind srequest.PaymentKind) error {
switch strings.TrimSpace(string(kind)) {
case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion):

View File

@@ -4,6 +4,7 @@ import (
"testing"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"github.com/tech/sendico/server/interface/api/srequest"
@@ -201,4 +202,54 @@ func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) {
if got.GetSettlementCurrency() != "RUB" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
if got.GetFxSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
}
}
func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
FX: &srequest.FXIntent{
Pair: &srequest.CurrencyPair{
Base: "RUB",
Quote: "USDT",
},
Side: srequest.FXSideBuyBaseSellQuote,
},
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.GetFxSide() != fxv1.Side_BUY_BASE_SELL_QUOTE {
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
}
if got.GetSettlementCurrency() != "RUB" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
}