Files
f755e1a3a4 feat(bitstamp): EN-1015 add orders + conversions and refactor for production-readiness (#724)
* feat(bitstamp): EN-1015 add orders + conversions and refactor for production-readiness

Adds first-class orders and conversions primitives to the Bitstamp connector and refactors the package along Formance conventions. The HTTP client is split into client/types.go + client/error.go with OpenTelemetry instrumentation and a typed DerivativesUnsupportedError that drives a process-lifetime try-and-skip cache for permission-gated endpoints (my_markets, fees, crypto-transactions). State is moved to opaque cursors with backward-compatible decoders so existing installs keep their watermarks. Payments are now a three-source union (user_transactions + crypto-transactions + withdrawal-requests) with fail-whole-cycle semantics on top-level source errors and log+continue on per-row map errors. Orders reconcile open_orders snapshots with order_status; conversions detect two-asset user_transactions.type=36 (instant buy/sell). Accounts surface the currency ticker as Name; sub-account transfers (types 14/33/35) are modelled as linked PAYOUT/PAYIN pairs correlated via metadata, reflecting Bitstamp's one-API-key-per-account scope. The Temporal task tree nests balances under accounts (Qonto FromPayload pattern) with payments, orders and conversions as independent roots. Install-time enrichment loads markets, my_markets, trading fees and withdrawal fees in parallel via a generic refreshCache[T] helper with TTL. Full transformation logic lives in a dedicated mappers/ subpackage with extensive unit coverage. MAPPINGS.md documents every decision, the error-policy matrix, and the known historical-orders gap.

* fix(bitstamp): address PR #724 CodeRabbit review — evict on order_status error, deterministic enrichment, log levels

Evict retention-expired tracked orders in fetchNextOrders when GetOrderStatus errors out: a stale ID past the 30-day window will keep failing the call indefinitely, growing state unbounded and spamming Error logs every cycle. Log the eviction at Info because the failure is expected for retention-expired entries. Make representative market/trading-fee selection in buildEnrichmentForCurrency deterministic by picking the lexicographically-smallest match on a composite key (CounterCurrency|MarketType for markets, CurrencyPair for fees) to prevent account-metadata flapping if the PSP returns rows reordered between cycles. Downgrade the derivatives-row skip log in fetchNextConversions from Errorf to Infof — it is an expected branch on a spot-only connector, not a fault. Drop a redundant AbsAmount wrap inside an IsZeroAmount call (Sign() already handles negatives natively). Add language tag to a MAPPINGS.md fenced block to satisfy markdownlint. New unit tests cover the eviction-on-error path and lock the deterministic-representative contract via permutation testing.

Co-authored-by: Cursor <[email protected]>

* Remove any cache initializations from Install step, remove useless skip behaviour for crypto-transactions and withdrawals

* Use GET request for my_markets endpoint, and only handle derivative not supported error where likely to occur

* Drastically reduce amount of caching - instead fetch enrichment data only when new accounts are found

* Delay fetch currencies calls until after we know if work needs to be done to reduce unneeded requests in inactive accounts

* Revert changes to balance fetching that made it only fetch balance once

* Import transactions with trailing zeros that exceed the currency's reported precision

* One exclusive decimal amount parser function among different functions

* Allow two orders with the same ID to be present in a slice by combining them into one record with multiple adjustments

* Use CreatedAt to determine order or order adjustments

* Make Orders workflow a child of accounts, and search markets related to the currency of the account

* Enhance metadata returned in orders

* Only save real account IDs related to orders

* Import each order event only once via the source currency

* Regenerate capabilities

* Add paging mechanism and fix expectations about market event IDs

* Export even if account is destination account

* Address coderabbit comments

* Fix comments on workflow header

* Address coderabbit comments regarding models order

* Only use user transactions endpoint to fetch both conversions and payments

* Truncate trailing digits when they exceed the precision bitstamp provided - raw values still preserved in raw payload

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Crimson Thompson <[email protected]>
2026-05-28 08:40:34 +00:00

292 lines
6.5 KiB
Go

package mappers
import (
"math/big"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var testCurrencies = map[string]int{
"BTC": 8,
"ETH": 18,
"EUR": 2,
"USD": 2,
"USDC": 6,
}
func TestNormalizeCurrency(t *testing.T) {
t.Parallel()
cases := map[string]string{
"btc": "BTC",
" eur ": "EUR",
"": "",
}
for in, want := range cases {
if got := NormalizeCurrency(in); got != want {
t.Errorf("normalize %q = %q, want %q", in, got, want)
}
}
}
func TestIsZeroAmount(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": true,
"0": true,
"0.0": true,
"0.00000000": true,
"-0": true,
"not-a-number": true, // unparseable → treated as zero
"0.0000001": false,
"1": false,
"-5.00": false,
}
for in, want := range cases {
if got := IsZeroAmount(in); got != want {
t.Errorf("isZero %q = %v, want %v", in, got, want)
}
}
}
func TestAbsAmount(t *testing.T) {
t.Parallel()
cases := map[string]string{
"-5.00": "5.00",
"5.00": "5.00",
"": "",
}
for in, want := range cases {
if got := AbsAmount(in); got != want {
t.Errorf("abs %q = %q, want %q", in, got, want)
}
}
}
func TestParseDecimalAmount(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
precision int
want *big.Int
wantErr bool
}{
{
name: "empty string",
value: "",
precision: 2,
wantErr: true,
},
{
name: "integer no decimal",
value: "100",
precision: 2,
want: big.NewInt(10000),
},
{
name: "exact precision",
value: "1.23",
precision: 2,
want: big.NewInt(123),
},
{
name: "fewer decimals than precision",
value: "1.2",
precision: 2,
want: big.NewInt(120),
},
{
name: "trailing zeros beyond precision truncated",
value: "1.23000",
precision: 2,
want: big.NewInt(123),
},
{
name: "single trailing zero beyond precision",
value: "0.500",
precision: 2,
want: big.NewInt(50),
},
{
name: "non-zero digits beyond precision truncated",
value: "1.235",
precision: 2,
want: big.NewInt(123),
},
{
// Bitstamp sub-precision noise on a high-precision asset: the extra
// digit is dropped because precision comes from Bitstamp's own API.
name: "sub-precision noise truncated not rounded",
value: "0.123456789",
precision: 8,
want: big.NewInt(12345678),
},
{
name: "precision zero with decimal errors",
value: "1.0",
precision: 0,
wantErr: true,
},
{
name: "zero amount",
value: "0.00",
precision: 2,
want: big.NewInt(0),
},
{
name: "large amount trailing zeros",
value: "12345.678900",
precision: 4,
want: big.NewInt(123456789),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseDecimalAmount(tc.value, tc.precision)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestResolveSinglePaymentAsset(t *testing.T) {
t.Parallel()
cases := []struct {
name string
amounts map[string]string
wantAsset string
wantMinor int64
wantOk bool
}{
{
name: "deposit: single non-zero known currency",
amounts: map[string]string{"btc": "1.5", "eur": "0", "usd": "0.0"},
wantAsset: "BTC/8",
wantMinor: 150000000,
wantOk: true,
},
{
name: "withdrawal: signed negative is absoluted",
amounts: map[string]string{"eur": "-25.50"},
wantAsset: "EUR/2",
wantMinor: 2550,
wantOk: true,
},
{
name: "two non-zero known currencies → not a payment",
amounts: map[string]string{"eur": "-5.00", "usdc": "5.810770"},
},
{
name: "all zero",
amounts: map[string]string{"eur": "0", "btc": "0"},
},
{
name: "unknown currency only",
amounts: map[string]string{"xyz": "1.0"},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
asset, amount, ok, err := ResolveSinglePaymentAsset(testCurrencies, tc.amounts)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ok != tc.wantOk {
t.Fatalf("ok=%v want %v", ok, tc.wantOk)
}
if !ok {
return
}
if asset != tc.wantAsset {
t.Errorf("asset=%q want %q", asset, tc.wantAsset)
}
if amount.Cmp(big.NewInt(tc.wantMinor)) != 0 {
t.Errorf("amount=%s want %d", amount, tc.wantMinor)
}
})
}
}
func TestResolveTwoAssetConversion(t *testing.T) {
t.Parallel()
cases := []struct {
name string
amounts map[string]string
wantSrcSym string
wantSrcAmt int64
wantDstSym string
wantDstAmt int64
wantOk bool
}{
{
name: "Quentin #679 EUR -> USDC fixture",
amounts: map[string]string{
"eur": "-5.00",
"usdc": "5.810770",
"usd": "0.0",
"btc": "0.0",
},
wantSrcSym: "EUR", wantSrcAmt: 500,
wantDstSym: "USDC", wantDstAmt: 5810770,
wantOk: true,
},
{
name: "single non-zero → not a conversion",
amounts: map[string]string{"eur": "-5.00"},
},
{
name: "two same-sign → not a conversion",
amounts: map[string]string{"eur": "5.00", "usdc": "5.810770"},
},
{
name: "three non-zero → not a conversion",
amounts: map[string]string{"eur": "-5", "usdc": "5.8", "btc": "0.0001"},
},
{
name: "unknown currency → not a conversion",
amounts: map[string]string{"eur": "-5.00", "xyz": "5.0"},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
src, dst, ok, err := ResolveTwoAssetConversion(testCurrencies, tc.amounts)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ok != tc.wantOk {
t.Fatalf("ok=%v want %v", ok, tc.wantOk)
}
if !ok {
return
}
if src.Symbol != tc.wantSrcSym || src.Amount.Cmp(big.NewInt(tc.wantSrcAmt)) != 0 {
t.Errorf("src=%s/%s want %s/%d", src.Symbol, src.Amount, tc.wantSrcSym, tc.wantSrcAmt)
}
if dst.Symbol != tc.wantDstSym || dst.Amount.Cmp(big.NewInt(tc.wantDstAmt)) != 0 {
t.Errorf("dst=%s/%s want %s/%d", dst.Symbol, dst.Amount, tc.wantDstSym, tc.wantDstAmt)
}
})
}
}
func TestPrecisionFor(t *testing.T) {
t.Parallel()
if p, err := PrecisionFor(testCurrencies, "eur"); err != nil || p != 2 {
t.Errorf("PrecisionFor eur = (%d, %v), want (2, nil)", p, err)
}
if _, err := PrecisionFor(testCurrencies, "xyz"); err == nil {
t.Error("PrecisionFor unknown should error")
}
}