Refactor payment verification to manual workflow and add comprehensive cleanup tools

Major changes:
• Remove BTCPay Server integration in favor of SilverPAY manual verification
• Add test data cleanup mechanisms (API endpoints and shell scripts)
• Fix compilation errors in TestController (IdentityReference vs CustomerIdentity)
• Add deployment automation scripts for Hostinger VPS
• Enhance integration testing with comprehensive E2E validation
• Add Blazor components and mobile-responsive CSS for admin interface
• Create production environment configuration scripts

Key Features Added:
• Manual payment verification through Admin panel Order Details
• Bulk test data cleanup with proper cascade handling
• Deployment automation with systemd service configuration
• Comprehensive E2E testing suite with SilverPAY integration validation
• Mobile-first admin interface improvements

Security & Production:
• Environment variable configuration for production secrets
• Proper JWT and VAPID key management
• SilverPAY API integration with live credentials
• Database cleanup and maintenance tools

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-25 19:29:00 +01:00
parent 1588c79df0
commit 127be759c8
46 changed files with 3470 additions and 971 deletions

View File

@@ -1,279 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Authorization;
using Newtonsoft.Json.Linq;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/btcpay-test")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class BTCPayTestController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IBTCPayServerService _btcPayService;
private readonly ILogger<BTCPayTestController> _logger;
public BTCPayTestController(
IConfiguration configuration,
IBTCPayServerService btcPayService,
ILogger<BTCPayTestController> logger)
{
_configuration = configuration;
_btcPayService = btcPayService;
_logger = logger;
}
[HttpGet("connection")]
public async Task<IActionResult> TestConnection()
{
try
{
var baseUrl = _configuration["BTCPayServer:BaseUrl"];
var apiKey = _configuration["BTCPayServer:ApiKey"];
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(apiKey))
{
return BadRequest(new { error = "BTCPay Server configuration missing" });
}
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
var client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
// Test basic connection by getting server info
var serverInfo = await client.GetServerInfo();
return Ok(new
{
status = "Connected",
baseUrl = baseUrl,
serverVersion = serverInfo?.Version,
supportedPaymentMethods = serverInfo?.SupportedPaymentMethods,
message = "BTCPay Server connection successful"
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name,
baseUrl = _configuration["BTCPayServer:BaseUrl"]
});
}
}
[HttpGet("stores")]
public async Task<IActionResult> GetStores()
{
try
{
var baseUrl = _configuration["BTCPayServer:BaseUrl"];
var apiKey = _configuration["BTCPayServer:ApiKey"];
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
var client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
// Get available stores
var stores = await client.GetStores();
return Ok(new
{
stores = stores.Select(s => new
{
id = s.Id,
name = s.Name,
website = s.Website,
defaultCurrency = s.DefaultCurrency
}).ToList(),
message = "Stores retrieved successfully"
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name
});
}
}
[HttpGet("invoice/{invoiceId}")]
public async Task<IActionResult> GetInvoiceDetails(string invoiceId)
{
try
{
var invoice = await _btcPayService.GetInvoiceAsync(invoiceId);
if (invoice == null)
{
return NotFound(new { error = "Invoice not found" });
}
// BTCPay Server v2 manages addresses internally
// Customers use the CheckoutLink for payments
var paymentInfo = new
{
checkoutMethod = "BTCPay Checkout",
info = "Use the checkout link to complete payment"
};
return Ok(new
{
invoiceId = invoice.Id,
status = invoice.Status,
amount = invoice.Amount,
currency = invoice.Currency,
checkoutLink = invoice.CheckoutLink,
expiresAt = invoice.ExpirationTime,
paymentMethods = paymentInfo,
metadata = invoice.Metadata,
message = "Invoice details retrieved successfully"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get invoice {InvoiceId}", invoiceId);
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name
});
}
}
[HttpPost("test-invoice")]
public async Task<IActionResult> CreateTestInvoice([FromBody] TestInvoiceRequest request)
{
try
{
var baseUrl = _configuration["BTCPayServer:BaseUrl"];
var apiKey = _configuration["BTCPayServer:ApiKey"];
var storeId = _configuration["BTCPayServer:StoreId"];
if (string.IsNullOrEmpty(storeId))
{
return BadRequest(new { error = "Store ID not configured" });
}
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
var client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
// Create test invoice
var invoiceRequest = new CreateInvoiceRequest
{
Amount = request.Amount,
Currency = request.Currency ?? "GBP",
Metadata = JObject.FromObject(new
{
orderId = $"test-{Guid.NewGuid()}",
source = "LittleShop-Test"
})
};
var invoice = await client.CreateInvoice(storeId, invoiceRequest);
return Ok(new
{
status = "Invoice Created",
invoiceId = invoice.Id,
amount = invoice.Amount,
currency = invoice.Currency,
checkoutLink = invoice.CheckoutLink,
expiresAt = invoice.ExpirationTime,
message = "Test invoice created successfully"
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name
});
}
}
[HttpPost("test-payment")]
public async Task<IActionResult> CreateTestPayment([FromBody] TestPaymentRequest request)
{
try
{
// Create a test order ID
var testOrderId = $"test-order-{Guid.NewGuid():N}".Substring(0, 20);
_logger.LogInformation("Creating test payment for {Currency} with amount {Amount} GBP",
request.CryptoCurrency, request.Amount);
// Use the actual service to create an invoice
var invoiceId = await _btcPayService.CreateInvoiceAsync(
request.Amount,
request.CryptoCurrency,
testOrderId,
"Test payment from BTCPay diagnostic endpoint"
);
// Get the invoice details
var invoice = await _btcPayService.GetInvoiceAsync(invoiceId);
// BTCPay Server v2 uses checkout links instead of exposing raw addresses
var checkoutUrl = invoice?.CheckoutLink;
return Ok(new
{
status = "Success",
invoiceId = invoiceId,
orderId = testOrderId,
amount = request.Amount,
currency = "GBP",
requestedCrypto = request.CryptoCurrency.ToString(),
checkoutLink = checkoutUrl,
paymentUrl = checkoutUrl ?? $"https://{_configuration["BTCPayServer:BaseUrl"]}/i/{invoiceId}",
message = !string.IsNullOrEmpty(checkoutUrl)
? "✅ Test payment created successfully - Use checkout link to complete payment"
: "⚠️ Invoice created but checkout link not available - Check BTCPay configuration"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create test payment");
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name,
hint = "Check that BTCPay Server has wallets configured for the requested currency"
});
}
}
}
public class TestInvoiceRequest
{
public decimal Amount { get; set; } = 0.01m;
public string? Currency { get; set; } = "GBP";
}
public class TestPaymentRequest
{
public decimal Amount { get; set; } = 10.00m;
public CryptoCurrency CryptoCurrency { get; set; } = CryptoCurrency.BTC;
}

View File

@@ -1,180 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LittleShop.DTOs;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/btcpay")]
public class BTCPayWebhookController : ControllerBase
{
private readonly ICryptoPaymentService _cryptoPaymentService;
private readonly IBTCPayServerService _btcPayService;
private readonly IConfiguration _configuration;
private readonly ILogger<BTCPayWebhookController> _logger;
public BTCPayWebhookController(
ICryptoPaymentService cryptoPaymentService,
IBTCPayServerService btcPayService,
IConfiguration configuration,
ILogger<BTCPayWebhookController> logger)
{
_cryptoPaymentService = cryptoPaymentService;
_btcPayService = btcPayService;
_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["BTCPAY-SIG"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
{
_logger.LogWarning("Webhook received without signature");
return BadRequest("Missing webhook signature");
}
// Validate webhook signature
var webhookSecret = _configuration["BTCPayServer:WebhookSecret"];
if (string.IsNullOrEmpty(webhookSecret))
{
_logger.LogError("BTCPay webhook secret not configured");
return StatusCode(500, "Webhook validation not configured");
}
if (!ValidateWebhookSignature(requestBody, signature, webhookSecret))
{
_logger.LogWarning("Invalid webhook signature");
return BadRequest("Invalid webhook signature");
}
// Parse webhook data
var webhookData = JsonSerializer.Deserialize<BTCPayWebhookDto>(requestBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (webhookData == null)
{
_logger.LogWarning("Unable to parse webhook data");
return BadRequest("Invalid webhook data");
}
_logger.LogInformation("Processing BTCPay webhook: Type={Type}, InvoiceId={InvoiceId}, StoreId={StoreId}",
webhookData.Type, webhookData.InvoiceId, webhookData.StoreId);
// Process the webhook based on event type
var success = await ProcessWebhookEvent(webhookData);
if (!success)
{
return BadRequest("Failed to process webhook");
}
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing BTCPay webhook");
return StatusCode(500, "Internal server error");
}
}
private bool ValidateWebhookSignature(string payload, string signature, string secret)
{
try
{
// BTCPay Server uses HMAC-SHA256 with format "sha256=<hex>"
if (!signature.StartsWith("sha256="))
{
return false;
}
var expectedHash = signature.Substring(7); // Remove "sha256=" prefix
var secretBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
var computedHash = hmac.ComputeHash(payloadBytes);
var computedHashHex = Convert.ToHexString(computedHash).ToLowerInvariant();
return expectedHash.Equals(computedHashHex, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating webhook signature");
return false;
}
}
private async Task<bool> ProcessWebhookEvent(BTCPayWebhookDto webhookData)
{
try
{
// Map BTCPay webhook event types to our payment status
var paymentStatus = MapWebhookEventToPaymentStatus(webhookData.Type);
if (!paymentStatus.HasValue)
{
_logger.LogInformation("Ignoring webhook event type: {Type}", webhookData.Type);
return true; // Not an error, just not a status we care about
}
// Extract payment details
var amount = webhookData.Payment?.PaymentMethodPaid ?? 0;
var transactionHash = webhookData.Payment?.TransactionData?.TransactionHash;
// Process the payment update
var success = await _cryptoPaymentService.ProcessPaymentWebhookAsync(
webhookData.InvoiceId,
paymentStatus.Value,
amount,
transactionHash);
if (success)
{
_logger.LogInformation("Successfully processed webhook for invoice {InvoiceId} with status {Status}",
webhookData.InvoiceId, paymentStatus.Value);
}
else
{
_logger.LogWarning("Failed to process webhook for invoice {InvoiceId}", webhookData.InvoiceId);
}
return success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing webhook event for invoice {InvoiceId}", webhookData.InvoiceId);
return false;
}
}
private static PaymentStatus? MapWebhookEventToPaymentStatus(string eventType)
{
return eventType switch
{
"InvoiceCreated" => PaymentStatus.Pending,
"InvoiceReceivedPayment" => PaymentStatus.Processing,
"InvoicePaymentSettled" => PaymentStatus.Completed,
"InvoiceProcessing" => PaymentStatus.Processing,
"InvoiceExpired" => PaymentStatus.Expired,
"InvoiceSettled" => PaymentStatus.Completed,
"InvoiceInvalid" => PaymentStatus.Failed,
_ => null // Unknown event type
};
}
}

View File

@@ -98,28 +98,28 @@ public class TestController : ControllerBase
{
// Get count before cleanup
var totalBots = await _context.Bots.CountAsync();
// Keep only the most recent active bot per platform
var keepBots = await _context.Bots
.Where(b => b.IsActive && b.Status == Enums.BotStatus.Active)
.GroupBy(b => b.PlatformId)
.Select(g => g.OrderByDescending(b => b.LastSeenAt ?? b.CreatedAt).First())
.ToListAsync();
var keepBotIds = keepBots.Select(b => b.Id).ToList();
// Delete old/inactive bots and related data
var botsToDelete = await _context.Bots
.Where(b => !keepBotIds.Contains(b.Id))
.ToListAsync();
_context.Bots.RemoveRange(botsToDelete);
await _context.SaveChangesAsync();
var deletedCount = botsToDelete.Count;
var remainingCount = keepBots.Count;
return Ok(new {
return Ok(new {
message = "Bot cleanup completed",
totalBots = totalBots,
deletedBots = deletedCount,
@@ -138,4 +138,117 @@ public class TestController : ControllerBase
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("cleanup-test-data")]
public async Task<IActionResult> CleanupTestData()
{
try
{
// Get counts before cleanup
var totalOrders = await _context.Orders.CountAsync();
var totalCryptoPayments = await _context.CryptoPayments.CountAsync();
var totalOrderItems = await _context.OrderItems.CountAsync();
// Find test orders (identity references starting with "test-")
var testOrders = await _context.Orders
.Where(o => o.IdentityReference != null && o.IdentityReference.StartsWith("test-"))
.ToListAsync();
var testOrderIds = testOrders.Select(o => o.Id).ToList();
// Remove crypto payments for test orders
var cryptoPaymentsToDelete = await _context.CryptoPayments
.Where(cp => testOrderIds.Contains(cp.OrderId))
.ToListAsync();
// Remove order items for test orders
var orderItemsToDelete = await _context.OrderItems
.Where(oi => testOrderIds.Contains(oi.OrderId))
.ToListAsync();
// Delete all related data
_context.CryptoPayments.RemoveRange(cryptoPaymentsToDelete);
_context.OrderItems.RemoveRange(orderItemsToDelete);
_context.Orders.RemoveRange(testOrders);
await _context.SaveChangesAsync();
// Get counts after cleanup
var remainingOrders = await _context.Orders.CountAsync();
var remainingCryptoPayments = await _context.CryptoPayments.CountAsync();
var remainingOrderItems = await _context.OrderItems.CountAsync();
return Ok(new {
message = "Test data cleanup completed",
before = new {
orders = totalOrders,
cryptoPayments = totalCryptoPayments,
orderItems = totalOrderItems
},
after = new {
orders = remainingOrders,
cryptoPayments = remainingCryptoPayments,
orderItems = remainingOrderItems
},
deleted = new {
orders = testOrders.Count,
cryptoPayments = cryptoPaymentsToDelete.Count,
orderItems = orderItemsToDelete.Count
},
testOrdersFound = testOrders.Select(o => new {
id = o.Id,
identityReference = o.IdentityReference,
createdAt = o.CreatedAt,
total = o.Total
})
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("database")]
public async Task<IActionResult> DatabaseHealthCheck()
{
try
{
// Test database connectivity by executing a simple query
var canConnect = await _context.Database.CanConnectAsync();
if (!canConnect)
{
return StatusCode(503, new {
status = "unhealthy",
message = "Cannot connect to database",
timestamp = DateTime.UtcNow
});
}
// Test actual query execution
var categoryCount = await _context.Categories.CountAsync();
var productCount = await _context.Products.CountAsync();
var orderCount = await _context.Orders.CountAsync();
return Ok(new {
status = "healthy",
message = "Database connection successful",
stats = new {
categories = categoryCount,
products = productCount,
orders = orderCount
},
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
return StatusCode(503, new {
status = "unhealthy",
error = ex.Message,
timestamp = DateTime.UtcNow
});
}
}
}