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,120 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record PosDeviceResult(bool Success, bool Skipped, string? ErrorCode, string? Detail = null)
|
||||
{
|
||||
public static PosDeviceResult Ok() => new(true, false, null);
|
||||
public static PosDeviceResult SkippedNotConfigured() => new(true, true, null);
|
||||
public static PosDeviceResult Fail(string code, string? detail = null) => new(false, false, code, detail);
|
||||
}
|
||||
|
||||
public interface IPosDeviceService
|
||||
{
|
||||
Task<PosDeviceResult> SendPaymentRequestAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
PosPaymentRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class PosDeviceService : IPosDeviceService
|
||||
{
|
||||
private const int DefaultPort = 8088;
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(90);
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<PosDeviceService> _logger;
|
||||
|
||||
public PosDeviceService(
|
||||
AppDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<PosDeviceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PosDeviceResult> SendPaymentRequestAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
PosPaymentRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (request.Amount <= 0)
|
||||
return PosDeviceResult.Fail("INVALID_AMOUNT");
|
||||
|
||||
var branch = await _db.Branches
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
|
||||
if (branch is null)
|
||||
return PosDeviceResult.Fail("BRANCH_NOT_FOUND");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(branch.PosDeviceIp))
|
||||
return PosDeviceResult.SkippedNotConfigured();
|
||||
|
||||
var port = branch.PosDevicePort is > 0 and <= 65535
|
||||
? branch.PosDevicePort.Value
|
||||
: DefaultPort;
|
||||
|
||||
var order = await _db.Orders
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId && o.CafeId == cafeId, ct);
|
||||
|
||||
if (order is null)
|
||||
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
|
||||
orderId = request.OrderId,
|
||||
branchId,
|
||||
};
|
||||
|
||||
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(nameof(PosDeviceService));
|
||||
client.Timeout = RequestTimeout;
|
||||
|
||||
using var response = await client.PostAsJsonAsync(url, payload, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogWarning(
|
||||
"POS device returned {Status} for {Url}: {Body}",
|
||||
(int)response.StatusCode,
|
||||
url,
|
||||
body.Length > 200 ? body[..200] : body);
|
||||
return PosDeviceResult.Fail(
|
||||
"POS_DEVICE_REJECTED",
|
||||
$"HTTP {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
return PosDeviceResult.Ok();
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return PosDeviceResult.Fail("POS_DEVICE_TIMEOUT");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "POS device connection failed for {Url}", url);
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "POS device response invalid for {Url}", url);
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user