littleshop/TeleBot/TeleBot/Services/LittleShopService.cs
SysAdmin 034b8facee Implement product multi-buys and variants system
Major restructuring of product variations:
- Renamed ProductVariation to ProductMultiBuy for quantity-based pricing (e.g., "3 for £25")
- Added new ProductVariant model for string-based options (colors, flavors)
- Complete separation of multi-buy pricing from variant selection

Features implemented:
- Multi-buy deals with automatic price-per-unit calculation
- Product variants for colors/flavors/sizes with stock tracking
- TeleBot checkout supports both multi-buys and variant selection
- Shopping cart correctly calculates multi-buy bundle prices
- Order system tracks selected variants and multi-buy choices
- Real-time bot activity monitoring with SignalR
- Public bot directory page with QR codes for Telegram launch
- Admin dashboard shows multi-buy and variant metrics

Technical changes:
- Updated all DTOs, services, and controllers
- Fixed cart total calculation for multi-buy bundles
- Comprehensive test coverage for new functionality
- All existing tests passing with new features

Database changes:
- Migrated ProductVariations to ProductMultiBuys
- Added ProductVariants table
- Updated OrderItems to track variants

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 00:30:12 +01:00

590 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LittleShop.Client;
using LittleShop.Client.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TeleBot.Models;
namespace TeleBot.Services
{
public interface ILittleShopService
{
Task<bool> AuthenticateAsync();
Task<List<Category>> GetCategoriesAsync();
Task<PagedResult<Product>> GetProductsAsync(Guid? categoryId = null, int page = 1);
Task<Product?> GetProductAsync(Guid productId);
Task<Order?> CreateOrderAsync(UserSession session, long telegramUserId = 0, string telegramUsername = "", string telegramDisplayName = "", string telegramFirstName = "", string telegramLastName = "");
Task<List<Order>> GetOrdersAsync(string identityReference);
Task<List<Order>> GetCustomerOrdersAsync(long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName);
Task<Order?> GetOrderAsync(Guid orderId);
Task<Order?> GetCustomerOrderAsync(Guid orderId, long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName);
Task<CryptoPayment?> CreatePaymentAsync(Guid orderId, string currency);
Task<List<CustomerMessage>?> GetPendingMessagesAsync();
Task<bool> MarkMessageAsSentAsync(Guid messageId, string? platformMessageId = null);
Task<bool> MarkMessageAsFailedAsync(Guid messageId, string reason);
Task<bool> SendCustomerMessageAsync(long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName, string subject, string content);
Task<List<CustomerMessage>?> GetCustomerConversationAsync(long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName);
}
public class LittleShopService : ILittleShopService
{
private readonly ILittleShopClient _client;
private readonly IConfiguration _configuration;
private readonly ILogger<LittleShopService> _logger;
private readonly IPrivacyService _privacyService;
private bool _isAuthenticated = false;
public LittleShopService(
ILittleShopClient client,
IConfiguration configuration,
ILogger<LittleShopService> logger,
IPrivacyService privacyService)
{
_client = client;
_configuration = configuration;
_logger = logger;
_privacyService = privacyService;
}
public async Task<bool> AuthenticateAsync()
{
if (_isAuthenticated)
return true;
try
{
var username = _configuration["LittleShop:Username"] ?? "bot-user";
var password = _configuration["LittleShop:Password"] ?? "bot-password";
var result = await _client.Authentication.LoginAsync(username, password);
if (result.IsSuccess && result.Data != null && !string.IsNullOrEmpty(result.Data.Token))
{
_client.Authentication.SetToken(result.Data.Token);
_isAuthenticated = true;
_logger.LogInformation("Successfully authenticated with LittleShop API");
return true;
}
_logger.LogWarning("Failed to authenticate with LittleShop API");
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error authenticating with LittleShop API");
return false;
}
}
public async Task<List<Category>> GetCategoriesAsync()
{
try
{
if (!await AuthenticateAsync())
return new List<Category>();
var result = await _client.Catalog.GetCategoriesAsync();
if (result.IsSuccess && result.Data != null)
{
return result.Data.Where(c => c.IsActive).ToList();
}
return new List<Category>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching categories");
return new List<Category>();
}
}
public async Task<PagedResult<Product>> GetProductsAsync(Guid? categoryId = null, int page = 1)
{
try
{
if (!await AuthenticateAsync())
return new PagedResult<Product> { Items = new List<Product>() };
var result = await _client.Catalog.GetProductsAsync(
pageNumber: page,
pageSize: 10,
categoryId: categoryId
);
if (result.IsSuccess && result.Data != null)
{
// Filter to active products only
result.Data.Items = result.Data.Items.Where(p => p.IsActive).ToList();
return result.Data;
}
return new PagedResult<Product> { Items = new List<Product>() };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching products");
return new PagedResult<Product> { Items = new List<Product>() };
}
}
public async Task<Product?> GetProductAsync(Guid productId)
{
try
{
if (!await AuthenticateAsync())
return null;
var result = await _client.Catalog.GetProductByIdAsync(productId);
if (result.IsSuccess && result.Data != null && result.Data.IsActive)
{
return result.Data;
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching product {ProductId}", productId);
return null;
}
}
public async Task<Order?> CreateOrderAsync(UserSession session, long telegramUserId = 0, string telegramUsername = "", string telegramDisplayName = "", string telegramFirstName = "", string telegramLastName = "")
{
try
{
if (!await AuthenticateAsync())
return null;
if (session.Cart.IsEmpty() || session.OrderFlow == null)
return null;
// Create or get customer instead of using anonymous reference
string? identityReference = null;
CreateCustomerRequest? customerInfo = null;
if (telegramUserId > 0)
{
// Create customer info for order creation
customerInfo = new CreateCustomerRequest
{
TelegramUserId = telegramUserId,
TelegramUsername = telegramUsername,
TelegramDisplayName = string.IsNullOrEmpty(telegramDisplayName) ? $"{telegramFirstName} {telegramLastName}".Trim() : telegramDisplayName,
TelegramFirstName = telegramFirstName,
TelegramLastName = telegramLastName,
AllowOrderUpdates = true,
AllowMarketing = false
};
_logger.LogInformation("Creating order for Telegram user {UserId} ({DisplayName})",
telegramUserId, customerInfo.TelegramDisplayName);
}
else
{
// Fallback to anonymous reference for legacy support
if (string.IsNullOrEmpty(session.OrderFlow.IdentityReference))
{
session.OrderFlow.IdentityReference = _privacyService.GenerateAnonymousReference();
}
identityReference = session.OrderFlow.IdentityReference;
_logger.LogInformation("Creating anonymous order with identity {Identity}", identityReference);
}
// Encrypt shipping info if PGP is enabled
string shippingData = $"{session.OrderFlow.ShippingName}\n" +
$"{session.OrderFlow.ShippingAddress}\n" +
$"{session.OrderFlow.ShippingCity}\n" +
$"{session.OrderFlow.ShippingPostCode}\n" +
$"{session.OrderFlow.ShippingCountry}";
if (session.Privacy.RequirePGP && !string.IsNullOrEmpty(session.Privacy.PGPPublicKey))
{
var encrypted = await _privacyService.EncryptWithPGP(shippingData, session.Privacy.PGPPublicKey);
if (encrypted != null)
{
// Store encrypted data in notes field
session.OrderFlow.Notes = $"PGP_ENCRYPTED_SHIPPING:\n{encrypted}";
session.OrderFlow.ShippingName = "PGP_ENCRYPTED";
session.OrderFlow.ShippingAddress = "PGP_ENCRYPTED";
session.OrderFlow.ShippingCity = "PGP_ENCRYPTED";
session.OrderFlow.ShippingPostCode = "PGP_ENCRYPTED";
}
}
var request = new CreateOrderRequest
{
// Use customer info if available, otherwise fallback to identity reference
CustomerInfo = customerInfo,
IdentityReference = identityReference,
ShippingName = session.OrderFlow.ShippingName ?? "",
ShippingAddress = session.OrderFlow.ShippingAddress ?? "",
ShippingCity = session.OrderFlow.ShippingCity ?? "",
ShippingPostCode = session.OrderFlow.ShippingPostCode ?? "",
ShippingCountry = session.OrderFlow.ShippingCountry ?? "United Kingdom",
Notes = session.OrderFlow.Notes,
Items = session.Cart.Items.Select(i => new CreateOrderItem
{
ProductId = i.ProductId,
ProductMultiBuyId = i.MultiBuyId,
SelectedVariant = i.SelectedVariant,
Quantity = i.Quantity
}).ToList()
};
var result = await _client.Orders.CreateOrderAsync(request);
if (result.IsSuccess && result.Data != null)
{
_logger.LogInformation("Order created successfully");
return result.Data;
}
_logger.LogWarning("Failed to create order: {Error}", result.ErrorMessage);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating order");
return null;
}
}
public async Task<List<Order>> GetOrdersAsync(string identityReference)
{
try
{
if (!await AuthenticateAsync())
return new List<Order>();
var result = await _client.Orders.GetOrdersByIdentityAsync(identityReference);
if (result.IsSuccess && result.Data != null)
{
return result.Data;
}
return new List<Order>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching orders");
return new List<Order>();
}
}
public async Task<Order?> GetOrderAsync(Guid orderId)
{
try
{
if (!await AuthenticateAsync())
return null;
var result = await _client.Orders.GetOrderByIdAsync(orderId);
if (result.IsSuccess && result.Data != null)
{
return result.Data;
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching order {OrderId}", orderId);
return null;
}
}
public async Task<Order?> GetCustomerOrderAsync(Guid orderId, long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName)
{
try
{
if (!await AuthenticateAsync())
return null;
// Get or create the customer to get customer ID
var customer = await _client.Customers.GetOrCreateCustomerAsync(new CreateCustomerRequest
{
TelegramUserId = telegramUserId,
TelegramUsername = telegramUsername,
TelegramDisplayName = displayName,
TelegramFirstName = firstName,
TelegramLastName = lastName,
AllowOrderUpdates = true,
AllowMarketing = false
});
if (!customer.IsSuccess || customer.Data == null)
{
return null;
}
// Get the specific order using customer validation
var result = await _client.Orders.GetOrderByCustomerIdAsync(customer.Data.Id, orderId);
if (result.IsSuccess && result.Data != null)
{
return result.Data;
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching customer order {OrderId}", orderId);
return null;
}
}
public async Task<CryptoPayment?> CreatePaymentAsync(Guid orderId, string currency)
{
try
{
if (!await AuthenticateAsync())
return null;
// Convert string currency to enum integer
var currencyInt = ConvertCurrencyToEnum(currency);
var result = await _client.Orders.CreatePaymentAsync(orderId, currencyInt);
if (result.IsSuccess && result.Data != null)
{
_logger.LogInformation("Payment created for order {OrderId} with currency {Currency}",
orderId, currency);
return result.Data;
}
_logger.LogWarning("Failed to create payment: {Error}", result.ErrorMessage);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating payment for order {OrderId}", orderId);
return null;
}
}
public async Task<List<CustomerMessage>?> GetPendingMessagesAsync()
{
try
{
if (!await AuthenticateAsync())
return null;
// Use the client messages service
var messages = await _client.Messages.GetPendingMessagesAsync("Telegram");
// Convert to TeleBot CustomerMessage format
return messages.Select(m => new CustomerMessage
{
Id = m.Id,
CustomerId = m.CustomerId,
TelegramUserId = m.TelegramUserId,
Subject = m.Subject,
Content = m.Content,
Type = (MessageType)m.Type,
IsUrgent = m.IsUrgent,
OrderReference = m.OrderReference,
CreatedAt = m.CreatedAt
}).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending messages");
return null;
}
}
public async Task<bool> MarkMessageAsSentAsync(Guid messageId, string? platformMessageId = null)
{
try
{
if (!await AuthenticateAsync())
return false;
return await _client.Messages.MarkMessageAsSentAsync(messageId, platformMessageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking message {MessageId} as sent", messageId);
return false;
}
}
public async Task<bool> MarkMessageAsFailedAsync(Guid messageId, string reason)
{
try
{
if (!await AuthenticateAsync())
return false;
return await _client.Messages.MarkMessageAsFailedAsync(messageId, reason);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking message {MessageId} as failed", messageId);
return false;
}
}
private static int ConvertCurrencyToEnum(string currency)
{
return currency.ToUpper() switch
{
"BTC" => 0,
"XMR" => 1,
"USDT" => 2,
"LTC" => 3,
"ETH" => 4,
"ZEC" => 5,
"DASH" => 6,
"DOGE" => 7,
_ => 0 // Default to BTC
};
}
public async Task<bool> SendCustomerMessageAsync(long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName, string subject, string content)
{
try
{
if (!await AuthenticateAsync())
return false;
// First, get or create the customer
var customer = await _client.Customers.GetOrCreateCustomerAsync(new CreateCustomerRequest
{
TelegramUserId = telegramUserId,
TelegramUsername = telegramUsername,
TelegramDisplayName = displayName,
TelegramFirstName = firstName,
TelegramLastName = lastName,
AllowOrderUpdates = true,
AllowMarketing = false
});
if (!customer.IsSuccess || customer.Data == null)
{
_logger.LogError("Failed to get or create customer for support message");
return false;
}
// Create the customer message
var messageData = new
{
CustomerId = customer.Data.Id,
Type = 3, // CustomerService
Subject = subject,
Content = content,
Direction = 1, // CustomerToAdmin
Priority = 5,
IsUrgent = false
};
var response = await _client.Messages.CreateCustomerMessageAsync(messageData);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending customer message");
return false;
}
}
public async Task<List<Order>> GetCustomerOrdersAsync(long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName)
{
try
{
if (!await AuthenticateAsync())
return new List<Order>();
// Get or create the customer
var customer = await _client.Customers.GetOrCreateCustomerAsync(new CreateCustomerRequest
{
TelegramUserId = telegramUserId,
TelegramUsername = telegramUsername,
TelegramDisplayName = displayName,
TelegramFirstName = firstName,
TelegramLastName = lastName,
AllowOrderUpdates = true,
AllowMarketing = false
});
if (!customer.IsSuccess || customer.Data == null)
{
return new List<Order>();
}
// Get customer orders using the new endpoint
var ordersResult = await _client.Orders.GetOrdersByCustomerIdAsync(customer.Data.Id);
if (ordersResult.IsSuccess && ordersResult.Data != null)
{
return ordersResult.Data;
}
return new List<Order>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching customer orders");
return new List<Order>();
}
}
public async Task<List<CustomerMessage>?> GetCustomerConversationAsync(long telegramUserId, string telegramUsername, string displayName, string firstName, string lastName)
{
try
{
if (!await AuthenticateAsync())
return null;
// First, get or create the customer
var customer = await _client.Customers.GetOrCreateCustomerAsync(new CreateCustomerRequest
{
TelegramUserId = telegramUserId,
TelegramUsername = telegramUsername,
TelegramDisplayName = displayName,
TelegramFirstName = firstName,
TelegramLastName = lastName,
AllowOrderUpdates = true,
AllowMarketing = false
});
if (!customer.IsSuccess || customer.Data == null)
{
return new List<CustomerMessage>();
}
// Get all messages for this customer (conversation history)
var clientMessages = await _client.Messages.GetCustomerMessagesAsync(customer.Data.Id);
// Convert to TeleBot CustomerMessage format
return clientMessages?.Select(m => new CustomerMessage
{
Id = m.Id,
CustomerId = m.CustomerId,
TelegramUserId = m.TelegramUserId,
Subject = m.Subject,
Content = m.Content,
Type = (MessageType)m.Type,
IsUrgent = m.IsUrgent,
OrderReference = m.OrderReference,
CreatedAt = m.CreatedAt,
Direction = m.Direction
}).ToList() ?? new List<CustomerMessage>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting customer conversation");
return null;
}
}
}
}