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>
590 lines
23 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
} |