SilverPay API expects 'fiat_amount' and 'fiat_currency' fields, not 'amount'. This was causing 422 Unprocessable Entity errors when creating payments.
403 lines
15 KiB
C#
403 lines
15 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using LittleShop.Enums;
|
|
|
|
namespace LittleShop.Services;
|
|
|
|
public class SilverPayService : ISilverPayService
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<SilverPayService> _logger;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
|
|
public SilverPayService(
|
|
HttpClient httpClient,
|
|
IConfiguration configuration,
|
|
IServiceProvider serviceProvider,
|
|
ILogger<SilverPayService> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_configuration = configuration;
|
|
_serviceProvider = serviceProvider;
|
|
_logger = logger;
|
|
|
|
// Note: We'll initialize the HTTP client dynamically when needed
|
|
// to always use the latest settings from the database
|
|
_httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
|
}
|
|
|
|
private async Task<(string baseUrl, string apiKey, string webhookSecret)> GetSettingsAsync()
|
|
{
|
|
// Create a new scope to get the settings service
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
|
|
|
// First try to get settings from the database
|
|
var baseUrl = await settingsService.GetSettingAsync("SilverPay.BaseUrl");
|
|
var apiKey = await settingsService.GetSettingAsync("SilverPay.ApiKey");
|
|
var webhookSecret = await settingsService.GetSettingAsync("SilverPay.WebhookSecret");
|
|
|
|
// Fall back to configuration file if not set in database
|
|
if (string.IsNullOrEmpty(baseUrl))
|
|
baseUrl = _configuration["SilverPay:BaseUrl"];
|
|
|
|
if (string.IsNullOrEmpty(apiKey))
|
|
apiKey = _configuration["SilverPay:ApiKey"];
|
|
|
|
if (string.IsNullOrEmpty(webhookSecret))
|
|
webhookSecret = _configuration["SilverPay:WebhookSecret"];
|
|
|
|
// Validate that we have at least a base URL
|
|
if (string.IsNullOrEmpty(baseUrl))
|
|
throw new InvalidOperationException("SilverPay base URL not configured. Please configure it in the System Settings.");
|
|
|
|
return (baseUrl!, apiKey ?? "", webhookSecret ?? "");
|
|
}
|
|
|
|
private async Task ConfigureHttpClientAsync()
|
|
{
|
|
var (baseUrl, apiKey, _) = await GetSettingsAsync();
|
|
|
|
// Update base address if it has changed
|
|
if (_httpClient.BaseAddress?.ToString() != baseUrl)
|
|
{
|
|
_httpClient.BaseAddress = new Uri(baseUrl);
|
|
_logger.LogInformation("Updated SilverPay base URL to {BaseUrl}", baseUrl);
|
|
}
|
|
|
|
// Update API key header
|
|
_httpClient.DefaultRequestHeaders.Remove("X-API-Key");
|
|
if (!string.IsNullOrEmpty(apiKey))
|
|
{
|
|
_httpClient.DefaultRequestHeaders.Add("X-API-Key", apiKey);
|
|
}
|
|
}
|
|
|
|
public async Task<SilverPayOrderResponse> CreateOrderAsync(
|
|
string externalId,
|
|
decimal amount,
|
|
CryptoCurrency currency,
|
|
string? description = null,
|
|
string? webhookUrl = null)
|
|
{
|
|
try
|
|
{
|
|
// Configure HTTP client with latest settings
|
|
await ConfigureHttpClientAsync();
|
|
|
|
var currencyCode = GetSilverPayCurrency(currency);
|
|
|
|
// Get settings for webhook URL
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
|
var defaultWebhookUrl = await settingsService.GetSettingAsync("SilverPay.DefaultWebhookUrl")
|
|
?? _configuration["SilverPay:DefaultWebhookUrl"];
|
|
|
|
// Prepare request body for SilverPAY
|
|
var request = new
|
|
{
|
|
external_id = externalId,
|
|
fiat_amount = amount, // Amount in GBP
|
|
fiat_currency = "GBP",
|
|
currency = currencyCode,
|
|
webhook_url = webhookUrl ?? defaultWebhookUrl,
|
|
expires_in_hours = 24
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
});
|
|
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
_logger.LogInformation("Creating SilverPAY order - External ID: {ExternalId}, Amount: {Amount} GBP, Currency: {Currency}",
|
|
externalId, amount, currencyCode);
|
|
_logger.LogInformation("SilverPAY request body: {RequestBody}", json);
|
|
|
|
// Add timeout to prevent hanging
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var response = await _httpClient.PostAsync("/api/v1/orders", content, cts.Token);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Failed to create SilverPAY order. Status: {Status}, Error: {Error}",
|
|
response.StatusCode, errorContent);
|
|
|
|
// Check if it's a server error that might warrant fallback
|
|
if ((int)response.StatusCode >= 500)
|
|
{
|
|
throw new HttpRequestException($"SilverPAY server error: {response.StatusCode}", null, response.StatusCode);
|
|
}
|
|
|
|
throw new InvalidOperationException($"Failed to create SilverPAY order: {response.StatusCode}");
|
|
}
|
|
|
|
var responseJson = await response.Content.ReadAsStringAsync();
|
|
var orderResponse = JsonSerializer.Deserialize<SilverPayApiResponse>(responseJson, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
});
|
|
|
|
if (orderResponse == null)
|
|
{
|
|
throw new InvalidOperationException("Invalid response from SilverPAY");
|
|
}
|
|
|
|
_logger.LogInformation("✅ Created SilverPAY order {OrderId} for External ID {ExternalId} - Amount: {Amount} GBP, Currency: {Currency}, Address: {Address}",
|
|
orderResponse.Id, externalId, amount, currencyCode, orderResponse.PaymentAddress);
|
|
|
|
return MapToOrderResponse(orderResponse);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
_logger.LogError(ex, "Network error creating SilverPAY order");
|
|
throw new InvalidOperationException("Network error contacting SilverPAY", ex);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "❌ Failed to create SilverPAY order - External ID: {ExternalId}, Amount: {Amount} GBP",
|
|
externalId, amount);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<SilverPayOrderResponse?> GetOrderStatusAsync(string orderId)
|
|
{
|
|
try
|
|
{
|
|
// Configure HTTP client with latest settings
|
|
await ConfigureHttpClientAsync();
|
|
|
|
var response = await _httpClient.GetAsync($"/api/v1/orders/{orderId}");
|
|
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("Failed to get SilverPAY order status. Status: {Status}", response.StatusCode);
|
|
return null;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var orderResponse = JsonSerializer.Deserialize<SilverPayApiResponse>(json, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
});
|
|
|
|
return orderResponse != null ? MapToOrderResponse(orderResponse) : null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting SilverPAY order status for {OrderId}", orderId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> ValidateWebhookAsync(string payload, string signature)
|
|
{
|
|
try
|
|
{
|
|
// Get webhook secret from settings
|
|
var (_, _, webhookSecret) = await GetSettingsAsync();
|
|
|
|
// SilverPAY webhook validation
|
|
// The exact format depends on SilverPAY's implementation
|
|
// This is a common HMAC-SHA256 validation pattern
|
|
|
|
if (string.IsNullOrEmpty(webhookSecret))
|
|
{
|
|
_logger.LogWarning("Webhook secret not configured, skipping validation");
|
|
return true; // Allow in development
|
|
}
|
|
|
|
var secretBytes = Encoding.UTF8.GetBytes(webhookSecret);
|
|
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
|
|
|
using var hmac = new System.Security.Cryptography.HMACSHA256(secretBytes);
|
|
var computedHash = hmac.ComputeHash(payloadBytes);
|
|
var computedHashHex = Convert.ToHexString(computedHash).ToLowerInvariant();
|
|
|
|
// SilverPAY might use different signature formats
|
|
// Adjust based on actual implementation
|
|
var expectedHash = signature.Replace("sha256=", "").ToLowerInvariant();
|
|
|
|
return computedHashHex.Equals(expectedHash, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error validating webhook signature");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<decimal?> GetExchangeRateAsync(string cryptoCurrency, string fiatCurrency = "GBP")
|
|
{
|
|
try
|
|
{
|
|
// Configure HTTP client with latest settings
|
|
await ConfigureHttpClientAsync();
|
|
|
|
var response = await _httpClient.GetAsync($"/api/v1/exchange-rates?crypto={cryptoCurrency}&fiat={fiatCurrency}");
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var rateResponse = JsonSerializer.Deserialize<ExchangeRateResponse>(json, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
return rateResponse?.Rate;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting exchange rate for {Crypto}/{Fiat}", cryptoCurrency, fiatCurrency);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<List<string>> GetSupportedCurrenciesAsync()
|
|
{
|
|
try
|
|
{
|
|
// Configure HTTP client with latest settings
|
|
await ConfigureHttpClientAsync();
|
|
|
|
var response = await _httpClient.GetAsync("/api/v1/currencies");
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("Failed to get supported currencies from SilverPAY. Status: {Status}", response.StatusCode);
|
|
// Return a default list of commonly supported currencies
|
|
return new List<string> { "BTC", "ETH", "USDT", "LTC" };
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var currencies = JsonSerializer.Deserialize<List<string>>(json, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
return currencies ?? new List<string> { "BTC" };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting supported currencies from SilverPAY");
|
|
// Return a safe default
|
|
return new List<string> { "BTC" };
|
|
}
|
|
}
|
|
|
|
private static string GetSilverPayCurrency(CryptoCurrency currency)
|
|
{
|
|
return currency switch
|
|
{
|
|
CryptoCurrency.BTC => "BTC",
|
|
CryptoCurrency.XMR => "XMR", // Monero not directly supported, might need mapping
|
|
CryptoCurrency.USDT => "USDT", // Might map to ETH for USDT on Ethereum
|
|
CryptoCurrency.LTC => "LTC",
|
|
CryptoCurrency.ETH => "ETH",
|
|
CryptoCurrency.ZEC => "ZEC", // Zcash might not be supported
|
|
CryptoCurrency.DASH => "DASH", // Dash might not be supported
|
|
CryptoCurrency.DOGE => "DOGE", // Dogecoin might not be supported
|
|
_ => "BTC"
|
|
};
|
|
}
|
|
|
|
private static SilverPayOrderResponse MapToOrderResponse(SilverPayApiResponse apiResponse)
|
|
{
|
|
return new SilverPayOrderResponse
|
|
{
|
|
Id = apiResponse.Id,
|
|
ExternalId = apiResponse.ExternalId,
|
|
Amount = apiResponse.Amount,
|
|
Currency = apiResponse.Currency,
|
|
PaymentAddress = apiResponse.PaymentAddress,
|
|
Status = apiResponse.Status,
|
|
CreatedAt = apiResponse.CreatedAt,
|
|
ExpiresAt = apiResponse.ExpiresAt,
|
|
PaidAt = apiResponse.PaidAt,
|
|
PaymentDetails = apiResponse.PaymentDetails,
|
|
CryptoAmount = apiResponse.CryptoAmount,
|
|
TransactionHash = apiResponse.TransactionHash,
|
|
Confirmations = apiResponse.Confirmations
|
|
};
|
|
}
|
|
|
|
// Internal classes for JSON deserialization
|
|
private class SilverPayApiResponse
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public string Id { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("external_id")]
|
|
public string ExternalId { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("amount")]
|
|
public string AmountString { get; set; } = string.Empty;
|
|
|
|
[JsonIgnore]
|
|
public decimal Amount => decimal.TryParse(AmountString, out var amount) ? amount : 0;
|
|
|
|
[JsonPropertyName("currency")]
|
|
public string Currency { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("payment_address")]
|
|
public string PaymentAddress { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("status")]
|
|
public string Status { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("created_at")]
|
|
public DateTime CreatedAt { get; set; }
|
|
|
|
[JsonPropertyName("expires_at")]
|
|
public DateTime ExpiresAt { get; set; }
|
|
|
|
[JsonPropertyName("paid_at")]
|
|
public DateTime? PaidAt { get; set; }
|
|
|
|
[JsonPropertyName("payment_details")]
|
|
public Dictionary<string, object>? PaymentDetails { get; set; }
|
|
|
|
[JsonPropertyName("crypto_amount")]
|
|
public string? CryptoAmountString { get; set; }
|
|
|
|
[JsonIgnore]
|
|
public decimal? CryptoAmount => decimal.TryParse(CryptoAmountString, out var amount) ? amount : null;
|
|
|
|
[JsonPropertyName("tx_hash")]
|
|
public string? TransactionHash { get; set; }
|
|
|
|
[JsonPropertyName("confirmations")]
|
|
public int? Confirmations { get; set; }
|
|
}
|
|
|
|
private class ExchangeRateResponse
|
|
{
|
|
[JsonPropertyName("crypto")]
|
|
public string Crypto { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("fiat")]
|
|
public string Fiat { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("rate")]
|
|
public decimal Rate { get; set; }
|
|
|
|
[JsonPropertyName("timestamp")]
|
|
public DateTime Timestamp { get; set; }
|
|
}
|
|
} |