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

280 lines
7.9 KiB
Go

package mappers
import (
"encoding/json"
"math/big"
"testing"
"github.com/hanzo-fi/payments/ee/plugins/bitstamp/client"
"github.com/hanzo-fi/payments/pkg/domain/models"
)
func newTx(raw string) client.UserTransaction {
var tx client.UserTransaction
if err := json.Unmarshal([]byte(raw), &tx); err != nil {
panic(err)
}
return tx
}
func TestUserTransactionToPSPPaymentDeposit(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 1001,
"datetime": "2025-09-25 14:42:59.000000",
"type": "0",
"fee": "0.00",
"btc": "0.5",
"usd": "0.00"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if res.Skip || res.Payment == nil {
t.Fatalf("expected payment, got %#v", res)
}
if res.Payment.Reference != "1001" {
t.Errorf("reference=%q, want 1001", res.Payment.Reference)
}
if res.Payment.Type != models.PAYMENT_TYPE_PAYIN {
t.Errorf("type=%v, want PAYIN", res.Payment.Type)
}
if res.Payment.Asset != "BTC/8" {
t.Errorf("asset=%q, want BTC/8", res.Payment.Asset)
}
if res.Payment.Amount.Cmp(big.NewInt(50000000)) != 0 {
t.Errorf("amount=%s, want 50000000", res.Payment.Amount)
}
if res.Payment.Status != models.PAYMENT_STATUS_SUCCEEDED {
t.Errorf("status=%v, want SUCCEEDED", res.Payment.Status)
}
if res.Payment.Metadata[MetadataKeyType] != "0" {
t.Errorf("missing type metadata: %v", res.Payment.Metadata)
}
if res.UnknownType {
t.Errorf("type 0 should be known")
}
}
func TestUserTransactionToPSPPaymentWithdrawalNegative(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 1002,
"datetime": "2025-09-25 14:42:59.000000",
"type": "1",
"fee": "0.01",
"eur": "-25.50"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if res.Payment == nil {
t.Fatal("expected payment")
}
if res.Payment.Type != models.PAYMENT_TYPE_PAYOUT {
t.Errorf("type=%v, want PAYOUT", res.Payment.Type)
}
// Amount must be positive (CLAUDE.md convention) even though the
// raw value is signed negative.
if res.Payment.Amount.Cmp(big.NewInt(2550)) != 0 {
t.Errorf("amount=%s, want 2550 (positive)", res.Payment.Amount)
}
if res.Payment.Metadata[MetadataKeyFee] != "0.01" {
t.Errorf("missing fee metadata: %v", res.Payment.Metadata)
}
}
func TestUserTransactionToPSPPaymentSkipsTradesAndConversions(t *testing.T) {
t.Parallel()
for _, txType := range []string{
TxTypeMarketTrade,
TxTypeBuySell,
TxTypeSmallBalanceConversionSrc,
TxTypeSmallBalanceConversionDst,
} {
tx := newTx(`{
"id": 2000,
"datetime": "2025-09-25 14:42:59.000000",
"type": "` + txType + `",
"fee": "0",
"eur": "-5.00",
"usdc": "5.81"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err for type %s: %v", txType, err)
}
if !res.Skip || res.Payment != nil {
t.Errorf("type %s should be skipped, got %#v", txType, res)
}
}
}
func TestUserTransactionToPSPPaymentSkipsDerivativesTypes(t *testing.T) {
t.Parallel()
for _, txType := range []string{
TxTypeDerivativesPeriodicSettlement,
TxTypeInsuranceFundClaim,
TxTypeInsuranceFundPremium,
TxTypeCollateralLiquidation,
} {
txType := txType
t.Run("type_"+txType, func(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 9000,
"datetime": "2025-09-25 14:42:59.000000",
"type": "` + txType + `",
"fee": "0",
"usd": "100.00"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("type %s: err: %v", txType, err)
}
if !res.Skip || !res.DerivativesRow {
t.Errorf("type %s: expected DerivativesRow skip, got %#v", txType, res)
}
})
}
}
func TestUserTransactionToPSPPaymentSkipsDerivatives(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 3000,
"datetime": "2025-09-25 14:42:59.000000",
"type": "0",
"fee": "0",
"btc": "1.0",
"margin_mode": "FLEXIBLE",
"leverage_rate": "5"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if !res.Skip || !res.DerivativesRow {
t.Errorf("expected DerivativesRow skip, got %#v", res)
}
}
func TestUserTransactionToPSPPaymentUnknownTypeIsEmitted(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 4000,
"datetime": "2025-09-25 14:42:59.000000",
"type": "999",
"fee": "0",
"btc": "0.1"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if res.Payment == nil {
t.Fatal("unknown type should still emit a PAYMENT_TYPE_OTHER row so it isn't lost")
}
if res.Payment.Type != models.PAYMENT_TYPE_OTHER {
t.Errorf("type=%v, want OTHER", res.Payment.Type)
}
if !res.UnknownType {
t.Error("UnknownType=true is the orchestrator's Warn signal")
}
}
func TestUserTransactionToPSPPaymentSkipsTwoAssetRow(t *testing.T) {
t.Parallel()
// A non-type-36 row that still presents two non-zero known
// currencies (defensive) — the payment mapper refuses to guess
// which leg is "the amount".
tx := newTx(`{
"id": 5000,
"datetime": "2025-09-25 14:42:59.000000",
"type": "33",
"fee": "0",
"eur": "-5.00",
"usdc": "5.81"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if !res.Skip || res.Payment != nil {
t.Errorf("two-asset payment row must skip, got %#v", res)
}
}
// TestUserTransactionToPSPPayment_TransferOutgoing asserts the sign-
// based remap for type-14 / 33 / 35 rows where the local account is
// the source (negative amount).
func TestUserTransactionToPSPPayment_TransferOutgoing(t *testing.T) {
t.Parallel()
cases := []string{"14", "33", "35"}
for _, txType := range cases {
txType := txType
t.Run("type_"+txType, func(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 12345,
"datetime": "2025-09-25 14:42:59.000000",
"type": "` + txType + `",
"fee": "0",
"btc": "-0.50000000"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if res.Skip || res.Payment == nil {
t.Fatalf("transfer row must emit a payment, got %#v", res)
}
if res.Payment.Type != models.PAYMENT_TYPE_PAYOUT {
t.Errorf("Type = %v, want PAYOUT (negative amount = local is source)", res.Payment.Type)
}
if res.Payment.Metadata[MetadataKeyTransferPairID] != "12345" {
t.Errorf("pair id = %q, want \"12345\"", res.Payment.Metadata[MetadataKeyTransferPairID])
}
if res.Payment.Metadata[MetadataKeyTransferDirection] != TransferDirectionOutgoing {
t.Errorf("direction = %q, want outgoing", res.Payment.Metadata[MetadataKeyTransferDirection])
}
// Amount is always non-negative (PSPPayment contract).
if res.Payment.Amount.Sign() <= 0 {
t.Errorf("Amount must be positive, got %s", res.Payment.Amount)
}
if got, want := res.Payment.Amount.Cmp(big.NewInt(50_000_000)), 0; got != want {
t.Errorf("Amount = %s, want 50_000_000 (0.5 BTC at precision 8)", res.Payment.Amount)
}
// Counterparty metadata absent — Bitstamp doesn't publish it.
if _, present := res.Payment.Metadata[MetadataKeyCounterpartySubAccountID]; present {
t.Errorf("counterparty must be omitted when not on the wire, got %+v", res.Payment.Metadata)
}
})
}
}
// TestUserTransactionToPSPPayment_TransferIncoming covers the
// positive-amount branch — the local account is the destination.
func TestUserTransactionToPSPPayment_TransferIncoming(t *testing.T) {
t.Parallel()
tx := newTx(`{
"id": 6789,
"datetime": "2025-09-25 14:42:59.000000",
"type": "14",
"fee": "0",
"btc": "0.50000000"
}`)
res, err := UserTransactionToPSPPayment(testCurrencies, tx)
if err != nil {
t.Fatalf("err: %v", err)
}
if res.Payment.Type != models.PAYMENT_TYPE_PAYIN {
t.Errorf("Type = %v, want PAYIN (positive amount = local is destination)", res.Payment.Type)
}
if res.Payment.Metadata[MetadataKeyTransferDirection] != TransferDirectionIncoming {
t.Errorf("direction = %q, want incoming", res.Payment.Metadata[MetadataKeyTransferDirection])
}
}