mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
* 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]>
292 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|