+source +destination in payments

This commit is contained in:
Stephan D
2026-03-10 19:15:20 +01:00
parent 9c2b3bf8bd
commit e5b4de5d48
16 changed files with 716 additions and 56 deletions

View File

@@ -14,8 +14,11 @@ import (
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -67,16 +70,24 @@ type PaymentQuotes struct {
}
type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
State string `json:"state,omitempty"`
Comment string `json:"comment,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"`
PaymentRef string `json:"paymentRef,omitempty"`
State string `json:"state,omitempty"`
Comment string `json:"comment,omitempty"`
Source *PaymentEndpoint `json:"source"`
Destination *PaymentEndpoint `json:"destination"`
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 PaymentEndpoint struct {
Type string `json:"type,omitempty"`
Data any `json:"data,omitempty"`
PaymentMethodRef string `json:"paymentMethodRef,omitempty"`
PayeeRef string `json:"payeeRef,omitempty"`
}
type PaymentOperation struct {
@@ -290,22 +301,257 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
intent := p.GetIntentSnapshot()
operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
failureCode, failureReason := firstFailure(operations)
return &Payment{
PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()),
Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()),
FailureCode: failureCode,
FailureReason: failureReason,
Operations: operations,
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
CreatedAt: timestampAsTime(p.GetCreatedAt()),
Meta: paymentMeta(p),
IdempotencyKey: "",
PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()),
Comment: strings.TrimSpace(intent.GetComment()),
Source: toPaymentEndpoint(intent.GetSource()),
Destination: toPaymentEndpoint(intent.GetDestination()),
FailureCode: failureCode,
FailureReason: failureReason,
Operations: operations,
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
CreatedAt: timestampAsTime(p.GetCreatedAt()),
Meta: paymentMeta(p),
}
}
func toPaymentEndpoint(endpoint *endpointv1.PaymentEndpoint) *PaymentEndpoint {
if endpoint == nil {
return nil
}
if paymentMethodRef := strings.TrimSpace(endpoint.GetPaymentMethodRef()); paymentMethodRef != "" {
return &PaymentEndpoint{PaymentMethodRef: paymentMethodRef}
}
if payeeRef := strings.TrimSpace(endpoint.GetPayeeRef()); payeeRef != "" {
return &PaymentEndpoint{PayeeRef: payeeRef}
}
method := endpoint.GetPaymentMethod()
if method == nil {
return nil
}
return &PaymentEndpoint{
Type: paymentEndpointType(method.GetType()),
Data: paymentEndpointData(method),
}
}
func paymentEndpointType(methodType endpointv1.PaymentMethodType) string {
switch methodType {
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN:
return string(srequest.EndpointTypeIBAN)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
return string(srequest.EndpointTypeCard)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN:
return string(srequest.EndpointTypeCardToken)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT:
return string(srequest.EndpointTypeBankAccount)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET:
return string(srequest.EndpointTypeWallet)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS:
return string(srequest.EndpointTypeExternalChain)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER:
return string(srequest.EndpointTypeLedger)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT:
return "account"
default:
return "unspecified"
}
}
func paymentEndpointData(method *endpointv1.PaymentMethod) any {
if method == nil {
return nil
}
switch method.GetType() {
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER:
type ledgerMethodData struct {
LedgerAccountRef string `bson:"ledgerAccountRef"`
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
}
var payload ledgerMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
return srequest.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef),
}
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET:
type walletMethodData struct {
WalletID string `bson:"walletId"`
}
var payload walletMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
return srequest.WalletEndpoint{
WalletID: strings.TrimSpace(payload.WalletID),
}
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS:
type cryptoMethodData struct {
Currency string `bson:"currency"`
Address string `bson:"address"`
Network string `bson:"network"`
DestinationTag *string `bson:"destinationTag,omitempty"`
}
var payload cryptoMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
endpoint := srequest.ExternalChainEndpoint{
Asset: &srequest.Asset{
Chain: parseChainNetwork(payload.Network),
TokenSymbol: strings.ToUpper(strings.TrimSpace(payload.Currency)),
},
Address: strings.TrimSpace(payload.Address),
}
if memo := strings.TrimSpace(strPtr(payload.DestinationTag)); memo != "" {
endpoint.Memo = memo
}
if endpoint.Asset.Chain == srequest.ChainNetworkUnspecified && endpoint.Asset.TokenSymbol == "" {
endpoint.Asset = nil
}
return endpoint
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
type cardMethodData struct {
Pan string `bson:"pan"`
FirstName string `bson:"firstName"`
LastName string `bson:"lastName"`
ExpMonth string `bson:"expMonth"`
ExpYear string `bson:"expYear"`
Country string `bson:"country,omitempty"`
}
var payload cardMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
return srequest.CardEndpoint{
Pan: strings.TrimSpace(payload.Pan),
FirstName: strings.TrimSpace(payload.FirstName),
LastName: strings.TrimSpace(payload.LastName),
ExpMonth: parseUint32(payload.ExpMonth),
ExpYear: parseUint32(payload.ExpYear),
Country: strings.TrimSpace(payload.Country),
}
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN:
type cardTokenMethodData struct {
Token string `bson:"token"`
Last4 string `bson:"last4,omitempty"`
}
var payload cardTokenMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
return srequest.CardTokenEndpoint{
Token: strings.TrimSpace(payload.Token),
MaskedPan: strings.TrimSpace(payload.Last4),
}
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT:
type bankAccountMethodData struct {
RecipientName string `bson:"recipientName"`
Inn string `bson:"inn"`
Kpp string `bson:"kpp"`
BankName string `bson:"bankName"`
Bik string `bson:"bik"`
AccountNumber string `bson:"accountNumber"`
CorrespondentAccount string `bson:"correspondentAccount"`
}
var payload bankAccountMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
return srequest.BankAccountEndpoint{
RecipientName: strings.TrimSpace(payload.RecipientName),
Inn: strings.TrimSpace(payload.Inn),
Kpp: strings.TrimSpace(payload.Kpp),
BankName: strings.TrimSpace(payload.BankName),
Bik: strings.TrimSpace(payload.Bik),
AccountNumber: strings.TrimSpace(payload.AccountNumber),
CorrespondentAccount: strings.TrimSpace(payload.CorrespondentAccount),
}
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN:
type ibanMethodData struct {
IBAN string `bson:"iban"`
AccountHolder string `bson:"accountHolder"`
BIC *string `bson:"bic,omitempty"`
BankName *string `bson:"bankName,omitempty"`
}
var payload ibanMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return toRawBSON(method.GetData())
}
return srequest.IBANEndpoint{
IBAN: strings.TrimSpace(payload.IBAN),
AccountHolder: strings.TrimSpace(payload.AccountHolder),
BIC: strings.TrimSpace(strPtr(payload.BIC)),
BankName: strings.TrimSpace(strPtr(payload.BankName)),
}
default:
return toRawBSON(method.GetData())
}
}
func toRawBSON(raw []byte) map[string]any {
if len(raw) == 0 {
return nil
}
var data map[string]any
if err := bson.Unmarshal(raw, &data); err != nil {
return nil
}
if len(data) == 0 {
return nil
}
return data
}
func parseChainNetwork(value string) srequest.ChainNetwork {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ETHEREUM_MAINNET":
return srequest.ChainNetworkEthereumMainnet
case "ARBITRUM_ONE":
return srequest.ChainNetworkArbitrumOne
case "TRON_MAINNET":
return srequest.ChainNetworkTronMainnet
case "TRON_NILE":
return srequest.ChainNetworkTronNile
case "", "UNSPECIFIED":
return srequest.ChainNetworkUnspecified
default:
return srequest.ChainNetwork(strings.ToLower(strings.TrimSpace(value)))
}
}
func parseUint32(value string) uint32 {
clean := strings.TrimSpace(value)
if clean == "" {
return 0
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0
}
return uint32(parsed)
}
func strPtr(v *string) string {
if v == nil {
return ""
}
return *v
}
func firstFailure(operations []PaymentOperation) (string, string) {
for _, op := range operations {
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {

View File

@@ -5,9 +5,12 @@ import (
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
@@ -137,6 +140,125 @@ func TestToPaymentMapsIntentComment(t *testing.T) {
}
}
func TestToPaymentMapsSourceAndDestination(t *testing.T) {
sourceRaw, err := bson.Marshal(struct {
WalletID string `bson:"walletId"`
}{
WalletID: "wallet-src-1",
})
if err != nil {
t.Fatalf("marshal source method data: %v", err)
}
destinationRaw, err := bson.Marshal(struct {
Currency string `bson:"currency"`
Address string `bson:"address"`
Network string `bson:"network"`
DestinationTag *string `bson:"destinationTag,omitempty"`
}{
Currency: "USDT",
Address: "TXabc",
Network: "TRON_MAINNET",
})
if err != nil {
t.Fatalf("marshal destination method data: %v", err)
}
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-src-dst",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
IntentSnapshot: &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
Data: sourceRaw,
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS,
Data: destinationRaw,
},
},
},
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if dto.Source == nil {
t.Fatal("expected source endpoint")
}
if got, want := dto.Source.Type, string(srequest.EndpointTypeWallet); got != want {
t.Fatalf("source type mismatch: got=%q want=%q", got, want)
}
sourceEndpoint, ok := dto.Source.Data.(srequest.WalletEndpoint)
if !ok {
t.Fatalf("source endpoint payload type mismatch: got=%T", dto.Source.Data)
}
if got, want := sourceEndpoint.WalletID, "wallet-src-1"; got != want {
t.Fatalf("source wallet id mismatch: got=%q want=%q", got, want)
}
if dto.Destination == nil {
t.Fatal("expected destination endpoint")
}
if got, want := dto.Destination.Type, string(srequest.EndpointTypeExternalChain); got != want {
t.Fatalf("destination type mismatch: got=%q want=%q", got, want)
}
destinationEndpoint, ok := dto.Destination.Data.(srequest.ExternalChainEndpoint)
if !ok {
t.Fatalf("destination endpoint payload type mismatch: got=%T", dto.Destination.Data)
}
if got, want := destinationEndpoint.Address, "TXabc"; got != want {
t.Fatalf("destination address mismatch: got=%q want=%q", got, want)
}
if destinationEndpoint.Asset == nil {
t.Fatal("expected destination asset")
}
if got, want := destinationEndpoint.Asset.TokenSymbol, "USDT"; got != want {
t.Fatalf("destination token mismatch: got=%q want=%q", got, want)
}
if got, want := destinationEndpoint.Asset.Chain, srequest.ChainNetworkTronMainnet; got != want {
t.Fatalf("destination chain mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentMapsEndpointRefs(t *testing.T) {
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-refs",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
IntentSnapshot: &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{
PaymentMethodRef: "pm-123",
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PayeeRef{
PayeeRef: "payee-777",
},
},
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if dto.Source == nil {
t.Fatal("expected source endpoint")
}
if got, want := dto.Source.PaymentMethodRef, "pm-123"; got != want {
t.Fatalf("source payment_method_ref mismatch: got=%q want=%q", got, want)
}
if dto.Destination == nil {
t.Fatal("expected destination endpoint")
}
if got, want := dto.Destination.PayeeRef, "payee-777"; got != want {
t.Fatalf("destination payee_ref mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
dto := toPaymentQuote(&quotationv2.PaymentQuote{
QuoteRef: "quote-1",