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>
This commit is contained in:
2025-09-20 19:22:29 +01:00
parent bcefd2c6fc
commit 553088390e
39 changed files with 3808 additions and 127 deletions

View File

@@ -9,22 +9,36 @@ namespace LittleShop.Controllers;
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
public AuthController(IAuthService authService)
public AuthController(IAuthService authService, ILogger<AuthController> logger)
{
_authService = authService;
_logger = logger;
}
[HttpPost("login")]
public async Task<ActionResult<AuthResponseDto>> Login([FromBody] LoginDto loginDto)
{
var result = await _authService.LoginAsync(loginDto);
if (result != null)
try
{
return Ok(result);
_logger.LogInformation("Login attempt for user: {Username}", loginDto.Username);
var result = await _authService.LoginAsync(loginDto);
if (result != null)
{
_logger.LogInformation("Login successful for user: {Username}", loginDto.Username);
return Ok(result);
}
_logger.LogWarning("Login failed for user: {Username}", loginDto.Username);
return Unauthorized(new { message = "Invalid credentials" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during login for user: {Username}", loginDto.Username);
return StatusCode(500, new { message = "An error occurred during login", error = ex.Message });
}
return Unauthorized(new { message = "Invalid credentials" });
}
}

View File

@@ -39,28 +39,36 @@ public class CatalogController : ControllerBase
[HttpGet("products")]
public async Task<ActionResult<PagedResult<ProductDto>>> GetProducts(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? categoryId = null)
{
var allProducts = categoryId.HasValue
? await _productService.GetProductsByCategoryAsync(categoryId.Value)
: await _productService.GetAllProductsAsync();
var productList = allProducts.ToList();
var totalCount = productList.Count;
var skip = (pageNumber - 1) * pageSize;
var pagedProducts = productList.Skip(skip).Take(pageSize).ToList();
var result = new PagedResult<ProductDto>
try
{
Items = pagedProducts,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(result);
var allProducts = categoryId.HasValue
? await _productService.GetProductsByCategoryAsync(categoryId.Value)
: await _productService.GetAllProductsAsync();
var productList = allProducts.ToList();
var totalCount = productList.Count;
var skip = (pageNumber - 1) * pageSize;
var pagedProducts = productList.Skip(skip).Take(pageSize).ToList();
var result = new PagedResult<ProductDto>
{
Items = pagedProducts,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(result);
}
catch (Exception ex)
{
// Log the error and return a proper error response
return StatusCode(500, new { message = "An error occurred while fetching products", error = ex.Message });
}
}
[HttpGet("products/{id}")]

View File

@@ -0,0 +1,190 @@
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/silverpay-test")]
public class SilverPayTestController : ControllerBase
{
private readonly ISilverPayService _silverPayService;
private readonly IConfiguration _configuration;
private readonly ILogger<SilverPayTestController> _logger;
public SilverPayTestController(
ISilverPayService silverPayService,
IConfiguration configuration,
ILogger<SilverPayTestController> logger)
{
_silverPayService = silverPayService;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Test SilverPAY connection and configuration
/// </summary>
[HttpGet("connection")]
public async Task<IActionResult> TestConnection()
{
try
{
var baseUrl = _configuration["SilverPay:BaseUrl"];
var hasApiKey = !string.IsNullOrEmpty(_configuration["SilverPay:ApiKey"]);
var useSilverPay = _configuration.GetValue<bool>("PaymentProvider:UseSilverPay", false);
// Try to get exchange rate as a simple connectivity test
decimal? rate = null;
string? error = null;
try
{
rate = await _silverPayService.GetExchangeRateAsync("BTC", "GBP");
}
catch (Exception ex)
{
error = ex.Message;
}
return Ok(new
{
service = "SilverPAY",
enabled = useSilverPay,
baseUrl,
hasApiKey,
connectionTest = rate.HasValue ? "Success" : "Failed",
exchangeRate = rate,
error,
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error testing SilverPAY connection");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Test creating a SilverPAY order
/// </summary>
[HttpPost("create-order")]
public async Task<IActionResult> TestCreateOrder([FromBody] TestOrderRequest request)
{
try
{
var useSilverPay = _configuration.GetValue<bool>("PaymentProvider:UseSilverPay", false);
if (!useSilverPay)
{
return BadRequest(new { error = "SilverPAY is not enabled. Set PaymentProvider:UseSilverPay to true in configuration." });
}
// Create a test order
var order = await _silverPayService.CreateOrderAsync(
request.ExternalId ?? $"TEST-{Guid.NewGuid():N}",
request.Amount,
request.Currency,
$"Test order - {request.Amount} GBP in {request.Currency}",
request.WebhookUrl
);
_logger.LogInformation("Created test SilverPAY order: {OrderId}", order.Id);
return Ok(new
{
success = true,
orderId = order.Id,
externalId = order.ExternalId,
amount = order.Amount,
currency = order.Currency,
paymentAddress = order.PaymentAddress,
cryptoAmount = order.CryptoAmount,
status = order.Status,
expiresAt = order.ExpiresAt,
message = $"Send {order.CryptoAmount ?? order.Amount} {order.Currency} to {order.PaymentAddress}"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating test SilverPAY order");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Get status of a SilverPAY order
/// </summary>
[HttpGet("order/{orderId}")]
public async Task<IActionResult> GetOrderStatus(string orderId)
{
try
{
var order = await _silverPayService.GetOrderStatusAsync(orderId);
if (order == null)
{
return NotFound(new { error = $"Order {orderId} not found" });
}
return Ok(new
{
orderId = order.Id,
externalId = order.ExternalId,
amount = order.Amount,
currency = order.Currency,
paymentAddress = order.PaymentAddress,
cryptoAmount = order.CryptoAmount,
status = order.Status,
createdAt = order.CreatedAt,
expiresAt = order.ExpiresAt,
paidAt = order.PaidAt,
transactionHash = order.TransactionHash,
confirmations = order.Confirmations,
paymentDetails = order.PaymentDetails
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting SilverPAY order status");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Test exchange rate conversion
/// </summary>
[HttpGet("exchange-rate")]
public async Task<IActionResult> GetExchangeRate([FromQuery] string crypto = "BTC", [FromQuery] string fiat = "GBP")
{
try
{
var rate = await _silverPayService.GetExchangeRateAsync(crypto.ToUpper(), fiat.ToUpper());
if (!rate.HasValue)
{
return NotFound(new { error = $"Exchange rate not available for {crypto}/{fiat}" });
}
return Ok(new
{
crypto = crypto.ToUpper(),
fiat = fiat.ToUpper(),
rate = rate.Value,
message = $"1 {crypto.ToUpper()} = {rate.Value:F2} {fiat.ToUpper()}",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting exchange rate");
return StatusCode(500, new { error = ex.Message });
}
}
public class TestOrderRequest
{
public string? ExternalId { get; set; }
public decimal Amount { get; set; } = 10.00m; // Default £10
public CryptoCurrency Currency { get; set; } = CryptoCurrency.BTC;
public string? WebhookUrl { get; set; }
}
}

View File

@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Text.Json;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/silverpay")]
public class SilverPayWebhookController : ControllerBase
{
private readonly ICryptoPaymentService _cryptoPaymentService;
private readonly ISilverPayService _silverPayService;
private readonly IConfiguration _configuration;
private readonly ILogger<SilverPayWebhookController> _logger;
public SilverPayWebhookController(
ICryptoPaymentService cryptoPaymentService,
ISilverPayService silverPayService,
IConfiguration configuration,
ILogger<SilverPayWebhookController> logger)
{
_cryptoPaymentService = cryptoPaymentService;
_silverPayService = silverPayService;
_configuration = configuration;
_logger = logger;
}
[HttpPost("webhook")]
public async Task<IActionResult> ProcessWebhook()
{
try
{
// Read the raw request body
using var reader = new StreamReader(Request.Body);
var requestBody = await reader.ReadToEndAsync();
// Get webhook signature from headers
var signature = Request.Headers["X-SilverPay-Signature"].FirstOrDefault()
?? Request.Headers["X-Webhook-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
{
_logger.LogWarning("SilverPAY webhook received without signature");
// For development, we might allow unsigned webhooks
if (!_configuration.GetValue<bool>("SilverPay:AllowUnsignedWebhooks", false))
{
return BadRequest("Missing webhook signature");
}
}
else
{
// Validate webhook signature
var isValid = await _silverPayService.ValidateWebhookAsync(requestBody, signature);
if (!isValid)
{
_logger.LogWarning("Invalid SilverPAY webhook signature");
return BadRequest("Invalid webhook signature");
}
}
// Parse webhook data
var webhookData = JsonSerializer.Deserialize<SilverPayWebhookData>(requestBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (webhookData == null)
{
_logger.LogWarning("Unable to parse SilverPAY webhook data");
return BadRequest("Invalid webhook data");
}
_logger.LogInformation("Processing SilverPAY webhook: OrderId={OrderId}, Status={Status}, TxHash={TxHash}, Confirmations={Confirmations}",
webhookData.OrderId, webhookData.Status, webhookData.TxHash, webhookData.Confirmations);
// Process the webhook based on status
var success = await ProcessWebhookEvent(webhookData);
if (!success)
{
return BadRequest("Failed to process webhook");
}
return Ok(new { status = "received", processed = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing SilverPAY webhook");
return StatusCode(500, "Internal server error");
}
}
[HttpPost("webhook/{provider}")]
public async Task<IActionResult> ProcessProviderWebhook(string provider)
{
// This endpoint handles provider-specific webhooks from SilverPAY
// (e.g., notifications from specific blockchain APIs)
try
{
using var reader = new StreamReader(Request.Body);
var requestBody = await reader.ReadToEndAsync();
_logger.LogInformation("Received SilverPAY provider webhook from {Provider}", provider);
_logger.LogDebug("Provider webhook payload: {Payload}", requestBody);
// For now, just acknowledge receipt
// The actual processing would depend on the provider's format
return Ok(new { status = "received", provider });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing SilverPAY provider webhook from {Provider}", provider);
return StatusCode(500, "Internal server error");
}
}
private async Task<bool> ProcessWebhookEvent(SilverPayWebhookData webhookData)
{
try
{
// Map SilverPAY status to our payment status
var paymentStatus = MapSilverPayStatus(webhookData.Status);
if (!paymentStatus.HasValue)
{
_logger.LogInformation("Ignoring SilverPAY webhook status: {Status}", webhookData.Status);
return true; // Not an error, just not a status we care about
}
// Process the payment update
var success = await _cryptoPaymentService.ProcessSilverPayWebhookAsync(
webhookData.OrderId,
paymentStatus.Value,
webhookData.Amount,
webhookData.TxHash,
webhookData.Confirmations);
if (success)
{
_logger.LogInformation("Successfully processed SilverPAY webhook for order {OrderId} with status {Status}",
webhookData.OrderId, paymentStatus.Value);
}
else
{
_logger.LogWarning("Failed to process SilverPAY webhook for order {OrderId}", webhookData.OrderId);
}
return success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing SilverPAY webhook event for order {OrderId}", webhookData.OrderId);
return false;
}
}
private static PaymentStatus? MapSilverPayStatus(string status)
{
return status?.ToLowerInvariant() switch
{
"pending" => PaymentStatus.Pending,
"waiting" => PaymentStatus.Pending,
"unconfirmed" => PaymentStatus.Processing,
"confirming" => PaymentStatus.Processing,
"partially_paid" => PaymentStatus.Processing,
"paid" => PaymentStatus.Paid,
"confirmed" => PaymentStatus.Completed,
"completed" => PaymentStatus.Completed,
"expired" => PaymentStatus.Expired,
"failed" => PaymentStatus.Failed,
"cancelled" => PaymentStatus.Failed,
_ => null // Unknown status
};
}
// Internal class for JSON deserialization
private class SilverPayWebhookData
{
public string OrderId { get; set; } = string.Empty;
public string ExternalId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty;
public string? TxHash { get; set; }
public decimal Amount { get; set; }
public int Confirmations { get; set; }
public int? BlockHeight { get; set; }
public DateTime Timestamp { get; set; }
public string? Currency { get; set; }
public Dictionary<string, object>? PaymentDetails { get; set; }
}
}

View File

@@ -0,0 +1,235 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Models;
using LittleShop.Services;
using System.Security.Cryptography;
using System.Text;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "AdminOnly")]
public class UsersController : ControllerBase
{
private readonly LittleShopContext _context;
private readonly ILogger<UsersController> _logger;
public UsersController(LittleShopContext context, ILogger<UsersController> logger)
{
_context = context;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<UserDto>>> GetUsers()
{
try
{
var users = await _context.Users
.Where(u => u.IsActive)
.Select(u => new UserDto
{
Id = u.Id,
Username = u.Username,
Email = u.Email,
Role = u.Role,
CreatedAt = u.CreatedAt,
IsActive = u.IsActive
})
.ToListAsync();
return Ok(users);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching users");
return StatusCode(500, new { message = "Error fetching users", error = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(Guid id)
{
try
{
var user = await _context.Users.FindAsync(id);
if (user == null || !user.IsActive)
{
return NotFound();
}
return Ok(new UserDto
{
Id = user.Id,
Username = user.Username,
Email = user.Email,
Role = user.Role,
CreatedAt = user.CreatedAt,
IsActive = user.IsActive
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching user {UserId}", id);
return StatusCode(500, new { message = "Error fetching user", error = ex.Message });
}
}
[HttpPost]
public async Task<ActionResult<UserDto>> CreateUser([FromBody] CreateUserDto createUserDto)
{
try
{
// Check if username already exists
if (await _context.Users.AnyAsync(u => u.Username == createUserDto.Username))
{
return BadRequest(new { message = "Username already exists" });
}
// Check if email already exists
if (!string.IsNullOrEmpty(createUserDto.Email) &&
await _context.Users.AnyAsync(u => u.Email == createUserDto.Email))
{
return BadRequest(new { message = "Email already exists" });
}
var user = new User
{
Id = Guid.NewGuid(),
Username = createUserDto.Username,
Email = createUserDto.Email,
Role = createUserDto.Role ?? "Staff",
PasswordHash = HashPassword(createUserDto.Password),
CreatedAt = DateTime.UtcNow,
IsActive = true
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_logger.LogInformation("User created: {Username}", user.Username);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, new UserDto
{
Id = user.Id,
Username = user.Username,
Email = user.Email,
Role = user.Role,
CreatedAt = user.CreatedAt,
IsActive = user.IsActive
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating user");
return StatusCode(500, new { message = "Error creating user", error = ex.Message });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUser(Guid id, [FromBody] UpdateUserDto updateUserDto)
{
try
{
var user = await _context.Users.FindAsync(id);
if (user == null)
{
return NotFound();
}
// Check if username is being changed to an existing one
if (!string.IsNullOrEmpty(updateUserDto.Username) &&
updateUserDto.Username != user.Username &&
await _context.Users.AnyAsync(u => u.Username == updateUserDto.Username))
{
return BadRequest(new { message = "Username already exists" });
}
// Check if email is being changed to an existing one
if (!string.IsNullOrEmpty(updateUserDto.Email) &&
updateUserDto.Email != user.Email &&
await _context.Users.AnyAsync(u => u.Email == updateUserDto.Email))
{
return BadRequest(new { message = "Email already exists" });
}
// Update fields
if (!string.IsNullOrEmpty(updateUserDto.Username))
user.Username = updateUserDto.Username;
if (updateUserDto.Email != null)
user.Email = updateUserDto.Email;
if (!string.IsNullOrEmpty(updateUserDto.Role))
user.Role = updateUserDto.Role;
if (!string.IsNullOrEmpty(updateUserDto.Password))
user.PasswordHash = HashPassword(updateUserDto.Password);
await _context.SaveChangesAsync();
_logger.LogInformation("User updated: {UserId}", id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating user {UserId}", id);
return StatusCode(500, new { message = "Error updating user", error = ex.Message });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(Guid id)
{
try
{
var user = await _context.Users.FindAsync(id);
if (user == null)
{
return NotFound();
}
// Don't delete the last admin user
if (user.Role == "Admin")
{
var adminCount = await _context.Users.CountAsync(u => u.Role == "Admin" && u.IsActive);
if (adminCount <= 1)
{
return BadRequest(new { message = "Cannot delete the last admin user" });
}
}
// Soft delete
user.IsActive = false;
await _context.SaveChangesAsync();
_logger.LogInformation("User deleted: {UserId}", id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting user {UserId}", id);
return StatusCode(500, new { message = "Error deleting user", error = ex.Message });
}
}
private static string HashPassword(string password)
{
using var pbkdf2 = new Rfc2898DeriveBytes(
password,
salt: Encoding.UTF8.GetBytes("LittleShopSalt2024!"),
iterations: 100000,
HashAlgorithmName.SHA256);
return Convert.ToBase64String(pbkdf2.GetBytes(32));
}
}