Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e346931
feat: enhance currency and token management with new market rates
chibie Jan 30, 2026
bcb7a78
feat(provider): onramp payin settlement and settling/refunding flow
chibie Feb 1, 2026
5d4c378
feat: implement SettleOut and SettleIn event handling in the indexing…
chibie Feb 2, 2026
6f97549
feat: update SettleIn event structure to include liquidity provider
chibie Feb 3, 2026
1dab913
feat: add webhook version handling to sender profile
chibie Feb 3, 2026
a1032df
feat: enhance payment order processing and webhook structure
chibie Feb 4, 2026
59ccd89
feat: implement v2 API for payment orders in sender and provider cont…
chibie Feb 4, 2026
4da3133
fix: improve error handling and validation in payment order processing
chibie Feb 4, 2026
d95634f
feat: enhance payment order amount calculation with direction handling
chibie Feb 4, 2026
22a22a6
fix: update profile test assertions and improve error handling in pro…
chibie Feb 4, 2026
637fe96
fix: enhance error handling and currency derivation in payin fulfillm…
chibie Feb 4, 2026
1c248b3
fix: enhance error handling and currency rate validation in order pro…
chibie Feb 4, 2026
0e27784
fix: update profile validation for rate slippage in profile update pr…
chibie Feb 4, 2026
e3850bc
fix: streamline settlement process in payin fulfillment and enhance e…
chibie Feb 4, 2026
867189e
fix: enhance validation handling in payin fulfillment process
chibie Feb 4, 2026
bf7f489
fix: enhance validation for buy and sell rates in profile update process
chibie Feb 4, 2026
289a456
fix: enhance order processing logic and error handling in provider an…
chibie Feb 4, 2026
10a4237
Implement onramp payment flow and direction-aware rate/refund handling
chibie Feb 7, 2026
721769d
feat: improve onramp payment flow and direction-aware rate/refund han…
chibie Feb 7, 2026
d89e09c
feat: implement order expiration logic for onramp and offramp processes
chibie Feb 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
.idea/
.claude/
vendor/
.vscode/
tmp/
Expand Down
164 changes: 130 additions & 34 deletions controllers/accounts/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ func (ctrl *ProfileController) UpdateSenderProfile(ctx *gin.Context) {
return
}

if payload.WebhookVersion != "" && payload.WebhookVersion != "1" && payload.WebhookVersion != "2" {
u.APIResponse(ctx, http.StatusBadRequest, "error", "Failed to validate payload", []types.ErrorData{{
Field: "WebhookVersion",
Message: "Must be \"1\" or \"2\"",
}})
return
}

// Get sender profile from the context
senderCtx, ok := ctx.Get("sender")
if !ok {
Expand Down Expand Up @@ -91,6 +99,10 @@ func (ctrl *ProfileController) UpdateSenderProfile(ctx *gin.Context) {
update.SetWebhookURL(payload.WebhookURL)
}

if payload.WebhookVersion != "" && payload.WebhookVersion != sender.WebhookVersion {
update.SetWebhookVersion(payload.WebhookVersion)
}

if payload.DomainWhitelist != nil {
update.SetDomainWhitelist(payload.DomainWhitelist)
}
Expand Down Expand Up @@ -355,21 +367,68 @@ func (ctrl *ProfileController) UpdateProviderProfile(ctx *gin.Context) {
continue
}

// Calculate rate from tokenPayload based on conversion type
var rate decimal.Decimal
if tokenPayload.ConversionRateType == providerordertoken.ConversionRateTypeFixed {
rate = tokenPayload.FixedConversionRate
} else {
rate = currency.MarketRate.Add(tokenPayload.FloatingConversionRate)
// Validate buy/sell rates - at least one direction must be configured
hasBuyRate := !tokenPayload.FixedBuyRate.IsZero() || !tokenPayload.FloatingBuyDelta.IsZero()
hasSellRate := !tokenPayload.FixedSellRate.IsZero() || !tokenPayload.FloatingSellDelta.IsZero()
if !hasBuyRate && !hasSellRate {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("At least one rate (buy or sell) must be configured for %s", tokenPayload.Symbol),
})
continue
}

// Validate buy_rate >= sell_rate when both are configured (provider profitability)
var buyRate, sellRate decimal.Decimal
if !tokenPayload.FixedBuyRate.IsZero() {
buyRate = tokenPayload.FixedBuyRate
} else if !tokenPayload.FloatingBuyDelta.IsZero() && !currency.MarketBuyRate.IsZero() {
buyRate = currency.MarketBuyRate.Add(tokenPayload.FloatingBuyDelta)
}
if !tokenPayload.FixedSellRate.IsZero() {
sellRate = tokenPayload.FixedSellRate
} else if !tokenPayload.FloatingSellDelta.IsZero() && !currency.MarketSellRate.IsZero() {
sellRate = currency.MarketSellRate.Add(tokenPayload.FloatingSellDelta)
}
if !buyRate.IsZero() && buyRate.LessThanOrEqual(decimal.Zero) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Buy rate must be positive for %s", tokenPayload.Symbol),
})
continue
}
if !sellRate.IsZero() && sellRate.LessThanOrEqual(decimal.Zero) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Sell rate must be positive for %s", tokenPayload.Symbol),
})
continue
}
if !buyRate.IsZero() && !sellRate.IsZero() && buyRate.LessThan(sellRate) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Buy rate must be >= sell rate for profitability (%s)", tokenPayload.Symbol),
})
continue
}

// Validate rate deviation for floating rates
if tokenPayload.ConversionRateType == providerordertoken.ConversionRateTypeFloating {
percentDeviation := u.AbsPercentageDeviation(currency.MarketRate, rate)
// Validate rate deviation only when rate was derived from floating (market + delta), not when using fixed rate
if tokenPayload.FixedBuyRate.IsZero() && !tokenPayload.FloatingBuyDelta.IsZero() && !currency.MarketBuyRate.IsZero() {
percentDeviation := u.AbsPercentageDeviation(currency.MarketBuyRate, buyRate)
if percentDeviation.GreaterThan(orderConf.PercentDeviationFromMarketRate) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Buy rate is too far from market rate for %s", tokenPayload.Symbol),
})
continue
}
}
if tokenPayload.FixedSellRate.IsZero() && !tokenPayload.FloatingSellDelta.IsZero() && !currency.MarketSellRate.IsZero() {
percentDeviation := u.AbsPercentageDeviation(currency.MarketSellRate, sellRate)
if percentDeviation.GreaterThan(orderConf.PercentDeviationFromMarketRate) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Rate is too far from market rate for %s", tokenPayload.Symbol),
Message: fmt.Sprintf("Sell rate is too far from market rate for %s", tokenPayload.Symbol),
})
continue
}
Expand All @@ -384,12 +443,32 @@ func (ctrl *ProfileController) UpdateProviderProfile(ctx *gin.Context) {
Message: fmt.Sprintf("Rate slippage cannot be less than 0.1%% for %s", tokenPayload.Symbol),
})
continue
} else if rate.Mul(tokenPayload.RateSlippage.Div(decimal.NewFromFloat(100))).GreaterThan(currency.MarketRate.Mul(decimal.NewFromFloat(0.05))) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Rate slippage is too high for %s", tokenPayload.Symbol),
})
continue
} else {
// Cap slippage percentage so fixed rates (e.g. rateRef=1) cannot bypass validation
const maxSlippagePercent = 5.0
if tokenPayload.RateSlippage.GreaterThan(decimal.NewFromFloat(maxSlippagePercent)) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Rate slippage is too high for %s (max %.1f%%)", tokenPayload.Symbol, maxSlippagePercent),
})
continue
}
// Check slippage against market rates (use sell rate as reference for offramp)
marketRef := currency.MarketSellRate
if marketRef.IsZero() {
marketRef = currency.MarketBuyRate
}
rateRef := sellRate
if rateRef.IsZero() {
rateRef = buyRate
}
if !marketRef.IsZero() && !rateRef.IsZero() && rateRef.Mul(tokenPayload.RateSlippage.Div(decimal.NewFromFloat(100))).GreaterThan(marketRef.Mul(decimal.NewFromFloat(0.05))) {
validationErrors = append(validationErrors, types.ErrorData{
Field: "Tokens",
Message: fmt.Sprintf("Rate slippage is too high for %s", tokenPayload.Symbol),
})
continue
}
}

// Check if token already exists for provider
Expand Down Expand Up @@ -443,11 +522,20 @@ func (ctrl *ProfileController) UpdateProviderProfile(ctx *gin.Context) {
tokenPayload.RateSlippage = existingToken.RateSlippage
}

// Choose a representative conversion rate for bucket calculations (offramp = sell side)
conversionRate := sellRate
if conversionRate.IsZero() {
conversionRate = buyRate
}
if conversionRate.IsZero() {
conversionRate = decimal.NewFromInt(1)
}
Comment on lines +525 to +532
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fallback conversionRate = 1 may misassign provision buckets.

When both buy and sell rates are zero (e.g., floating deltas configured but market rates not yet available), falling back to 1 treats token amounts as 1:1 with fiat for bucket range matching. This could place the provider in incorrect buckets until a market rate is published.

Consider either skipping bucket assignment for this token or logging a warning so the misconfiguration is visible.

🤖 Prompt for AI Agents
In `@controllers/accounts/profile.go` around lines 525 - 532, The current fallback
sets conversionRate := sellRate then to buyRate then to decimal.NewFromInt(1),
which masks cases where both sellRate and buyRate are zero and can misassign
provision buckets; instead, detect when sellRate.IsZero() && buyRate.IsZero()
and in that case do not set conversionRate to 1 but skip bucket assignment for
that token (or return a clear no-op), and emit a warning log including the token
identifier and the zero rates so operators can see the issue; update the code
paths that use conversionRate (the bucket assignment logic that follows this
block) to handle the "no conversion rate" case safely.


tokenOperations = append(tokenOperations, TokenOperation{
TokenPayload: tokenPayload,
ProviderToken: providerToken,
Currency: currency,
Rate: rate,
Rate: conversionRate,
IsUpdate: isUpdate,
ExistingToken: existingToken,
})
Expand Down Expand Up @@ -586,9 +674,10 @@ func (ctrl *ProfileController) UpdateProviderProfile(ctx *gin.Context) {
SetSettlementAddress(op.TokenPayload.SettlementAddress).
SetNetwork(op.TokenPayload.Network).
SetRateSlippage(op.TokenPayload.RateSlippage).
SetConversionRateType(op.TokenPayload.ConversionRateType).
SetFixedConversionRate(op.TokenPayload.FixedConversionRate).
SetFloatingConversionRate(op.TokenPayload.FloatingConversionRate).
SetFixedBuyRate(op.TokenPayload.FixedBuyRate).
SetFixedSellRate(op.TokenPayload.FixedSellRate).
SetFloatingBuyDelta(op.TokenPayload.FloatingBuyDelta).
SetFloatingSellDelta(op.TokenPayload.FloatingSellDelta).
SetMaxOrderAmount(op.TokenPayload.MaxOrderAmount).
SetMinOrderAmount(op.TokenPayload.MinOrderAmount).
SetMaxOrderAmountOtc(op.TokenPayload.MaxOrderAmountOTC).
Expand All @@ -610,9 +699,10 @@ func (ctrl *ProfileController) UpdateProviderProfile(ctx *gin.Context) {
// Create new token
_, err = tx.ProviderOrderToken.
Create().
SetConversionRateType(op.TokenPayload.ConversionRateType).
SetFixedConversionRate(op.TokenPayload.FixedConversionRate).
SetFloatingConversionRate(op.TokenPayload.FloatingConversionRate).
SetFixedBuyRate(op.TokenPayload.FixedBuyRate).
SetFixedSellRate(op.TokenPayload.FixedSellRate).
SetFloatingBuyDelta(op.TokenPayload.FloatingBuyDelta).
SetFloatingSellDelta(op.TokenPayload.FloatingSellDelta).
SetMaxOrderAmount(op.TokenPayload.MaxOrderAmount).
SetMinOrderAmount(op.TokenPayload.MinOrderAmount).
SetMaxOrderAmountOtc(op.TokenPayload.MaxOrderAmountOTC).
Expand Down Expand Up @@ -829,12 +919,17 @@ func (ctrl *ProfileController) GetSenderProfile(ctx *gin.Context) {
kybRejectionComment = kybProfile.KybRejectionComment
}

webhookVersion := sender.WebhookVersion
if webhookVersion == "" {
webhookVersion = "1"
}
response := &types.SenderProfileResponse{
ID: sender.ID,
FirstName: user.FirstName,
LastName: user.LastName,
Email: user.Email,
WebhookURL: sender.WebhookURL,
WebhookVersion: webhookVersion,
DomainWhitelist: sender.DomainWhitelist,
Tokens: tokensPayload,
APIKey: *apiKey,
Expand Down Expand Up @@ -958,17 +1053,18 @@ func (ctrl *ProfileController) GetProviderProfile(ctx *gin.Context) {
tokensPayload := make([]types.ProviderOrderTokenPayload, len(orderTokens))
for i, orderToken := range orderTokens {
payload := types.ProviderOrderTokenPayload{
Symbol: orderToken.Edges.Token.Symbol,
ConversionRateType: orderToken.ConversionRateType,
FixedConversionRate: orderToken.FixedConversionRate,
FloatingConversionRate: orderToken.FloatingConversionRate,
MaxOrderAmount: orderToken.MaxOrderAmount,
MinOrderAmount: orderToken.MinOrderAmount,
MaxOrderAmountOTC: orderToken.MaxOrderAmountOtc,
MinOrderAmountOTC: orderToken.MinOrderAmountOtc,
RateSlippage: orderToken.RateSlippage,
SettlementAddress: orderToken.SettlementAddress,
Network: orderToken.Network,
Symbol: orderToken.Edges.Token.Symbol,
FixedBuyRate: orderToken.FixedBuyRate,
FixedSellRate: orderToken.FixedSellRate,
FloatingBuyDelta: orderToken.FloatingBuyDelta,
FloatingSellDelta: orderToken.FloatingSellDelta,
MaxOrderAmount: orderToken.MaxOrderAmount,
MinOrderAmount: orderToken.MinOrderAmount,
MaxOrderAmountOTC: orderToken.MaxOrderAmountOtc,
MinOrderAmountOTC: orderToken.MinOrderAmountOtc,
RateSlippage: orderToken.RateSlippage,
SettlementAddress: orderToken.SettlementAddress,
Network: orderToken.Network,
}
tokensPayload[i] = payload
}
Expand Down
59 changes: 35 additions & 24 deletions controllers/accounts/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,10 @@ func TestProfile(t *testing.T) {
Currency: "KES",
Tokens: []types.ProviderOrderTokenPayload{{
Symbol: testCtx.token.Symbol,
FixedBuyRate: decimal.NewFromFloat(1.0),
FixedSellRate: decimal.NewFromFloat(1.0),
FloatingBuyDelta: decimal.Zero,
FloatingSellDelta: decimal.Zero,
Network: testCtx.orderToken.Network,
RateSlippage: decimal.NewFromFloat(25), // 25% slippage
MaxOrderAmountOTC: decimal.Zero,
Expand All @@ -588,7 +592,7 @@ func TestProfile(t *testing.T) {
var response types.Response
err = json.Unmarshal(res.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "Rate slippage is too high for TST", response.Message)
assert.Contains(t, response.Message, "Rate slippage is too high")
})

t.Run("fails when rate slippage is less than 0.1", func(t *testing.T) {
Expand All @@ -598,6 +602,10 @@ func TestProfile(t *testing.T) {
Currency: "KES",
Tokens: []types.ProviderOrderTokenPayload{{
Symbol: testCtx.token.Symbol,
FixedBuyRate: decimal.NewFromFloat(1.0),
FixedSellRate: decimal.NewFromFloat(1.0),
FloatingBuyDelta: decimal.Zero,
FloatingSellDelta: decimal.Zero,
Network: testCtx.orderToken.Network,
RateSlippage: decimal.NewFromFloat(0.09), // 0.09% slippage
MaxOrderAmountOTC: decimal.Zero,
Expand All @@ -610,7 +618,7 @@ func TestProfile(t *testing.T) {
var response types.Response
err = json.Unmarshal(res.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "Rate slippage cannot be less than 0.1% for TST", response.Message)
assert.Contains(t, response.Message, "Rate slippage cannot be less than 0.1%")
})

t.Run("succeeds with valid rate slippage", func(t *testing.T) {
Expand All @@ -619,16 +627,17 @@ func TestProfile(t *testing.T) {
HostIdentifier: testCtx.providerProfile.HostIdentifier,
Currency: "KES",
Tokens: []types.ProviderOrderTokenPayload{{
Symbol: testCtx.token.Symbol,
ConversionRateType: testCtx.orderToken.ConversionRateType,
FixedConversionRate: testCtx.orderToken.FixedConversionRate,
FloatingConversionRate: testCtx.orderToken.FloatingConversionRate,
MaxOrderAmount: testCtx.orderToken.MaxOrderAmount,
MinOrderAmount: testCtx.orderToken.MinOrderAmount,
Network: testCtx.orderToken.Network,
RateSlippage: decimal.NewFromFloat(5), // 5% slippage
MaxOrderAmountOTC: decimal.Zero,
MinOrderAmountOTC: decimal.Zero,
Symbol: testCtx.token.Symbol,
FixedBuyRate: decimal.NewFromFloat(1.0),
FixedSellRate: decimal.NewFromFloat(1.0),
FloatingBuyDelta: decimal.Zero,
FloatingSellDelta: decimal.Zero,
MaxOrderAmount: testCtx.orderToken.MaxOrderAmount,
MinOrderAmount: testCtx.orderToken.MinOrderAmount,
Network: testCtx.orderToken.Network,
RateSlippage: decimal.NewFromFloat(5), // 5% slippage
MaxOrderAmountOTC: decimal.Zero,
MinOrderAmountOTC: decimal.Zero,
}},
}
res := profileUpdateRequest(payload)
Expand Down Expand Up @@ -658,14 +667,15 @@ func TestProfile(t *testing.T) {
// HostIdentifier: testCtx.providerProfile.HostIdentifier,
// Currencies: []string{"KES"},
// Tokens: []types.ProviderOrderTokenPayload{{
// Currency: testCtx.orderToken.Edges.Currency.Code,
// Symbol: testCtx.orderToken.Edges.Token.Symbol,
// ConversionRateType: testCtx.orderToken.ConversionRateType,
// FixedConversionRate: testCtx.orderToken.FixedConversionRate,
// FloatingConversionRate: testCtx.orderToken.FloatingConversionRate,
// MaxOrderAmount: testCtx.orderToken.MaxOrderAmount,
// MinOrderAmount: testCtx.orderToken.MinOrderAmount,
// Network: testCtx.orderToken.Network,
// Currency: testCtx.orderToken.Edges.Currency.Code,
// Symbol: testCtx.orderToken.Edges.Token.Symbol,
// FixedBuyRate: decimal.NewFromFloat(1.0),
// FixedSellRate: decimal.NewFromFloat(1.0),
// FloatingBuyDelta: decimal.Zero,
// FloatingSellDelta: decimal.Zero,
// MaxOrderAmount: testCtx.orderToken.MaxOrderAmount,
// MinOrderAmount: testCtx.orderToken.MinOrderAmount,
// Network: testCtx.orderToken.Network,
// }},
// }
// res := profileUpdateRequest(payload)
Expand Down Expand Up @@ -1284,15 +1294,16 @@ func TestProfile(t *testing.T) {
Save(ctx)
assert.NoError(t, err)

// Create a provider order token for USD
// Create a provider order token for USD using new two-sided pricing fields
_, err = db.Client.ProviderOrderToken.
Create().
SetProviderID(testCtx.providerProfile.ID).
SetTokenID(testCtx.token.ID).
SetCurrencyID(usd.ID).
SetConversionRateType("floating").
SetFixedConversionRate(decimal.NewFromInt(0)).
SetFloatingConversionRate(decimal.NewFromInt(2)).
SetFixedBuyRate(decimal.NewFromInt(2)). // synthetic buy/sell spread for test
SetFixedSellRate(decimal.NewFromInt(1)).
SetFloatingBuyDelta(decimal.Zero).
SetFloatingSellDelta(decimal.Zero).
SetMaxOrderAmount(decimal.NewFromInt(200)).
SetMinOrderAmount(decimal.NewFromInt(10)).
SetMaxOrderAmountOtc(decimal.Zero).
Expand Down
Loading
Loading