package orchestrator import ( "strings" "time" "github.com/shopspring/decimal" oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/pkg/merrors" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "google.golang.org/protobuf/proto" 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 orchestratorv1.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 orchestratorv1.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 orchestratorv1.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 } 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(), 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 ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine { if len(lines) == 0 { return nil } charges := make([]*ledgerv1.PostingLine, 0, len(lines)) for _, line := range lines { if line == nil || isWalletTargetFeeLine(line) || strings.TrimSpace(line.GetLedgerAccountRef()) == "" { continue } money := cloneProtoMoney(line.GetMoney()) if money == nil { continue } charges = append(charges, &ledgerv1.PostingLine{ LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), Money: money, LineType: ledgerLineTypeFromAccounting(line.GetLineType()), }) } if len(charges) == 0 { return nil } return charges } func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv1.LineType { switch lineType { case accountingv1.PostingLineType_POSTING_LINE_SPREAD: return ledgerv1.LineType_LINE_SPREAD case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: return ledgerv1.LineType_LINE_REVERSAL case accountingv1.PostingLineType_POSTING_LINE_FEE, accountingv1.PostingLineType_POSTING_LINE_TAX: return ledgerv1.LineType_LINE_FEE default: return ledgerv1.LineType_LINE_MAIN } } 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 } func moneyEquals(a, b moneyGetter) bool { if a == nil || b == nil { return false } if !strings.EqualFold(a.GetCurrency(), b.GetCurrency()) { return false } return strings.TrimSpace(a.GetAmount()) == strings.TrimSpace(b.GetAmount()) } func conversionAmountFromMetadata(meta map[string]string, fx *orchestratorv1.FXIntent) (*moneyv1.Money, error) { if meta == nil { meta = map[string]string{} } amount := strings.TrimSpace(meta["amount"]) if amount == "" { return nil, merrors.InvalidArgument("conversion amount metadata is required") } currency := strings.TrimSpace(meta["currency"]) if currency == "" && fx != nil && fx.GetPair() != nil { currency = strings.TrimSpace(fx.GetPair().GetBase()) } if currency == "" { return nil, merrors.InvalidArgument("conversion currency metadata is required") } return &moneyv1.Money{ Currency: currency, Amount: amount, }, nil }