diff --git a/controllers/provider/provider_test.go b/controllers/provider/provider_test.go index c0e92cc1..d2007438 100644 --- a/controllers/provider/provider_test.go +++ b/controllers/provider/provider_test.go @@ -118,7 +118,9 @@ func setup() error { for i := 0; i < 10; i++ { // Skip sleep in test mode to avoid timeout // time.Sleep(time.Duration(time.Duration(rand.Intn(10)) * time.Second)) - _, err := test.CreateTestPaymentOrder(nil, map[string]interface{}{ + // Reuse testCtx.token so CreateERC20Token is not called repeatedly (would insert + // duplicate TST rows and break CreateTestSenderProfile's Symbol-only lookup). + _, err := test.CreateTestPaymentOrder(testCtx.token, map[string]interface{}{ "gateway_id": uuid.New().String(), "provider": providerProfile, }) @@ -2112,7 +2114,9 @@ func TestProvider(t *testing.T) { WithTransactions(). Only(context.Background()) assert.NoError(t, err) - assert.Equal(t, paymentorder.StatusSettling, updatedOrder.Status) + // Settlement tx fails in tests; compensating path deletes the settling log and + // reverts the order to fulfilling so the provider can retry. + assert.Equal(t, paymentorder.StatusFulfilling, updatedOrder.Status) fulfillingLogExists, err := db.Client.TransactionLog.Query(). Where( diff --git a/controllers/sender/sender.go b/controllers/sender/sender.go index 7a976af2..95bce50c 100644 --- a/controllers/sender/sender.go +++ b/controllers/sender/sender.go @@ -364,13 +364,13 @@ func (ctrl *SenderController) InitiatePaymentOrder(ctx *gin.Context) { rateValidationResult := rateResult.rateResult achievableRate := rateValidationResult.Rate - // Validate that the provided rate is achievable - // Allow for a small tolerance (0.1%) to account for minor rate fluctuations + // Validate provided rate against market rate with same symmetric 0.1% band as blockchain indexer + // (validateAndPreparePaymentOrderData: abs(event.Rate - validated) <= tolerance). tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% tolerance - if payload.Rate.LessThan(achievableRate.Sub(tolerance)) { + if payload.Rate.Sub(achievableRate).Abs().GreaterThan(tolerance) { u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{ Field: "Rate", - Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", payload.Rate, achievableRate), + Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", payload.Rate, achievableRate), }) return } @@ -946,11 +946,11 @@ func (ctrl *SenderController) initiateOfframpOrderV2(ctx *gin.Context, payload t } rateValidationResult = rateResult achievableRate := rateValidationResult.Rate - tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) - if providedRate.LessThan(achievableRate.Sub(tolerance)) { + tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer + if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) { u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{ Field: "Rate", - Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate), + Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate), }) return } @@ -1015,11 +1015,11 @@ func (ctrl *SenderController) initiateOfframpOrderV2(ctx *gin.Context, payload t } rateValidationResult = rateResult achievableRate := rateValidationResult.Rate - tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% tolerance - if providedRate.LessThan(achievableRate.Sub(tolerance)) { + tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer + if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) { u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{ Field: "Rate", - Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate), + Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate), }) return } @@ -1594,11 +1594,11 @@ func (ctrl *SenderController) initiateOnrampOrderV2(ctx *gin.Context, payload ty } rateValidationResult = rateResult achievableRate := rateValidationResult.Rate - tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) - if providedRate.GreaterThan(achievableRate.Add(tolerance)) { + tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer + if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) { u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{ Field: "Rate", - Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate), + Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate), }) return } @@ -1662,11 +1662,11 @@ func (ctrl *SenderController) initiateOnrampOrderV2(ctx *gin.Context, payload ty } rateValidationResult = rateResult achievableRate := rateValidationResult.Rate - tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) - if providedRate.GreaterThan(achievableRate.Add(tolerance)) { + tolerance := achievableRate.Mul(decimal.NewFromFloat(0.001)) // 0.1% — symmetric with indexer + if providedRate.Sub(achievableRate).Abs().GreaterThan(tolerance) { u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", types.ErrorData{ Field: "Rate", - Message: fmt.Sprintf("Provided rate %s is not achievable. Available rate is %s", providedRate, achievableRate), + Message: fmt.Sprintf("Provided rate %s is outside 0.1%% tolerance of market rate %s", providedRate, achievableRate), }) return } diff --git a/controllers/sender/sender_test.go b/controllers/sender/sender_test.go index 1b4d1957..1ad02b18 100644 --- a/controllers/sender/sender_test.go +++ b/controllers/sender/sender_test.go @@ -87,7 +87,7 @@ func setup() error { SetDecimals(6). SetNetworkID(networkId). SetIsEnabled(true). - SetBaseCurrency("NGN"). // Set to NGN to avoid Redis dependency + SetBaseCurrency("NGN"). // Direct NGN/NGN match: ValidateRate normalizes to 1; payloads must use rate 1 (symmetric tolerance vs queue 750) OnConflict(). UpdateNewValues(). ID(context.Background()) @@ -120,7 +120,7 @@ func setup() error { return fmt.Errorf("CreateTestProviderProfile.sender_test: %w", err) } - // Create ProviderOrderToken for rate validation (two-sided rates; match test payload rate 750) + // Create ProviderOrderToken for rate validation (Redis queue uses 750; NGN/NGN direct match normalizes ValidateRate to 1, matching payload rate "1") providerOrderToken, err := test.AddProviderOrderTokenToProvider(map[string]interface{}{ "provider": providerProfile, "token_id": int(tokenId), @@ -139,7 +139,7 @@ func setup() error { } // Create ProvisionBucket for bucket-based rate validation - // Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat + // Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1 bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{ "provider_id": providerProfile.ID, "currency_id": currency.ID, @@ -418,7 +418,7 @@ func TestSender(t *testing.T) { validPayload := map[string]interface{}{ "amount": "100", "token": testCtx.nativeTokenSymbol, - "rate": "750", + "rate": "1", "network": testCtx.nativeNetworkIdentifier, "recipient": map[string]interface{}{ "institution": "MOMONGPC", @@ -492,7 +492,7 @@ func TestSender(t *testing.T) { payload := map[string]interface{}{ "amount": "0", "token": testCtx.token.Symbol, - "rate": "750", + "rate": "1", "network": network.Identifier, "recipient": map[string]interface{}{ "institution": "MOMONGPC", @@ -528,7 +528,7 @@ func TestSender(t *testing.T) { payload := map[string]interface{}{ "amount": "-100", "token": testCtx.token.Symbol, - "rate": "750", + "rate": "1", "network": network.Identifier, "recipient": map[string]interface{}{ "institution": "MOMONGPC", @@ -644,7 +644,7 @@ func TestSender(t *testing.T) { payload := map[string]interface{}{ "amount": "100", "token": testCtx.token.Symbol, - "rate": "750", + "rate": "1", "network": network.Identifier, "recipient": map[string]interface{}{ "institution": "MOMONGPC", @@ -795,7 +795,7 @@ func TestSender(t *testing.T) { assert.NotNil(t, providerOrderToken, "Provider order token should be created") // Create ProvisionBucket for bucket-based rate validation - // Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat + // Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1 bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{ "provider_id": providerProfile.ID, "currency_id": currency.ID, @@ -872,7 +872,7 @@ func TestSender(t *testing.T) { payload := map[string]interface{}{ "amount": "100", "token": testCtx.token.Symbol, - "rate": "750", + "rate": "1", "network": testNetwork.Identifier, "reference": fmt.Sprintf("maxfeecap_test_%d", time.Now().UnixNano()), "recipient": map[string]interface{}{ @@ -982,7 +982,7 @@ func TestSender(t *testing.T) { assert.NotNil(t, providerOrderToken, "Provider order token should be created") // Create ProvisionBucket for bucket-based rate validation - // Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat + // Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1 bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{ "provider_id": providerProfile.ID, "currency_id": currency.ID, @@ -1058,7 +1058,7 @@ func TestSender(t *testing.T) { payload := map[string]interface{}{ "amount": "100", "token": testCtx.token.Symbol, - "rate": "750", + "rate": "1", "network": testNetwork.Identifier, "reference": fmt.Sprintf("maxfeecap_below_%d", time.Now().UnixNano()), "recipient": map[string]interface{}{ @@ -1154,7 +1154,7 @@ func TestSender(t *testing.T) { "currency_id": currency.ID, "network": testCtx.networkIdentifier, "conversion_rate_type": "fixed", - "fixed_conversion_rate": decimal.NewFromFloat(750.0), // Match test payload rate + "fixed_conversion_rate": decimal.NewFromFloat(750.0), // Provider DB rate; ValidateRate still returns 1 for NGN/NGN direct match vs payload "1" "max_order_amount": decimal.NewFromFloat(10000), "min_order_amount": decimal.NewFromFloat(1), "max_order_amount_otc": decimal.Zero, @@ -1168,7 +1168,7 @@ func TestSender(t *testing.T) { assert.NotNil(t, providerOrderToken, "Provider order token should be created") // Create ProvisionBucket for bucket-based rate validation - // Bucket range should accommodate: amount (100) * rate (750) = 75,000 fiat + // Bucket range accommodates fiat notionally from queue rate (e.g. 100 * 750); initiation uses direct-match rate 1 bucket, err := test.CreateTestProvisionBucket(map[string]interface{}{ "provider_id": providerProfile.ID, "currency_id": currency.ID, @@ -1237,7 +1237,7 @@ func TestSender(t *testing.T) { payload := map[string]interface{}{ "amount": "100", "token": testCtx.token.Symbol, - "rate": "750", + "rate": "1", "network": testNetwork.Identifier, "reference": fmt.Sprintf("nomaxfeecap_%d", time.Now().UnixNano()), "recipient": map[string]interface{}{ diff --git a/services/common/order.go b/services/common/order.go index 8b0ff207..ef9f0fa5 100644 --- a/services/common/order.go +++ b/services/common/order.go @@ -976,6 +976,48 @@ func HandleCancellation(ctx context.Context, createdPaymentOrder *ent.PaymentOrd return nil } +// handleCancellationForIndexedOrder updates the existing payment order for this gateway when one exists +// (e.g. API order linked in ProcessPaymentOrderFromBlockchain before validation). Calling +// HandleCancellation(ctx, nil, paymentOrderFields, ...) creates a second row with the same gateway_id +// while the original can remain pending — duplicate orders. +func handleCancellationForIndexedOrder( + ctx context.Context, + network *ent.Network, + gatewayID string, + existingOrder *ent.PaymentOrder, + paymentOrderFields *types.PaymentOrderFields, + reason string, + refundOrder func(context.Context, *ent.Network, string) error, +) error { + normGW := normalizeGatewayID(gatewayID) + var orderToCancel *ent.PaymentOrder + if existingOrder != nil && normalizeGatewayID(existingOrder.GatewayID) == normGW { + orderToCancel = existingOrder + } else { + foundOrder, err := db.Client.PaymentOrder. + Query(). + Where( + paymentorder.GatewayIDEQ(normGW), + paymentorder.HasTokenWith( + tokenent.HasNetworkWith( + networkent.IdentifierEQ(network.Identifier), + ), + ), + ). + Only(ctx) + if err != nil && !ent.IsNotFound(err) { + return fmt.Errorf("%s - failed to query order for cancellation: %w", normGW, err) + } + if foundOrder != nil { + orderToCancel = foundOrder + } + } + if orderToCancel != nil { + return HandleCancellation(ctx, orderToCancel, nil, reason, refundOrder) + } + return HandleCancellation(ctx, nil, paymentOrderFields, reason, refundOrder) +} + // CheckAMLCompliance checks if a transaction is compliant with AML regulations. func CheckAMLCompliance(rpcUrl string, txHash string) (bool, error) { if !strings.Contains(rpcUrl, "shield3") { @@ -1464,16 +1506,17 @@ func validateAndPreparePaymentOrderData( utils.RateSideSell, // This is for offramp orders ) - if rateResult.Rate == decimal.NewFromInt(1) && paymentOrderFields.Rate != decimal.NewFromInt(1) { - err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder) + // Must check rateErr before using rateResult (error path may leave result zero-valued). + if rateErr != nil { + err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, fmt.Sprintf("Rate validation failed: %s", rateErr.Error()), refundOrder) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err) } return nil, nil, nil, nil, nil, nil } - if rateErr != nil { - err := HandleCancellation(ctx, nil, paymentOrderFields, fmt.Sprintf("Rate validation failed: %s", rateErr.Error()), refundOrder) + if rateResult.Rate == decimal.NewFromInt(1) && paymentOrderFields.Rate != decimal.NewFromInt(1) { + err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Rate validation failed", refundOrder) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err) } @@ -1485,7 +1528,7 @@ func validateAndPreparePaymentOrderData( rateDiff := event.Rate.Sub(rateResult.Rate).Abs() if rateDiff.GreaterThan(tolerance) { - err := HandleCancellation(ctx, nil, paymentOrderFields, "Rate validation failed", refundOrder) + err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Rate validation failed", refundOrder) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err) } @@ -1531,7 +1574,9 @@ func validateAndPreparePaymentOrderData( // 3. Provider does not support the network // 4. Provider does not support the currency // 5. Provider have not configured a settlement address for the network - _ = HandleCancellation(ctx, nil, paymentOrderFields, "Provider not available", refundOrder) + if cErr := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Provider not available", refundOrder); cErr != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("%s - failed to handle cancellation: %w", paymentOrderFields.GatewayID, cErr) + } return nil, nil, nil, nil, nil, nil } else { return nil, nil, nil, nil, nil, fmt.Errorf("%s - failed to fetch provider: %w", paymentOrderFields.GatewayID, err) @@ -1545,7 +1590,7 @@ func validateAndPreparePaymentOrderData( } if provisionBucket == nil && !isPrivate { - err := HandleCancellation(ctx, nil, paymentOrderFields, "Amount is larger than the maximum bucket", refundOrder) + err := handleCancellationForIndexedOrder(ctx, network, paymentOrderFields.GatewayID, existingOrder, paymentOrderFields, "Amount is larger than the maximum bucket", refundOrder) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf("failed to handle cancellation: %w", err) } diff --git a/utils/test/db.go b/utils/test/db.go index 5ddc6351..6dfca11b 100644 --- a/utils/test/db.go +++ b/utils/test/db.go @@ -18,6 +18,7 @@ import ( "github.com/paycrest/aggregator/ent/fiatcurrency" "github.com/paycrest/aggregator/ent/institution" "github.com/paycrest/aggregator/ent/paymentorder" + "github.com/paycrest/aggregator/ent/network" "github.com/paycrest/aggregator/ent/paymentorderfulfillment" "github.com/paycrest/aggregator/ent/providerordertoken" "github.com/paycrest/aggregator/ent/providerprofile" @@ -385,14 +386,42 @@ func CreateTestSenderProfile(overrides map[string]interface{}) (*ent.SenderProfi payload[key] = value } - _token, err := db.Client.Token. - Query(). - Where( - token.SymbolEQ(payload["token"].(string)), - ). - Only(context.Background()) - if err != nil { - return nil, err + var _token *ent.Token + var err error + if tid, ok := payload["token_id"]; ok && tid != nil { + switch v := tid.(type) { + case int: + _token, err = db.Client.Token.Get(context.Background(), v) + case int32: + _token, err = db.Client.Token.Get(context.Background(), int(v)) + case int64: + _token, err = db.Client.Token.Get(context.Background(), int(v)) + default: + return nil, fmt.Errorf("CreateTestSenderProfile: unsupported token_id type %T", tid) + } + if err != nil { + return nil, err + } + } else { + symbol := payload["token"].(string) + q := db.Client.Token.Query().Where(token.SymbolEQ(symbol)) + if nid, ok := payload["network_id"]; ok && nid != nil { + switch v := nid.(type) { + case int: + q = q.Where(token.HasNetworkWith(network.IDEQ(v))) + case int32: + q = q.Where(token.HasNetworkWith(network.IDEQ(int(v)))) + case int64: + q = q.Where(token.HasNetworkWith(network.IDEQ(int(v)))) + } + } + _token, err = q.Only(context.Background()) + if err != nil && ent.IsNotSingular(err) { + _token, err = q.Order(ent.Asc(token.FieldID)).First(context.Background()) + } + if err != nil { + return nil, err + } } feePercent, _ := decimal.NewFromString(payload["fee_percent"].(string)) @@ -518,6 +547,13 @@ func AddProviderOrderTokenToProvider(overrides map[string]interface{}) (*ent.Pro payload[key] = value } + var payoutAddr string + if v, ok := payload["payout_address"].(string); ok && v != "" { + payoutAddr = v + } else { + payoutAddr = payload["settlement_address"].(string) + } + if payload["token_id"].(int) == 0 { // Create test token token, err := CreateERC20Token(map[string]interface{}{}) @@ -540,6 +576,7 @@ func AddProviderOrderTokenToProvider(overrides map[string]interface{}) (*ent.Pro SetFloatingBuyDelta(payload["floating_buy_delta"].(decimal.Decimal)). SetFloatingSellDelta(payload["floating_sell_delta"].(decimal.Decimal)). SetSettlementAddress(payload["settlement_address"].(string)). + SetPayoutAddress(payoutAddr). SetNetwork(payload["network"].(string)). SetTokenID(payload["token_id"].(int)). SetCurrencyID(payload["currency_id"].(uuid.UUID)).