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

163 lines
5.1 KiB
Go

package bitstamp
import (
"context"
"encoding/json"
"fmt"
"regexp"
"sort"
"github.com/hanzo-fi/payments/ee/plugins/bitstamp/client"
"github.com/hanzo-fi/payments/ee/plugins/bitstamp/mappers"
"github.com/hanzo-fi/payments/pkg/domain/models"
)
var marketEventIDRegexp = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// fetchNextOrders queries account_order_data for every tradeable market
// listed in the parent account's metadata, emitting one PSPOrder per
// event. The last-seen order ID per market is persisted in state so
// subsequent calls use it as since_id to avoid re-fetching processed events.
func (p *Plugin) fetchNextOrders(ctx context.Context, req models.FetchNextOrdersRequest) (models.FetchNextOrdersResponse, error) {
state := ordersState{LastSeenEventIDPerMarket: map[string]string{}}
if len(req.State) > 0 {
if err := json.Unmarshal(req.State, &state); err != nil {
return models.FetchNextOrdersResponse{}, fmt.Errorf("unmarshal orders state: %w", err)
}
if state.LastSeenEventIDPerMarket == nil {
state.LastSeenEventIDPerMarket = map[string]string{}
}
}
accountReference, markets, err := tradeableMarketsFromPayload(req.FromPayload)
if err != nil {
return models.FetchNextOrdersResponse{}, err
}
// Deterministic market order required for hasMore behaviour
sort.Strings(markets)
currencies, err := p.getCurrencies(ctx)
if err != nil {
return models.FetchNextOrdersResponse{}, err
}
var orders []models.PSPOrder
skipping := state.HasMoreCurrentMarket != ""
for _, marketName := range markets {
// Skip markets until we reach the one where the previous page stopped.
if skipping {
if marketName != state.HasMoreCurrentMarket {
continue
}
skipping = false
}
sinceID := state.LastSeenEventIDPerMarket[marketName]
var sinceIDPtr *string
if isValidMarketEventID(sinceID) {
sinceIDPtr = &sinceID
}
events, err := p.client.GetAccountOrderData(ctx, marketName, sinceIDPtr)
if err != nil {
if client.IsNotFoundError(err) {
p.logger.WithField("market", marketName).
Infof("order event history not available for this market type, skipping")
continue
}
return models.FetchNextOrdersResponse{}, fmt.Errorf("failed to fetch market %q order events: %w", marketName, err)
}
var lastEventID string
for _, event := range events {
// the ID we got from the state was already imported by the previous job
if sinceID != "" && event.EventID == sinceID {
continue
}
if !isValidMarketEventID(event.EventID) {
p.logger.WithField("market", marketName).WithField("eventID", event.EventID).WithField("order_id", event.Data.IDStr).
Debugf("skipping event with invalid ID")
continue
}
order, err := mappers.AccountOrderDataEventToPSPOrder(currencies, accountReference, marketName, event)
if err != nil {
p.logger.
WithField("market", marketName).
WithField("eventID", event.EventID).
WithField("orderReference", event.Data.IDStr).
Errorf("failed to map order event: %v", err)
continue
}
lastEventID = event.EventID
orders = append(orders, *order)
if req.PageSize > 0 && len(orders) >= req.PageSize {
break
}
}
if lastEventID != "" {
state.LastSeenEventIDPerMarket[marketName] = lastEventID
}
if req.PageSize > 0 && len(orders) >= req.PageSize {
state.HasMoreCurrentMarket = marketName
payload, err := json.Marshal(state)
if err != nil {
return models.FetchNextOrdersResponse{}, fmt.Errorf("marshal orders state: %w", err)
}
return models.FetchNextOrdersResponse{
Orders: orders,
NewState: payload,
HasMore: true,
}, nil
}
}
state.HasMoreCurrentMarket = ""
payload, err := json.Marshal(state)
if err != nil {
return models.FetchNextOrdersResponse{}, fmt.Errorf("marshal orders state: %w", err)
}
return models.FetchNextOrdersResponse{
Orders: orders,
NewState: payload,
HasMore: false,
}, nil
}
// tradeableMarketsFromPayload extracts the account reference and tradeable
// market symbols from the PSPAccount JSON passed as FromPayload. The engine
// unwraps its own {"id":…,"payload":…} envelope before invoking the plugin,
// so this function receives the raw PSPAccount JSON directly.
func tradeableMarketsFromPayload(payload json.RawMessage) (string, []string, error) {
if len(payload) == 0 {
return "", nil, fmt.Errorf("missing payload in FromPayload")
}
var account models.PSPAccount
if err := json.Unmarshal(payload, &account); err != nil {
return "", nil, fmt.Errorf("unmarshal from payload: %w", err)
}
raw, ok := account.Metadata[mappers.MetadataKeyTradableMarkets]
if !ok || raw == "" {
return account.Reference, nil, nil
}
var markets []string
if err := json.Unmarshal([]byte(raw), &markets); err != nil {
return "", nil, fmt.Errorf("unmarshal tradeable markets: %w", err)
}
return account.Reference, markets, nil
}
// isValidMarketEventID reports whether s is a UUID-formatted event ID
// (e.g. "000652ba-1467-f198-0000-00d800000020") usable as a since_id value.
func isValidMarketEventID(s string) bool {
return marketEventIDRegexp.MatchString(s)
}