Files
zeekay 7d727874d5 chore: rebrand Formance Payments -> Hanzo Payments (hanzo-fi)
- go.mod module path + all self-imports: github.com/formancehq/payments -> github.com/hanzo-fi/payments
  (1590 .go files / 3152 refs; 20 modules: root, pkg/client, pkg/domain, 18 ce/plugins).
  External dep kept: formancehq/go-libs.
- plugins: each ce/plugins/* consumed a *published* pkg/domain; added local
  'replace .../pkg/domain => ../../../pkg/domain' (consistent with root) so every
  standalone plugin module builds in-monorepo. compile-plugin.sh MOD var rebranded too.
- branding: README, LICENSE (dual MIT+EE; upstream copyright retained + Hanzo added),
  goreleaser (release images -> registry.hanzo.ai/hanzo-fi, OCI labels, homepage),
  proto go_package, renovate, speakeasy gen.yaml, connector-template *.gotpl.
- CI: removed Numary/Formance workflows + composite action; added canonical hanzo.yml +
  cicd.yml (hanzoai/ci, gated HANZO_CI_ENABLED) + secretless ci.yml (go build/vet).
- kept as data/compat keys: wire capability enums ALLOW_FORMANCE_*, generated SDK type
  client.Formance, domain field FormanceRedirectURL, OTEL_SERVICE_NAME, upstream base image.
- verified: go build ./... (default + -tags ee) + all 20 nested modules green.
2026-07-05 13:08:55 -07:00

176 lines
5.2 KiB
Go

package bitstamp
import (
"context"
"errors"
"fmt"
"strings"
"github.com/hanzo-fi/payments/ee/plugins/bitstamp/client"
"github.com/hanzo-fi/payments/ee/plugins/bitstamp/mappers"
"golang.org/x/sync/errgroup"
)
const (
pathMarkets = "/api/v2/markets/"
pathMyMarkets = "/api/v2/my_markets/"
pathFeesTrading = "/api/v2/fees/trading/"
pathFeesWithdrawal = "/api/v2/fees/withdrawal/"
)
// ErrPartialEnrichment signals at least one enrichment source failed.
// The orchestrator continues — accounts ship without the missing
// metadata rather than failing the cycle.
var ErrPartialEnrichment = errors.New("account data enrichment: at least one source failed")
// enrichmentState holds enrichment data for a single accounts cycle.
// Fetched fresh on each call to fetchAccountEnrichmentData; no caching.
type enrichmentState struct {
markets []client.Market
myMarkets []client.MyMarket
tradingFees []client.TradingFee
withdrawalFees []client.WithdrawalFee
}
// fetchAccountEnrichmentData fetches the four enrichment sources in parallel and
// returns the combined result. DerivativesUnsupportedError is swallowed
// per source. Any other error wraps ErrPartialEnrichment.
func (p *Plugin) fetchAccountEnrichmentData(ctx context.Context) (enrichmentState, error) {
var state enrichmentState
var g errgroup.Group
g.Go(func() error {
rows, err := p.client.GetMarkets(ctx)
if err != nil {
return fmt.Errorf("failed to refresh %s: %w", pathMarkets, err)
}
state.markets = rows
return nil
})
g.Go(func() error {
rows, err := p.client.GetMyMarkets(ctx)
if err != nil {
var derivErr *client.DerivativesUnsupportedError
if errors.As(err, &derivErr) {
return nil
}
return fmt.Errorf("failed to refresh %s: %w", pathMyMarkets, err)
}
state.myMarkets = rows
return nil
})
g.Go(func() error {
rows, err := p.client.GetTradingFees(ctx)
if err != nil {
return fmt.Errorf("failed to refresh %s: %w", pathFeesTrading, err)
}
state.tradingFees = rows
return nil
})
g.Go(func() error {
rows, err := p.client.GetWithdrawalFees(ctx)
if err != nil {
return fmt.Errorf("failed to refresh %s: %w", pathFeesWithdrawal, err)
}
state.withdrawalFees = rows
return nil
})
if err := g.Wait(); err != nil {
p.logger.WithField("error", err.Error()).
Errorf("enrichment refresh: at least one source failed")
return state, fmt.Errorf("%w: %v", ErrPartialEnrichment, err)
}
return state, nil
}
// buildEnrichmentForCurrency assembles the AccountEnrichment payload
// for a single currency from the provided enrichment data. Returns the
// zero value when nothing relevant is present.
func buildEnrichmentForCurrency(enrich enrichmentState, currencyIndex map[string]client.Currency, symbol string) mappers.AccountEnrichment {
currentCurrency := currencyIndex[symbol]
result := mappers.AccountEnrichment{Networks: currentCurrency.Networks}
for _, f := range enrich.withdrawalFees {
if strings.EqualFold(f.Currency, symbol) {
result.WithdrawalFees = append(result.WithdrawalFees, f)
}
}
for _, m := range enrich.myMarkets {
base, quote, ok := splitURLSymbol(m.URLSymbol, currencyIndex)
if !ok {
continue
}
if base == symbol || quote == symbol {
result.TradableMarkets = append(result.TradableMarkets, m)
}
}
// Representative snapshot (not authoritative per-pair). Pick the
// lexicographically-first match by stable key so the value does
// not flap if the API returns rows in a different order between
// cycles.
var repMarket *client.Market
for i := range enrich.markets {
m := &enrich.markets[i]
if strings.ToUpper(m.BaseCurrency) != symbol {
continue
}
if repMarket == nil || marketKey(*m) < marketKey(*repMarket) {
repMarket = m
}
}
if repMarket != nil {
result.MinOrderValue = repMarket.MinimumOrderValue
result.MarketType = repMarket.MarketType
}
var repFee *client.TradingFee
for i := range enrich.tradingFees {
f := &enrich.tradingFees[i]
base, _, ok := splitURLSymbol(f.CurrencyPair, currencyIndex)
if !ok || base != symbol {
continue
}
if repFee == nil || f.CurrencyPair < repFee.CurrencyPair {
repFee = f
}
}
if repFee != nil {
result.MakerFee = repFee.Fees.Maker
result.TakerFee = repFee.Fees.Taker
}
return result
}
// marketKey produces a stable composite ordering key for selecting
// a representative Market row. CounterCurrency disambiguates pairs
// sharing the same base (BTC/USD vs BTC/EUR vs BTC/USDT); MarketType
// tiebreaks SPOT vs other flavours on the same pair.
func marketKey(m client.Market) string {
return m.CounterCurrency + "|" + m.MarketType
}
// splitURLSymbol parses Bitstamp's lowercase concat pair (e.g.
// "btcusd", "btcusdc") into uppercase (base, quote) using the known
// currencies map. Returns ok=false on unrecognisable shapes.
func splitURLSymbol(urlSymbol string, currencies map[string]client.Currency) (base, quote string, ok bool) {
s := strings.ToLower(strings.TrimSpace(urlSymbol))
if s == "" {
return "", "", false
}
for baseLen := 3; baseLen <= 5 && baseLen < len(s); baseLen++ {
b := strings.ToUpper(s[:baseLen])
q := strings.ToUpper(s[baseLen:])
if _, ok1 := currencies[b]; !ok1 {
continue
}
if _, ok2 := currencies[q]; !ok2 {
continue
}
return b, q, true
}
return "", "", false
}