littleshop/LittleShop/Services/SilverPayService.cs
SysAdmin 553088390e Remove BTCPay completely, integrate SilverPAY only, configure TeleBot with real token
- Removed all BTCPay references from services and configuration
- Implemented SilverPAY as sole payment provider (no fallback)
- Fixed JWT authentication with proper key length (256+ bits)
- Added UsersController with full CRUD operations
- Updated User model with Email and Role properties
- Configured TeleBot with real Telegram bot token
- Fixed launchSettings.json with JWT environment variable
- E2E tests passing for authentication, catalog, orders
- Payment creation pending SilverPAY server fix

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 19:22:29 +01:00

310 lines
11 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 string _baseUrl;
private readonly string _apiKey;
private readonly string _webhookSecret;
public SilverPayService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<SilverPayService> logger)
{
_httpClient = httpClient;
_configuration = configuration;
_logger = logger;
_baseUrl = _configuration["SilverPay:BaseUrl"] ?? throw new ArgumentException("SilverPay:BaseUrl not configured");
_apiKey = _configuration["SilverPay:ApiKey"] ?? "";
_webhookSecret = _configuration["SilverPay:WebhookSecret"] ?? "";
// Configure HTTP client
_httpClient.BaseAddress = new Uri(_baseUrl);
_httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
if (!string.IsNullOrEmpty(_apiKey))
{
_httpClient.DefaultRequestHeaders.Add("X-API-Key", _apiKey);
}
_logger.LogInformation("Initialized SilverPAY connection to {BaseUrl}", _baseUrl);
}
public async Task<SilverPayOrderResponse> CreateOrderAsync(
string externalId,
decimal amount,
CryptoCurrency currency,
string? description = null,
string? webhookUrl = null)
{
try
{
var currencyCode = GetSilverPayCurrency(currency);
// 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 ?? _configuration["SilverPay:DefaultWebhookUrl"],
expires_in_hours = 24
};
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
var content = new StringContent(json, Encoding.UTF8, "application/json");
_logger.LogDebug("Creating SilverPAY order - External ID: {ExternalId}, Amount: {Amount} GBP, Currency: {Currency}",
externalId, amount, currencyCode);
// 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
{
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 Task<bool> ValidateWebhookAsync(string payload, string signature)
{
try
{
// 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 Task.FromResult(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 Task.FromResult(computedHashHex.Equals(expectedHash, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating webhook signature");
return Task.FromResult(false);
}
}
public async Task<decimal?> GetExchangeRateAsync(string cryptoCurrency, string fiatCurrency = "GBP")
{
try
{
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;
}
}
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 decimal Amount { get; set; }
[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 decimal? CryptoAmount { get; set; }
[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; }
}
}