Skip to content

Commit

Permalink
Market "house" currencies (#93)
Browse files Browse the repository at this point in the history
* Add support for third-party markets "house currency". Currency rates are displayed in the UI next to the local currency price. This helps explains the price discrepency for websites using their own currency system

* Corrected Rustyloot market price calculations (convert from "coins" to USD)

* Corrected Tradeit market price cash/crypto discount multiplier (now 35%)

* Corrected Tradeit "trade" market accepted payment options

* Add market type flag for gambling websites, for transparency
  • Loading branch information
rhyskoedijk authored May 10, 2024
1 parent 1097cae commit 7ca7dde
Show file tree
Hide file tree
Showing 14 changed files with 150 additions and 28 deletions.
2 changes: 1 addition & 1 deletion SCMM.Shared.Data.Models/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public static string FirstCharToLower(this string value)
return value.First().ToString().ToLower() + value.Substring(1);
}

public static string Pluralise(this string value, int count = 0)
public static string Pluralise(this string value, long count = 0)
{
if (String.IsNullOrEmpty(value))
{
Expand Down
91 changes: 79 additions & 12 deletions SCMM.Steam.Data.Models/Attributes/BuyFromAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using SCMM.Steam.Data.Models.Enums;
using SCMM.Shared.Data.Models;
using SCMM.Steam.Data.Models.Enums;
using SCMM.Steam.Data.Models.Extensions;

namespace SCMM.Steam.Data.Models.Attributes;
Expand All @@ -10,33 +11,65 @@ public class BuyFromAttribute : Attribute

public PriceFlags AcceptedPayments { get; set; }

/// <summary>
/// Fixed amount to subtract from the price
/// </summary>
public long DiscountFixedAmount { get; set; }

/// <summary>
/// Multiplier to subtract from the price
/// </summary>
public float DiscountMultiplier { get; set; }

public long FeeSurcharge { get; set; }
/// <summary>
/// Fixed amount to add to the price
/// </summary>
public long SurchargeFixedAmount { get; set; }

/// <summary>
/// Multiplier to add to the price
/// </summary>
public float SurchargeMultiplier { get; set; }

/// <summary>
/// If the market uses an in-house currency, this is the description (e.g. "coins")
/// </summary>
public string HouseCurrencyName { get; set; }

/// <summary>
/// If the market uses a in-house currency, this is the number of decimal places used (e.g. 2 for 2 decimal places)
/// </summary>
public int HouseCurrencyScale { get; set; }

public float FeeRate { get; set; }
/// <summary>
/// If the market uses a in-house currency, this is the exchange rate to convert it to USD
/// </summary>
public double HouseCurrencyToUsdExchangeRate { get; set; }

public long CalculateBuyPrice(long price)
{
var buyPrice = price;
if (DiscountMultiplier > 0 && buyPrice > 0)
{
buyPrice -= (long)Math.Round(buyPrice * DiscountMultiplier, 0);
}

return Math.Max(0, buyPrice);
}

public long CalculateBuyFees(long price)
{
var buyFees = 0L;
if (FeeRate != 0 && price > 0)
if (DiscountMultiplier > 0 && price > 0)
{
buyFees += price.MarketSaleFeeComponentAsInt(FeeRate);
buyFees -= (long)Math.Round(price * DiscountMultiplier, 0);
}
if (FeeSurcharge != 0 && price > 0)
if (DiscountFixedAmount != 0 && price > 0)
{
buyFees += FeeSurcharge;
buyFees -= SurchargeFixedAmount;
}
if (SurchargeMultiplier != 0 && price > 0)
{
buyFees += (long)Math.Round(price * SurchargeMultiplier, 0);
}
if (SurchargeFixedAmount != 0 && price > 0)
{
buyFees += SurchargeFixedAmount;
}

return buyFees;
Expand All @@ -48,4 +81,38 @@ public string GenerateBuyUrl(string appId, string appName, ulong? classId, strin
Uri.EscapeDataString(appId ?? String.Empty), Uri.EscapeDataString(appName?.ToLower() ?? String.Empty), classId, Uri.EscapeDataString(name ?? String.Empty)
);
}

public IExchangeableCurrency GetHouseCurrency()
{
if (!String.IsNullOrEmpty(HouseCurrencyName) && HouseCurrencyToUsdExchangeRate > 0)
{
return new MarketHouseCurrency()
{
Id = 0,
Name = HouseCurrencyName,
SuffixText = HouseCurrencyName.ToLower(),
Scale = HouseCurrencyScale,
ExchangeRateMultiplier = (Constants.SteamDefaultCurrencyExchangeRate / (decimal)HouseCurrencyToUsdExchangeRate)
};
}

return null;
}

public class MarketHouseCurrency : IExchangeableCurrency
{
public uint Id { get; set; }

public string Name { get; set; }

public string PrefixText { get; set; }

public string SuffixText { get; set; }

public string CultureName { get; set; }

public int Scale { get; set; }

public decimal ExchangeRateMultiplier { get; set; }
}
}
2 changes: 2 additions & 0 deletions SCMM.Steam.Data.Models/Attributes/MarketAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public MarketAttribute(params ulong[] supportAppIds)

public bool IsFirstParty { get; set; }

public bool IsCasino { get; set; }

public string Color { get; set; }

public string AffiliateUrl { get; set; }
Expand Down
2 changes: 2 additions & 0 deletions SCMM.Steam.Data.Models/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ public static class Constants
public const string SteamAssetClassDescriptionTypeBBCode = "bbcode";

public const int SteamCurrencyIdUSD = 1;
public const decimal SteamCurrencyExchangeRateUSD = 1.0m;
public const string SteamCurrencyVLV = "VLV";
public const string SteamCurrencyUSD = "USD";
public const string SteamCurrencyEUR = "EUR";
public const string SteamCurrencyCNY = "CNY";

public const int SteamDefaultCurrencyId = SteamCurrencyIdUSD;
public const string SteamDefaultCurrency = SteamCurrencyUSD;
public const decimal SteamDefaultCurrencyExchangeRate = SteamCurrencyExchangeRateUSD;

public const string SteamLanguageEnglish = "english";
public const string SteamDefaultLanguage = SteamLanguageEnglish;
Expand Down
9 changes: 5 additions & 4 deletions SCMM.Steam.Data.Models/Enums/MarketType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ public enum MarketType : byte

[Display(Name = "Tradeit.gg")]
[Market(Constants.RustAppId, Constants.CSGOAppId, Color = "#27273F", AffiliateUrl = "https://tradeit.gg/?aff=scmm")]
[BuyFrom(Url = "https://tradeit.gg/{1}/trade?aff=scmm&search={3}", AcceptedPayments = PriceFlags.Trade | PriceFlags.Cash | PriceFlags.Crypto)]
[BuyFrom(Url = "https://tradeit.gg/{1}/store?aff=scmm&search={3}", AcceptedPayments = PriceFlags.Cash | PriceFlags.Crypto, DiscountMultiplier = 0.25f /* 25% */)]
[BuyFrom(Url = "https://tradeit.gg/{1}/trade?aff=scmm&search={3}", AcceptedPayments = PriceFlags.Trade)]
[BuyFrom(Url = "https://tradeit.gg/{1}/store?aff=scmm&search={3}", AcceptedPayments = PriceFlags.Cash | PriceFlags.Crypto, DiscountMultiplier = 0.35f /* 35% */)]
TradeitGG = 14,

[Display(Name = "CS.Deals - Trade")]
Expand Down Expand Up @@ -160,8 +160,9 @@ public enum MarketType : byte
SkinSerpent = 35,

[Display(Name = "Rustyloot.gg")]
[Market(Constants.RustAppId, Color = "#ffb135", AffiliateUrl = "https://rustyloot.gg/r/SCMM")]
[BuyFrom(Url = "https://rustyloot.gg/r/SCMM?withdraw=true&rust=true", AcceptedPayments = PriceFlags.Trade | PriceFlags.Cash | PriceFlags.Crypto)]
[Market(Constants.RustAppId, Color = "#ffb135", IsCasino = true, AffiliateUrl = "https://rustyloot.gg/r/SCMM")]
[BuyFrom(Url = "https://rustyloot.gg/r/SCMM?withdraw=true&rust=true", AcceptedPayments = PriceFlags.Trade | PriceFlags.Cash | PriceFlags.Crypto,
HouseCurrencyName = "Coin", HouseCurrencyScale = 2, HouseCurrencyToUsdExchangeRate = 0.64516129032258064516129032258065)]
Rustyloot = 36,

/*
Expand Down
5 changes: 5 additions & 0 deletions SCMM.Steam.Data.Models/Extensions/MarketExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public static bool IsFirstParty(this MarketType marketType)
return GetCustomAttribute<MarketAttribute>(marketType)?.IsFirstParty ?? false;
}

public static bool IsCasino(this MarketType marketType)
{
return GetCustomAttribute<MarketAttribute>(marketType)?.IsCasino ?? false;
}

public static bool IsAppSupported(this MarketType marketType, ulong appId)
{
return GetSupportedAppIds(marketType)?.Contains(appId) == true;
Expand Down
5 changes: 5 additions & 0 deletions SCMM.Steam.Data.Models/MarketPrice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public class MarketPrice

public IExchangeableCurrency Currency { get; set; }

/// <summary>
/// If non-null, this represents the markets in-house currency (e.g. "coins") from which the price is derived
/// </summary>
public IExchangeableCurrency HouseCurrency { get; set; }

public long Price { get; set; }

public long Fee { get; set; }
Expand Down
2 changes: 2 additions & 0 deletions SCMM.Steam.Data.Store/SteamAssetDescription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ public IEnumerable<MarketPrice> GetBuyPrices(IExchangeableCurrency currency, Mar
MarketType = steamStoreMarket,
AcceptedPayments = buyFromOption.AcceptedPayments,
Currency = currency,
HouseCurrency = buyFromOption.GetHouseCurrency(),
Price = buyFromOption.CalculateBuyPrice(lowestPrice),
Fee = buyFromOption.CalculateBuyFees(lowestPrice),
Supply = (!StoreItem.IsAvailable ? 0 : null),
Expand Down Expand Up @@ -339,6 +340,7 @@ public IEnumerable<MarketPrice> GetBuyPrices(IExchangeableCurrency currency, Mar
MarketType = marketPrice.Key,
AcceptedPayments = buyFromOption.AcceptedPayments,
Currency = currency,
HouseCurrency = buyFromOption.GetHouseCurrency(),
Price = buyFromOption.CalculateBuyPrice(lowestPrice),
Fee = buyFromOption.CalculateBuyFees(lowestPrice),
Supply = marketPrice.Value.Supply,
Expand Down
2 changes: 1 addition & 1 deletion SCMM.Steam.Data.Store/SteamMarketItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ public void UpdateSellPrices(MarketType type, PriceWithSupply? price)
{
From = x.Type,
Price = (x.Price + 1),
Fee = (x.BuyFrom.FeeRate != 0 ? (x.Price + 1).MarketSaleFeeComponentAsInt(x.BuyFrom.FeeRate) : 0) + x.BuyFrom.FeeSurcharge
Fee = (x.BuyFrom.SurchargeMultiplier != 0 ? (x.Price + 1).MarketSaleFeeComponentAsInt(x.BuyFrom.SurchargeMultiplier) : 0) + x.BuyFrom.SurchargeFixedAmount
})
.MinBy(x => x.Price);
BuyLaterFrom = lowestBuyPrice.From;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ private async Task UpdateRustylootPricesForApp(ILogger logger, SteamApp app, Ste
var item = dbItems.FirstOrDefault(x => x.Name == rustylootItemGroup.Key)?.Item;
if (item != null)
{
var minPrice = rustylootItemGroup.Min(x => x.Price);
var normalisedPrice = minPrice > 0 ? (long)(Math.Round((decimal)minPrice / 1000, 2) * 100) : 0;
var lowestHousePrice = rustylootItemGroup.Min(x => x.Price);
var normalisedHousePrice = lowestHousePrice > 0 ? (long)(Math.Round((decimal)lowestHousePrice / 1000, 2) * 100) : 0; // Round and remove 1 digit of precision to normalise with the Steam price format
var normalisedUsdPrice = usdCurrency.CalculateExchange(normalisedHousePrice, Rustyloot.GetBuyFromOptions()?.FirstOrDefault()?.GetHouseCurrency()); // Convert from coins to USD
var supply = rustylootItemGroup.Sum(x => x.Amount);
item.UpdateBuyPrices(Rustyloot, new PriceWithSupply
{
Price = supply > 0 ? item.Currency.CalculateExchange(normalisedPrice, usdCurrency) : 0,
Price = supply > 0 ? item.Currency.CalculateExchange(normalisedUsdPrice, usdCurrency) : 0,
Supply = supply
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,24 +659,48 @@
@if ((price.Price + (State.Profile.ItemIncludeMarketFees ? price.Fee : 0)) > 0)
{
<span class="pl-2">@State.Currency.ToPriceString((price.Price + (State.Profile.ItemIncludeMarketFees ? price.Fee : 0)))</span>
@if (State.Profile.ItemIncludeMarketFees && price.Fee != 0)
@if ((price.HousePrice != null) || (price.Fee != 0 && State.Profile.ItemIncludeMarketFees))
{
<MudTooltip>
<TooltipContent>
@if (price.Fee > 0)
@if (price.HousePrice != null)
{
<span>Price includes @State.Currency.ToPriceString(price.Fee) in estimated fees charged by @price.MarketType.GetDisplayName() payment providers</span>
<p>
<span>This price has been converted from the @price.MarketType.GetDisplayName() in-house currency, <strong>"@price.HousePriceName?.ToLower()"</strong>. </span>
<span>The original price was <strong>@State.Currency.ToPriceString(price.HousePrice.Value, dense: true) @price.HousePriceName?.ToLower()?.Pluralise(price.HousePrice.Value)</strong>. The conversion rate is <strong>1 @price.HousePriceName?.ToLower() = [email protected]((decimal)price.Price / (decimal)price.HousePrice.Value, 2) @State.Currency.Name</strong></span>
</p>
}
else
@if (price.Fee != 0 && State.Profile.ItemIncludeMarketFees)
{
<span>Price includes @State.Currency.ToPriceString(price.Fee) in estimate discounts gained by purchasing @price.MarketType.GetDisplayName() balance</span>
<p>
@if (price.Fee > 0)
{
<span>This price includes @State.Currency.ToPriceString(price.Fee) in estimated fees charged by @price.MarketType.GetDisplayName() or their payment providers</span>
}
else
{
<span>This price includes @State.Currency.ToPriceString(price.Fee) in estimated bonus balance from purchasing @price.MarketType.GetDisplayName() balance</span>
}
</p>
}
</TooltipContent>
<ChildContent>
<i class="fa fa-fw fa-comment-dollar ml-1"></i>
</ChildContent>
</MudTooltip>
}
@if (price.MarketType.IsCasino())
{
<MudTooltip>
<TooltipContent>
<p>@price.MarketType.GetDisplayName() is a gambling website. There <i>might</i> be restrictions on deposits and withdraws, such as minimum wagers.</p>
<p>Make sure you read the @price.MarketType.GetDisplayName() terms of service, ensure you are legally allowed to gamble and that you fully understand the risks of gambling.</p>
</TooltipContent>
<ChildContent>
<i class="fa fa-fw fa-dice ml-1"></i>
</ChildContent>
</MudTooltip>
}
}
else
{
Expand Down
4 changes: 4 additions & 0 deletions SCMM.Web.Data.Models/UI/Item/ItemBasicMarketPriceDTO.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public class ItemBasicMarketPriceDTO
{
public MarketType MarketType { get; set; }

public long? HousePrice { get; set; }

public string HousePriceName { get; set; }

public long Price { get; set; }

public long Fee { get; set; }
Expand Down
4 changes: 4 additions & 0 deletions SCMM.Web.Data.Models/UI/Item/ItemMarketPriceDTO.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ public class ItemMarketPriceDTO

public PriceFlags AcceptedPayments { get; set; }

public long? HousePrice { get; set; }

public string HousePriceName { get; set; }

public long Price { get; set; }

public long Fee { get; set; }
Expand Down
9 changes: 7 additions & 2 deletions SCMM.Web.Server/Mappers/SteamAssetDescriptionMapperProfile.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AutoMapper;
using SCMM.Shared.Data.Models.Extensions;
using SCMM.Steam.Data.Models;
using SCMM.Steam.Data.Store;
using SCMM.Web.Data.Models.UI.Item;
Expand Down Expand Up @@ -99,10 +100,14 @@ public SteamAssetDescriptionMapperProfile()
.ForMember(x => x.Prices, o => o.MapFromAssetBuyPrices(p => p));

CreateMap<MarketPrice, ItemBasicMarketPriceDTO>()
.ForMember(x => x.Price, o => o.MapFromUsingCurrencyExchange(p => p.Price, p => p.Currency));
.ForMember(x => x.Price, o => o.MapFromUsingCurrencyExchange(p => p.Price, p => p.Currency))
.ForMember(x => x.HousePrice, o => o.MapFrom(p => (p.Price > 0 && p.HouseCurrency != null ? (long?)p.HouseCurrency.CalculateExchange(p.Price / p.Currency.ExchangeRateMultiplier) : null)))
.ForMember(x => x.HousePriceName, o => o.MapFrom(p => (p.HouseCurrency != null ? p.HouseCurrency.Name : null)));

CreateMap<MarketPrice, ItemMarketPriceDTO>()
.ForMember(x => x.Price, o => o.MapFromUsingCurrencyExchange(p => p.Price, p => p.Currency));
.ForMember(x => x.Price, o => o.MapFromUsingCurrencyExchange(p => p.Price, p => p.Currency))
.ForMember(x => x.HousePrice, o => o.MapFrom(p => (p.Price > 0 && p.HouseCurrency != null ? (long?)p.HouseCurrency.CalculateExchange(p.Price / p.Currency.ExchangeRateMultiplier) : null)))
.ForMember(x => x.HousePriceName, o => o.MapFrom(p => (p.HouseCurrency != null ? p.HouseCurrency.Name : null)));

CreateMap<ItemInteraction, ItemInteractionDTO>();
}
Expand Down

0 comments on commit 7ca7dde

Please sign in to comment.