package sendico import ( "math/big" "strconv" "strings" "time" "git.sendico.io/tech/sxchnge/product/payments" ) const maxMinor int64 = 9007199254740991 func safePaymentRef(value string) bool { if len(value) == 0 || len(value) > 200 { return false } for _, c := range value { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') { return false } } return true } // Decimal parsing never rounds provider values or converts money through floats. func minorMoney(m moneyDTO, currency string, scale int) (int64, error) { if m.Currency != currency || scale < 0 || scale > 8 { return 0, payments.Unavailable } parts := strings.Split(m.Amount, ".") if len(parts) > 2 || parts[0] == "" { return 0, payments.Unavailable } digits := parts[0] if len(parts) == 2 { if parts[1] == "" || len(parts[1]) > scale { return 0, payments.Unavailable } digits += parts[1] + strings.Repeat("0", scale-len(parts[1])) } else { digits += strings.Repeat("0", scale) } for _, r := range digits { if r < '0' || r > '9' { return 0, payments.Unavailable } } value, err := strconv.ParseInt(digits, 10, 64) if err != nil || value > maxMinor { return 0, payments.Unavailable } return value, nil } func majorMoney(minor int64, scale int) string { digits := strconv.FormatInt(minor, 10) if scale == 0 { return digits } if len(digits) <= scale { digits = strings.Repeat("0", scale+1-len(digits)) + digits } return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:] } func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) { if !dto.Executable || dto.State != "executable" { switch dto.BlockReason { case "limit_blocked": return payments.Quote{}, payments.LimitExceeded case "amount_too_small", "amount_too_large": return payments.Quote{}, payments.InvalidAmount default: return payments.Quote{}, payments.Unsupported } } if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) { return payments.Quote{}, payments.ExpiredQuote } debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision) if err != nil { return payments.Quote{}, err } principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision) if err != nil { return payments.Quote{}, err } credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision) if err != nil { return payments.Quote{}, err } if principal != r.Amount || debit < principal || credit <= 0 { return payments.Quote{}, payments.Unavailable } if r.Kind != "topUp" && debit > source.Available { return payments.Quote{}, payments.InsufficientFunds } num, den := big.NewInt(credit), big.NewInt(principal) num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil)) den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil)) gcd := new(big.Int).GCD(nil, nil, num, den) num.Quo(num, gcd) den.Quo(den, gcd) if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor { return payments.Quote{}, payments.Unsupported } return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil } func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) { if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() { return payments.RemoteOperation{}, payments.Unavailable } status := "pending" switch dto.State { case "orchestration_state_settled": status = "completed" case "orchestration_state_failed": status = "failed" case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention": default: return payments.RemoteOperation{}, payments.Unavailable } return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil } func accountByID(accounts []payments.Account, id string) (payments.Account, error) { for _, a := range accounts { if a.ID == id { return a, nil } } return payments.Account{}, payments.NotFound } func ledgerEndpoint(id string) endpointDTO { return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}} } func methodEndpoint(id string) endpointDTO { return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}} } type mappedIntent struct { wire paymentIntentDTO request payments.Request source, destination payments.Account } func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) { source, err := accountByID(accounts, r.Source) if err != nil { return mappedIntent{}, err } if r.Amount <= 0 || r.Amount > maxMinor { return mappedIntent{}, payments.InvalidAmount } intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"} destination := source switch r.Kind { case "payout": r.Destination = "" if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod { return mappedIntent{}, payments.InvalidRecipient } intent.Kind = "payout" intent.Destination = methodEndpoint(source.PayoutMethod) // Store the safe provisioned display label, never present an opaque method ID. r.Recipient = source.PayoutLabel case "exchange": r.Recipient = "" destination, err = accountByID(accounts, r.Destination) if err != nil { return mappedIntent{}, err } if source.ID == destination.ID || source.Currency == destination.Currency { return mappedIntent{}, payments.Unsupported } intent.Kind = "fx_conversion" intent.Destination = ledgerEndpoint(destination.ID) case "topUp": r.Destination = "" r.Recipient = "" if source.FundingMethod == "" { return mappedIntent{}, payments.Unsupported } intent.Kind = "internal_transfer" intent.Source = methodEndpoint(source.FundingMethod) intent.Destination = ledgerEndpoint(source.ID) default: return mappedIntent{}, payments.Unsupported } return mappedIntent{intent, r, source, destination}, nil } func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) { if balance.ID != row.ID { return payments.Account{}, payments.Unavailable } minor, err := minorMoney(balance.Money, row.Currency, scale) if err != nil { return payments.Account{}, err } account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]} for _, ref := range []string{account.PayoutMethod, account.FundingMethod} { if ref != "" && !validRef(ref) { return payments.Account{}, payments.Unavailable } } if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) { return payments.Account{}, payments.Unavailable } return account, nil }