398 lines
10 KiB
Go
398 lines
10 KiB
Go
package quotation
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shopspring/decimal"
|
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
|
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
)
|
|
|
|
type moneyGetter interface {
|
|
GetAmount() string
|
|
GetCurrency() string
|
|
}
|
|
|
|
const (
|
|
feeLineMetaTarget = "fee_target"
|
|
feeLineTargetWallet = "wallet"
|
|
feeLineMetaWalletRef = "fee_wallet_ref"
|
|
feeLineMetaWalletType = "fee_wallet_type"
|
|
)
|
|
|
|
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
|
|
if input == nil {
|
|
return nil
|
|
}
|
|
return &moneyv1.Money{
|
|
Currency: input.GetCurrency(),
|
|
Amount: input.GetAmount(),
|
|
}
|
|
}
|
|
|
|
func cloneMetadata(input map[string]string) map[string]string {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
clone := make(map[string]string, len(input))
|
|
for k, v := range input {
|
|
clone[k] = v
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func cloneStringList(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
clean := strings.TrimSpace(value)
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
result = append(result, clean)
|
|
}
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine {
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
|
for _, line := range lines {
|
|
if line == nil {
|
|
continue
|
|
}
|
|
if cloned, ok := proto.Clone(line).(*feesv1.DerivedPostingLine); ok {
|
|
out = append(out, cloned)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneFeeRules(rules []*feesv1.AppliedRule) []*feesv1.AppliedRule {
|
|
if len(rules) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*feesv1.AppliedRule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if rule == nil {
|
|
continue
|
|
}
|
|
if cloned, ok := proto.Clone(rule).(*feesv1.AppliedRule); ok {
|
|
out = append(out, cloned)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *moneyv1.Money {
|
|
if len(lines) == 0 || currency == "" {
|
|
return nil
|
|
}
|
|
total := decimal.Zero
|
|
for _, line := range lines {
|
|
if line == nil || line.GetMoney() == nil {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(line.GetMoney().GetCurrency(), currency) {
|
|
continue
|
|
}
|
|
amount, err := decimal.NewFromString(line.GetMoney().GetAmount())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
switch line.GetSide() {
|
|
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
|
total = total.Sub(amount.Abs())
|
|
default:
|
|
total = total.Add(amount.Abs())
|
|
}
|
|
}
|
|
if total.IsZero() {
|
|
return nil
|
|
}
|
|
return &moneyv1.Money{
|
|
Currency: currency,
|
|
Amount: total.String(),
|
|
}
|
|
}
|
|
|
|
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
|
|
if fxQuote == nil {
|
|
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
|
|
}
|
|
qSide := fxQuote.GetSide()
|
|
if qSide == fxv1.Side_SIDE_UNSPECIFIED {
|
|
qSide = side
|
|
}
|
|
|
|
switch qSide {
|
|
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
|
pay := cloneProtoMoney(fxQuote.GetQuoteAmount())
|
|
settle := cloneProtoMoney(fxQuote.GetBaseAmount())
|
|
if pay == nil {
|
|
pay = cloneProtoMoney(intentAmount)
|
|
}
|
|
if settle == nil {
|
|
settle = cloneProtoMoney(intentAmount)
|
|
}
|
|
return pay, settle
|
|
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
|
pay := cloneProtoMoney(fxQuote.GetBaseAmount())
|
|
settle := cloneProtoMoney(fxQuote.GetQuoteAmount())
|
|
if pay == nil {
|
|
pay = cloneProtoMoney(intentAmount)
|
|
}
|
|
if settle == nil {
|
|
settle = cloneProtoMoney(intentAmount)
|
|
}
|
|
return pay, settle
|
|
default:
|
|
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
|
|
}
|
|
}
|
|
|
|
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode sharedv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
|
|
if pay == nil {
|
|
return nil, nil
|
|
}
|
|
debitDecimal, err := decimalFromMoney(pay)
|
|
if err != nil {
|
|
return cloneProtoMoney(pay), cloneProtoMoney(settlement)
|
|
}
|
|
|
|
settlementCurrency := pay.GetCurrency()
|
|
if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
|
settlementCurrency = settlement.GetCurrency()
|
|
}
|
|
|
|
settlementDecimal := debitDecimal
|
|
if settlement != nil {
|
|
if val, err := decimalFromMoney(settlement); err == nil {
|
|
settlementDecimal = val
|
|
}
|
|
}
|
|
|
|
applyChargeToDebit := func(m *moneyv1.Money) {
|
|
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
|
|
if err != nil || converted == nil {
|
|
return
|
|
}
|
|
if val, err := decimalFromMoney(converted); err == nil {
|
|
debitDecimal = debitDecimal.Add(val)
|
|
}
|
|
}
|
|
|
|
applyChargeToSettlement := func(m *moneyv1.Money) {
|
|
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
|
|
if err != nil || converted == nil {
|
|
return
|
|
}
|
|
if val, err := decimalFromMoney(converted); err == nil {
|
|
settlementDecimal = settlementDecimal.Sub(val)
|
|
}
|
|
}
|
|
|
|
switch mode {
|
|
case sharedv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
|
// Sender pays the fee: keep settlement fixed, increase debit.
|
|
applyChargeToDebit(fee)
|
|
default:
|
|
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
|
|
applyChargeToSettlement(fee)
|
|
}
|
|
|
|
if network != nil && network.GetNetworkFee() != nil {
|
|
switch mode {
|
|
case sharedv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
|
applyChargeToDebit(network.GetNetworkFee())
|
|
default:
|
|
applyChargeToSettlement(network.GetNetworkFee())
|
|
}
|
|
}
|
|
|
|
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
|
|
}
|
|
|
|
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
|
|
if m == nil {
|
|
return decimal.Zero, nil
|
|
}
|
|
return decimal.NewFromString(m.GetAmount())
|
|
}
|
|
|
|
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
|
return &moneyv1.Money{
|
|
Currency: currency,
|
|
Amount: value.String(),
|
|
}
|
|
}
|
|
|
|
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
|
|
if m == nil || strings.TrimSpace(targetCurrency) == "" {
|
|
return nil, nil
|
|
}
|
|
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
|
|
return cloneProtoMoney(m), nil
|
|
}
|
|
return convertWithQuote(m, quote, targetCurrency)
|
|
}
|
|
|
|
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
|
|
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
base := strings.TrimSpace(quote.GetPair().GetBase())
|
|
qt := strings.TrimSpace(quote.GetPair().GetQuote())
|
|
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
|
|
if err != nil || price.IsZero() {
|
|
return nil, err
|
|
}
|
|
value, err := decimalFromMoney(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch {
|
|
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
|
|
return makeMoney(targetCurrency, value.Mul(price)), nil
|
|
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
|
|
return makeMoney(targetCurrency, value.Div(price)), nil
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
var pricedAt *timestamppb.Timestamp
|
|
if !src.PricedAt.IsZero() {
|
|
pricedAt = timestamppb.New(src.PricedAt.UTC())
|
|
}
|
|
return &oraclev1.Quote{
|
|
QuoteRef: src.QuoteRef,
|
|
Pair: src.Pair,
|
|
Side: src.Side,
|
|
Price: &moneyv1.Decimal{Value: src.Price},
|
|
BaseAmount: cloneProtoMoney(src.BaseAmount),
|
|
QuoteAmount: cloneProtoMoney(src.QuoteAmount),
|
|
ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(),
|
|
PricedAt: pricedAt,
|
|
Provider: src.Provider,
|
|
RateRef: src.RateRef,
|
|
Firm: src.Firm,
|
|
}
|
|
}
|
|
|
|
func setFeeLineTarget(lines []*feesv1.DerivedPostingLine, target string) {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" || len(lines) == 0 {
|
|
return
|
|
}
|
|
for _, line := range lines {
|
|
if line == nil {
|
|
continue
|
|
}
|
|
if line.Meta == nil {
|
|
line.Meta = map[string]string{}
|
|
}
|
|
line.Meta[feeLineMetaTarget] = target
|
|
if strings.EqualFold(target, feeLineTargetWallet) {
|
|
line.LedgerAccountRef = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func feeLineTarget(line *feesv1.DerivedPostingLine) string {
|
|
if line == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(line.GetMeta()[feeLineMetaTarget])
|
|
}
|
|
|
|
func isWalletTargetFeeLine(line *feesv1.DerivedPostingLine) bool {
|
|
return strings.EqualFold(feeLineTarget(line), feeLineTargetWallet)
|
|
}
|
|
|
|
func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletType string) {
|
|
walletRef = strings.TrimSpace(walletRef)
|
|
walletType = strings.TrimSpace(walletType)
|
|
if walletRef == "" || len(lines) == 0 {
|
|
return
|
|
}
|
|
for _, line := range lines {
|
|
if line == nil {
|
|
continue
|
|
}
|
|
if line.Meta == nil {
|
|
line.Meta = map[string]string{}
|
|
}
|
|
line.Meta[feeLineMetaWalletRef] = walletRef
|
|
if walletType != "" {
|
|
line.Meta[feeLineMetaWalletType] = walletType
|
|
}
|
|
}
|
|
}
|
|
|
|
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
|
|
expiry := time.Time{}
|
|
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
|
|
expiry = feeQuote.GetExpiresAt().AsTime()
|
|
}
|
|
if expiry.IsZero() {
|
|
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
|
|
}
|
|
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
|
|
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
|
|
if fxExpiry.Before(expiry) {
|
|
expiry = fxExpiry
|
|
}
|
|
}
|
|
return expiry
|
|
}
|
|
|
|
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
|
|
if account == "" || len(lines) == 0 {
|
|
return lines
|
|
}
|
|
for _, line := range lines {
|
|
if line == nil || isWalletTargetFeeLine(line) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
|
|
continue
|
|
}
|
|
line.LedgerAccountRef = account
|
|
}
|
|
return lines
|
|
}
|