212 lines
7.1 KiB
Go
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
|
|
}
|