payment rails
This commit is contained in:
47
api/server/interface/api/srequest/endpoint_payloads.go
Normal file
47
api/server/interface/api/srequest/endpoint_payloads.go
Normal 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"`
|
||||
}
|
||||
211
api/server/interface/api/srequest/endpoint_union.go
Normal file
211
api/server/interface/api/srequest/endpoint_union.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
50
api/server/interface/api/srequest/payment_enums.go
Normal file
50
api/server/interface/api/srequest/payment_enums.go
Normal 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"
|
||||
)
|
||||
12
api/server/interface/api/srequest/payment_intent.go
Normal file
12
api/server/interface/api/srequest/payment_intent.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
313
api/server/interface/api/srequest/payment_types_test.go
Normal file
313
api/server/interface/api/srequest/payment_types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
api/server/interface/api/srequest/payment_value_objects.go
Normal file
20
api/server/interface/api/srequest/payment_value_objects.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user