Files

212 lines
7.1 KiB
Go

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
}