Files
meezi/src/Meezi.API/Services/PosDeviceService.cs
T
soroush.asadi ef15fd6247 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>
2026-05-27 21:33:48 +03:30

121 lines
4.0 KiB
C#

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);
}
}
}