feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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<SnappfoodWebhookService> _logger;
|
||||
|
||||
public SnappfoodWebhookService(
|
||||
AppDbContext db,
|
||||
IKdsNotifier kdsNotifier,
|
||||
IConfiguration configuration,
|
||||
ILogger<SnappfoodWebhookService> 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<OrderItem>();
|
||||
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<int> 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());
|
||||
}
|
||||
Reference in New Issue
Block a user