payment rails

This commit is contained in:
Stephan D
2025-12-10 18:40:55 +01:00
parent 81d2db394b
commit 47899e25d4
39 changed files with 3043 additions and 909 deletions

View File

@@ -0,0 +1,47 @@
package srequest
// Asset represents a chain/token pair for blockchain endpoints.
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
// LedgerEndpoint represents a ledger account payload.
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
// ManagedWalletEndpoint represents a managed wallet payload.
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
// ExternalChainEndpoint represents an external chain address payload.
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
// CardEndpoint represents a card payout payload.
type CardEndpoint struct {
Pan string `json:"pan,omitempty"`
Token string `json:"token,omitempty"`
Cardholder string `json:"cardholder,omitempty"`
ExpMonth uint32 `json:"exp_month,omitempty"`
ExpYear uint32 `json:"exp_year,omitempty"`
Country string `json:"country,omitempty"`
MaskedPan string `json:"masked_pan,omitempty"`
}
// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility.
type LegacyPaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Card *CardEndpoint `json:"card,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

View File

@@ -0,0 +1,211 @@
package srequest
import (
"encoding/json"
"github.com/tech/sendico/pkg/merrors"
)
type EndpointType string
const (
EndpointTypeLedger EndpointType = "ledger"
EndpointTypeManagedWallet EndpointType = "managed_wallet"
EndpointTypeExternalChain EndpointType = "external_chain"
EndpointTypeCard EndpointType = "card"
)
// Endpoint is a discriminated union for payment endpoints.
type Endpoint struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]string) (Endpoint, error) {
data, err := json.Marshal(payload)
if err != nil {
return Endpoint{}, merrors.Internal("marshal endpoint payload failed")
}
return Endpoint{
Type: kind,
Data: data,
Metadata: cloneStringMap(metadata),
}, nil
}
func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error {
if e.Type == "" {
return merrors.InvalidArgument("endpoint type is required")
}
if e.Type != expected {
return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type))
}
if len(e.Data) == 0 {
return merrors.InvalidArgument("endpoint data is required for type " + string(expected))
}
if err := json.Unmarshal(e.Data, dst); err != nil {
return merrors.InvalidArgument("decode " + string(expected) + " endpoint: " + err.Error())
}
return nil
}
func (e *Endpoint) UnmarshalJSON(data []byte) error {
var envelope struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(data, &envelope); err == nil {
if envelope.Type != "" || len(envelope.Data) > 0 {
if envelope.Type == "" {
return merrors.InvalidArgument("endpoint type is required")
}
*e = Endpoint{
Type: envelope.Type,
Data: envelope.Data,
Metadata: cloneStringMap(envelope.Metadata),
}
return nil
}
}
var legacy LegacyPaymentEndpoint
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(&legacy)
if err != nil {
return err
}
if endpoint == nil {
return merrors.InvalidArgument("endpoint payload is empty")
}
*e = *endpoint
return nil
}
func NewLedgerEndpointDTO(payload LedgerEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeLedger, payload, metadata)
}
func NewManagedWalletEndpointDTO(payload ManagedWalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeManagedWallet, payload, metadata)
}
func NewExternalChainEndpointDTO(payload ExternalChainEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeExternalChain, payload, metadata)
}
func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCard, payload, metadata)
}
func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) {
var payload LedgerEndpoint
return payload, e.decodePayload(EndpointTypeLedger, &payload)
}
func (e Endpoint) DecodeManagedWallet() (ManagedWalletEndpoint, error) {
var payload ManagedWalletEndpoint
return payload, e.decodePayload(EndpointTypeManagedWallet, &payload)
}
func (e Endpoint) DecodeExternalChain() (ExternalChainEndpoint, error) {
var payload ExternalChainEndpoint
return payload, e.decodePayload(EndpointTypeExternalChain, &payload)
}
func (e Endpoint) DecodeCard() (CardEndpoint, error) {
var payload CardEndpoint
return payload, e.decodePayload(EndpointTypeCard, &payload)
}
func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) {
if old == nil {
return nil, nil
}
count := 0
var endpoint Endpoint
var err error
if old.Ledger != nil {
count++
endpoint, err = NewLedgerEndpointDTO(*old.Ledger, old.Metadata)
}
if old.ManagedWallet != nil {
count++
endpoint, err = NewManagedWalletEndpointDTO(*old.ManagedWallet, old.Metadata)
}
if old.ExternalChain != nil {
count++
endpoint, err = NewExternalChainEndpointDTO(*old.ExternalChain, old.Metadata)
}
if old.Card != nil {
count++
endpoint, err = NewCardEndpointDTO(*old.Card, old.Metadata)
}
if err != nil {
return nil, err
}
if count == 0 {
return nil, merrors.InvalidArgument("exactly one endpoint must be set")
}
if count > 1 {
return nil, merrors.InvalidArgument("only one endpoint can be set")
}
return &endpoint, nil
}
func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint, error) {
if new == nil {
return nil, nil
}
legacy := &LegacyPaymentEndpoint{
Metadata: cloneStringMap(new.Metadata),
}
switch new.Type {
case EndpointTypeLedger:
payload, err := new.DecodeLedger()
if err != nil {
return nil, err
}
legacy.Ledger = &payload
case EndpointTypeManagedWallet:
payload, err := new.DecodeManagedWallet()
if err != nil {
return nil, err
}
legacy.ManagedWallet = &payload
case EndpointTypeExternalChain:
payload, err := new.DecodeExternalChain()
if err != nil {
return nil, err
}
legacy.ExternalChain = &payload
case EndpointTypeCard:
payload, err := new.DecodeCard()
if err != nil {
return nil, err
}
legacy.Card = &payload
default:
return nil, merrors.InvalidArgument("unsupported endpoint type: " + string(new.Type))
}
return legacy, nil
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -1,17 +1,60 @@
package srequest
import (
"github.com/tech/sendico/pkg/merrors"
)
type QuotePayment struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
Intent PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type InitiatePayment struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
Metadata map[string]string `json:"metadata,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FxQuoteRef string `json:"fxQuoteRef,omitempty"`
Intent *PaymentIntent `json:"intent,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
}
func (r QuotePayment) Validate() error {
if r.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
if validator, ok := any(r.Intent).(interface{ Validate() error }); ok {
if err := validator.Validate(); err != nil {
return err
}
}
return nil
}
// Validate проверяет базовые инварианты запроса на инициацию платежа.
func (r InitiatePayment) Validate() error {
if r.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
hasIntent := r.Intent != nil
hasQuote := r.QuoteRef != ""
switch {
case !hasIntent && !hasQuote:
return merrors.NoData("either intent or quoteRef must be provided")
case hasIntent && hasQuote:
return merrors.DataConflict("intent and quoteRef are mutually exclusive")
}
if hasIntent {
if validator, ok := any(*r.Intent).(interface{ Validate() error }); ok {
if err := validator.Validate(); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,50 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
// Strings keep JSON readable; conversion helpers map these to proto enums.
type PaymentKind string
const (
PaymentKindUnspecified PaymentKind = "unspecified"
PaymentKindPayout PaymentKind = "payout"
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
PaymentKindFxConversion PaymentKind = "fx_conversion"
)
// SettlementMode matches orchestrator settlement behavior.
type SettlementMode string
const (
SettlementModeUnspecified SettlementMode = "unspecified"
SettlementModeFixSource SettlementMode = "fix_source"
SettlementModeFixReceived SettlementMode = "fix_received"
)
// FXSide mirrors the common FX side enum.
type FXSide string
const (
FXSideUnspecified FXSide = "unspecified"
FXSideBuyBaseSellQuote FXSide = "buy_base_sell_quote"
FXSideSellBaseBuyQuote FXSide = "sell_base_buy_quote"
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork string
const (
ChainNetworkUnspecified ChainNetwork = "unspecified"
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
ChainNetworkOtherEVM ChainNetwork = "other_evm"
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy string
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = "unspecified"
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = "block_posting"
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = "sweep_org_cash"
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = "invoice_later"
)

View File

@@ -0,0 +1,12 @@
package srequest
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *Endpoint `json:"source,omitempty"`
Destination *Endpoint `json:"destination,omitempty"`
Amount *Money `json:"amount,omitempty"`
RequiresFX bool `json:"requires_fx,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}

View File

@@ -1,103 +0,0 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
type PaymentKind int32
const (
PaymentKindUnspecified PaymentKind = 0
PaymentKindPayout PaymentKind = 1
PaymentKindInternalTransfer PaymentKind = 2
PaymentKindFxConversion PaymentKind = 3
)
// FXSide mirrors the common FX side enum.
type FXSide int32
const (
FXSideUnspecified FXSide = 0
FXSideBuyBaseSellQuote FXSide = 1
FXSideSellBaseBuyQuote FXSide = 2
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork int32
const (
ChainNetworkUnspecified ChainNetwork = 0
ChainNetworkEthereumMainnet ChainNetwork = 1
ChainNetworkArbitrumOne ChainNetwork = 2
ChainNetworkOtherEVM ChainNetwork = 3
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy int32
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = 0
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = 1
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = 2
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = 3
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *PaymentEndpoint `json:"source,omitempty"`
Destination *PaymentEndpoint `json:"destination,omitempty"`
Amount *Money `json:"amount,omitempty"`
RequiresFX bool `json:"requires_fx,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
FeePolicy *PolicyOverrides `json:"fee_policy,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type PaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
type PolicyOverrides struct {
InsufficientNet InsufficientNetPolicy `json:"insufficient_net,omitempty"`
}

View File

@@ -0,0 +1,313 @@
package srequest
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
meta := map[string]string{"note": "meta"}
t.Run("ledger", func(t *testing.T) {
payload := LedgerEndpoint{LedgerAccountRef: "acc-1", ContraLedgerAccountRef: "contra-1"}
endpoint, err := NewLedgerEndpointDTO(payload, meta)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
if string(endpoint.Data) != `{"ledger_account_ref":"acc-1","contra_ledger_account_ref":"contra-1"}` {
t.Fatalf("unexpected data: %s", endpoint.Data)
}
decoded, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
meta["note"] = "changed"
if endpoint.Metadata["note"] != "meta" {
t.Fatalf("metadata should be copied, got %s", endpoint.Metadata["note"])
}
})
t.Run("managed wallet", func(t *testing.T) {
payload := ManagedWalletEndpoint{
ManagedWalletRef: "mw-1",
Asset: &Asset{
Chain: ChainNetworkArbitrumOne,
TokenSymbol: "USDC",
ContractAddress: "0xabc",
},
}
endpoint, err := NewManagedWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build managed wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("external chain", func(t *testing.T) {
payload := ExternalChainEndpoint{
Asset: &Asset{
Chain: ChainNetworkOtherEVM,
TokenSymbol: "ETH",
},
Address: "0x123",
Memo: "memo",
}
endpoint, err := NewExternalChainEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build external chain endpoint: %v", err)
}
if endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("expected type %s got %s", EndpointTypeExternalChain, endpoint.Type)
}
decoded, err := endpoint.DecodeExternalChain()
if err != nil {
t.Fatalf("decode external chain: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("card", func(t *testing.T) {
payload := CardEndpoint{Pan: "pan", Token: "token", Cardholder: "Jane", ExpMonth: 12, ExpYear: 2030, Country: "US", MaskedPan: "****"}
endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"})
if err != nil {
t.Fatalf("build card endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCard {
t.Fatalf("expected type %s got %s", EndpointTypeCard, endpoint.Type)
}
decoded, err := endpoint.DecodeCard()
if err != nil {
t.Fatalf("decode card: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["k"] != "v" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["k"])
}
})
t.Run("type mismatch", func(t *testing.T) {
endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if _, err := endpoint.DecodeCard(); err == nil || !strings.Contains(err.Error(), "expected endpoint type") {
t.Fatalf("expected type mismatch error, got %v", err)
}
})
t.Run("invalid json data", func(t *testing.T) {
endpoint := Endpoint{Type: EndpointTypeLedger, Data: json.RawMessage("not-json")}
if _, err := endpoint.DecodeLedger(); err == nil {
t.Fatalf("expected decode error")
}
})
}
func TestPaymentIntentJSONRoundTrip(t *testing.T) {
sourcePayload := LedgerEndpoint{LedgerAccountRef: "source"}
source, err := NewLedgerEndpointDTO(sourcePayload, map[string]string{"src": "meta"})
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := ExternalChainEndpoint{Address: "0xabc", Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "USDC"}}
dest, err := NewExternalChainEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindPayout,
Source: &source,
Destination: &dest,
Amount: &Money{Amount: "10", Currency: "USD"},
RequiresFX: true,
FX: &FXIntent{
Pair: &CurrencyPair{Base: "USD", Quote: "EUR"},
Side: FXSideBuyBaseSellQuote,
Firm: true,
TTLms: 5000,
PreferredProvider: "provider",
MaxAgeMs: 10,
},
SettlementMode: SettlementModeFixReceived,
Attributes: map[string]string{"k": "v"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.RequiresFX != intent.RequiresFX || decoded.SettlementMode != intent.SettlementMode {
t.Fatalf("scalar fields changed after round trip")
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.FX == nil || decoded.FX.PreferredProvider != intent.FX.PreferredProvider {
t.Fatalf("fx mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("source/destination missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeLedger()
if err != nil {
t.Fatalf("decode source after round trip: %v", err)
}
if sourceDecoded != sourcePayload {
t.Fatalf("source payload mismatch after round trip: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeExternalChain()
if err != nil {
t.Fatalf("decode destination after round trip: %v", err)
}
if !reflect.DeepEqual(destDecoded, destPayload) {
t.Fatalf("destination payload mismatch after round trip: %#v vs %#v", destDecoded, destPayload)
}
if decoded.Attributes["k"] != "v" {
t.Fatalf("attributes mismatch after round trip")
}
}
func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
sourcePayload := ManagedWalletEndpoint{ManagedWalletRef: "mw"}
source, err := NewManagedWalletEndpointDTO(sourcePayload, nil)
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := LedgerEndpoint{LedgerAccountRef: "dest-ledger"}
dest, err := NewLedgerEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindInternalTransfer,
Source: &source,
Destination: &dest,
Amount: &Money{Amount: "1", Currency: "USD"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.RequiresFX || decoded.FX != nil {
t.Fatalf("unexpected fx data in minimal intent: %#v", decoded)
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("endpoints missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode source: %v", err)
}
if !reflect.DeepEqual(sourceDecoded, sourcePayload) {
t.Fatalf("source payload mismatch: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeLedger()
if err != nil {
t.Fatalf("decode destination: %v", err)
}
if destDecoded != destPayload {
t.Fatalf("destination payload mismatch: %#v vs %#v", destDecoded, destPayload)
}
}
func TestLegacyEndpointRoundTrip(t *testing.T) {
legacy := &LegacyPaymentEndpoint{
ExternalChain: &ExternalChainEndpoint{
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"},
Address: "0x123",
Memo: "memo",
},
Metadata: map[string]string{"note": "legacy"},
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(legacy)
if err != nil {
t.Fatalf("convert legacy to dto: %v", err)
}
if endpoint == nil || endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("unexpected endpoint result: %#v", endpoint)
}
legacy.Metadata["note"] = "changed"
if endpoint.Metadata["note"] != "legacy" {
t.Fatalf("metadata should be copied from legacy")
}
roundTrip, err := EndpointDTOToLegacyPaymentEndpoint(endpoint)
if err != nil {
t.Fatalf("convert dto back to legacy: %v", err)
}
if roundTrip == nil || roundTrip.ExternalChain == nil {
t.Fatalf("round trip legacy missing payload: %#v", roundTrip)
}
if !reflect.DeepEqual(roundTrip.ExternalChain, legacy.ExternalChain) {
t.Fatalf("round trip payload mismatch: %#v vs %#v", roundTrip.ExternalChain, legacy.ExternalChain)
}
if roundTrip.Metadata["note"] != "legacy" {
t.Fatalf("metadata mismatch after round trip: %v", roundTrip.Metadata)
}
}
func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) {
_, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{
Ledger: &LedgerEndpoint{LedgerAccountRef: "a"},
Card: &CardEndpoint{Token: "t"},
})
if err == nil {
t.Fatalf("expected error when multiple legacy endpoints are set")
}
}
func TestEndpointUnmarshalLegacyShape(t *testing.T) {
raw := []byte(`{"ledger":{"ledger_account_ref":"abc"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal legacy shape: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
payload, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger from legacy shape: %v", err)
}
if payload.LedgerAccountRef != "abc" {
t.Fatalf("unexpected payload from legacy shape: %#v", payload)
}
}

View File

@@ -0,0 +1,20 @@
package srequest
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}