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,72 @@
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface ICommissionCalculator
|
||||
{
|
||||
Task<decimal> ResolveRatePercentAsync(string cafeId, DeliveryPlatform platform, CancellationToken ct = default);
|
||||
decimal CalculateCommission(decimal grossTotal, decimal ratePercent);
|
||||
Task<decimal> CalculateForOrderAsync(
|
||||
string cafeId,
|
||||
UnifiedDeliveryOrder order,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CommissionCalculator : ICommissionCalculator
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly DeliveryPlatformsOptions _options;
|
||||
|
||||
public CommissionCalculator(AppDbContext db, IOptions<DeliveryPlatformsOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<decimal> ResolveRatePercentAsync(
|
||||
string cafeId,
|
||||
DeliveryPlatform platform,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var custom = await _db.DeliveryCommissionRates
|
||||
.Where(r => r.CafeId == cafeId && r.Platform == platform && r.IsActive)
|
||||
.Select(r => (decimal?)r.RatePercent)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (custom is > 0)
|
||||
return custom.Value;
|
||||
|
||||
return platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => _options.DefaultSnappfoodCommissionPercent,
|
||||
DeliveryPlatform.Tap30 => _options.DefaultTap30CommissionPercent,
|
||||
DeliveryPlatform.Digikala => _options.DefaultDigikalaCommissionPercent,
|
||||
_ => 0m
|
||||
};
|
||||
}
|
||||
|
||||
public decimal CalculateCommission(decimal grossTotal, decimal ratePercent) =>
|
||||
grossTotal <= 0 || ratePercent <= 0
|
||||
? 0m
|
||||
: Math.Round(grossTotal * ratePercent / 100m, 0);
|
||||
|
||||
public async Task<decimal> CalculateForOrderAsync(
|
||||
string cafeId,
|
||||
UnifiedDeliveryOrder order,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (order.Payment.Commission is decimal fromPlatform)
|
||||
return fromPlatform;
|
||||
|
||||
var rate = await ResolveRatePercentAsync(cafeId, order.Platform, ct);
|
||||
var gross = order.Payment.Total > 0
|
||||
? order.Payment.Total
|
||||
: order.Items.Sum(i => i.UnitPrice * i.Quantity);
|
||||
return CalculateCommission(gross, rate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Meezi.API.Models.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IDeliveryFinanceReportService
|
||||
{
|
||||
Task<DeliveryRevenueReportDto> GetRevenueByPlatformAsync(
|
||||
string cafeId,
|
||||
DateTime utcFrom,
|
||||
DateTime utcTo,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryFinanceReportService : IDeliveryFinanceReportService
|
||||
{
|
||||
private static readonly OrderStatus[] RevenueStatuses =
|
||||
[
|
||||
OrderStatus.Confirmed,
|
||||
OrderStatus.Preparing,
|
||||
OrderStatus.Ready,
|
||||
OrderStatus.Delivered
|
||||
];
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public DeliveryFinanceReportService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<DeliveryRevenueReportDto> GetRevenueByPlatformAsync(
|
||||
string cafeId,
|
||||
DateTime utcFrom,
|
||||
DateTime utcTo,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var orders = await _db.Orders
|
||||
.Where(o => o.CafeId == cafeId
|
||||
&& o.DeliveryPlatform != null
|
||||
&& o.CreatedAt >= utcFrom
|
||||
&& o.CreatedAt < utcTo
|
||||
&& RevenueStatuses.Contains(o.Status))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var platforms = Enum.GetValues<DeliveryPlatform>()
|
||||
.Where(p => p != DeliveryPlatform.Direct)
|
||||
.Select(platform =>
|
||||
{
|
||||
var subset = orders.Where(o => o.DeliveryPlatform == platform).ToList();
|
||||
var gross = subset.Sum(o => o.Total);
|
||||
var commission = subset.Sum(o => o.PlatformCommission);
|
||||
return new PlatformRevenueDto(
|
||||
platform,
|
||||
PlatformLabel(platform),
|
||||
subset.Count,
|
||||
gross,
|
||||
commission,
|
||||
gross - commission);
|
||||
})
|
||||
.Where(p => p.OrderCount > 0)
|
||||
.ToList();
|
||||
|
||||
return new DeliveryRevenueReportDto(
|
||||
$"{utcFrom:yyyy-MM-dd} — {utcTo:yyyy-MM-dd}",
|
||||
utcFrom,
|
||||
utcTo,
|
||||
platforms,
|
||||
platforms.Sum(p => p.GrossRevenue),
|
||||
platforms.Sum(p => p.Commission),
|
||||
platforms.Sum(p => p.NetRevenue));
|
||||
}
|
||||
|
||||
private static string PlatformLabel(DeliveryPlatform platform) => platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => "اسنپفود",
|
||||
DeliveryPlatform.Tap30 => "تپسی",
|
||||
DeliveryPlatform.Digikala => "دیجیکالا",
|
||||
_ => platform.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public record DeliveryProcessResult(bool Success, string? MeeziOrderId, string? ErrorCode, string? Message);
|
||||
|
||||
public interface IDeliveryOrderProcessor
|
||||
{
|
||||
Task<DeliveryProcessResult> ProcessAsync(
|
||||
string webhookLogId,
|
||||
UnifiedDeliveryOrder unified,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryOrderProcessor : IDeliveryOrderProcessor
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IKdsNotifier _kds;
|
||||
private readonly ICommissionCalculator _commission;
|
||||
private readonly IInventoryService _inventory;
|
||||
private readonly ISnappfoodClient _snappfood;
|
||||
private readonly ITap30Client _tap30;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DeliveryOrderProcessor> _logger;
|
||||
|
||||
public DeliveryOrderProcessor(
|
||||
AppDbContext db,
|
||||
IKdsNotifier kds,
|
||||
ICommissionCalculator commission,
|
||||
IInventoryService inventory,
|
||||
ISnappfoodClient snappfood,
|
||||
ITap30Client tap30,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DeliveryOrderProcessor> logger)
|
||||
{
|
||||
_db = db;
|
||||
_kds = kds;
|
||||
_commission = commission;
|
||||
_inventory = inventory;
|
||||
_snappfood = snappfood;
|
||||
_tap30 = tap30;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DeliveryProcessResult> ProcessAsync(
|
||||
string webhookLogId,
|
||||
UnifiedDeliveryOrder unified,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var log = await _db.WebhookLogs.FirstOrDefaultAsync(w => w.Id == webhookLogId, ct);
|
||||
if (log is null)
|
||||
return new DeliveryProcessResult(false, null, "LOG_NOT_FOUND", "Webhook log missing.");
|
||||
|
||||
log.AttemptCount++;
|
||||
try
|
||||
{
|
||||
var cafe = await ResolveCafeAsync(unified, ct);
|
||||
if (cafe is null)
|
||||
{
|
||||
await FailLogAsync(log, "Unknown vendor.", ct);
|
||||
return new DeliveryProcessResult(false, null, "VENDOR_NOT_FOUND", "Unknown vendor.");
|
||||
}
|
||||
|
||||
log.CafeId = cafe.Id;
|
||||
log.ExternalOrderId = unified.ExternalId;
|
||||
|
||||
var duplicate = await _db.Orders.AnyAsync(
|
||||
o => o.CafeId == cafe.Id
|
||||
&& o.DeliveryPlatform == unified.Platform
|
||||
&& o.ExternalOrderId == unified.ExternalId,
|
||||
ct);
|
||||
|
||||
if (duplicate)
|
||||
{
|
||||
await CompleteLogAsync(log, null, success: true, error: null, ct);
|
||||
return new DeliveryProcessResult(true, null, null, "Duplicate ignored.");
|
||||
}
|
||||
|
||||
var branchId = await _db.Branches
|
||||
.Where(b => b.CafeId == cafe.Id && b.IsActive)
|
||||
.OrderBy(b => b.Name)
|
||||
.Select(b => b.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var menuItems = await _db.MenuItems
|
||||
.Where(m => m.CafeId == cafe.Id && m.IsAvailable)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var orderItems = new List<OrderItem>();
|
||||
decimal subtotal = 0;
|
||||
|
||||
foreach (var line in unified.Items)
|
||||
{
|
||||
var menuItem = menuItems.FirstOrDefault(m =>
|
||||
(!string.IsNullOrEmpty(line.Sku) && m.Id == line.Sku)
|
||||
|| m.Name.Equals(line.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| (m.NameEn != null && m.NameEn.Equals(line.Name, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (menuItem is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Delivery {Platform} item {Name} not matched for cafe {CafeId}",
|
||||
unified.Platform,
|
||||
line.Name,
|
||||
cafe.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
subtotal += line.UnitPrice * line.Quantity;
|
||||
orderItems.Add(new OrderItem
|
||||
{
|
||||
MenuItemId = menuItem.Id,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = line.UnitPrice,
|
||||
Notes = line.Notes ?? unified.Platform.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
if (orderItems.Count == 0)
|
||||
{
|
||||
await FailLogAsync(log, "No menu items matched.", ct);
|
||||
return new DeliveryProcessResult(false, null, "INVALID_MENU_ITEMS", "No menu items matched.");
|
||||
}
|
||||
|
||||
var platformCommission = await _commission.CalculateForOrderAsync(cafe.Id, unified, ct);
|
||||
|
||||
var taxRate = cafe.DefaultTaxRate > 0
|
||||
? cafe.DefaultTaxRate
|
||||
: await _db.Taxes.Where(t => t.CafeId == cafe.Id && t.IsDefault)
|
||||
.Select(t => t.Rate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (taxRate == 0) taxRate = 9m;
|
||||
|
||||
var gross = unified.Payment.Total > 0 ? unified.Payment.Total : subtotal;
|
||||
var taxTotal = Math.Round((gross - platformCommission) * taxRate / 100m, 0);
|
||||
var total = gross;
|
||||
|
||||
var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, ct);
|
||||
var order = new Order
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
BranchId = branchId,
|
||||
OrderType = unified.Delivery.Type == "pickup" ? OrderType.Takeaway : OrderType.Delivery,
|
||||
Source = MapSource(unified.Platform),
|
||||
Status = MapStatus(unified.Status),
|
||||
DisplayNumber = displayNumber,
|
||||
ExternalOrderId = unified.ExternalId,
|
||||
DeliveryPlatform = unified.Platform,
|
||||
PlatformCommission = platformCommission,
|
||||
DeliveryMetaJson = JsonSerializer.Serialize(unified.Delivery),
|
||||
Subtotal = subtotal,
|
||||
TaxTotal = taxTotal,
|
||||
Total = total,
|
||||
Items = orderItems
|
||||
};
|
||||
|
||||
if (unified.Platform == DeliveryPlatform.Snappfood)
|
||||
order.SnappfoodOrderId = unified.ExternalId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(unified.Customer.Phone))
|
||||
{
|
||||
var phone = unified.Customer.Phone.Trim();
|
||||
var customer = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == phone, ct);
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new Customer
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
Name = unified.Customer.Name,
|
||||
Phone = phone,
|
||||
Group = CustomerGroup.New
|
||||
};
|
||||
_db.Customers.Add(customer);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
order.CustomerId = customer.Id;
|
||||
order.GuestName = unified.Customer.Name;
|
||||
order.GuestPhone = phone;
|
||||
}
|
||||
else
|
||||
{
|
||||
order.GuestName = unified.Customer.Name;
|
||||
}
|
||||
|
||||
if (unified.Payment.IsPaid && total > 0)
|
||||
{
|
||||
order.Payments.Add(new Payment
|
||||
{
|
||||
Method = unified.Payment.Method.Equals("cash", StringComparison.OrdinalIgnoreCase)
|
||||
? PaymentMethod.Cash
|
||||
: PaymentMethod.Card,
|
||||
Amount = total,
|
||||
Status = PaymentStatus.Completed
|
||||
});
|
||||
}
|
||||
|
||||
_db.Orders.Add(order);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await TryDeductInventoryAsync(cafe.Id, orderItems, ct);
|
||||
|
||||
var loaded = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.Include(o => o.Table)
|
||||
.FirstAsync(o => o.Id == order.Id, ct);
|
||||
|
||||
await _kds.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), ct);
|
||||
PrinterBackgroundJobs.QueueKitchenPrint(_scopeFactory, cafe.Id, order.Id);
|
||||
|
||||
await AcknowledgePlatformAsync(unified, ct);
|
||||
|
||||
await CompleteLogAsync(log, order.Id, success: true, error: null, ct);
|
||||
return new DeliveryProcessResult(true, order.Id, null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Delivery order processing failed for log {LogId}", webhookLogId);
|
||||
await FailLogAsync(log, ex.Message, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Cafe?> ResolveCafeAsync(UnifiedDeliveryOrder unified, CancellationToken ct) =>
|
||||
unified.Platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.SnappfoodVendorId == unified.VendorId, ct),
|
||||
DeliveryPlatform.Tap30 => await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.Tap30VendorId == unified.VendorId, ct),
|
||||
DeliveryPlatform.Digikala => await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.DigikalaVendorId == unified.VendorId, ct),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task AcknowledgePlatformAsync(UnifiedDeliveryOrder unified, CancellationToken ct)
|
||||
{
|
||||
switch (unified.Platform)
|
||||
{
|
||||
case DeliveryPlatform.Snappfood:
|
||||
await _snappfood.AcknowledgeOrderAsync(unified.ExternalId, ct);
|
||||
break;
|
||||
case DeliveryPlatform.Tap30:
|
||||
await _tap30.AcknowledgeOrderAsync(unified.ExternalId, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryDeductInventoryAsync(
|
||||
string cafeId,
|
||||
List<OrderItem> items,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
var orderId = items[0].OrderId;
|
||||
await _inventory.DeductForOrderAsync(
|
||||
cafeId,
|
||||
orderId,
|
||||
items.Select(i => (i.MenuItemId, i.Quantity)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static OrderSource MapSource(DeliveryPlatform platform) => platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => OrderSource.SnappFood,
|
||||
DeliveryPlatform.Tap30 => OrderSource.Tap30,
|
||||
DeliveryPlatform.Digikala => OrderSource.Digikala,
|
||||
_ => OrderSource.Pos
|
||||
};
|
||||
|
||||
private static OrderStatus MapStatus(UnifiedDeliveryStatus status) => status switch
|
||||
{
|
||||
UnifiedDeliveryStatus.Pending => OrderStatus.Pending,
|
||||
UnifiedDeliveryStatus.Confirmed => OrderStatus.Confirmed,
|
||||
UnifiedDeliveryStatus.Preparing => OrderStatus.Preparing,
|
||||
UnifiedDeliveryStatus.Ready => OrderStatus.Ready,
|
||||
UnifiedDeliveryStatus.Delivered => OrderStatus.Delivered,
|
||||
UnifiedDeliveryStatus.Cancelled => OrderStatus.Cancelled,
|
||||
_ => OrderStatus.Confirmed
|
||||
};
|
||||
|
||||
private async Task FailLogAsync(WebhookLog log, string error, CancellationToken ct)
|
||||
{
|
||||
log.Success = false;
|
||||
log.Processed = true;
|
||||
log.ErrorMessage = error.Length > 2000 ? error[..2000] : error;
|
||||
log.ProcessedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (log.AttemptCount >= 3)
|
||||
_logger.LogError(
|
||||
"Delivery webhook dead-letter: platform {Platform} external {ExternalId} — {Error}",
|
||||
log.Platform,
|
||||
log.ExternalOrderId,
|
||||
error);
|
||||
}
|
||||
|
||||
private async Task CompleteLogAsync(
|
||||
WebhookLog log,
|
||||
string? meeziOrderId,
|
||||
bool success,
|
||||
string? error,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log.Success = success;
|
||||
log.Processed = true;
|
||||
log.MeeziOrderId = meeziOrderId;
|
||||
log.ErrorMessage = error;
|
||||
log.ProcessedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IDeliveryStatusSyncService
|
||||
{
|
||||
Task<bool> SyncInternalStatusAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
OrderStatus newStatus,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<bool> ApplyPlatformStatusAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string platformStatus,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryStatusSyncService : IDeliveryStatusSyncService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IKdsNotifier _kds;
|
||||
private readonly ISnappfoodClient _snappfood;
|
||||
private readonly ITap30Client _tap30;
|
||||
private readonly IInventoryService _inventory;
|
||||
private readonly ILogger<DeliveryStatusSyncService> _logger;
|
||||
|
||||
public DeliveryStatusSyncService(
|
||||
AppDbContext db,
|
||||
IKdsNotifier kds,
|
||||
ISnappfoodClient snappfood,
|
||||
ITap30Client tap30,
|
||||
IInventoryService inventory,
|
||||
ILogger<DeliveryStatusSyncService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_kds = kds;
|
||||
_snappfood = snappfood;
|
||||
_tap30 = tap30;
|
||||
_inventory = inventory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncInternalStatusAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
OrderStatus newStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var order = await _db.Orders
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, ct);
|
||||
|
||||
if (order?.DeliveryPlatform is null || string.IsNullOrEmpty(order.ExternalOrderId))
|
||||
return false;
|
||||
|
||||
var platformStatus = MapToPlatformStatus(newStatus);
|
||||
await NotifyPlatformAsync(order.DeliveryPlatform.Value, order.ExternalOrderId, platformStatus, ct);
|
||||
|
||||
if (newStatus == OrderStatus.Delivered && !string.IsNullOrEmpty(order.SnappfoodOrderId))
|
||||
await _snappfood.NotifyOrderDeliveredAsync(order.SnappfoodOrderId, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyPlatformStatusAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string platformStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var order = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.FirstOrDefaultAsync(
|
||||
o => o.DeliveryPlatform == platform && o.ExternalOrderId == externalOrderId,
|
||||
ct);
|
||||
|
||||
if (order is null)
|
||||
return false;
|
||||
|
||||
var mapped = MapFromPlatformStatus(platformStatus);
|
||||
if (order.Status == mapped)
|
||||
return true;
|
||||
|
||||
if (mapped == OrderStatus.Cancelled)
|
||||
await RollbackInventoryPlaceholderAsync(order, ct);
|
||||
|
||||
order.Status = mapped;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _kds.NotifyOrderStatusChangedAsync(order.CafeId, order.Id, mapped, ct);
|
||||
if (!string.IsNullOrEmpty(order.TableId))
|
||||
await _kds.NotifyTableStatusChangedAsync(order.CafeId, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task NotifyPlatformAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
switch (platform)
|
||||
{
|
||||
case DeliveryPlatform.Snappfood:
|
||||
await _snappfood.NotifyOrderStatusAsync(externalOrderId, status, ct);
|
||||
break;
|
||||
case DeliveryPlatform.Tap30:
|
||||
await _tap30.NotifyOrderStatusAsync(externalOrderId, status, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task RollbackInventoryPlaceholderAsync(Order order, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Delivery order {OrderId} cancelled — inventory rollback pending BOM linkage",
|
||||
order.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string MapToPlatformStatus(OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Pending => "pending",
|
||||
OrderStatus.Confirmed => "confirmed",
|
||||
OrderStatus.Preparing => "preparing",
|
||||
OrderStatus.Ready => "ready",
|
||||
OrderStatus.Delivered => "delivered",
|
||||
OrderStatus.Cancelled => "cancelled",
|
||||
_ => "confirmed"
|
||||
};
|
||||
|
||||
private static OrderStatus MapFromPlatformStatus(string platformStatus) =>
|
||||
platformStatus.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => OrderStatus.Pending,
|
||||
"confirmed" => OrderStatus.Confirmed,
|
||||
"preparing" or "in_progress" => OrderStatus.Preparing,
|
||||
"ready" => OrderStatus.Ready,
|
||||
"delivered" or "completed" => OrderStatus.Delivered,
|
||||
"cancelled" or "canceled" => OrderStatus.Cancelled,
|
||||
_ => OrderStatus.Confirmed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Hangfire;
|
||||
using Meezi.API.Jobs;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public record WebhookIngressResult(bool Accepted, string? WebhookLogId, string? ErrorCode, string? Message);
|
||||
|
||||
public interface IDeliveryWebhookIngressService
|
||||
{
|
||||
Task<WebhookIngressResult> ReceiveAsync(
|
||||
DeliveryPlatform platform,
|
||||
string rawBody,
|
||||
string? signatureHeader,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryWebhookIngressService : IDeliveryWebhookIngressService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IWebhookSignatureService _signatures;
|
||||
private readonly IOrderNormalizer _normalizer;
|
||||
|
||||
public DeliveryWebhookIngressService(
|
||||
AppDbContext db,
|
||||
IWebhookSignatureService signatures,
|
||||
IOrderNormalizer normalizer)
|
||||
{
|
||||
_db = db;
|
||||
_signatures = signatures;
|
||||
_normalizer = normalizer;
|
||||
}
|
||||
|
||||
public async Task<WebhookIngressResult> ReceiveAsync(
|
||||
DeliveryPlatform platform,
|
||||
string rawBody,
|
||||
string? signatureHeader,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var signatureValid = _signatures.Verify(platform, rawBody, signatureHeader);
|
||||
|
||||
var log = new WebhookLog
|
||||
{
|
||||
Id = $"wh_{Guid.NewGuid():N}"[..24],
|
||||
Platform = platform,
|
||||
RawBody = rawBody,
|
||||
SignatureHeader = signatureHeader,
|
||||
SignatureValid = signatureValid,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.WebhookLogs.Add(log);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (!signatureValid)
|
||||
return new WebhookIngressResult(false, log.Id, "UNAUTHORIZED", "Invalid signature.");
|
||||
|
||||
var unified = _normalizer.FromJson(platform, rawBody);
|
||||
if (unified is null)
|
||||
{
|
||||
log.Success = false;
|
||||
log.Processed = true;
|
||||
log.ErrorMessage = "Could not normalize payload.";
|
||||
log.ProcessedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new WebhookIngressResult(false, log.Id, "VALIDATION_ERROR", log.ErrorMessage);
|
||||
}
|
||||
|
||||
BackgroundJob.Enqueue<ProcessDeliveryOrderJob>(job =>
|
||||
job.ExecuteAsync(log.Id, unified, CancellationToken.None));
|
||||
|
||||
return new WebhookIngressResult(true, log.Id, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Snappfood;
|
||||
using Meezi.API.Models.Tap30;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IOrderNormalizer
|
||||
{
|
||||
UnifiedDeliveryOrder? FromSnappfood(SnappfoodWebhookOrder payload);
|
||||
UnifiedDeliveryOrder? FromTap30(Tap30WebhookOrder payload);
|
||||
UnifiedDeliveryOrder? FromJson(DeliveryPlatform platform, string rawJson);
|
||||
}
|
||||
|
||||
public class OrderNormalizer : IOrderNormalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public UnifiedDeliveryOrder? FromSnappfood(SnappfoodWebhookOrder payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.OrderId) || string.IsNullOrWhiteSpace(payload.VendorId))
|
||||
return null;
|
||||
|
||||
var items = payload.Items.Select(i => new UnifiedDeliveryItem(
|
||||
Sku: i.Name,
|
||||
Name: i.Name,
|
||||
Quantity: i.Quantity,
|
||||
UnitPrice: i.UnitPrice,
|
||||
Notes: "Snappfood")).ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
return new UnifiedDeliveryOrder(
|
||||
payload.OrderId.Trim(),
|
||||
DeliveryPlatform.Snappfood,
|
||||
payload.VendorId.Trim(),
|
||||
DateTime.UtcNow,
|
||||
new UnifiedDeliveryCustomer(
|
||||
payload.CustomerName ?? "Snappfood",
|
||||
payload.CustomerPhone ?? ""),
|
||||
items,
|
||||
new UnifiedDeliveryPayment(
|
||||
payload.Total,
|
||||
"online",
|
||||
true,
|
||||
null),
|
||||
new UnifiedDeliveryInfo("delivery"),
|
||||
UnifiedDeliveryStatus.Confirmed);
|
||||
}
|
||||
|
||||
public UnifiedDeliveryOrder? FromTap30(Tap30WebhookOrder payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.OrderId) || string.IsNullOrWhiteSpace(payload.VendorId))
|
||||
return null;
|
||||
|
||||
var items = (payload.Items ?? [])
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i.Name) && i.Quantity > 0)
|
||||
.Select(i => new UnifiedDeliveryItem(
|
||||
Sku: i.Sku ?? i.Name,
|
||||
Name: i.Name,
|
||||
Quantity: i.Quantity,
|
||||
UnitPrice: i.UnitPrice,
|
||||
i.Notes))
|
||||
.ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
var customer = payload.Customer ?? new Tap30Customer(null, null, null, null, null);
|
||||
var deliveryType = string.IsNullOrWhiteSpace(payload.DeliveryType)
|
||||
? "delivery"
|
||||
: payload.DeliveryType.Trim().ToLowerInvariant();
|
||||
|
||||
return new UnifiedDeliveryOrder(
|
||||
payload.OrderId.Trim(),
|
||||
DeliveryPlatform.Tap30,
|
||||
payload.VendorId.Trim(),
|
||||
DateTime.UtcNow,
|
||||
new UnifiedDeliveryCustomer(
|
||||
customer.Name ?? "Tap30",
|
||||
customer.Phone ?? "",
|
||||
customer.Address,
|
||||
customer.Lat,
|
||||
customer.Lng),
|
||||
items,
|
||||
new UnifiedDeliveryPayment(
|
||||
payload.Total,
|
||||
payload.PaymentMethod ?? "online",
|
||||
payload.IsPaid ?? true,
|
||||
payload.Commission),
|
||||
new UnifiedDeliveryInfo(
|
||||
deliveryType,
|
||||
payload.EstimatedMinutes,
|
||||
payload.DriverName,
|
||||
payload.DriverPhone),
|
||||
MapTap30Status(payload.Status));
|
||||
}
|
||||
|
||||
public UnifiedDeliveryOrder? FromJson(DeliveryPlatform platform, string rawJson)
|
||||
{
|
||||
return platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => FromSnappfood(
|
||||
JsonSerializer.Deserialize<SnappfoodWebhookOrder>(rawJson, JsonOptions)!),
|
||||
DeliveryPlatform.Tap30 => FromTap30(
|
||||
JsonSerializer.Deserialize<Tap30WebhookOrder>(rawJson, JsonOptions)!),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static UnifiedDeliveryStatus MapTap30Status(string? status) =>
|
||||
status?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => UnifiedDeliveryStatus.Pending,
|
||||
"confirmed" => UnifiedDeliveryStatus.Confirmed,
|
||||
"preparing" or "in_progress" => UnifiedDeliveryStatus.Preparing,
|
||||
"ready" => UnifiedDeliveryStatus.Ready,
|
||||
"delivered" or "completed" => UnifiedDeliveryStatus.Delivered,
|
||||
"cancelled" or "canceled" => UnifiedDeliveryStatus.Cancelled,
|
||||
_ => UnifiedDeliveryStatus.Confirmed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.Core.Enums;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IWebhookSignatureService
|
||||
{
|
||||
bool Verify(DeliveryPlatform platform, string rawBody, string? signatureHeader);
|
||||
}
|
||||
|
||||
public class WebhookSignatureService : IWebhookSignatureService
|
||||
{
|
||||
private readonly DeliveryPlatformsOptions _options;
|
||||
|
||||
public WebhookSignatureService(IOptions<DeliveryPlatformsOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public bool Verify(DeliveryPlatform platform, string rawBody, string? signatureHeader)
|
||||
{
|
||||
var secret = platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => _options.Snappfood.WebhookSecret,
|
||||
DeliveryPlatform.Tap30 => _options.Tap30.WebhookSecret,
|
||||
DeliveryPlatform.Digikala => _options.Digikala.WebhookSecret,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
return false;
|
||||
|
||||
var provided = signatureHeader.Trim();
|
||||
if (provided.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
|
||||
provided = provided["sha256=".Length..];
|
||||
|
||||
var expected = ComputeHmacSha256Hex(rawBody, secret);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(provided));
|
||||
}
|
||||
|
||||
public static string ComputeHmacSha256Hex(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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user