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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -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();
}
}