littleshop/LittleShop/Services/OrderService.cs
sysadmin e534e51b91
All checks were successful
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Successful in 1m13s
fix: Show Processing status orders in Pending Payment tab
- Modified OrdersController to include Processing (legacy) status in pending tab
- Updated badge count to include Processing orders in PendingPaymentCount
- Added database reset script that preserves bot tokens and integrations

Processing status (OrderStatus=20) is a legacy unpaid status that should be visible
in the Pending Payment workflow to allow staff to retry failed payment creation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:39:08 +00:00

756 lines
30 KiB
C#

using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public class OrderService : IOrderService
{
private readonly LittleShopContext _context;
private readonly ILogger<OrderService> _logger;
private readonly ICustomerService _customerService;
private readonly IPushNotificationService _pushNotificationService;
private readonly ITeleBotMessagingService _teleBotMessagingService;
public OrderService(LittleShopContext context, ILogger<OrderService> logger, ICustomerService customerService, IPushNotificationService pushNotificationService, ITeleBotMessagingService teleBotMessagingService)
{
_context = context;
_logger = logger;
_customerService = customerService;
_pushNotificationService = pushNotificationService;
_teleBotMessagingService = teleBotMessagingService;
}
public async Task<IEnumerable<OrderDto>> GetAllOrdersAsync()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariant)
.Include(o => o.Payments)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersByIdentityAsync(string identityReference)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariant)
.Include(o => o.Payments)
.Where(o => o.IdentityReference == identityReference && o.Status != OrderStatus.Cancelled)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersByCustomerIdAsync(Guid customerId)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariant)
.Include(o => o.Payments)
.Where(o => o.CustomerId == customerId && o.Status != OrderStatus.Cancelled)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<OrderDto?> GetOrderByIdAsync(Guid id)
{
var order = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariant)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == id);
return order == null ? null : MapToDto(order);
}
public async Task<OrderDto> CreateOrderAsync(CreateOrderDto createOrderDto)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Handle customer creation/linking during checkout
Guid? customerId = null;
string? identityReference = null;
if (createOrderDto.CustomerInfo != null)
{
// Create customer during checkout process
var customer = await _customerService.GetOrCreateCustomerAsync(
createOrderDto.CustomerInfo.TelegramUserId,
createOrderDto.CustomerInfo.TelegramDisplayName,
createOrderDto.CustomerInfo.TelegramUsername,
createOrderDto.CustomerInfo.TelegramFirstName,
createOrderDto.CustomerInfo.TelegramLastName);
customerId = customer?.Id;
}
else if (createOrderDto.CustomerId.HasValue)
{
// Order for existing customer
customerId = createOrderDto.CustomerId;
}
else
{
// Anonymous order (legacy support)
identityReference = createOrderDto.IdentityReference;
}
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
IdentityReference = identityReference,
Status = OrderStatus.PendingPayment,
TotalAmount = 0,
Currency = "GBP",
ShippingName = createOrderDto.ShippingName,
ShippingAddress = createOrderDto.ShippingAddress,
ShippingCity = createOrderDto.ShippingCity,
ShippingPostCode = createOrderDto.ShippingPostCode,
ShippingCountry = createOrderDto.ShippingCountry,
Notes = createOrderDto.Notes,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.Orders.Add(order);
decimal totalAmount = 0;
foreach (var itemDto in createOrderDto.Items)
{
var product = await _context.Products.FindAsync(itemDto.ProductId);
if (product == null || !product.IsActive)
{
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
}
ProductMultiBuy? multiBuy = null;
ProductVariant? variant = null;
decimal unitPrice = product.Price;
// Check for variant price override (highest priority after multi-buy)
if (itemDto.ProductVariantId.HasValue)
{
variant = await _context.ProductVariants.FindAsync(itemDto.ProductVariantId.Value);
if (variant == null || !variant.IsActive || variant.ProductId != itemDto.ProductId)
{
throw new ArgumentException($"Product variant {itemDto.ProductVariantId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
}
// Use variant price if it has an override, otherwise use product base price
if (variant.Price.HasValue)
{
unitPrice = variant.Price.Value;
}
}
if (itemDto.ProductMultiBuyId.HasValue)
{
multiBuy = await _context.ProductMultiBuys.FindAsync(itemDto.ProductMultiBuyId.Value);
if (multiBuy == null || !multiBuy.IsActive || multiBuy.ProductId != itemDto.ProductId)
{
throw new ArgumentException($"Product multi-buy {itemDto.ProductMultiBuyId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
}
// When using a multi-buy, the quantity represents how many of that multi-buy bundle
// For example: buying 2 of the "3 for £25" multi-buy means 6 total items for £50
unitPrice = multiBuy.Price;
}
var orderItem = new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = itemDto.ProductId,
ProductMultiBuyId = itemDto.ProductMultiBuyId,
ProductVariantId = itemDto.ProductVariantId,
Quantity = itemDto.Quantity,
UnitPrice = unitPrice,
TotalPrice = unitPrice * itemDto.Quantity
};
_context.OrderItems.Add(orderItem);
totalAmount += orderItem.TotalPrice;
}
order.TotalAmount = totalAmount;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
if (customerId.HasValue)
{
_logger.LogInformation("Created order {OrderId} for customer {CustomerId} with total {Total}",
order.Id, customerId.Value, totalAmount);
}
else
{
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
order.Id, identityReference, totalAmount);
}
// Send notification about new order to admin users
await SendNewOrderNotification(order);
// Reload order with includes
var createdOrder = await GetOrderByIdAsync(order.Id);
return createdOrder!;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<bool> UpdateOrderStatusAsync(Guid id, UpdateOrderStatusDto updateOrderStatusDto)
{
var order = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariant)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == id);
if (order == null) return false;
var previousStatus = order.Status;
order.Status = updateOrderStatusDto.Status;
if (!string.IsNullOrEmpty(updateOrderStatusDto.TrackingNumber))
{
order.TrackingNumber = updateOrderStatusDto.TrackingNumber;
}
if (!string.IsNullOrEmpty(updateOrderStatusDto.Notes))
{
order.Notes = updateOrderStatusDto.Notes;
}
if (updateOrderStatusDto.Status == OrderStatus.Shipped && order.ShippedAt == null)
{
order.ShippedAt = DateTime.UtcNow;
}
if (updateOrderStatusDto.Status == OrderStatus.PaymentReceived && previousStatus != OrderStatus.PaymentReceived)
{
await RecordSalesLedgerAsync(order);
await DeductStockAsync(order);
}
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated order {OrderId} status from {PreviousStatus} to {NewStatus}", id, previousStatus, updateOrderStatusDto.Status);
// Send push notifications for status changes
await SendOrderStatusNotification(order, previousStatus, updateOrderStatusDto.Status);
return true;
}
public async Task<bool> CancelOrderAsync(Guid id, string identityReference)
{
var order = await _context.Orders
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == id);
if (order == null)
{
_logger.LogWarning("Cannot cancel order {OrderId} - order not found", id);
return false;
}
// Verify ownership - support both CustomerInfo-based and legacy IdentityReference-based orders
bool isOwner = false;
if (order.Customer != null && identityReference.StartsWith("telegram:"))
{
// Extract telegram user ID from identity reference (format: "telegram:12345:username")
var parts = identityReference.Split(':');
if (parts.Length >= 2 && long.TryParse(parts[1], out var telegramUserId))
{
isOwner = order.Customer.TelegramUserId == telegramUserId;
}
}
else if (!string.IsNullOrEmpty(order.IdentityReference))
{
// Legacy: match by identity reference
isOwner = order.IdentityReference == identityReference;
}
if (!isOwner)
{
_logger.LogWarning("Cannot cancel order {OrderId} - identity mismatch (provided: {Identity}, customer: {CustomerId})",
id, identityReference, order.CustomerId);
return false;
}
if (order.Status != OrderStatus.PendingPayment)
{
_logger.LogWarning("Cannot cancel order {OrderId} - status is {Status}, must be PendingPayment",
id, order.Status);
return false;
}
order.Status = OrderStatus.Cancelled;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Cancelled order {OrderId} by identity {Identity}", id, identityReference);
return true;
}
private static OrderDto MapToDto(Order order)
{
return new OrderDto
{
Id = order.Id,
CustomerId = order.CustomerId,
IdentityReference = order.IdentityReference,
Status = order.Status,
Customer = order.Customer != null ? new CustomerSummaryDto
{
Id = order.Customer.Id,
DisplayName = !string.IsNullOrEmpty(order.Customer.TelegramDisplayName) ? order.Customer.TelegramDisplayName :
!string.IsNullOrEmpty(order.Customer.TelegramUsername) ? $"@{order.Customer.TelegramUsername}" :
$"{order.Customer.TelegramFirstName} {order.Customer.TelegramLastName}".Trim(),
TelegramUsername = order.Customer.TelegramUsername,
TotalOrders = order.Customer.TotalOrders,
TotalSpent = order.Customer.TotalSpent,
CustomerType = order.Customer.TotalOrders == 0 ? "New" :
order.Customer.TotalOrders == 1 ? "First-time" :
order.Customer.TotalOrders < 5 ? "Regular" :
order.Customer.TotalOrders < 20 ? "Loyal" : "VIP",
RiskScore = order.Customer.RiskScore,
LastActiveAt = order.Customer.LastActiveAt,
IsBlocked = order.Customer.IsBlocked
} : null,
TotalAmount = order.TotalAmount,
Currency = order.Currency,
ShippingName = order.ShippingName,
ShippingAddress = order.ShippingAddress,
ShippingCity = order.ShippingCity,
ShippingPostCode = order.ShippingPostCode,
ShippingCountry = order.ShippingCountry,
Notes = order.Notes,
TrackingNumber = order.TrackingNumber,
CreatedAt = order.CreatedAt,
UpdatedAt = order.UpdatedAt,
PaidAt = order.PaidAt,
// Workflow timestamps
AcceptedAt = order.AcceptedAt,
PackingStartedAt = order.PackingStartedAt,
DispatchedAt = order.DispatchedAt,
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
ActualDeliveryDate = order.ActualDeliveryDate,
OnHoldAt = order.OnHoldAt,
// Workflow details
AcceptedByUser = order.AcceptedByUser,
PackedByUser = order.PackedByUser,
DispatchedByUser = order.DispatchedByUser,
OnHoldReason = order.OnHoldReason,
// Legacy field (for backward compatibility)
ShippedAt = order.ShippedAt,
Items = order.Items.Select(oi => new OrderItemDto
{
Id = oi.Id,
ProductId = oi.ProductId,
ProductMultiBuyId = oi.ProductMultiBuyId,
ProductVariantId = oi.ProductVariantId,
ProductName = oi.Product.Name,
ProductMultiBuyName = oi.ProductMultiBuy?.Name,
ProductVariantName = oi.ProductVariant?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice,
TotalPrice = oi.TotalPrice
}).ToList(),
Payments = order.Payments.Select(cp => new CryptoPaymentDto
{
Id = cp.Id,
OrderId = cp.OrderId,
Currency = cp.Currency,
WalletAddress = cp.WalletAddress,
RequiredAmount = cp.RequiredAmount,
PaidAmount = cp.PaidAmount,
Status = cp.Status,
BTCPayInvoiceId = cp.BTCPayInvoiceId,
TransactionHash = cp.TransactionHash,
CreatedAt = cp.CreatedAt,
PaidAt = cp.PaidAt,
ExpiresAt = cp.ExpiresAt
}).ToList()
};
}
// Enhanced workflow methods
public async Task<bool> AcceptOrderAsync(Guid id, string userName, AcceptOrderDto acceptDto)
{
var order = await _context.Orders
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == id);
if (order == null || order.Status != OrderStatus.PaymentReceived)
return false;
var previousStatus = order.Status;
order.Status = OrderStatus.Accepted;
order.AcceptedAt = DateTime.UtcNow;
order.AcceptedByUser = userName;
if (!string.IsNullOrEmpty(acceptDto.Notes))
order.Notes = acceptDto.Notes;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} accepted by {User}", id, userName);
// Send push notifications
await SendOrderStatusNotification(order, previousStatus, OrderStatus.Accepted);
return true;
}
public async Task<bool> StartPackingAsync(Guid id, string userName, StartPackingDto packingDto)
{
var order = await _context.Orders
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == id);
if (order == null || order.Status != OrderStatus.Accepted)
return false;
var previousStatus = order.Status;
order.Status = OrderStatus.Packing;
order.PackingStartedAt = DateTime.UtcNow;
order.PackedByUser = userName;
if (!string.IsNullOrEmpty(packingDto.Notes))
order.Notes = packingDto.Notes;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} packing started by {User}", id, userName);
// Send push notifications
await SendOrderStatusNotification(order, previousStatus, OrderStatus.Packing);
return true;
}
public async Task<bool> DispatchOrderAsync(Guid id, string userName, DispatchOrderDto dispatchDto)
{
var order = await _context.Orders
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.Id == id);
if (order == null || order.Status != OrderStatus.Packing)
return false;
order.Status = OrderStatus.Dispatched;
order.DispatchedAt = DateTime.UtcNow;
order.DispatchedByUser = userName;
order.TrackingNumber = dispatchDto.TrackingNumber;
// Calculate expected delivery date (working days only)
var expectedDate = DateTime.UtcNow.AddDays(dispatchDto.EstimatedDeliveryDays);
while (expectedDate.DayOfWeek == DayOfWeek.Saturday || expectedDate.DayOfWeek == DayOfWeek.Sunday)
{
expectedDate = expectedDate.AddDays(1);
}
order.ExpectedDeliveryDate = expectedDate;
if (!string.IsNullOrEmpty(dispatchDto.Notes))
order.Notes = dispatchDto.Notes;
order.UpdatedAt = DateTime.UtcNow;
// Set legacy field for backward compatibility
order.ShippedAt = order.DispatchedAt;
await _context.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} dispatched by {User} with tracking {TrackingNumber}", id, userName, dispatchDto.TrackingNumber);
// Send push notifications
await SendOrderStatusNotification(order, OrderStatus.Packing, OrderStatus.Dispatched);
return true;
}
public async Task<bool> PutOnHoldAsync(Guid id, string userName, PutOnHoldDto holdDto)
{
var order = await _context.Orders.FindAsync(id);
if (order == null || order.Status == OrderStatus.OnHold)
return false;
order.Status = OrderStatus.OnHold;
order.OnHoldAt = DateTime.UtcNow;
order.OnHoldReason = holdDto.Reason;
if (!string.IsNullOrEmpty(holdDto.Notes))
order.Notes = holdDto.Notes;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} put on hold by {User}: {Reason}", id, userName, holdDto.Reason);
return true;
}
public async Task<bool> RemoveFromHoldAsync(Guid id, string userName)
{
var order = await _context.Orders.FindAsync(id);
if (order == null || order.Status != OrderStatus.OnHold)
return false;
// Return to appropriate status based on workflow progress
if (order.AcceptedAt.HasValue && order.PackingStartedAt.HasValue)
order.Status = OrderStatus.Packing;
else if (order.AcceptedAt.HasValue)
order.Status = OrderStatus.Accepted;
else
order.Status = OrderStatus.PaymentReceived;
order.OnHoldAt = null;
order.OnHoldReason = null;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} removed from hold by {User}, returned to {Status}", id, userName, order.Status);
return true;
}
public async Task<bool> MarkDeliveredAsync(Guid id, MarkDeliveredDto deliveredDto)
{
var order = await _context.Orders.FindAsync(id);
if (order == null || order.Status != OrderStatus.Dispatched)
return false;
order.Status = OrderStatus.Delivered;
order.ActualDeliveryDate = deliveredDto.ActualDeliveryDate ?? DateTime.UtcNow;
if (!string.IsNullOrEmpty(deliveredDto.Notes))
order.Notes = deliveredDto.Notes;
order.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Order {OrderId} marked as delivered", id);
return true;
}
// Workflow queries
public async Task<IEnumerable<OrderDto>> GetOrdersByStatusAsync(OrderStatus status)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.Status == status)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersRequiringActionAsync()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.Status == OrderStatus.PendingPayment || o.Status == OrderStatus.PaymentReceived)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
return orders.Select(MapToDto);
}
public async Task<IEnumerable<OrderDto>> GetOrdersForPackingAsync()
{
return await GetOrdersByStatusAsync(OrderStatus.Accepted);
}
public async Task<IEnumerable<OrderDto>> GetOrdersOnHoldAsync()
{
return await GetOrdersByStatusAsync(OrderStatus.OnHold);
}
public async Task<OrderStatusCountsDto> GetOrderStatusCountsAsync()
{
// Single efficient query to get all status counts
var orders = await _context.Orders
.Select(o => new { o.Status })
.ToListAsync();
var statusCounts = new OrderStatusCountsDto
{
// Include legacy Processing status in PendingPayment count (orders stuck without payment)
PendingPaymentCount = orders.Count(o => o.Status == OrderStatus.PendingPayment || o.Status == OrderStatus.Processing),
RequiringActionCount = orders.Count(o => o.Status == OrderStatus.PaymentReceived),
ForPackingCount = orders.Count(o => o.Status == OrderStatus.Accepted),
DispatchedCount = orders.Count(o => o.Status == OrderStatus.Dispatched),
OnHoldCount = orders.Count(o => o.Status == OrderStatus.OnHold),
DeliveredCount = orders.Count(o => o.Status == OrderStatus.Delivered),
CancelledCount = orders.Count(o => o.Status == OrderStatus.Cancelled)
};
return statusCounts;
}
private async Task SendNewOrderNotification(Order order)
{
try
{
var title = "🛒 New Order Received";
var body = $"Order #{order.Id.ToString()[..8]} created for £{order.TotalAmount:F2}. Awaiting payment.";
// Send notification to all admin users about new order
await _pushNotificationService.SendOrderNotificationAsync(order.Id, title, body);
_logger.LogInformation("Sent new order notification for order {OrderId}", order.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send new order notification for order {OrderId}", order.Id);
}
}
private async Task SendOrderStatusNotification(Order order, OrderStatus previousStatus, OrderStatus newStatus)
{
try
{
var title = GetOrderStatusNotificationTitle(newStatus);
var body = GetOrderStatusNotificationBody(order, previousStatus, newStatus);
// Send notification to admin users about order status change
await _pushNotificationService.SendOrderNotificationAsync(order.Id, title, body);
// Send TeleBot message to customer (if customer exists)
if (order.Customer != null)
{
await _teleBotMessagingService.SendOrderStatusUpdateAsync(order.Id, newStatus);
}
_logger.LogInformation("Sent order status notifications for order {OrderId}: {Status} (Admin + Customer)", order.Id, newStatus);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send order status notification for order {OrderId}", order.Id);
}
}
private static string GetOrderStatusNotificationTitle(OrderStatus status)
{
return status switch
{
OrderStatus.PaymentReceived => "💰 Payment Confirmed",
OrderStatus.Accepted => "✅ Order Accepted",
OrderStatus.Packing => "📦 Being Packed",
OrderStatus.Dispatched => "🚚 Order Dispatched",
OrderStatus.Delivered => "🎉 Order Delivered",
OrderStatus.OnHold => "⏸️ Order On Hold",
OrderStatus.Cancelled => "❌ Order Cancelled",
OrderStatus.Refunded => "💸 Order Refunded",
_ => "📋 Order Updated"
};
}
private static string GetOrderStatusNotificationBody(Order order, OrderStatus previousStatus, OrderStatus newStatus)
{
var orderId = order.Id.ToString()[..8];
var amount = order.TotalAmount.ToString("F2");
return newStatus switch
{
OrderStatus.PaymentReceived => $"Order #{orderId} payment confirmed (£{amount}). Ready for acceptance.",
OrderStatus.Accepted => $"Order #{orderId} has been accepted and is ready for packing.",
OrderStatus.Packing => $"Order #{orderId} is being packed. Will be dispatched soon.",
OrderStatus.Dispatched => $"Order #{orderId} dispatched with tracking: {order.TrackingNumber ?? "TBA"}",
OrderStatus.Delivered => $"Order #{orderId} has been delivered successfully.",
OrderStatus.OnHold => $"Order #{orderId} has been put on hold: {order.OnHoldReason}",
OrderStatus.Cancelled => $"Order #{orderId} has been cancelled.",
OrderStatus.Refunded => $"Order #{orderId} has been refunded (£{amount}).",
_ => $"Order #{orderId} status updated from {previousStatus} to {newStatus}."
};
}
private async Task RecordSalesLedgerAsync(Order order)
{
var payment = order.Payments.FirstOrDefault(p => p.Status == PaymentStatus.Completed);
foreach (var item in order.Items)
{
var ledgerEntry = new SalesLedger
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = item.ProductId,
ProductName = item.Product.Name,
Quantity = item.Quantity,
SalePriceFiat = item.TotalPrice,
FiatCurrency = "GBP",
SalePriceBTC = payment?.PaidAmount,
Cryptocurrency = payment?.Currency.ToString(),
SoldAt = DateTime.UtcNow
};
_context.SalesLedgers.Add(ledgerEntry);
_logger.LogInformation("Recorded sales ledger entry for Order {OrderId}, Product {ProductId}, Quantity {Quantity}",
order.Id, item.ProductId, item.Quantity);
}
}
private async Task DeductStockAsync(Order order)
{
foreach (var item in order.Items)
{
var product = await _context.Products.FindAsync(item.ProductId);
if (product != null && product.StockQuantity >= item.Quantity)
{
product.StockQuantity -= item.Quantity;
product.UpdatedAt = DateTime.UtcNow;
_logger.LogInformation("Deducted {Quantity} units from product {ProductId} stock (Order {OrderId})",
item.Quantity, item.ProductId, order.Id);
}
else if (product != null)
{
_logger.LogWarning("Insufficient stock for product {ProductId}. Order {OrderId} requires {Required} but only {Available} available",
item.ProductId, order.Id, item.Quantity, product.StockQuantity);
}
}
}
}