op payment info added #641

Merged
tech merged 1 commits from bff-640 into main 2026-03-04 17:03:13 +00:00
4 changed files with 142 additions and 28 deletions

View File

@@ -79,16 +79,18 @@ type Payment struct {
} }
type PaymentOperation struct { type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"` StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
OperationRef string `json:"operationRef,omitempty"` Amount *paymenttypes.Money `json:"amount,omitempty"`
Gateway string `json:"gateway,omitempty"` ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
FailureCode string `json:"failureCode,omitempty"` OperationRef string `json:"operationRef,omitempty"`
FailureReason string `json:"failureReason,omitempty"` Gateway string `json:"gateway,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"` FailureCode string `json:"failureCode,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"` FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
} }
type paymentQuoteResponse struct { type paymentQuoteResponse struct {
@@ -287,7 +289,7 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil { if p == nil {
return nil return nil
} }
operations := toUserVisibleOperations(p.GetStepExecutions()) operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
failureCode, failureReason := firstFailure(operations) failureCode, failureReason := firstFailure(operations)
return &Payment{ return &Payment{
PaymentRef: p.GetPaymentRef(), PaymentRef: p.GetPaymentRef(),
@@ -312,7 +314,7 @@ func firstFailure(operations []PaymentOperation) (string, string) {
return "", "" return "", ""
} }
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation { func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
if len(steps) == 0 { if len(steps) == 0 {
return nil return nil
} }
@@ -321,7 +323,7 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) { if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue continue
} }
ops = append(ops, toPaymentOperation(step)) ops = append(ops, toPaymentOperation(step, quote))
} }
if len(ops) == 0 { if len(ops) == 0 {
return nil return nil
@@ -329,17 +331,20 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
return ops return ops
} }
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation { func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) PaymentOperation {
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs()) operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
amount, convertedAmount := operationAmounts(step.GetStepCode(), quote)
op := PaymentOperation{ op := PaymentOperation{
StepRef: step.GetStepRef(), StepRef: step.GetStepRef(),
Code: step.GetStepCode(), Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()), State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()), Label: strings.TrimSpace(step.GetUserLabel()),
OperationRef: operationRef, Amount: amount,
Gateway: string(gateway), ConvertedAmount: convertedAmount,
StartedAt: timestampAsTime(step.GetStartedAt()), OperationRef: operationRef,
CompletedAt: timestampAsTime(step.GetCompletedAt()), Gateway: string(gateway),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
} }
failure := step.GetFailure() failure := step.GetFailure()
if failure == nil { if failure == nil {
@@ -353,6 +358,54 @@ func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
return op return op
} }
func operationAmounts(stepCode string, quote *quotationv2.PaymentQuote) (*paymenttypes.Money, *paymenttypes.Money) {
if quote == nil {
return nil, nil
}
operation := stepOperationToken(stepCode)
primary := firstValidMoney(
toMoney(quote.GetDestinationAmount()),
toMoney(quote.GetTransferPrincipalAmount()),
toMoney(quote.GetPayerTotalDebitAmount()),
)
if operation != "fx_convert" {
return primary, nil
}
base := firstValidMoney(
toMoney(quote.GetTransferPrincipalAmount()),
toMoney(quote.GetPayerTotalDebitAmount()),
toMoney(quote.GetFxQuote().GetBaseAmount()),
)
quoteAmount := firstValidMoney(
toMoney(quote.GetDestinationAmount()),
toMoney(quote.GetFxQuote().GetQuoteAmount()),
)
return base, quoteAmount
}
func stepOperationToken(stepCode string) string {
parts := strings.Split(strings.ToLower(strings.TrimSpace(stepCode)), ".")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[len(parts)-1])
}
func firstValidMoney(values ...*paymenttypes.Money) *paymenttypes.Money {
for _, value := range values {
if value == nil {
continue
}
if strings.TrimSpace(value.GetAmount()) == "" || strings.TrimSpace(value.GetCurrency()) == "" {
continue
}
return value
}
return nil
}
const ( const (
externalRefKindOperation = "operation_ref" externalRefKindOperation = "operation_ref"
) )

View File

@@ -4,6 +4,7 @@ import (
"testing" "testing"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
@@ -33,7 +34,7 @@ func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
}, },
} }
ops := toUserVisibleOperations(steps) ops := toUserVisibleOperations(steps, nil)
if len(ops) != 2 { if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops)) t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
} }
@@ -149,7 +150,7 @@ func TestToPaymentOperation_MapsOperationRefAndGateway(t *testing.T) {
Ref: "op-123", Ref: "op-123",
}, },
}, },
}) }, nil)
if got, want := op.OperationRef, "op-123"; got != want { if got, want := op.OperationRef, "op-123"; got != want {
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want) t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
@@ -164,7 +165,7 @@ func TestToPaymentOperation_InfersGatewayFromStepCode(t *testing.T) {
StepRef: "step-2", StepRef: "step-2",
StepCode: "edge.1_2.ledger.debit", StepCode: "edge.1_2.ledger.debit",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED, State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}) }, nil)
if got := op.OperationRef; got != "" { if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got) t.Fatalf("expected empty operation_ref, got=%q", got)
@@ -187,7 +188,7 @@ func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
Ref: "payout-123", Ref: "payout-123",
}, },
}, },
}) }, nil)
if got := op.OperationRef; got != "" { if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got) t.Fatalf("expected empty operation_ref, got=%q", got)
@@ -196,3 +197,57 @@ func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want) t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
} }
} }
func TestToPaymentOperation_MapsAmount(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-4",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, &quotationv2.PaymentQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected amount to be mapped")
}
if got, want := op.Amount.Amount, "100.00"; got != want {
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "EUR"; got != want {
t.Fatalf("amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.ConvertedAmount; got != nil {
t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
}
}
func TestToPaymentOperation_MapsFxTwoAmounts(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-5",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, &quotationv2.PaymentQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected fx base amount to be mapped")
}
if got, want := op.Amount.Amount, "110.00"; got != want {
t.Fatalf("base amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "USDT"; got != want {
t.Fatalf("base amount.currency mismatch: got=%q want=%q", got, want)
}
if op.ConvertedAmount == nil {
t.Fatal("expected fx converted amount to be mapped")
}
if got, want := op.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.ConvertedAmount.Currency, "EUR"; got != want {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -207,7 +207,7 @@ type grpcQuotationClient struct {
callTimeout time.Duration callTimeout time.Duration
} }
func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) { func newQuotationClient(_ context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
cfg.setDefaults() cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" { if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("payment quotation: address is required") return nil, merrors.InvalidArgument("payment quotation: address is required")

View File

@@ -397,8 +397,14 @@ components:
label: label:
description: Human-readable operation label. description: Human-readable operation label.
type: string type: string
amount:
description: Primary money amount associated with the operation.
$ref: ../common/money.yaml#/components/schemas/Money
convertedAmount:
description: Secondary amount for conversion operations (for example FX convert output amount).
$ref: ../common/money.yaml#/components/schemas/Money
operationRef: operationRef:
description: Internal operation reference identifier reported by the gateway. description: External operation reference identifier reported by the gateway.
type: string type: string
gateway: gateway:
description: Gateway microservice type handling the operation. description: Gateway microservice type handling the operation.