- 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>
310 lines
11 KiB
C#
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; }
|
|
}
|
|
} |