diff --git a/controllers/provider/provider.go b/controllers/provider/provider.go index 7d7135e6..74ecbbcf 100644 --- a/controllers/provider/provider.go +++ b/controllers/provider/provider.go @@ -59,7 +59,7 @@ type ProviderController struct { func NewProviderController() *ProviderController { return &ProviderController{ balanceService: balance.New(), - nativeService: services.NewNativeService(), + nativeService: services.NewNativeService(), } } @@ -1211,6 +1211,12 @@ func (ctrl *ProviderController) FulfillOrder(ctx *gin.Context) { // Check if this is a payin order (onramp) isPayin := fulfillment.Edges.Order != nil && fulfillment.Edges.Order.Direction == paymentorder.DirectionOnramp + // Short-circuit payin orders already in settling: treat as success to avoid double-processing + if isPayin && fulfillment.Edges.Order.Status == paymentorder.StatusSettling { + u.APIResponse(ctx, http.StatusOK, "success", "Order already settling", nil) + return + } + // Handle payin orders if isPayin { ctrl.handlePayinFulfillment(ctx, orderID, payload, fulfillment, provider) @@ -1655,11 +1661,21 @@ func (ctrl *ProviderController) handleRefundOutcomeFulfillment(ctx *gin.Context, // handlePayinFulfillment handles payin (onramp) order fulfillment func (ctrl *ProviderController) handlePayinFulfillment(ctx *gin.Context, orderID uuid.UUID, payload types.FulfillOrderPayload, fulfillment *ent.PaymentOrderFulfillment, provider *ent.ProviderProfile) { + reqCtx := ctx.Request.Context() + // Only proceed to settlement when validation succeeded; reject failed/pending like handlePayoutFulfillment // Authorization is required only for settlement (ValidationStatusSuccess); failed validation reports do not need it switch payload.ValidationStatus { case paymentorderfulfillment.ValidationStatusFailed: - _, err := fulfillment.Update(). + currentOrder, err := storage.Client.PaymentOrder. + Query(). + Where(paymentorder.IDEQ(orderID)). + Only(reqCtx) + if err == nil && currentOrder != nil && currentOrder.Status == paymentorder.StatusSettling { + u.APIResponse(ctx, http.StatusOK, "success", "Order already settling", nil) + return + } + _, err = fulfillment.Update(). SetValidationStatus(paymentorderfulfillment.ValidationStatusFailed). SetValidationError(payload.ValidationError). Save(ctx) @@ -1705,15 +1721,23 @@ func (ctrl *ProviderController) handlePayinFulfillment(ctx *gin.Context, orderID }). WithProvider(). WithSenderProfile(). - Only(ctx) + Only(reqCtx) if err != nil { logger.Errorf("Failed to fetch order details: %v", err) u.APIResponse(ctx, http.StatusInternalServerError, "error", "Failed to fetch order details", nil) return } + if orderWithDetails.Status == paymentorder.StatusSettling { + u.APIResponse(ctx, http.StatusOK, "success", "Settlement already submitted or completed", nil) + return + } + if orderWithDetails.Status != paymentorder.StatusPending && orderWithDetails.Status != paymentorder.StatusFulfilling { + u.APIResponse(ctx, http.StatusConflict, "error", fmt.Sprintf("Order must be fulfilling before settlement, current status is %s", orderWithDetails.Status), nil) + return + } // Derive currency from order's institution (provider can have same token for multiple fiat currencies) - institution, err := u.GetInstitutionByCode(ctx, orderWithDetails.Institution, true) + institution, err := u.GetInstitutionByCode(reqCtx, orderWithDetails.Institution, true) if err != nil { logger.Errorf("Failed to fetch institution for payin settlement: %v", err) u.APIResponse(ctx, http.StatusInternalServerError, "error", "Failed to fetch order institution", nil) @@ -1735,7 +1759,7 @@ func (ctrl *ProviderController) handlePayinFulfillment(ctx *gin.Context, orderID providerordertoken.NetworkEQ(orderWithDetails.Edges.Token.Edges.Network.Identifier), providerordertoken.HasCurrencyWith(fiatcurrency.CodeEQ(currencyCode)), ). - Only(ctx) + Only(reqCtx) if err != nil { logger.Errorf("Failed to fetch provider order token: %v", err) u.APIResponse(ctx, http.StatusInternalServerError, "error", "Failed to fetch provider configuration", nil) @@ -1747,6 +1771,23 @@ func (ctrl *ProviderController) handlePayinFulfillment(ctx *gin.Context, orderID return } + if orderWithDetails.Status == paymentorder.StatusPending { + orderWithDetails, err = ctrl.promotePendingPayinOrderToFulfilling(reqCtx, orderWithDetails, provider) + if err != nil { + logger.Errorf("Failed to promote payin order to fulfilling: %v", err) + u.APIResponse(ctx, http.StatusConflict, "error", err.Error(), nil) + return + } + if orderWithDetails.Status == paymentorder.StatusSettling { + u.APIResponse(ctx, http.StatusOK, "success", "Settlement already submitted or completed", nil) + return + } + if orderWithDetails.Status != paymentorder.StatusFulfilling { + u.APIResponse(ctx, http.StatusConflict, "error", fmt.Sprintf("Order must be fulfilling before settlement, current status is %s", orderWithDetails.Status), nil) + return + } + } + // Generate Gateway order ID for settleIn: keccak256(abi.encode(payoutAddress, aggregatorAddress, paymentOrderID, chainID)) if cryptoConf.AggregatorAccountEVM == "" { logger.Errorf("Aggregator EVM address not configured") @@ -2026,6 +2067,111 @@ func (ctrl *ProviderController) executePayinSettlement(ctx *gin.Context, order * return nil } +func (ctrl *ProviderController) promotePendingPayinOrderToFulfilling(ctx context.Context, order *ent.PaymentOrder, provider *ent.ProviderProfile) (*ent.PaymentOrder, error) { + if order == nil { + return nil, fmt.Errorf("order not found") + } + if order.Direction != paymentorder.DirectionOnramp { + return nil, fmt.Errorf("order is not an onramp order") + } + if order.Status == paymentorder.StatusFulfilling { + return order, nil + } + if order.Status == paymentorder.StatusSettling { + return order, nil + } + if order.Status != paymentorder.StatusPending { + return nil, fmt.Errorf("order must be pending or fulfilling before settlement, current status is %s", order.Status) + } + if order.Edges.Provider != nil && order.Edges.Provider.ID != provider.ID { + return nil, fmt.Errorf("order does not belong to provider") + } + + tx, err := storage.Client.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("failed to start transaction: %w", err) + } + + var transactionLog *ent.TransactionLog + _, err = tx.PaymentOrder. + Query(). + Where( + paymentorder.IDEQ(order.ID), + paymentorder.HasTransactionsWith( + transactionlog.StatusEQ(transactionlog.StatusOrderFulfilling), + ), + ). + Only(ctx) + if err != nil { + if !ent.IsNotFound(err) { + _ = tx.Rollback() + return nil, fmt.Errorf("failed to check fulfilling log: %w", err) + } + transactionLog, err = tx.TransactionLog. + Create(). + SetStatus(transactionlog.StatusOrderFulfilling). + Save(ctx) + if err != nil { + _ = tx.Rollback() + return nil, fmt.Errorf("failed to create fulfilling log: %w", err) + } + } + + orderBuilder := tx.PaymentOrder. + Update(). + Where( + paymentorder.IDEQ(order.ID), + paymentorder.StatusEQ(paymentorder.StatusPending), + ). + SetStatus(paymentorder.StatusFulfilling) + + if order.Edges.Provider == nil { + orderBuilder = orderBuilder.SetProviderID(provider.ID) + } + if transactionLog != nil { + orderBuilder = orderBuilder.AddTransactions(transactionLog) + } + + updatedCount, err := orderBuilder.Save(ctx) + if err != nil { + _ = tx.Rollback() + return nil, fmt.Errorf("failed to update order status to fulfilling: %w", err) + } + if updatedCount == 0 { + _ = tx.Rollback() + refreshed, refErr := storage.Client.PaymentOrder. + Query(). + Where(paymentorder.IDEQ(order.ID)). + WithProvider(). + WithToken(func(tq *ent.TokenQuery) { + tq.WithNetwork() + }). + WithSenderProfile(). + Only(ctx) + if refErr != nil { + return nil, fmt.Errorf("failed to reload order after pending->fulfilling race: %w", refErr) + } + if refreshed.Status != paymentorder.StatusFulfilling && refreshed.Status != paymentorder.StatusSettling { + return nil, fmt.Errorf("unexpected order status after pending->fulfilling race: %s", refreshed.Status) + } + return refreshed, nil + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit pending->fulfilling promotion: %w", err) + } + + return storage.Client.PaymentOrder. + Query(). + Where(paymentorder.IDEQ(order.ID)). + WithProvider(). + WithToken(func(tq *ent.TokenQuery) { + tq.WithNetwork() + }). + WithSenderProfile(). + Only(ctx) +} + // CancelOrder controller cancels an order func (ctrl *ProviderController) CancelOrder(ctx *gin.Context) { var payload types.CancelOrderPayload @@ -2905,4 +3051,4 @@ func (ctrl *ProviderController) UpdateProviderBalance(ctx *gin.Context) { }() u.APIResponse(ctx, http.StatusOK, "success", "Balance updated successfully", nil) -} +} \ No newline at end of file diff --git a/controllers/provider/provider_test.go b/controllers/provider/provider_test.go index 358f4071..8f20df64 100644 --- a/controllers/provider/provider_test.go +++ b/controllers/provider/provider_test.go @@ -11,10 +11,14 @@ import ( "time" "github.com/alicebob/miniredis/v2" + coretypes "github.com/ethereum/go-ethereum/core/types" "github.com/google/uuid" "github.com/jarcoal/httpmock" _ "github.com/mattn/go-sqlite3" "github.com/paycrest/aggregator/ent" + "github.com/paycrest/aggregator/ent/paymentorder" + "github.com/paycrest/aggregator/ent/providerbalances" + "github.com/paycrest/aggregator/ent/transactionlog" "github.com/paycrest/aggregator/routers/middleware" "github.com/paycrest/aggregator/services" db "github.com/paycrest/aggregator/storage" @@ -1950,6 +1954,248 @@ func TestProvider(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "Order fulfilled successfully", response.Message) }) + + t.Run("onramp preflight failure leaves pending order unchanged", func(t *testing.T) { + _, _, cleanup := setupIsolatedTest(t) + defer cleanup() + + originalCryptoConf := cryptoConf + cryptoConf.AggregatorAccountEVM = "0x1111111111111111111111111111111111111111" + defer func() { cryptoConf = originalCryptoConf }() + + senderUser, err := test.CreateTestUser(map[string]interface{}{ + "email": fmt.Sprintf("sender_preflight_%s@test.com", uuid.New().String()), + "scope": "sender", + }) + assert.NoError(t, err) + + senderProfile, err := test.CreateTestSenderProfile(map[string]interface{}{ + "user_id": senderUser.ID, + "token": testCtx.token.Symbol, + }) + assert.NoError(t, err) + + order, err := test.CreateTestPaymentOrder(testCtx.token, map[string]interface{}{ + "sender": senderProfile, + "provider": testCtx.provider, + "gateway_id": "", + "status": "pending", + "memo": "", + "refund_or_recipient_address": "0x4444444444444444444444444444444444444444", + }) + assert.NoError(t, err) + + order, err = order.Update(). + SetDirection(paymentorder.DirectionOnramp). + SetSenderFee(decimal.NewFromInt(5)). + SetAmount(decimal.NewFromInt(10)). + SetRate(decimal.NewFromInt(750)). + Save(context.Background()) + assert.NoError(t, err) + + txID := "0xpreflight" + fmt.Sprint(rand.Intn(1000000)) + payload := map[string]interface{}{ + "timestamp": time.Now().Unix(), + "validationStatus": "success", + "txId": txID, + "psp": "psp-name", + } + + signature := token.GenerateHMACSignature(payload, testCtx.apiKeySecret) + headers := map[string]string{ + "Authorization": "HMAC " + testCtx.apiKey.ID.String() + ":" + signature, + } + + res, err := test.PerformRequest(t, "POST", "/orders/"+order.ID.String()+"/fulfill", payload, headers, router) + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, res.Code) + + updatedOrder, err := db.Client.PaymentOrder.Query(). + Where(paymentorder.IDEQ(order.ID)). + Only(context.Background()) + assert.NoError(t, err) + assert.Equal(t, paymentorder.StatusPending, updatedOrder.Status) + + fulfillingLogExists, err := db.Client.TransactionLog.Query(). + Where( + transactionlog.StatusEQ(transactionlog.StatusOrderFulfilling), + transactionlog.HasPaymentOrderWith(paymentorder.IDEQ(order.ID)), + ). + Exist(context.Background()) + assert.NoError(t, err) + assert.False(t, fulfillingLogExists) + }) + + t.Run("onramp success promotes pending order before settling", func(t *testing.T) { + _, _, cleanup := setupIsolatedTest(t) + defer cleanup() + + originalCryptoConf := cryptoConf + cryptoConf.AggregatorAccountEVM = "0x1111111111111111111111111111111111111111" + defer func() { cryptoConf = originalCryptoConf }() + + senderUser, err := test.CreateTestUser(map[string]interface{}{ + "email": fmt.Sprintf("sender_%s@test.com", uuid.New().String()), + "scope": "sender", + }) + assert.NoError(t, err) + + senderProfile, err := test.CreateTestSenderProfile(map[string]interface{}{ + "user_id": senderUser.ID, + "token": testCtx.token.Symbol, + }) + assert.NoError(t, err) + + _, err = db.Client.Network.UpdateOneID(testCtx.token.Edges.Network.ID). + SetGatewayContractAddress("0x2222222222222222222222222222222222222222"). + Save(context.Background()) + assert.NoError(t, err) + + _, err = db.Client.ProviderBalances.Create(). + SetProviderID(testCtx.provider.ID). + SetToken(testCtx.token). + SetAvailableBalance(decimal.NewFromInt(0)). + SetTotalBalance(decimal.NewFromInt(200)). + SetReservedBalance(decimal.NewFromInt(100)). + SetIsAvailable(true). + Save(context.Background()) + assert.NoError(t, err) + + _, err = test.AddProviderOrderTokenToProvider(map[string]interface{}{ + "provider": testCtx.provider, + "token_id": testCtx.token.ID, + "currency_id": testCtx.currency.ID, + "network": testCtx.token.Edges.Network.Identifier, + "settlement_address": "0x3333333333333333333333333333333333333333", + }) + assert.NoError(t, err) + + order, err := test.CreateTestPaymentOrder(testCtx.token, map[string]interface{}{ + "sender": senderProfile, + "provider": testCtx.provider, + "gateway_id": "", + "status": "pending", + "memo": "", + "refund_or_recipient_address": "0x4444444444444444444444444444444444444444", + }) + assert.NoError(t, err) + + order, err = order.Update(). + SetDirection(paymentorder.DirectionOnramp). + SetSenderFee(decimal.NewFromInt(5)). + SetAmount(decimal.NewFromInt(10)). + SetRate(decimal.NewFromInt(750)). + SetFeeAddress("0x1234567890123456789012345678901234567890"). + Save(context.Background()) + assert.NoError(t, err) + + txID := "0xpayin" + fmt.Sprint(rand.Intn(1000000)) + payload := map[string]interface{}{ + "timestamp": time.Now().Unix(), + "validationStatus": "success", + "txId": txID, + "psp": "psp-name", + "authorization": (*coretypes.SetCodeAuthorization)(nil), + } + + signature := token.GenerateHMACSignature(payload, testCtx.apiKeySecret) + headers := map[string]string{ + "Authorization": "HMAC " + testCtx.apiKey.ID.String() + ":" + signature, + } + + res, err := test.PerformRequest(t, "POST", "/orders/"+order.ID.String()+"/fulfill", payload, headers, router) + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, res.Code) + + updatedOrder, err := db.Client.PaymentOrder.Query(). + Where(paymentorder.IDEQ(order.ID)). + WithTransactions(). + Only(context.Background()) + assert.NoError(t, err) + assert.Equal(t, paymentorder.StatusFulfilling, updatedOrder.Status) + + fulfillingLogExists, err := db.Client.TransactionLog.Query(). + Where( + transactionlog.StatusEQ(transactionlog.StatusOrderFulfilling), + transactionlog.HasPaymentOrderWith(paymentorder.IDEQ(order.ID)), + ). + Exist(context.Background()) + assert.NoError(t, err) + assert.True(t, fulfillingLogExists) + }) + + t.Run("onramp late failed callback does not overwrite settling order", func(t *testing.T) { + _, _, cleanup := setupIsolatedTest(t) + defer cleanup() + + senderUser, err := test.CreateTestUser(map[string]interface{}{ + "email": fmt.Sprintf("sender_settling_%s@test.com", uuid.New().String()), + "scope": "sender", + }) + assert.NoError(t, err) + + senderProfile, err := test.CreateTestSenderProfile(map[string]interface{}{ + "user_id": senderUser.ID, + "token": testCtx.token.Symbol, + }) + assert.NoError(t, err) + + tokenBalance, err := db.Client.ProviderBalances.Create(). + SetProviderID(testCtx.provider.ID). + SetToken(testCtx.token). + SetAvailableBalance(decimal.NewFromInt(0)). + SetTotalBalance(decimal.NewFromInt(200)). + SetReservedBalance(decimal.NewFromInt(25)). + SetIsAvailable(true). + Save(context.Background()) + assert.NoError(t, err) + + order, err := test.CreateTestPaymentOrder(testCtx.token, map[string]interface{}{ + "sender": senderProfile, + "provider": testCtx.provider, + "gateway_id": "0xsettling-order", + "status": "settling", + "memo": "", + "refund_or_recipient_address": "0x4444444444444444444444444444444444444444", + }) + assert.NoError(t, err) + + order, err = order.Update(). + SetDirection(paymentorder.DirectionOnramp). + SetSenderFee(decimal.NewFromInt(5)). + SetAmount(decimal.NewFromInt(10)). + SetRate(decimal.NewFromInt(750)). + Save(context.Background()) + assert.NoError(t, err) + + txID := "0xlatefail" + fmt.Sprint(rand.Intn(1000000)) + payload := map[string]interface{}{ + "timestamp": time.Now().Unix(), + "validationStatus": "failed", + "validationError": "late callback", + "txId": txID, + "psp": "psp-name", + } + + signature := token.GenerateHMACSignature(payload, testCtx.apiKeySecret) + headers := map[string]string{ + "Authorization": "HMAC " + testCtx.apiKey.ID.String() + ":" + signature, + } + + res, err := test.PerformRequest(t, "POST", "/orders/"+order.ID.String()+"/fulfill", payload, headers, router) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.Code) + + updatedOrder, err := db.Client.PaymentOrder.Get(context.Background(), order.ID) + assert.NoError(t, err) + assert.Equal(t, paymentorder.StatusSettling, updatedOrder.Status) + + updatedBalance, err := db.Client.ProviderBalances.Query(). + Where(providerbalances.IDEQ(tokenBalance.ID)). + Only(context.Background()) + assert.NoError(t, err) + assert.Equal(t, decimal.NewFromInt(25), updatedBalance.ReservedBalance) + }) }) t.Run("SearchPaymentOrders", func(t *testing.T) { @@ -2361,4 +2607,4 @@ func TestProvider(t *testing.T) { }) }) -} +} \ No newline at end of file