using System.Security.Cryptography; using System.Text; using Meezi.API.Models.Orders; using Meezi.API.Services.Printing; using Meezi.API.Models.Snappfood; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace Meezi.API.Services; public interface ISnappfoodWebhookService { bool VerifySignature(string rawBody, string? signatureHeader); Task<(bool Success, string? Error)> ProcessOrderAsync(SnappfoodWebhookOrder order, CancellationToken cancellationToken = default); } public class SnappfoodWebhookService : ISnappfoodWebhookService { private readonly AppDbContext _db; private readonly IKdsNotifier _kdsNotifier; private readonly IConfiguration _configuration; private readonly ILogger _logger; public SnappfoodWebhookService( AppDbContext db, IKdsNotifier kdsNotifier, IConfiguration configuration, ILogger logger) { _db = db; _kdsNotifier = kdsNotifier; _configuration = configuration; _logger = logger; } public bool VerifySignature(string rawBody, string? signatureHeader) { var secret = _configuration["Snappfood:WebhookSecret"]; if (string.IsNullOrWhiteSpace(secret)) return true; if (string.IsNullOrWhiteSpace(signatureHeader)) return false; var expected = ComputeHmac(rawBody, secret); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signatureHeader.Trim())); } public async Task<(bool Success, string? Error)> ProcessOrderAsync( SnappfoodWebhookOrder order, CancellationToken cancellationToken = default) { var cafe = await _db.Cafes .FirstOrDefaultAsync(c => c.SnappfoodVendorId == order.VendorId, cancellationToken); if (cafe is null) return (false, "Unknown vendor."); var existing = await _db.Orders .AnyAsync(o => o.CafeId == cafe.Id && o.SnappfoodOrderId == order.OrderId, cancellationToken); if (existing) return (true, null); var menuItems = await _db.MenuItems .Where(m => m.CafeId == cafe.Id && m.IsAvailable) .ToListAsync(cancellationToken); var orderItems = new List(); decimal subtotal = 0; foreach (var item in order.Items) { var menuItem = menuItems.FirstOrDefault(m => m.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase) || (m.NameEn != null && m.NameEn.Equals(item.Name, StringComparison.OrdinalIgnoreCase))); if (menuItem is null) { _logger.LogWarning("Snappfood item {Name} not matched for cafe {CafeId}", item.Name, cafe.Id); continue; } var lineTotal = item.UnitPrice * item.Quantity; subtotal += lineTotal; orderItems.Add(new OrderItem { MenuItemId = menuItem.Id, Quantity = item.Quantity, UnitPrice = item.UnitPrice, Notes = "Snappfood" }); } if (orderItems.Count == 0) return (false, "No menu items matched."); var taxRate = await _db.Taxes .Where(t => t.CafeId == cafe.Id && t.IsDefault) .Select(t => t.Rate) .FirstOrDefaultAsync(cancellationToken); var taxTotal = Math.Round(subtotal * taxRate / 100m, 0); var total = order.Total > 0 ? order.Total : subtotal + taxTotal; var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, cancellationToken); var meeziOrder = new Order { CafeId = cafe.Id, OrderType = OrderType.Delivery, Status = OrderStatus.Confirmed, DisplayNumber = displayNumber, SnappfoodOrderId = order.OrderId, Subtotal = subtotal, TaxTotal = taxTotal, Total = total, Items = orderItems }; if (!string.IsNullOrWhiteSpace(order.CustomerPhone)) { var customer = await _db.Customers .FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == order.CustomerPhone, cancellationToken); if (customer is null) { customer = new Customer { CafeId = cafe.Id, Name = order.CustomerName ?? "Snappfood", Phone = order.CustomerPhone, Group = CustomerGroup.New }; _db.Customers.Add(customer); await _db.SaveChangesAsync(cancellationToken); } meeziOrder.CustomerId = customer.Id; } _db.Orders.Add(meeziOrder); await _db.SaveChangesAsync(cancellationToken); var loaded = await _db.Orders .Include(o => o.Items) .ThenInclude(i => i.MenuItem) .Include(o => o.Table) .FirstAsync(o => o.Id == meeziOrder.Id, cancellationToken); await _kdsNotifier.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), cancellationToken); return (true, null); } private static string ComputeHmac(string body, string secret) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); return Convert.ToHexString(hash).ToLowerInvariant(); } private async Task AllocateDisplayNumberAsync(string cafeId, CancellationToken ct) { var max = await _db.Orders .Where(o => o.CafeId == cafeId) .MaxAsync(o => (int?)o.DisplayNumber, ct); return (max ?? 0) + 1; } private static LiveOrderDto MapLive(Order o) => new( o.Id, o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id), o.Status, o.Table?.Number, o.OrderType, o.Total, o.CreatedAt, o.Items.Select(i => new OrderItemDto( i.Id, i.MenuItemId, i.MenuItem?.Name ?? "", i.Quantity, i.UnitPrice, i.Notes, i.IsVoided, i.VoidedAt)).ToList(), o.Source); }