10 Commits

Author SHA1 Message Date
soroush.asadi 4cc1c3a423 feat(payment): FlatRender Pay (ZarinPal broker) checkout + webhook
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m3s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 53s
CI/CD / Deploy · all services (push) Successful in 1m41s
Adds a signed broker integration for online plan purchases:
- FlatPayService: POST /v1/pay/request with X-Api-Key + X-Signature =
  hex(HMAC-SHA256(secret, raw JSON bytes)); the exact serialized bytes are both
  signed and sent. VerifyWebhook does a fixed-time compare of the digest, plus an
  in-memory first-seen idempotency set.
- POST /api/payment/request (auth, ManageBilling): parses a "Tier:Months" product
  (e.g. "Pro:12"), prices it via the plan catalog, creates a Pending SubscriptionPayment
  (provider=FlatPay) as the order, and returns the broker payment URL. The order id is
  the client_ref / metadata.payment_id.
- POST /api/payment/webhook (anonymous; HMAC is the auth — 401 on bad signature):
  on status=Paid + first-seen id, grants the order via the shared plan-activation
  path (extracted ActivatePaymentAsync, reused by all providers). Always 200 after a
  valid signature so the broker won't retry an accepted job.
- Config FlatPay__{ApiKey,Secret,BaseUrl,ReturnUrl} (env-supplied; secrets stay out
  of git), compose + .env.example wiring. PaymentProvider.FlatPay appended (int, no
  migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 04:20:02 +03:30
soroush.asadi b0896dc777 feat(pos): bridge the card terminal through the print agent + LAN auto-detect
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m29s
The card-terminal integration only ever worked when the API could reach the
terminal's IP directly — impossible for the cloud deployment, where the terminal
sits on the café LAN (the same wall the Print Agent already climbs for printers).
And the terminal IP had to be typed by hand. Both fixed by reusing the agent.

Cloud→LAN relay:
- PrintAgentRegistry.SendPaymentAsync sends a PaymentRequest to the café's online
  agent and awaits its ack (PaymentResult on the hub); 95s window for the customer.
- PosDeviceService now prefers an online agent (branch-matched, else any café
  agent) to relay POST /pay over the LAN, and falls back to the direct HTTP call
  only when no agent is connected (on-prem). Agent errors map back to POS_DEVICE_*.
- Agent (Program.cs + PosTerminal.cs) handles PaymentRequest → POSTs the amount to
  the terminal's local http://ip:port/pay and reports approval/decline/timeout.

Auto-detect:
- Registry.ScanAsync + hub ReportScan; POST /print-agents/scan asks online agents
  to scan their /24 for given ports and merges the hosts found.
- Agent NetworkScanner scans the LAN (:9100 printers, :8088 terminals) with a
  short per-host TCP probe.
- Dashboard: a "تشخیص خودکار" (auto-detect) button on the POS-device, receipt and
  kitchen IP fields scans via the agent and fills the IP:port from a found host.

Backend + agent build clean; dashboard tsc clean. NOTE: the agent app is not in
CI — it must be rebuilt and redeployed on the café PC to gain these handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:01:21 +03:30
soroush.asadi f368765419 fix(pos): charge the server amount, and don't book unconfirmed card payments
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m49s
Two go-live money-correctness bugs in the POS pay flow (deferred TODO #1/#2):

#2 — pay against the server's amount, not a client recompute. The pay sheet
took `orderAmountDue(payTarget) || total`, so any time the server figure was
absent/zero it silently fell back to the POS's own 9% tax recompute. The backend
records whatever amount the client posts (it only uses its own order.Total to
decide closure), so a client/server mismatch books the wrong cash-drawer amount.
Now a real (server) order always charges orderAmountDue(serverOrder); only a
genuinely-local offline order — which has no server figure — uses the client
total.

#1 — don't record a card payment that wasn't confirmed. A connected terminal
that declines already throws POS_DEVICE_* and records nothing. But when no
terminal is wired up the request is "skipped" and the card was booked as paid
with zero proof it cleared. Now, when the card leg isn't machine-confirmed, the
cashier must confirm "card approved on the terminal?" before it's recorded;
cancel records nothing.

Also raise the shared AlertDialog to z-[80] so a confirmation renders above the
POS pay sheet (z-[60]) and its busy overlay (z-[70]); still below toasts.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:13:21 +03:30
soroush.asadi 197f6f2d38 feat(print): dashboard UI for print servers + auto-discovered printer pickers
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m44s
Phase 4 (final). Settings → Printers now has a "Print servers" section: add a
print server (issues a one-time pairing code with steps), see each agent's online
status, its auto-discovered printers, test any of them, and revoke. Receipt,
kitchen and per-station printers can now be picked from a dropdown of discovered
devices instead of typing an IP (manual IP stays as fallback). Wires the device
mappings through the branch print-settings + kitchen-station DTOs/services and adds
the device-test endpoint. fa/en/ar strings added.

Completes the cloud↔LAN print-agent feature (entities/hub → routing → agent → UI).
Remaining polish: agent system-tray + run-at-login + installer, optional LAN scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:28:27 +03:30
soroush.asadi 7d5af0c81b feat(print): Windows print agent — the cloud↔LAN bridge (Phase 3)
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 29s
A standalone net10.0-windows console app (agent/Meezi.PrintAgent) installed on the
café cash PC. It pairs with a one-time code (POST /print-agent/claim), stores the
token in %APPDATA%, holds a SignalR connection to /hubs/print-agent (retries
forever, re-reports on reconnect), discovers installed printers via WMI (USB +
network, classified), and prints jobs it receives by writing raw ESC/POS bytes —
winspool RAW passthrough for installed printers, raw TCP for ip:port devices —
acking each job back. Not in the API solution or CI (own net10.0-windows build);
see agent/README.md for build/publish/pair. Builds clean; startup + pairing flow
smoke-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:16:28 +03:30
soroush.asadi 9e47a4e60c feat(print): route print jobs through a local agent, fall back to TCP
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m40s
Phase 2. NetworkPrinterService now builds the ESC/POS bytes (as before) and
dispatches them via a new router: if the receipt/kitchen/station printer is mapped
to a PrintDevice whose agent is online, the bytes are sent to that agent over the
hub and we await its ack; otherwise it falls back to a direct TCP connection (raw
IP), so existing on-prem/reachable printers keep working unchanged. Adds nullable
mapping columns Branch.ReceiptPrintDeviceId / KitchenPrintDeviceId and
KitchenStation.PrintDeviceId (additive migration), plus TestPrintDeviceAsync for
testing an agent printer. The cloud can now reach LAN/USB printers it never could.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:07:16 +03:30
soroush.asadi cb57c61a11 feat(print): cloud↔local print-agent foundation (hub, pairing, registry)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
First phase of auto-discovered printing for cloud-hosted cafés whose printers are
on the local network (the cloud can't reach a LAN/USB printer directly). Adds:
- PrintAgent + PrintDevice entities (+ additive migration) — a per-café local
  bridge and the printers it reports.
- PrintAgentHub (/hubs/print-agent): agents connect outbound, authenticated by a
  token in access_token (not the user JWT); ReportPrinters upserts devices,
  PrintJob is pushed to the agent, JobResult/Heartbeat come back.
- PrintAgentRegistry (singleton): tracks connected agents and dispatches a job to
  one, awaiting its ack with a timeout.
- Pairing: POST /cafes/{id}/print-agents/pairing-code (ManagePrintSettings) issues
  a short one-time code; anonymous POST /print-agent/claim redeems it for a
  long-lived token (only its SHA-256 hash is stored). List + revoke endpoints,
  online status from the registry.

Inert until Phase 2 routes jobs through it and the agent app (Phase 3) connects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:02:25 +03:30
soroush.asadi 67450393fc fix(pos): cashier can't delete/reduce an item already sent to the kitchen
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (push) Successful in 41s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m54s
On the POS, once a line is fired to the kitchen its sent quantity is the locked
portion: a user without the VoidOrder permission (the default cashier) can no
longer remove that line or decrease it below what was sent — otherwise they could
send food and then erase it from the order (charge less / pocket cash). The unsent
portion of a line stays freely editable, and adding more is always allowed. The
delete button is replaced by a lock icon on sent lines, and the minus button is
disabled at the sent floor. Gated by VoidOrder, so owners/managers with the
permission are unaffected. Mirrors the server-side order-cancel lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:40:13 +03:30
soroush.asadi ae5c750d34 fix(notifications): don't lose live alerts until a page refresh
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m50s
The SignalR connection used the default auto-reconnect, which gives up after
~30s and, even when it did reconnect, never re-ran JoinCafe — so the client
dropped out of the café group and silently stopped receiving notifications until
a manual refresh. Now it retries forever (capped backoff), re-joins the group on
reconnect (and catches up via invalidate), and re-establishes the connection when
the network returns or the tab is refocused. As a safety net, the unread/bell and
tab-badge polls now run in background tabs too (refetchIntervalInBackground).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:28:47 +03:30
soroush.asadi f985deb233 fix(offline): stop the sync queue badge getting stuck above zero
Two bugs made "N در صف" persist even when online:
- The badge counted poisoned ops (failed after 5 retries, never removed), so it
  never returned to 0. Now the badge counts only retryable (active) ops; poisoned
  ops are tracked separately as failedCount and surfaced as a red "N failed —
  clear" chip the user can tap to discard them.
- The manual-retry click drained the LEGACY order_queue, not the real outbox the
  app actually uses — so clicking did nothing for stuck items. It now drains the
  outbox (drainOutbox), invalidates queries on success, and recounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:28:47 +03:30
55 changed files with 10106 additions and 108 deletions
+8
View File
@@ -83,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
ZARINPAL_MERCHANT_ID=
ZARINPAL_SANDBOX=false
# ── Payment: FlatRender Pay (ZarinPal broker) ─────────────────────────────────
# Broker keys from the FlatRender dashboard. Webhook is registered at the broker as
# https://api.meezi.ir/api/payment/webhook. Keep the live secret OUT of git.
FLATPAY_API_KEY=
FLATPAY_SECRET=
FLATPAY_BASE_URL=https://pay.flatrender.ir
FLATPAY_RETURN_URL=https://meezi.ir/payment/return
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
# Empty = OTP is logged to API console (fine for dev, not for production)
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace Meezi.PrintAgent;
/// <summary>Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json.</summary>
public class AgentConfig
{
/// <summary>Origin of the Meezi API, e.g. https://app.meezi.ir.</summary>
public string? ApiBaseUrl { get; set; }
public string? Token { get; set; }
public string? CafeId { get; set; }
public string? AgentId { get; set; }
public string? Name { get; set; }
private static string Dir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeeziPrintAgent");
private static string FilePath => Path.Combine(Dir, "config.json");
public bool IsPaired => !string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(ApiBaseUrl);
public static AgentConfig Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AgentConfig>(File.ReadAllText(FilePath)) ?? new AgentConfig();
}
catch
{
// corrupt/unreadable config → start fresh
}
return new AgentConfig();
}
public void Save()
{
Directory.CreateDirectory(Dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Windows-only: uses winspool (raw printing) + WMI (printer discovery).
Overrides the repo-wide net10.0 / central package management on purpose so
this app stays independent of the API build (CI never compiles it). -->
<TargetFramework>net10.0-windows</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Meezi.PrintAgent</RootNamespace>
<AssemblyName>MeeziPrintAgent</AssemblyName>
<Version>0.1.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
<PackageReference Include="System.Management" Version="10.0.0" />
</ItemGroup>
</Project>
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace Meezi.PrintAgent;
/// <summary>A host on the café LAN answering on a probed port. Property names match
/// the cloud's <c>DiscoveredDevice</c> record so SignalR maps them across.</summary>
public record ScannedDevice(string Ip, int Port, string Kind);
/// <summary>
/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP
/// ports — used to auto-find network printers (:9100) and card terminals (:8088)
/// so the café owner doesn't have to type IP addresses.
/// </summary>
public static class NetworkScanner
{
private const int MaxConcurrency = 128;
private const int ConnectTimeoutMs = 300;
public static async Task<List<ScannedDevice>> ScanAsync(string portsCsv, CancellationToken ct)
{
var ports = portsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(p => int.TryParse(p, out var n) ? n : 0)
.Where(n => n is > 0 and <= 65535)
.Distinct()
.ToList();
if (ports.Count == 0) ports = [9100, 8088];
var results = new ConcurrentBag<ScannedDevice>();
using var gate = new SemaphoreSlim(MaxConcurrency);
var tasks = new List<Task>();
foreach (var prefix in LocalSubnets())
{
for (var host = 1; host <= 254; host++)
{
var ip = $"{prefix}.{host}";
foreach (var port in ports)
{
await gate.WaitAsync(ct);
tasks.Add(Task.Run(async () =>
{
try
{
if (await CanConnectAsync(ip, port))
results.Add(new ScannedDevice(ip, port, Classify(port)));
}
finally { gate.Release(); }
}, ct));
}
}
}
await Task.WhenAll(tasks);
return results
.DistinctBy(d => $"{d.Ip}:{d.Port}")
.OrderBy(d => d.Ip)
.ThenBy(d => d.Port)
.ToList();
}
/// <summary>Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces.</summary>
private static IEnumerable<string> LocalSubnets()
{
var seen = new HashSet<string>();
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
if (IPAddress.IsLoopback(ua.Address)) continue;
var b = ua.Address.GetAddressBytes();
var prefix = $"{b[0]}.{b[1]}.{b[2]}";
if (seen.Add(prefix)) yield return prefix;
}
}
}
private static async Task<bool> CanConnectAsync(string ip, int port)
{
try
{
using var client = new TcpClient();
var connect = client.ConnectAsync(ip, port);
var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs));
if (done != connect) return false;
await connect; // observe exceptions
return client.Connected;
}
catch
{
return false;
}
}
private static string Classify(int port) => port switch
{
9100 => "network-printer",
8088 => "pos-terminal",
_ => "other",
};
}
+43
View File
@@ -0,0 +1,43 @@
using System.Net.Http.Json;
namespace Meezi.PrintAgent;
/// <summary>Redeems a one-time pairing code for a long-lived agent token.</summary>
public static class Pairing
{
private record ClaimReq(string code, string? name, string? machineName);
private record ApiEnvelope<T>(bool success, T? data);
private record ClaimData(string agentId, string token, string cafeId, string agentName);
public static async Task<AgentConfig?> ClaimAsync(string apiBaseUrl, string code, string name)
{
using var http = new HttpClient { BaseAddress = new Uri(apiBaseUrl), Timeout = TimeSpan.FromSeconds(20) };
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync("/api/print-agent/claim",
new ClaimReq(code, name, Environment.MachineName));
}
catch (Exception ex)
{
Console.WriteLine($" network error: {ex.Message}");
return null;
}
if (!resp.IsSuccessStatusCode)
return null;
var env = await resp.Content.ReadFromJsonAsync<ApiEnvelope<ClaimData>>();
if (env?.success != true || env.data is null)
return null;
return new AgentConfig
{
ApiBaseUrl = apiBaseUrl.TrimEnd('/'),
Token = env.data.token,
CafeId = env.data.cafeId,
AgentId = env.data.agentId,
Name = env.data.agentName,
};
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Net.Http.Json;
namespace Meezi.PrintAgent;
/// <summary>
/// Relays a card-terminal payment on the café LAN. The cloud can't reach the
/// terminal's private IP, so it hands the agent the amount and the terminal's
/// ip:port; the agent POSTs to the terminal's local HTTP <c>/pay</c> endpoint and
/// reports back whether it was approved.
/// </summary>
public static class PosTerminal
{
// A card payment blocks on the customer inserting/approving — allow plenty.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) };
public static async Task<(bool Ok, string? Error)> SendPaymentAsync(
string ip, int port, long amount, string orderId, CancellationToken ct)
{
var url = $"http://{ip}:{port}/pay";
try
{
using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct);
if (resp.IsSuccessStatusCode) return (true, null);
return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}");
}
catch (TaskCanceledException)
{
return (false, "POS_DEVICE_TIMEOUT");
}
catch (Exception ex)
{
return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}");
}
}
}
@@ -0,0 +1,45 @@
using System.Management;
using System.Runtime.Versioning;
namespace Meezi.PrintAgent;
/// <summary>One printer the agent can reach. SystemName is what it prints to (the
/// Windows printer name, or "ip:port" for a raw network device).</summary>
public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind);
[SupportedOSPlatform("windows")]
public static class PrinterDiscovery
{
/// <summary>Every printer installed on this PC (USB and network-with-driver alike).</summary>
public static List<DiscoveredPrinter> Discover()
{
var list = new List<DiscoveredPrinter>();
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, PortName, Network FROM Win32_Printer");
foreach (var o in searcher.Get())
{
using var p = (ManagementObject)o;
var name = p["Name"]?.ToString();
if (string.IsNullOrWhiteSpace(name)) continue;
var port = p["PortName"]?.ToString() ?? "";
var network = p["Network"] as bool? ?? false;
list.Add(new DiscoveredPrinter(name!, name!, ClassifyKind(port, network)));
}
}
catch
{
// WMI unavailable — report nothing rather than crash.
}
return list;
}
private static string ClassifyKind(string port, bool network)
{
var up = port.ToUpperInvariant();
if (up.StartsWith("USB") || up.StartsWith("DOT4")) return "usb";
if (network || up.StartsWith("IP_") || up.StartsWith("WSD") || up.Contains(':')) return "network";
return "other";
}
}
+164
View File
@@ -0,0 +1,164 @@
using Meezi.PrintAgent;
using Microsoft.AspNetCore.SignalR.Client;
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine("=== Meezi Print Agent (پرینت‌سرور میزی) ===");
var config = AgentConfig.Load();
var wantsPair = args.Length > 0 && args[0].Equals("pair", StringComparison.OrdinalIgnoreCase);
if (!config.IsPaired || wantsPair)
{
var paired = await PairInteractiveAsync(config);
if (paired is null)
{
Console.WriteLine("Pairing cancelled or failed.");
return 1;
}
paired.Save();
config = paired;
Console.WriteLine($"✓ Paired as '{config.Name}'. Configuration saved.");
}
await RunAsync(config);
return 0;
static async Task<AgentConfig?> PairInteractiveAsync(AgentConfig existing)
{
var defaultUrl = existing.ApiBaseUrl ?? "https://app.meezi.ir";
Console.Write($"Meezi API URL [{defaultUrl}]: ");
var url = Console.ReadLine();
if (string.IsNullOrWhiteSpace(url)) url = defaultUrl;
Console.Write("Pairing code (from Dashboard → Settings → Printers): ");
var code = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(code)) return null;
Console.Write($"Name for this PC [{Environment.MachineName}]: ");
var name = Console.ReadLine();
if (string.IsNullOrWhiteSpace(name)) name = Environment.MachineName;
Console.WriteLine("Pairing…");
var cfg = await Pairing.ClaimAsync(url!, code!, name!);
if (cfg is null) Console.WriteLine(" Invalid/expired code, or the URL is wrong.");
return cfg;
}
static async Task RunAsync(AgentConfig config)
{
var hubUrl = $"{config.ApiBaseUrl!.TrimEnd('/')}/hubs/print-agent" +
$"?access_token={Uri.EscapeDataString(config.Token!)}";
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect(new ForeverRetry())
.Build();
connection.On<string, string, string>("PrintJob", async (jobId, printerSystemName, base64) =>
{
var ok = false;
string? err = null;
try
{
var data = Convert.FromBase64String(base64);
await RawPrinter.PrintAsync(printerSystemName, data, CancellationToken.None);
ok = true;
Console.WriteLine($"[print] {data.Length} bytes → {printerSystemName} ✓");
}
catch (Exception ex)
{
err = ex.Message;
Console.WriteLine($"[print] {printerSystemName} ✗ {ex.Message}");
}
try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
});
// Cloud → agent: relay a card-terminal payment to the terminal on the LAN.
connection.On<string, string, int, long, string>("PaymentRequest", async (requestId, ip, port, amount, orderId) =>
{
var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None);
Console.WriteLine(ok
? $"[pay] {amount} → {ip}:{port} ✓"
: $"[pay] {ip}:{port} ✗ {err}");
try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ }
});
// Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088).
connection.On<string, string>("ScanNetwork", async (requestId, ports) =>
{
List<ScannedDevice> found;
try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); }
catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; }
Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " +
string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}")));
try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ }
});
connection.Reconnected += async _ =>
{
Console.WriteLine("[hub] reconnected");
await SafeReportAsync(connection);
};
connection.Closed += _ =>
{
Console.WriteLine("[hub] connection closed");
return Task.CompletedTask;
};
await ConnectWithRetryAsync(connection);
Console.WriteLine("[hub] connected");
await SafeReportAsync(connection);
// Heartbeat + re-report every 2 minutes (printers added/removed get picked up).
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
while (await timer.WaitForNextTickAsync())
{
try
{
await connection.InvokeAsync("Heartbeat");
await SafeReportAsync(connection);
}
catch { /* will recover on reconnect */ }
}
});
Console.WriteLine("Agent running. Leave this window open. Press Ctrl+C to quit.");
await Task.Delay(Timeout.Infinite);
}
static async Task SafeReportAsync(HubConnection connection)
{
try
{
var printers = PrinterDiscovery.Discover();
await connection.InvokeAsync("ReportPrinters", printers);
Console.WriteLine($"[printers] reported {printers.Count}: " +
string.Join(", ", printers.Select(p => $"{p.DisplayName} ({p.Kind})")));
}
catch (Exception ex)
{
Console.WriteLine($"[printers] report failed: {ex.Message}");
}
}
static async Task ConnectWithRetryAsync(HubConnection connection)
{
while (true)
{
try { await connection.StartAsync(); return; }
catch (Exception ex)
{
Console.WriteLine($"[hub] connect failed: {ex.Message}; retrying in 5s");
await Task.Delay(5000);
}
}
}
/// <summary>Retry reconnecting forever with capped exponential backoff.</summary>
sealed class ForeverRetry : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext ctx) =>
TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5))));
}
+95
View File
@@ -0,0 +1,95 @@
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Meezi.PrintAgent;
/// <summary>Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW
/// passthrough) or to an "ip:port" endpoint (raw TCP).</summary>
[SupportedOSPlatform("windows")]
public static class RawPrinter
{
public static async Task PrintAsync(string systemName, byte[] data, CancellationToken ct)
{
if (TryParseEndpoint(systemName, out var ip, out var port))
{
await PrintTcpAsync(ip, port, data, ct);
return;
}
if (!SendBytesToPrinter(systemName, data))
throw new Exception($"winspool write failed (last error {Marshal.GetLastWin32Error()})");
}
private static bool TryParseEndpoint(string s, out string ip, out int port)
{
ip = "";
port = 9100;
var idx = s.LastIndexOf(':');
if (idx <= 0) return false;
var host = s[..idx];
if (!host.Contains('.')) return false; // not an IPv4-ish host → treat as printer name
if (int.TryParse(s[(idx + 1)..], out var p)) port = p;
ip = host;
return true;
}
private static async Task PrintTcpAsync(string ip, int port, byte[] data, CancellationToken ct)
{
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(8));
await client.ConnectAsync(ip, port, cts.Token);
await using var stream = client.GetStream();
await stream.WriteAsync(data, cts.Token);
await stream.FlushAsync(cts.Token);
}
// ── winspool raw printing ────────────────────────────────────────────────
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DOCINFOW
{
[MarshalAs(UnmanagedType.LPWStr)] public string pDocName;
[MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
[MarshalAs(UnmanagedType.LPWStr)] public string pDataType;
}
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool ClosePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool StartDocPrinter(IntPtr hPrinter, int level, ref DOCINFOW di);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool EndDocPrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool StartPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool EndPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
private static bool SendBytesToPrinter(string printerName, byte[] bytes)
{
if (!OpenPrinter(printerName, out var hPrinter, IntPtr.Zero)) return false;
try
{
var di = new DOCINFOW { pDocName = "Meezi Receipt", pDataType = "RAW" };
if (!StartDocPrinter(hPrinter, 1, ref di)) return false;
try
{
if (!StartPagePrinter(hPrinter)) return false;
var ptr = Marshal.AllocHGlobal(bytes.Length);
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
if (!WritePrinter(hPrinter, ptr, bytes.Length, out _)) return false;
}
finally { Marshal.FreeHGlobal(ptr); }
EndPagePrinter(hPrinter);
}
finally { EndDocPrinter(hPrinter); }
}
finally { ClosePrinter(hPrinter); }
return true;
}
}
+43
View File
@@ -0,0 +1,43 @@
# Meezi Print Agent (پرینت‌سرور میزی)
A tiny Windows background app that lets the **cloud-hosted** Meezi reach printers on
the café's **local network** (USB or Wi-Fi/Ethernet). The cloud can't open a
connection to a `192.168.x.x` or USB printer directly — this agent runs on the cash
PC (which *is* on that network), connects **outward** to Meezi over SignalR, reports
the printers it can see, and prints the jobs the cloud sends it.
```
Cloud API ──SignalR(out)──► Print Agent (cash PC) ──► USB / LAN printers
```
## How it works
1. In the dashboard: **Settings → Printers → Add print server** → you get a pairing code.
2. Run the agent on the cash PC, enter the code once. It saves a token to
`%APPDATA%\MeeziPrintAgent\config.json` and connects.
3. It reports every printer installed on that PC. Back in the dashboard you map
*receipt / kitchen / bar* to a printer from the dropdown — no IP typing.
4. When Meezi prints, the bytes (ESC/POS) are relayed to the agent, which writes them
raw to the chosen printer (`winspool` for installed printers, raw TCP for
`ip:port` devices).
## Build & run (dev)
Requires the .NET 10 SDK on Windows.
```sh
# restore via the Nexus mirror (nuget.org is blocked on this network)
dotnet restore agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj -s https://mirror.soroushasadi.com/repository/nuget-group/
dotnet run --project agent/Meezi.PrintAgent # first run prompts to pair
dotnet run --project agent/Meezi.PrintAgent -- pair # re-pair later
```
## Publish a single .exe for cafés
```sh
dotnet publish agent/Meezi.PrintAgent -c Release -r win-x64 \
-p:PublishSingleFile=true --self-contained true -o dist/agent
# → dist/agent/MeeziPrintAgent.exe
```
## Notes / roadmap
- **Not part of the API solution or CI** — it targets `net10.0-windows` and builds on its own.
- Console MVP today. Next: system-tray UI, run-at-login (Task Scheduler / service), auto-update, and an optional LAN scan for raw `ip:9100` printers that aren't installed in Windows.
- The token is bearer-equivalent — keep `config.json` on a trusted machine. Revoke from the dashboard if a PC is lost.
+4
View File
@@ -94,6 +94,10 @@ services:
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
FlatPay__ApiKey: "${FLATPAY_API_KEY:-}"
FlatPay__Secret: "${FLATPAY_SECRET:-}"
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
FlatPay__ReturnUrl: "${FLATPAY_RETURN_URL:-https://meezi.ir/payment/return}"
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
: request.PosDeviceIp.Trim();
if (request.PosDevicePort.HasValue)
branch.PosDevicePort = request.PosDevicePort.Value;
if (request.ReceiptPrintDeviceId is not null)
branch.ReceiptPrintDeviceId = string.IsNullOrWhiteSpace(request.ReceiptPrintDeviceId)
? null
: request.ReceiptPrintDeviceId;
if (request.KitchenPrintDeviceId is not null)
branch.KitchenPrintDeviceId = string.IsNullOrWhiteSpace(request.KitchenPrintDeviceId)
? null
: request.KitchenPrintDeviceId;
branch.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
b.ReceiptFooter,
b.WifiPassword,
b.PosDeviceIp,
b.PosDevicePort);
b.PosDevicePort,
b.ReceiptPrintDeviceId,
b.KitchenPrintDeviceId);
}
@@ -0,0 +1,129 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Payments;
using Meezi.API.Services;
using Meezi.API.Services.Payments;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>FlatRender Pay (ZarinPal broker) checkout + webhook.</summary>
[ApiController]
public class PaymentController : CafeApiControllerBase
{
private readonly IBillingService _billing;
private readonly IFlatPayService _flatPay;
private readonly ILogger<PaymentController> _logger;
public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger<PaymentController> logger)
{
_billing = billing;
_flatPay = flatPay;
_logger = logger;
}
/// <summary>Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to.</summary>
[Authorize]
[HttpPost("api/payment/request")]
public async Task<IActionResult> CreatePayment(
[FromBody] PaymentRequestDto request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
if (string.IsNullOrEmpty(tenant.CafeId)) return Unauthorized();
if (request?.ProductId is null || !TryParseProduct(request.ProductId, out var tier, out var months))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_PRODUCT", "productId must be a \"Tier:Months\" bundle, e.g. \"Pro:12\".")));
var (paymentId, amountToman, code, message) =
await _billing.CreateFlatPayOrderAsync(tenant.CafeId, tier, months, ct);
if (paymentId is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
var description = $"میزی — اشتراک {tier} ({months} ماه)";
var url = await _flatPay.RequestAsync(
tenant.CafeId, request.ProductId, (long)amountToman, description, paymentId, ct);
if (string.IsNullOrEmpty(url))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("PAYMENT_FAILED", "Could not start the payment.")));
return Ok(new ApiResponse<PaymentRequestResponse>(true, new PaymentRequestResponse(url, paymentId)));
}
/// <summary>Broker → us. Security is the HMAC signature (no user auth). Always 200 after a valid
/// signature so the broker doesn't retry a job we've accepted.</summary>
[AllowAnonymous]
[HttpPost("api/payment/webhook")]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms, ct);
var raw = ms.ToArray();
var signature = Request.Headers["X-FlatPay-Signature"].ToString();
if (!_flatPay.VerifyWebhook(raw, signature))
return Unauthorized();
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
var status = GetString(root, "status");
var brokerId = GetString(root, "id") ?? GetString(root, "payment_id");
if (string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(brokerId)
&& _flatPay.TryMarkProcessed(brokerId))
{
var meta = root.TryGetProperty("metadata", out var m) && m.ValueKind == JsonValueKind.Object
? m
: default;
var paymentId = GetString(meta, "payment_id");
if (!string.IsNullOrEmpty(paymentId))
await _billing.CompleteFlatPayAsync(paymentId, brokerId, ct);
else
_logger.LogWarning("FlatPay webhook Paid but missing metadata.payment_id (broker id {Id})", brokerId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "FlatPay webhook processing error");
}
return Ok();
}
/// <summary>Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12).</summary>
private static bool TryParseProduct(string productId, out PlanTier tier, out int months)
{
tier = default;
months = 0;
var parts = productId.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) return false;
return Enum.TryParse(parts[0], ignoreCase: true, out tier)
&& tier != PlanTier.Free
&& int.TryParse(parts[1], out months)
&& months > 0;
}
private static string? GetString(JsonElement el, string name)
{
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var v))
return null;
return v.ValueKind switch
{
JsonValueKind.String => v.GetString(),
JsonValueKind.Number => v.ToString(),
_ => null,
};
}
}
@@ -0,0 +1,63 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Hubs;
using Meezi.API.Models.Printing;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Anonymous endpoint the print-agent installer calls to redeem a pairing code for
/// a long-lived token. The token is returned exactly once; only its hash is stored.
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/print-agent")]
public class PrintAgentPairingController : ControllerBase
{
private readonly AppDbContext _db;
public PrintAgentPairingController(AppDbContext db) => _db = db;
[HttpPost("claim")]
public async Task<IActionResult> Claim([FromBody] ClaimAgentRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("CODE_REQUIRED", "Pairing code is required.")));
var now = DateTime.UtcNow;
var agent = await _db.PrintAgents
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a =>
a.PairingCode == request.Code &&
a.TokenHash == null &&
!a.Revoked &&
a.DeletedAt == null &&
a.PairingCodeExpiresAt > now, ct);
if (agent is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_OR_EXPIRED_CODE", "This pairing code is invalid or has expired.")));
var token = NewToken();
agent.TokenHash = PrintAgentHub.HashToken(token);
agent.PairingCode = null;
agent.PairingCodeExpiresAt = null;
if (!string.IsNullOrWhiteSpace(request.Name)) agent.Name = request.Name!.Trim();
else if (!string.IsNullOrWhiteSpace(request.MachineName)) agent.Name = request.MachineName!.Trim();
agent.LastSeenAt = now;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<ClaimAgentResponse>(
true, new ClaimAgentResponse(agent.Id, token, agent.CafeId, agent.Name)));
}
private static string NewToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
}
@@ -0,0 +1,164 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Hubs;
using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>Manage the local print agents paired to a café (cloud → LAN bridge).</summary>
[Route("api/cafes/{cafeId}/print-agents")]
public class PrintAgentsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IPrintAgentRegistry _registry;
private readonly IPrinterService _printer;
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer)
{
_db = db;
_registry = registry;
_printer = printer;
}
[HttpGet]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewPrintSettings) is { } permDenied) return permDenied;
var agents = await _db.PrintAgents
.Where(a => a.CafeId == cafeId)
.Include(a => a.Devices)
.OrderBy(a => a.CreatedAt)
.ToListAsync(ct);
var dtos = agents.Select(a => new PrintAgentDto(
a.Id,
a.Name,
a.BranchId,
_registry.IsOnline(a.Id),
a.TokenHash is not null,
a.LastSeenAt,
a.CreatedAt,
a.Devices
.OrderBy(d => d.DisplayName)
.Select(d => new PrintAgentDeviceDto(d.Id, d.SystemName, d.DisplayName, d.Kind, d.LastSeenAt))
.ToList()
)).ToList();
return Ok(new ApiResponse<IReadOnlyList<PrintAgentDto>>(true, dtos));
}
/// <summary>Create a pending agent and a short one-time code the installer enters to pair.</summary>
[HttpPost("pairing-code")]
public async Task<IActionResult> CreatePairingCode(
string cafeId,
[FromBody] CreatePairingCodeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var code = await GenerateUniqueCodeAsync(ct);
var agent = new PrintAgent
{
CafeId = cafeId,
BranchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId,
Name = string.IsNullOrWhiteSpace(request.Name) ? "پرینت‌سرور" : request.Name!.Trim(),
PairingCode = code,
PairingCodeExpiresAt = DateTime.UtcNow.AddMinutes(15),
};
_db.PrintAgents.Add(agent);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<PairingCodeResponse>(
true, new PairingCodeResponse(agent.Id, code, agent.PairingCodeExpiresAt!.Value)));
}
/// <summary>Unpair/revoke an agent — it can no longer connect or print.</summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Revoke(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var agent = await _db.PrintAgents.FirstOrDefaultAsync(a => a.Id == id && a.CafeId == cafeId, ct);
if (agent is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("AGENT_NOT_FOUND", "Print agent not found.")));
agent.Revoked = true;
agent.TokenHash = null;
agent.PairingCode = null;
agent.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
/// <summary>Send a test page to a discovered printer through its agent.</summary>
[HttpPost("devices/{deviceId}/test")]
public async Task<IActionResult> TestDevice(string cafeId, string deviceId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var result = await _printer.TestPrintDeviceAsync(cafeId, deviceId, ct);
return result.Success
? Ok(new ApiResponse<object>(true, null))
: BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
}
/// <summary>Ask the café's online agents to scan their LAN for devices (network
/// printers on :9100, card terminals on :8088) so the owner can pick instead of
/// typing an IP. Merges results across agents.</summary>
[HttpPost("scan")]
public async Task<IActionResult> Scan(
string cafeId,
[FromBody] ScanRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
var online = _registry.OnlineAgentIdsForCafe(cafeId);
if (online.Count == 0)
return BadRequest(new ApiResponse<object>(
false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network.")));
var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim();
var merged = new Dictionary<string, ScannedDeviceDto>();
foreach (var agentId in online)
{
foreach (var d in await _registry.ScanAsync(agentId, ports, ct))
merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind);
}
var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList();
return Ok(new ApiResponse<IReadOnlyList<ScannedDeviceDto>>(true, dtos));
}
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
{
for (var attempt = 0; attempt < 8; attempt++)
{
var code = RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString();
var now = DateTime.UtcNow;
var clash = await _db.PrintAgents.IgnoreQueryFilters().AnyAsync(
a => a.PairingCode == code && a.PairingCodeExpiresAt > now && a.TokenHash == null, ct);
if (!clash) return code;
}
// Extremely unlikely; fall back to a longer code.
return RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString() +
RandomNumberGenerator.GetInt32(10, 100).ToString();
}
}
@@ -13,6 +13,7 @@ using Meezi.API.Services;
using Meezi.API.Services.Delivery;
using Meezi.Infrastructure.Services.Platform;
using Meezi.API.Services.Printing;
using Meezi.API.Services.Payments;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure;
using Serilog;
@@ -94,6 +95,14 @@ public static class ServiceCollectionExtensions
services.AddScoped<IDemoSeedService, DemoSeedService>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>();
services.Configure<FlatPayOptions>(configuration.GetSection(FlatPayOptions.SectionName));
services.AddHttpClient<IFlatPayService, FlatPayService>((sp, c) =>
{
var baseUrl = configuration["FlatPay:BaseUrl"];
c.BaseAddress = new Uri(string.IsNullOrWhiteSpace(baseUrl) ? "https://pay.flatrender.ir" : baseUrl);
c.Timeout = TimeSpan.FromSeconds(30);
});
services.AddHttpClient(nameof(PosDeviceService));
services.AddScoped<IPosDeviceService, PosDeviceService>();
services.AddScoped<SubscriptionRenewalReminderJob>();
@@ -224,6 +233,7 @@ public static class ServiceCollectionExtensions
app.MapControllers();
app.MapHub<KdsHub>("/hubs/kds");
app.MapHub<GuestOrderHub>("/hubs/guest-order");
app.MapHub<PrintAgentHub>("/hubs/print-agent");
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
+119
View File
@@ -0,0 +1,119 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Services.Printing;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Hubs;
/// <summary>
/// Local print agents connect here (outbound from the café PC), authenticated by
/// their token in the <c>access_token</c> query param — agents are not users, so
/// the hub self-authenticates rather than relying on the user JWT pipeline.
/// They report the printers they can see and receive print jobs to relay locally.
/// </summary>
[AllowAnonymous]
public class PrintAgentHub : Hub
{
private readonly AppDbContext _db;
private readonly IPrintAgentRegistry _registry;
private readonly ILogger<PrintAgentHub> _logger;
public PrintAgentHub(AppDbContext db, IPrintAgentRegistry registry, ILogger<PrintAgentHub> logger)
{
_db = db;
_registry = registry;
_logger = logger;
}
/// <summary>SHA-256 (hex) of an agent token — what we persist and compare against.</summary>
public static string HashToken(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
public override async Task OnConnectedAsync()
{
var token = Context.GetHttpContext()?.Request.Query["access_token"].ToString();
if (string.IsNullOrEmpty(token)) { Context.Abort(); return; }
var hash = HashToken(token);
var agent = await _db.PrintAgents
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.TokenHash == hash && !a.Revoked && a.DeletedAt == null);
if (agent is null) { Context.Abort(); return; }
_registry.Register(Context.ConnectionId, agent.Id, agent.CafeId);
agent.LastSeenAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_registry.Unregister(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
public record ReportedPrinter(string SystemName, string DisplayName, string? Kind);
/// <summary>Agent → cloud: the current set of printers it can reach. Upserts devices.</summary>
public async Task ReportPrinters(IReadOnlyList<ReportedPrinter> printers)
{
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
var existing = await _db.PrintDevices.IgnoreQueryFilters()
.Where(d => d.AgentId == ctx.AgentId)
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var p in printers ?? [])
{
if (string.IsNullOrWhiteSpace(p.SystemName)) continue;
var match = existing.FirstOrDefault(d => d.SystemName == p.SystemName);
if (match is null)
{
_db.PrintDevices.Add(new PrintDevice
{
CafeId = ctx.CafeId,
AgentId = ctx.AgentId,
SystemName = p.SystemName,
DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.SystemName : p.DisplayName,
Kind = string.IsNullOrWhiteSpace(p.Kind) ? "other" : p.Kind!,
LastSeenAt = now,
});
}
else
{
match.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? match.DisplayName : p.DisplayName;
if (!string.IsNullOrWhiteSpace(p.Kind)) match.Kind = p.Kind!;
match.LastSeenAt = now;
match.DeletedAt = null; // a printer that came back is no longer "gone"
}
}
await _db.SaveChangesAsync();
}
/// <summary>Agent → cloud: acknowledgement of a dispatched print job.</summary>
public void JobResult(string jobId, bool success, string? error) =>
_registry.CompleteJob(jobId, success, error);
/// <summary>Agent → cloud: result of a relayed card-terminal payment.</summary>
public void PaymentResult(string requestId, bool success, string? error) =>
_registry.CompleteJob(requestId, success, error);
/// <summary>Agent → cloud: hosts found by a LAN scan (network printers, card terminals).</summary>
public void ReportScan(string requestId, IReadOnlyList<DiscoveredDevice> devices) =>
_registry.CompleteScan(requestId, devices ?? []);
/// <summary>Agent → cloud: keep-alive so the dashboard can show an accurate "last seen".</summary>
public async Task Heartbeat()
{
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
var agent = await _db.PrintAgents.IgnoreQueryFilters().FirstOrDefaultAsync(a => a.Id == ctx.AgentId);
if (agent is null) return;
agent.LastSeenAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
}
@@ -7,18 +7,21 @@ public record KitchenStationDto(
string? PrinterIp,
int PrinterPort,
int SortOrder,
int CategoryCount);
int CategoryCount,
string? PrintDeviceId);
public record CreateKitchenStationRequest(
string Name,
string? BranchId,
string? PrinterIp,
int PrinterPort = 9100,
int SortOrder = 0);
int SortOrder = 0,
string? PrintDeviceId = null);
public record UpdateKitchenStationRequest(
string? Name,
string? BranchId,
string? PrinterIp,
int? PrinterPort,
int? SortOrder);
int? SortOrder,
string? PrintDeviceId);
@@ -0,0 +1,6 @@
namespace Meezi.API.Models.Payments;
/// <summary>Body for POST /api/payment/request. ProductId is a "Tier:Months" bundle, e.g. "Pro:12".</summary>
public record PaymentRequestDto(string ProductId);
public record PaymentRequestResponse(string Url, string PaymentId);
@@ -0,0 +1,31 @@
namespace Meezi.API.Models.Printing;
public record PrintAgentDeviceDto(
string Id,
string SystemName,
string DisplayName,
string Kind,
DateTime LastSeenAt);
public record PrintAgentDto(
string Id,
string Name,
string? BranchId,
bool Online,
bool Paired,
DateTime? LastSeenAt,
DateTime CreatedAt,
IReadOnlyList<PrintAgentDeviceDto> Devices);
public record CreatePairingCodeRequest(string? Name, string? BranchId);
public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresAt);
public record ClaimAgentRequest(string Code, string? Name, string? MachineName);
public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName);
/// <summary>Ask online agents to scan the LAN for the given comma-separated TCP ports.</summary>
public record ScanRequest(string? Ports);
public record ScannedDeviceDto(string Ip, int Port, string Kind);
+6 -2
View File
@@ -12,7 +12,9 @@ public record BranchPrintSettingsDto(
string? ReceiptFooter,
string? WifiPassword,
string? PosDeviceIp,
int? PosDevicePort);
int? PosDevicePort,
string? ReceiptPrintDeviceId,
string? KitchenPrintDeviceId);
public record PatchBranchPrintSettingsRequest(
string? ReceiptPrinterIp,
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
string? ReceiptFooter,
string? WifiPassword,
string? PosDeviceIp,
int? PosDevicePort);
int? PosDevicePort,
string? ReceiptPrintDeviceId,
string? KitchenPrintDeviceId);
public record PosPaymentRequest(string OrderId, decimal Amount);
+93 -1
View File
@@ -40,6 +40,21 @@ public interface IBillingService
string cafeId,
string paymentId,
CancellationToken cancellationToken = default);
/// <summary>Price a plan+months bundle and create a Pending FlatPay SubscriptionPayment
/// (the "order"); the returned id is passed to the broker as client_ref / metadata.payment_id.</summary>
Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
string cafeId,
PlanTier tier,
int months,
CancellationToken cancellationToken = default);
/// <summary>Grant a FlatPay order after the broker reports it Paid: activate the plan using
/// the same coverage/queueing logic as the other providers. Idempotent.</summary>
Task<bool> CompleteFlatPayAsync(
string paymentId,
string? refId,
CancellationToken cancellationToken = default);
}
public class BillingService : IBillingService
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
payment.RefId = verify.RefId;
await ActivatePaymentAsync(payment, cancellationToken);
return new BillingVerifyResult(true, successUrl);
}
/// <summary>Apply a paid SubscriptionPayment: book it after the current coverage (queued) or
/// activate it now, update the cafe plan, persist, and send the confirmation SMS. Shared by all
/// providers (gateway callbacks and the FlatPay webhook).</summary>
private async Task ActivatePaymentAsync(SubscriptionPayment payment, CancellationToken cancellationToken)
{
var cafe = payment.Cafe;
var now = DateTime.UtcNow;
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
}
return new BillingVerifyResult(true, successUrl);
public async Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
string cafeId,
PlanTier tier,
int months,
CancellationToken cancellationToken = default)
{
if (months is < 1 or > 36)
return (null, 0m, "INVALID_MONTHS", "Months must be between 1 and 36.");
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null)
return (null, 0m, "NOT_FOUND", "Cafe not found.");
if (!await _platformCatalog.IsBillableOnlineAsync(tier, cancellationToken))
return (null, 0m, "NOT_BILLABLE", "This plan requires contacting sales.");
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(tier, cancellationToken);
if (monthly <= 0)
return (null, 0m, "NOT_BILLABLE", "This plan has no online price.");
var amountToman = monthly * months;
var payment = new SubscriptionPayment
{
CafeId = cafeId,
PlanTier = tier,
Months = months,
AmountToman = amountToman,
AmountRials = PlanPricing.ToRials(amountToman),
Provider = PaymentProvider.FlatPay,
Status = SubscriptionPaymentStatus.Pending,
};
_db.SubscriptionPayments.Add(payment);
await _db.SaveChangesAsync(cancellationToken);
return (payment.Id, amountToman, null, null);
}
public async Task<bool> CompleteFlatPayAsync(
string paymentId,
string? refId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(paymentId))
return false;
var payment = await _db.SubscriptionPayments
.Include(p => p.Cafe)
.FirstOrDefaultAsync(
p => p.Id == paymentId && p.Provider == PaymentProvider.FlatPay,
cancellationToken);
if (payment is null)
{
_logger.LogWarning("FlatPay grant: no pending order {PaymentId}", paymentId);
return false;
}
// Already granted (webhook redelivery / double-process) → idempotent no-op.
if (payment.Status is SubscriptionPaymentStatus.Completed or SubscriptionPaymentStatus.Scheduled)
return true;
payment.RefId = refId;
await ActivatePaymentAsync(payment, cancellationToken);
_logger.LogInformation("FlatPay grant applied: payment {PaymentId} → {Tier} x{Months}m",
payment.Id, payment.PlanTier, payment.Months);
return true;
}
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
s.PrinterIp,
s.PrinterPort,
s.SortOrder,
s.PrintDeviceId,
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
})
.ToListAsync(ct);
return stations.Select(s => new KitchenStationDto(
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount)).ToList();
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId)).ToList();
}
public async Task<KitchenStationDto?> CreateAsync(
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
Name = request.Name.Trim(),
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
SortOrder = request.SortOrder
};
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
if (request.PrinterPort.HasValue)
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
if (request.PrintDeviceId is not null)
entity.PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId;
if (request.SortOrder.HasValue)
entity.SortOrder = request.SortOrder.Value;
@@ -137,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
x.PrinterIp,
x.PrinterPort,
x.SortOrder,
x.PrintDeviceId,
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
})
.FirstOrDefaultAsync(ct);
return s is null
? null
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount);
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId);
}
}
@@ -0,0 +1,151 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace Meezi.API.Services.Payments;
public sealed class FlatPayOptions
{
public const string SectionName = "FlatPay";
public string ApiKey { get; set; } = "";
public string Secret { get; set; } = "";
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
public string ReturnUrl { get; set; } = "https://meezi.ir/payment/return";
}
/// <summary>
/// Client for the FlatRender Pay broker (a ZarinPal front). Requests are authenticated
/// with <c>X-Api-Key</c> + <c>X-Signature</c> = hex(HMAC-SHA256(secret, raw JSON bytes));
/// webhooks are verified the same way. The signature is computed over the EXACT bytes
/// that are sent/received, so we serialize once and reuse the buffer.
/// </summary>
public interface IFlatPayService
{
/// <summary>Create a payment at the broker and return its hosted payment URL (null on failure).
/// <paramref name="clientRef"/> is echoed back and also embedded in metadata.payment_id.</summary>
Task<string?> RequestAsync(
string userId, string productId, long amountToman, string description, string clientRef,
CancellationToken ct = default);
/// <summary>Fixed-time compare hex(HMAC(secret, rawBytes)) against the webhook signature header.</summary>
bool VerifyWebhook(byte[] rawBytes, string? signature);
/// <summary>Idempotency: true only the first time a given broker payment id is seen.</summary>
bool TryMarkProcessed(string id);
}
public sealed class FlatPayService : IFlatPayService
{
private readonly HttpClient _http;
private readonly FlatPayOptions _opts;
private readonly ILogger<FlatPayService> _logger;
// Webhooks can be redelivered; remember the broker ids we've already granted.
private readonly ConcurrentDictionary<string, byte> _seen = new();
public FlatPayService(HttpClient http, IOptions<FlatPayOptions> opts, ILogger<FlatPayService> logger)
{
_http = http;
_opts = opts.Value;
_logger = logger;
}
public async Task<string?> RequestAsync(
string userId, string productId, long amountToman, string description, string clientRef,
CancellationToken ct = default)
{
var body = new PayRequestBody(
amountToman,
"IRT",
description,
clientRef,
_opts.ReturnUrl,
new PayMetadata(userId, productId, clientRef));
// Serialize once: these exact bytes are both signed and sent.
var bytes = JsonSerializer.SerializeToUtf8Bytes(body);
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/pay/request");
req.Content = new ByteArrayContent(bytes);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes));
try
{
using var resp = await _http.SendAsync(req, ct);
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogError("FlatPay /v1/pay/request failed {Status}: {Body}", (int)resp.StatusCode, respBody);
return null;
}
using var doc = JsonDocument.Parse(respBody);
var url = ExtractPaymentUrl(doc.RootElement);
if (string.IsNullOrEmpty(url))
_logger.LogError("FlatPay request returned no payment_url: {Body}", respBody);
return url;
}
catch (Exception ex)
{
_logger.LogError(ex, "FlatPay request error");
return null;
}
}
public bool VerifyWebhook(byte[] rawBytes, string? signature)
{
if (string.IsNullOrWhiteSpace(signature)) return false;
var expected = Sign(rawBytes);
var provided = signature.Trim().ToLowerInvariant();
// Compare the ascii hex digests in fixed time.
var a = Encoding.ASCII.GetBytes(expected);
var b = Encoding.ASCII.GetBytes(provided);
return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b);
}
public bool TryMarkProcessed(string id) =>
!string.IsNullOrEmpty(id) && _seen.TryAdd(id, 0);
private string Sign(byte[] body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret));
return Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
}
private static string? ExtractPaymentUrl(JsonElement root)
{
if (TryGetString(root, "payment_url") is { } direct) return direct;
// Some broker responses nest the result under "data".
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
return TryGetString(data, "payment_url");
return null;
}
private static string? TryGetString(JsonElement el, string name) =>
el.ValueKind == JsonValueKind.Object
&& el.TryGetProperty(name, out var v)
&& v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private sealed record PayRequestBody(
[property: JsonPropertyName("amount")] long Amount,
[property: JsonPropertyName("currency")] string Currency,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("client_ref")] string ClientRef,
[property: JsonPropertyName("return_url")] string ReturnUrl,
[property: JsonPropertyName("metadata")] PayMetadata Metadata);
private sealed record PayMetadata(
[property: JsonPropertyName("user_id")] string UserId,
[property: JsonPropertyName("product_id")] string ProductId,
[property: JsonPropertyName("payment_id")] string PaymentId);
}
+50 -2
View File
@@ -1,6 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPrintAgentRegistry _agents;
private readonly ILogger<PosDeviceService> _logger;
public PosDeviceService(
AppDbContext db,
IHttpClientFactory httpClientFactory,
IPrintAgentRegistry agents,
ILogger<PosDeviceService> logger)
{
_db = db;
_httpClientFactory = httpClientFactory;
_agents = agents;
_logger = logger;
}
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
if (order is null)
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero);
var ip = branch.PosDeviceIp!.Trim();
// Prefer relaying through a local print agent on the café LAN — the cloud
// can't reach the terminal's private IP directly (same reason the agent
// exists for printers). Fall back to a direct call only on-prem / when no
// agent is connected.
var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct);
if (agentId is not null)
{
var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct);
if (outcome.Success)
return PosDeviceResult.Ok();
_logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error);
return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error);
}
var payload = new
{
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
amount,
orderId = request.OrderId,
branchId,
};
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
var url = $"http://{ip}:{port}/pay";
try
{
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
}
}
/// <summary>The online agent best placed to reach this branch's terminal — one
/// bound to the branch if present, else any online agent of the café.</summary>
private async Task<string?> ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct)
{
var online = _agents.OnlineAgentIdsForCafe(cafeId);
if (online.Count == 0) return null;
var agents = await _db.PrintAgents
.AsNoTracking()
.Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null)
.ToListAsync(ct);
return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id
?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id;
}
/// <summary>Normalize an agent-relay error string back to a POS_DEVICE_* code.</summary>
private static string MapAgentError(string? error) => error switch
{
null or "" => "POS_DEVICE_FAILED",
var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT",
var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0],
var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED",
var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED",
_ => "POS_DEVICE_CONNECTION_FAILED",
};
}
@@ -22,6 +22,7 @@ public interface IPrinterService
string? stationId = null,
CancellationToken ct = default);
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default);
}
public class NetworkPrinterService : IPrinterService
@@ -29,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
private readonly AppDbContext _db;
private readonly IOrderService _orders;
private readonly ReceiptBuilder _receiptBuilder;
private readonly IPrintAgentRegistry _agents;
private readonly ILogger<NetworkPrinterService> _logger;
public NetworkPrinterService(
AppDbContext db,
IOrderService orders,
ReceiptBuilder receiptBuilder,
IPrintAgentRegistry agents,
ILogger<NetworkPrinterService> logger)
{
_db = db;
_orders = orders;
_receiptBuilder = receiptBuilder;
_agents = agents;
_logger = logger;
}
@@ -49,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND");
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
var branch = ctx.Value.branch;
if (string.IsNullOrWhiteSpace(branch.ReceiptPrintDeviceId) && string.IsNullOrWhiteSpace(branch.ReceiptPrinterIp))
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
return await SendToPrinterAsync(
ctx.Value.branch.ReceiptPrinterIp!,
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
return await DispatchAsync(
cafeId,
branch.ReceiptPrintDeviceId,
branch.ReceiptPrinterIp,
branch.ReceiptPrinterPort ?? 9100,
bytes,
ct);
}
@@ -122,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
? null
: stations.FirstOrDefault(s => s.Id == group.Key);
string? deviceId;
string? ip;
int port;
string? stationLabel = null;
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
if (station is not null && (!string.IsNullOrWhiteSpace(station.PrintDeviceId) || !string.IsNullOrWhiteSpace(station.PrinterIp)))
{
deviceId = station.PrintDeviceId;
ip = station.PrinterIp;
port = station.PrinterPort;
stationLabel = station.Name;
}
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrintDeviceId) || !string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
{
deviceId = ctx.Value.branch.KitchenPrintDeviceId;
ip = ctx.Value.branch.KitchenPrinterIp;
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
}
@@ -147,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
var bytes = _receiptBuilder.BuildKitchenTicket(
ctx.Value.printCtx with { StationName = stationLabel },
itemsOnly);
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
if (result.Success)
anyPrinted = true;
else
@@ -166,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
}
public async Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default)
{
var device = await _db.PrintDevices.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
if (device is null)
return PrintResult.Fail("DEVICE_NOT_FOUND");
if (!_agents.IsOnline(device.AgentId))
return PrintResult.Fail("AGENT_OFFLINE");
var bytes = _receiptBuilder.BuildTestPage();
var outcome = await _agents.SendJobAsync(device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
return outcome.Success ? PrintResult.Ok() : PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
}
/// <summary>
/// Send bytes to a printer, preferring a local print agent when one is mapped and
/// online (the only way to reach a LAN/USB printer from the cloud); otherwise fall
/// back to a direct TCP connection (on-prem deployments / reachable printers).
/// </summary>
private async Task<PrintResult> DispatchAsync(
string cafeId,
string? deviceId,
string? ip,
int port,
byte[] bytes,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(deviceId))
{
var device = await _db.PrintDevices.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
if (device is not null && _agents.IsOnline(device.AgentId))
{
var outcome = await _agents.SendJobAsync(
device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
if (outcome.Success)
{
_logger.LogInformation("Printed {Bytes} bytes via agent {Agent} → {Printer}",
bytes.Length, device.AgentId, device.SystemName);
return PrintResult.Ok();
}
_logger.LogWarning("Agent print failed ({Printer}): {Error}", device.SystemName, outcome.Error);
// Only surface the failure if there's no IP to fall back to.
if (string.IsNullOrWhiteSpace(ip))
return PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
}
else if (string.IsNullOrWhiteSpace(ip))
{
return PrintResult.Fail("AGENT_OFFLINE");
}
// Agent offline/missing but an IP is configured → fall through to TCP.
}
if (!string.IsNullOrWhiteSpace(ip))
return await SendToPrinterAsync(ip!.Trim(), port, bytes, ct);
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
}
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
string cafeId,
string orderId,
@@ -0,0 +1,177 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Meezi.API.Hubs;
namespace Meezi.API.Services.Printing;
public record PrintJobRequest(string PrinterSystemName, byte[] Payload);
public record PrintJobOutcome(bool Success, string? Error);
/// <summary>A host the agent found on the café LAN responding on a probed port
/// (a network printer on :9100, a card terminal on :8088, …).</summary>
public record DiscoveredDevice(string Ip, int Port, string Kind);
/// <summary>
/// Tracks which print agents are currently connected (by SignalR connection) and
/// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory:
/// a dropped process simply means agents reconnect and re-register.
/// </summary>
public interface IPrintAgentRegistry
{
void Register(string connectionId, string agentId, string cafeId);
void Unregister(string connectionId);
(string AgentId, string CafeId)? Resolve(string connectionId);
bool IsOnline(string agentId);
IReadOnlySet<string> OnlineAgentIds();
/// <summary>Online agents belonging to a café — used to pick a LAN bridge for a
/// card-terminal payment or a network scan.</summary>
IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId);
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
void CompleteJob(string jobId, bool success, string? error);
/// <summary>Relay a card-terminal payment through the agent on the café LAN; it
/// POSTs the amount to the terminal at ip:port and acks the approval result.</summary>
Task<PrintJobOutcome> SendPaymentAsync(
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default);
/// <summary>Ask the agent to scan its LAN for hosts answering on the given ports.</summary>
Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
string agentId, string ports, CancellationToken ct = default);
void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices);
}
public class PrintAgentRegistry : IPrintAgentRegistry
{
private readonly IHubContext<PrintAgentHub> _hub;
private readonly ConcurrentDictionary<string, (string AgentId, string CafeId)> _byConnection = new();
private readonly ConcurrentDictionary<string, string> _agentConnection = new(); // agentId -> connectionId
private readonly ConcurrentDictionary<string, TaskCompletionSource<PrintJobOutcome>> _pending = new();
private readonly ConcurrentDictionary<string, TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>> _pendingScans = new();
public PrintAgentRegistry(IHubContext<PrintAgentHub> hub) => _hub = hub;
public void Register(string connectionId, string agentId, string cafeId)
{
_byConnection[connectionId] = (agentId, cafeId);
_agentConnection[agentId] = connectionId;
}
public void Unregister(string connectionId)
{
if (!_byConnection.TryRemove(connectionId, out var info)) return;
// Only drop the agent→connection mapping if it still points at this socket
// (a fast reconnect may already have replaced it with a newer one).
if (_agentConnection.TryGetValue(info.AgentId, out var current) && current == connectionId)
_agentConnection.TryRemove(info.AgentId, out _);
}
public (string AgentId, string CafeId)? Resolve(string connectionId) =>
_byConnection.TryGetValue(connectionId, out var info) ? info : null;
public bool IsOnline(string agentId) => _agentConnection.ContainsKey(agentId);
public IReadOnlySet<string> OnlineAgentIds() => _agentConnection.Keys.ToHashSet();
public IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId) =>
_byConnection.Values
.Where(v => v.CafeId == cafeId)
.Select(v => v.AgentId)
.ToHashSet();
public async Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default)
{
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
return new PrintJobOutcome(false, "AGENT_OFFLINE");
var jobId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[jobId] = tcs;
try
{
await _hub.Clients.Client(connectionId).SendAsync(
"PrintJob", jobId, job.PrinterSystemName, Convert.ToBase64String(job.Payload), ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "TIMEOUT")));
return await tcs.Task;
}
catch (Exception ex)
{
return new PrintJobOutcome(false, ex.Message);
}
finally
{
_pending.TryRemove(jobId, out _);
}
}
public void CompleteJob(string jobId, bool success, string? error)
{
if (_pending.TryGetValue(jobId, out var tcs))
tcs.TrySetResult(new PrintJobOutcome(success, error));
}
public async Task<PrintJobOutcome> SendPaymentAsync(
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default)
{
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
return new PrintJobOutcome(false, "AGENT_OFFLINE");
var requestId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[requestId] = tcs;
try
{
await _hub.Clients.Client(connectionId).SendAsync(
"PaymentRequest", requestId, ip, port, amount, orderId, ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Card payment waits on the customer at the terminal — give it the same
// headroom the direct path uses.
timeout.CancelAfter(TimeSpan.FromSeconds(95));
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT")));
return await tcs.Task;
}
catch (Exception ex)
{
return new PrintJobOutcome(false, ex.Message);
}
finally
{
_pending.TryRemove(requestId, out _);
}
}
public async Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
string agentId, string ports, CancellationToken ct = default)
{
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
return [];
var requestId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingScans[requestId] = tcs;
try
{
await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
using var reg = timeout.Token.Register(() => tcs.TrySetResult([]));
return await tcs.Task;
}
catch
{
return [];
}
finally
{
_pendingScans.TryRemove(requestId, out _);
}
}
public void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices)
{
if (_pendingScans.TryGetValue(requestId, out var tcs))
tcs.TrySetResult(devices);
}
}
+6
View File
@@ -44,6 +44,12 @@
"MerchantId": "",
"Sandbox": true
},
"FlatPay": {
"BaseUrl": "https://pay.flatrender.ir",
"ReturnUrl": "https://meezi.ir/payment/return",
"ApiKey": "",
"Secret": ""
},
"Billing": {
"DashboardBaseUrl": "http://localhost:3101"
},
+5
View File
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
public int? ReceiptPrinterPort { get; set; }
public string? KitchenPrinterIp { get; set; }
public int? KitchenPrinterPort { get; set; }
/// <summary>Optional <see cref="PrintDevice"/> to route through a local print agent
/// (preferred over the raw IP when its agent is online). Cloud-hosted cafés use this.</summary>
public string? ReceiptPrintDeviceId { get; set; }
public string? KitchenPrintDeviceId { get; set; }
public int PaperWidthMm { get; set; } = 80;
public bool AutoCutEnabled { get; set; } = true;
public string? ReceiptHeader { get; set; }
@@ -7,6 +7,11 @@ public class KitchenStation : TenantEntity
public string Name { get; set; } = string.Empty;
public string? PrinterIp { get; set; }
public int PrinterPort { get; set; } = 9100;
/// <summary>Optional <see cref="PrintDevice"/> routed through a local print agent
/// (preferred over <see cref="PrinterIp"/> when its agent is online).</summary>
public string? PrintDeviceId { get; set; }
public int SortOrder { get; set; }
public Cafe Cafe { get; set; } = null!;
+29
View File
@@ -0,0 +1,29 @@
namespace Meezi.Core.Entities;
/// <summary>
/// A local print bridge installed on a café PC. It connects outbound to the cloud
/// over SignalR (authenticated by its token), reports the printers it can see, and
/// relays print jobs to them — so the cloud can reach LAN/USB printers it could
/// never connect to directly.
/// </summary>
public class PrintAgent : TenantEntity
{
public string? BranchId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Short one-time code shown in the dashboard; the agent exchanges it for a token.</summary>
public string? PairingCode { get; set; }
public DateTime? PairingCodeExpiresAt { get; set; }
/// <summary>SHA-256 (hex) of the long-lived agent token. Null until the agent is paired.</summary>
public string? TokenHash { get; set; }
/// <summary>Last time the agent connected or sent a heartbeat (UTC).</summary>
public DateTime? LastSeenAt { get; set; }
public bool Revoked { get; set; }
public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; }
public ICollection<PrintDevice> Devices { get; set; } = [];
}
+18
View File
@@ -0,0 +1,18 @@
namespace Meezi.Core.Entities;
/// <summary>A printer discovered and reported by a <see cref="PrintAgent"/>.</summary>
public class PrintDevice : TenantEntity
{
public string AgentId { get; set; } = string.Empty;
/// <summary>Stable identifier the agent uses to print (OS printer name, or "ip:port").</summary>
public string SystemName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
/// <summary>"usb" | "network" | "other".</summary>
public string Kind { get; set; } = "other";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
public PrintAgent Agent { get; set; } = null!;
}
+3 -1
View File
@@ -4,7 +4,9 @@ public enum PaymentProvider
{
ZarinPal = 0,
Tara = 1,
SnappPay = 2
SnappPay = 2,
// Appended (stored as int) so existing rows keep their meaning — no migration needed.
FlatPay = 3
}
public static class PaymentProviderIds
@@ -54,6 +54,8 @@ public class AppDbContext : DbContext
public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>();
public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
public DbSet<KitchenStation> KitchenStations => Set<KitchenStation>();
public DbSet<PrintAgent> PrintAgents => Set<PrintAgent>();
public DbSet<PrintDevice> PrintDevices => Set<PrintDevice>();
public DbSet<SubscriptionPayment> SubscriptionPayments => Set<SubscriptionPayment>();
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
@@ -459,6 +461,32 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
});
modelBuilder.Entity<PrintAgent>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(120).IsRequired();
e.Property(x => x.PairingCode).HasMaxLength(16);
e.Property(x => x.TokenHash).HasMaxLength(128);
e.HasIndex(x => x.TokenHash);
e.HasIndex(x => x.PairingCode);
e.HasIndex(x => x.CafeId);
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasMany(x => x.Devices).WithOne(d => d.Agent).HasForeignKey(d => d.AgentId).OnDelete(DeleteBehavior.Cascade);
// Café-wide agents (BranchId null) stay visible inside any branch scope.
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId || x.BranchId == null));
});
modelBuilder.Entity<PrintDevice>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.SystemName).HasMaxLength(256).IsRequired();
e.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
e.Property(x => x.Kind).HasMaxLength(20);
e.HasIndex(x => new { x.AgentId, x.SystemName }).IsUnique();
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<SubscriptionPayment>(e =>
{
e.HasKey(x => x.Id);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPrintAgents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PrintAgents",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
BranchId = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
PairingCode = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
PairingCodeExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
TokenHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Revoked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PrintAgents", x => x.Id);
table.ForeignKey(
name: "FK_PrintAgents_Branches_BranchId",
column: x => x.BranchId,
principalTable: "Branches",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PrintAgents_Cafes_CafeId",
column: x => x.CafeId,
principalTable: "Cafes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PrintDevices",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
AgentId = table.Column<string>(type: "text", nullable: false),
SystemName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PrintDevices", x => x.Id);
table.ForeignKey(
name: "FK_PrintDevices_PrintAgents_AgentId",
column: x => x.AgentId,
principalTable: "PrintAgents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_BranchId",
table: "PrintAgents",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_CafeId",
table: "PrintAgents",
column: "CafeId");
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_PairingCode",
table: "PrintAgents",
column: "PairingCode");
migrationBuilder.CreateIndex(
name: "IX_PrintAgents_TokenHash",
table: "PrintAgents",
column: "TokenHash");
migrationBuilder.CreateIndex(
name: "IX_PrintDevices_AgentId_SystemName",
table: "PrintDevices",
columns: new[] { "AgentId", "SystemName" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PrintDevices");
migrationBuilder.DropTable(
name: "PrintAgents");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPrintDeviceRouting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PrintDeviceId",
table: "KitchenStations",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "KitchenPrintDeviceId",
table: "Branches",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ReceiptPrintDeviceId",
table: "Branches",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PrintDeviceId",
table: "KitchenStations");
migrationBuilder.DropColumn(
name: "KitchenPrintDeviceId",
table: "Branches");
migrationBuilder.DropColumn(
name: "ReceiptPrintDeviceId",
table: "Branches");
}
}
}
@@ -155,6 +155,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("KitchenPrintDeviceId")
.HasColumnType("text");
b.Property<string>("KitchenPrinterIp")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReceiptPrintDeviceId")
.HasColumnType("text");
b.Property<string>("ReceiptPrinterIp")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
@@ -1313,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PrintDeviceId")
.HasColumnType("text");
b.Property<string>("PrinterIp")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
@@ -1935,6 +1944,104 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("PlatformSettings");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("BranchId")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PairingCode")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTime?>("PairingCodeExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Revoked")
.HasColumnType("boolean");
b.Property<string>("TokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("BranchId");
b.HasIndex("CafeId");
b.HasIndex("PairingCode");
b.HasIndex("TokenHash");
b.ToTable("PrintAgents");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AgentId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SystemName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("AgentId", "SystemName")
.IsUnique();
b.ToTable("PrintDevices");
});
modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
{
b.Property<string>("Id")
@@ -3150,6 +3257,35 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
{
b.HasOne("Meezi.Core.Entities.PrintAgent", "Agent")
.WithMany("Devices")
.HasForeignKey("AgentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Agent");
});
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
@@ -3473,6 +3609,11 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Payments");
});
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
{
b.Navigation("Devices");
});
modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
{
b.Navigation("Transactions");
+23
View File
@@ -333,6 +333,11 @@
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
"posDeviceIp": "عنوان IP لجهاز نقطة البيع",
"detect": "كشف تلقائي",
"detecting": "جارٍ فحص الشبكة…",
"detectNone": "لم يُعثر على أجهزة في الشبكة",
"detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي",
"detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.",
"testSent": "تم إرسال الاختبار إلى الطابعة.",
"sent": "تم الإرسال إلى الطابعة.",
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
@@ -351,6 +356,24 @@
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
"saveError": "تعذّر حفظ المحطة."
},
"agents": {
"title": "خوادم الطباعة (اكتشاف تلقائي)",
"hint": "ثبّت وكيل طباعة Meezi على جهاز الكاشير لاكتشاف طابعات USB والشبكة تلقائياً والطباعة عبره.",
"add": "إضافة خادم طباعة",
"pairingTitle": "أدخل هذا الرمز في وكيل الطباعة على جهاز الكاشير:",
"pairingSteps": "ثبّت وشغّل وكيل طباعة Meezi على الجهاز المتصل بالطابعات ثم أدخل هذا الرمز. صالح لمدة 15 دقيقة.",
"empty": "لا يوجد خادم طباعة متصل بعد.",
"online": "متصل",
"offline": "غير متصل",
"noDevices": "جارٍ اكتشاف الطابعات…",
"test": "اختبار",
"receiptVia": "طابعة الإيصال (عبر الخادم)",
"kitchenVia": "طابعة المطبخ (عبر الخادم)",
"viaServer": "الطابعة (عبر الخادم)",
"useIpInstead": "— استخدام IP يدوي —",
"revokeConfirm": "إزالة خادم الطباعة «{name}»؟ لن يتمكن من الطباعة بعد ذلك.",
"codeError": "تعذّر إنشاء الرمز."
}
},
"receipt": {
+23
View File
@@ -352,6 +352,11 @@
"posDeviceSection": "Card POS terminal",
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
"posDeviceIp": "POS device IP address",
"detect": "Auto-detect",
"detecting": "Scanning the network…",
"detectNone": "No devices found on the network",
"detectOffline": "A print server must be online to auto-detect",
"detectHint": "The print server scans your LAN to find the device.",
"testSent": "Test sent to the printer.",
"sent": "Sent to the printer.",
"noStationItems": "This order has no items for that station.",
@@ -370,6 +375,24 @@
"empty": "No stations yet. Add Kitchen and Bar to print their items separately.",
"deleteConfirm": "Delete station “{name}”? Its categories will fall back to the kitchen printer.",
"saveError": "Failed to save the station."
},
"agents": {
"title": "Print servers (auto-discovery)",
"hint": "Install the Meezi print agent on the cash PC to auto-detect its USB & network printers and print through it.",
"add": "Add print server",
"pairingTitle": "Enter this code in the print agent on the cash PC:",
"pairingSteps": "Install and run the Meezi Print Agent on the PC connected to the printers, then enter this code. It is valid for 15 minutes.",
"empty": "No print server connected yet.",
"online": "Online",
"offline": "Offline",
"noDevices": "Discovering printers…",
"test": "Test",
"receiptVia": "Receipt printer (via server)",
"kitchenVia": "Kitchen printer (via server)",
"viaServer": "Printer (via server)",
"useIpInstead": "— Use manual IP —",
"revokeConfirm": "Remove print server “{name}”? It will no longer be able to print.",
"codeError": "Could not create code."
}
},
"receipt": {
+23
View File
@@ -352,6 +352,11 @@
"posDeviceSection": "دستگاه پوز (کارتخوان)",
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال می‌شود (POST /pay).",
"posDeviceIp": "آدرس IP دستگاه پوز",
"detect": "تشخیص خودکار",
"detecting": "در حال جستجوی شبکه…",
"detectNone": "دستگاهی در شبکه پیدا نشد",
"detectOffline": "برای تشخیص خودکار باید پرینت‌سرور روشن و متصل باشد",
"detectHint": "پرینت‌سرور شبکه محلی را برای یافتن دستگاه اسکن می‌کند.",
"testSent": "تست به پرینتر ارسال شد.",
"sent": "به پرینتر ارسال شد.",
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
@@ -370,6 +375,24 @@
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتم‌هایشان جدا چاپ شود.",
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دسته‌های آن به پرینتر آشپزخانه برمی‌گردند.",
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
},
"agents": {
"title": "پرینت‌سرورها (شناسایی خودکار پرینتر)",
"hint": "روی کامپیوتر صندوق، برنامهٔ «پرینت‌سرور میزی» را نصب کنید تا پرینترهای USB و شبکه به‌صورت خودکار شناسایی شوند و چاپ از طریق آن انجام شود.",
"add": "افزودن پرینت‌سرور",
"pairingTitle": "این کد را در برنامهٔ پرینت‌سرور روی کامپیوتر صندوق وارد کنید:",
"pairingSteps": "برنامهٔ «پرینت‌سرور میزی» را روی همان کامپیوتری که به پرینترها وصل است نصب و اجرا کنید، سپس این کد را وارد کنید. کد تا ۱۵ دقیقه معتبر است.",
"empty": "هنوز پرینت‌سروری متصل نشده است.",
"online": "آنلاین",
"offline": "آفلاین",
"noDevices": "در حال یافتن پرینترها…",
"test": "تست",
"receiptVia": "پرینتر رسید (از پرینت‌سرور)",
"kitchenVia": "پرینتر آشپزخانه (از پرینت‌سرور)",
"viaServer": "پرینتر (از پرینت‌سرور)",
"useIpInstead": "— استفاده از IP دستی —",
"revokeConfirm": "حذف پرینت‌سرور «{name}»؟ پس از آن دیگر نمی‌تواند چاپ کند.",
"codeError": "ایجاد کد ناموفق بود."
}
},
"receipt": {
@@ -1,61 +1,76 @@
"use client";
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
import { WifiOff, CloudUpload, RefreshCw, AlertTriangle } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useLocale } from "next-intl";
import { cn } from "@/lib/utils";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { useLocale } from "next-intl";
import { getQueueCount } from "@/lib/offline/offline-db";
import {
getAllQueueItems,
getQueueCount,
removeQueueItem,
markQueueItemFailed,
} from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client";
/** Manual retry — fires one sync pass immediately (used as onClick). */
async function runManualSync(
setSyncing: (v: boolean) => void,
setQueueCount: (n: number) => void
) {
if (!navigator.onLine) return;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
await removeQueueItem(item.id);
} catch {
await markQueueItemFailed(item.id);
}
}
} finally {
setSyncing(false);
setQueueCount(await getQueueCount());
}
}
drainOutbox,
getActiveOutboxCount,
getFailedOutboxCount,
discardFailedOps,
} from "@/lib/offline/outbox";
export function SyncStatusIndicator() {
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
useSyncQueueStore();
const {
queueCount,
failedCount,
isSyncing,
isOnline,
setSyncing,
setQueueCount,
setFailedCount,
} = useSyncQueueStore();
const queryClient = useQueryClient();
const locale = useLocale();
const isFa = locale !== "en";
const show = !isOnline || queueCount > 0 || isSyncing;
if (!show) return null;
const recount = async () => {
setQueueCount((await getActiveOutboxCount()) + (await getQueueCount()));
setFailedCount(await getFailedOutboxCount());
};
// Manual retry — drains the REAL outbox (the engine the app actually uses),
// then refreshes server data and the counts.
const retry = async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) return;
if (isSyncing) return;
setSyncing(true);
try {
const res = await drainOutbox();
if (res.sent > 0) await queryClient.invalidateQueries();
} finally {
setSyncing(false);
await recount();
}
};
// Poisoned ops can never sync (permanent 4xx) — let the user clear them so the
// badge doesn't sit stuck forever.
const clearFailed = async () => {
await discardFailedOps();
await recount();
};
const showPending = !isOnline || queueCount > 0 || isSyncing;
const showFailed = !showPending && failedCount > 0;
if (!showPending && !showFailed) return null;
if (showFailed) {
return (
<button
type="button"
onClick={() => void clearFailed()}
title={isFa ? "حذف موارد ناموفق همگام‌سازی" : "Clear failed sync items"}
className="flex cursor-pointer items-center gap-1.5 rounded-full bg-red-100 px-2.5 py-1 text-[11px] font-medium text-red-800 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300"
>
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
<span>{isFa ? `${failedCount} ناموفق — پاک کردن` : `${failedCount} failed — clear`}</span>
</button>
);
}
const label = isFa
? !isOnline
@@ -72,13 +87,9 @@ export function SyncStatusIndicator() {
return (
<button
type="button"
onClick={() => void runManualSync(setSyncing, setQueueCount)}
onClick={() => void retry()}
disabled={isSyncing || !isOnline}
title={
isFa
? "برای همگام‌سازی دستی کلیک کنید"
: "Click to retry sync"
}
title={isFa ? "برای همگام‌سازی دستی کلیک کنید" : "Click to retry sync"}
className={cn(
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
"disabled:cursor-not-allowed",
@@ -13,7 +13,7 @@ import {
Search, Plus, Minus, Trash2, Send, CreditCard, SplitSquareHorizontal,
X, WifiOff, ShoppingCart, Users, Coffee, ArrowRight, LayoutGrid, Armchair,
Banknote, Check, Delete, ReceiptText, ShoppingBag, Loader2,
BadgePercent, Sparkles, Home, StickyNote,
BadgePercent, Sparkles, Home, StickyNote, Lock,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
@@ -26,7 +26,9 @@ import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submi
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { printReceipt } from "@/lib/api/print";
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
import { useConfirm } from "@/components/providers/confirm-provider";
import { Can } from "@/components/auth/can";
import { useHasPermission } from "@/lib/permissions";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
@@ -111,6 +113,12 @@ export function Pos2Screen() {
const activeOrderId = useCartStore((s) => s.activeOrderId);
const appliedCoupon = useCartStore((s) => s.appliedCoupon);
// Removing/reducing an item that's already been fired to the kitchen is a void —
// a cashier must NOT be able to do it (send food, then erase it). Gated on the
// VoidOrder permission; the unsent portion of a line stays freely editable.
const canVoid = useHasPermission("VoidOrder");
const confirm = useConfirm();
// local view state
const [view, setView] = useState<"board" | "order">("board");
const [activeTable, setActiveTable] = useState<TableBoardItem | null>(null);
@@ -282,9 +290,31 @@ export function Pos2Screen() {
try {
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
const payBranchId = payTarget.branchId ?? orderBranchId ?? undefined;
// Card leg: push the amount to the configured terminal and wait for it. A
// connected terminal that declines throws POS_DEVICE_* (caught below →
// nothing recorded). If no terminal is wired up the request is "skipped",
// so we have NO machine proof the card actually cleared.
let cardConfirmedByTerminal = false;
if (cardTotal > 0 && payBranchId) {
// push the card amount to the configured terminal (no-op/skip if none)
await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
const res = await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
cardConfirmedByTerminal = res.sent && !res.skipped;
}
// No integrated-terminal confirmation → make the cashier confirm the card
// was approved before we book it as paid; otherwise a declined card gets
// recorded as revenue the café never received.
if (cardTotal > 0 && !cardConfirmedByTerminal) {
setBusy(false); // hide the processing overlay so the dialog is interactive
const approved = await confirm({
title: "تأیید پرداخت کارتی",
description: `پرداخت کارتی ${fmt(cardTotal)} تومان روی دستگاه پوز با موفقیت انجام شد؟`,
confirmLabel: "بله، پرداخت شد",
cancelLabel: "خیر، لغو",
});
if (!approved) {
notify.error("ثبت پرداخت لغو شد");
return; // finally resets the guards; nothing recorded
}
setBusy(true);
}
await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, {
payments,
@@ -361,12 +391,31 @@ export function Pos2Screen() {
const ticketProps = {
cafeId,
canVoid,
// mark fully-sent lines so their note becomes read-only (a note-only change on
// an already-sent line would otherwise be silently dropped on the next send).
lines: live.map((l) => ({ ...l, synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity })),
// sentQty = how much of this line is already fired to the kitchen (the locked
// portion a non-void user may neither remove nor reduce below).
lines: live.map((l) => ({
...l,
sentQty: syncedQty[l.menuItem.id] ?? 0,
synced: (syncedQty[l.menuItem.id] ?? 0) >= l.quantity,
})),
subtotal, discount, tax, total, count, pendingCount,
onBump: (id: string, d: number) => { const l = items.find((x) => x.menuItem.id === id); if (l) updateQty(id, l.quantity + d); },
onRemove: removeItem, onSend: send, onPay: openPay, onSplit: openPay,
onBump: (id: string, d: number) => {
const l = items.find((x) => x.menuItem.id === id);
if (!l) return;
const sent = syncedQty[id] ?? 0;
// Block reducing below what's already been sent unless the user can void.
if (!canVoid && l.quantity + d < sent) return;
updateQty(id, l.quantity + d);
},
onRemove: (id: string) => {
const sent = syncedQty[id] ?? 0;
if (!canVoid && sent > 0) return; // can't delete an item already sent to the kitchen
removeItem(id);
},
onSend: send, onPay: openPay, onSplit: openPay,
onNote: (id: string, notes: string) => setNotes(id, notes),
canPrint: !!activeOrderId && !isLocalOrder(activeOrderId),
onPrintReceipt: printActiveReceipt,
@@ -620,7 +669,15 @@ export function Pos2Screen() {
{payTarget && (
<Pos2PaySheet
tableName={title}
amountDue={orderAmountDue(payTarget) || total}
// Charge the server's authoritative outstanding amount. Only a
// genuinely-local (offline) order has no server figure to trust, so
// only then fall back to the client-computed total. Never silently
// swap a real order's server amount for the POS's own 9% recompute.
amountDue={
isLocalOrder(payTarget.id)
? orderAmountDue(payTarget) || total
: orderAmountDue(payTarget)
}
loyaltyPoints={payLoyalty}
onClose={() => setPayTarget(null)}
onConfirm={confirmPay}
@@ -741,11 +798,11 @@ function Pos2Extras({ cafeId }: { cafeId: string }) {
}
// ── Order ticket ─────────────────────────────────────────────────────────────
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean };
type TicketLine = { menuItem: MenuItem; quantity: number; notes?: string; synced?: boolean; sentQty?: number };
function Ticket({
cafeId, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
cafeId, canVoid, lines, subtotal, discount, tax, total, count, pendingCount, onBump, onRemove, onNote, onSend, onPay, onSplit, canPrint, onPrintReceipt,
}: {
cafeId: string; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
cafeId: string; canVoid: boolean; lines: TicketLine[]; subtotal: number; discount: number; tax: number; total: number;
count: number; pendingCount: number;
onBump: (id: string, d: number) => void; onRemove: (id: string) => void;
onNote: (id: string, notes: string) => void;
@@ -771,8 +828,21 @@ function Ticket({
<p className="line-clamp-1 font-medium">{l.menuItem.name}</p>
<p className="text-xs text-muted-foreground">{fmt(l.menuItem.price)} تومان</p>
</div>
{(() => {
const sent = l.sentQty ?? 0;
const minusLocked = !canVoid && l.quantity <= sent;
const removeLocked = !canVoid && sent > 0;
return (
<>
<div className="flex items-center gap-1">
<button type="button" onClick={() => onBump(l.menuItem.id, -1)} className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95" aria-label="کم">
<button
type="button"
onClick={() => onBump(l.menuItem.id, -1)}
disabled={minusLocked}
title={minusLocked ? "این تعداد به آشپزخانه ارسال شده و قابل کاهش نیست" : undefined}
className="flex size-11 items-center justify-center rounded-lg bg-muted hover:bg-accent active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-muted"
aria-label="کم"
>
<Minus className="size-4" />
</button>
<span className="w-7 text-center font-bold">{fmt(l.quantity)}</span>
@@ -780,9 +850,22 @@ function Ticket({
<Plus className="size-4" />
</button>
</div>
{removeLocked ? (
<span
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground/60"
title="به آشپزخانه ارسال شده — برای حذف نیاز به دسترسی ابطال است"
aria-label="ارسال‌شده؛ قابل حذف نیست"
>
<Lock className="size-4" />
</span>
) : (
<button type="button" onClick={() => onRemove(l.menuItem.id)} className="flex size-9 items-center justify-center rounded-lg text-red-500 hover:bg-red-50" aria-label="حذف">
<Trash2 className="size-4" />
</button>
)}
</>
);
})()}
</div>
{l.synced ? (
// Already sent to the kitchen — note is read-only (can't be changed now).
@@ -1,13 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2, Radar } from "lucide-react";
import { apiGet, apiPatch } from "@/lib/api/client";
import {
listPrintAgents,
createPairingCode,
revokePrintAgent,
testPrintDevice,
deviceOptions,
scanNetwork,
type PairingCode,
type ScannedDevice,
} from "@/lib/api/print-agents";
import { ApiClientError } from "@/lib/api/client";
import { printErrorMessage } from "@/lib/api/print";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { useConfirm } from "@/components/providers/confirm-provider";
import { notify } from "@/lib/notify";
type BranchPrintSettings = {
branchId: string;
@@ -22,6 +37,8 @@ type BranchPrintSettings = {
wifiPassword?: string | null;
posDeviceIp?: string | null;
posDevicePort?: number | null;
receiptPrintDeviceId?: string | null;
kitchenPrintDeviceId?: string | null;
};
type SettingsPrinterPanelProps = {
@@ -46,6 +63,15 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
const [wifiPassword, setWifiPassword] = useState("");
const [posDeviceIp, setPosDeviceIp] = useState("");
const [posDevicePort, setPosDevicePort] = useState("8088");
const [receiptDeviceId, setReceiptDeviceId] = useState("");
const [kitchenDeviceId, setKitchenDeviceId] = useState("");
const { data: agents = [] } = useQuery({
queryKey: ["print-agents", cafeId],
queryFn: () => listPrintAgents(cafeId),
enabled: !!cafeId,
});
const devices = deviceOptions(agents);
const { data: branches = [], isLoading: branchesLoading } = useQuery({
queryKey: ["branches", cafeId],
@@ -77,6 +103,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
setWifiPassword(settings.wifiPassword ?? "");
setPosDeviceIp(settings.posDeviceIp ?? "");
setPosDevicePort(String(settings.posDevicePort ?? 8088));
setReceiptDeviceId(settings.receiptPrintDeviceId ?? "");
setKitchenDeviceId(settings.kitchenPrintDeviceId ?? "");
}, [settings]);
const save = useMutation({
@@ -95,6 +123,8 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
wifiPassword: wifiPassword.trim() || null,
posDeviceIp: posDeviceIp.trim() || null,
posDevicePort: parseInt(posDevicePort, 10) || 8088,
receiptPrintDeviceId: receiptDeviceId || null,
kitchenPrintDeviceId: kitchenDeviceId || null,
}
),
onSuccess: () => {
@@ -103,6 +133,37 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
},
});
const qc = useQueryClient();
const confirm = useConfirm();
const [pairing, setPairing] = useState<PairingCode | null>(null);
const createCode = useMutation({
mutationFn: () => createPairingCode(cafeId),
onSuccess: (c) => {
setPairing(c);
void qc.invalidateQueries({ queryKey: ["print-agents", cafeId] });
},
onError: () => notify.error(t("agents.codeError")),
});
const revoke = useMutation({
mutationFn: (id: string) => revokePrintAgent(cafeId, id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["print-agents", cafeId] }),
});
const testDevice = useMutation({
mutationFn: (deviceId: string) => testPrintDevice(cafeId, deviceId),
onSuccess: () => notify.success(t("testSent")),
onError: (err) => notify.error(printErrorMessage(err, t)),
});
const handleRevoke = async (id: string, name: string) => {
const ok = await confirm({
description: t("agents.revokeConfirm", { name }),
variant: "destructive",
confirmLabel: tCommon("confirm"),
});
if (ok) revoke.mutate(id);
};
if (branchesLoading) {
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
}
@@ -134,6 +195,127 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
</p>
) : null}
{/* Print servers — auto-discovered printers via the local agent */}
<section className="space-y-3 rounded-lg border border-border/80 bg-muted/20 p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-sm font-medium">
<Server className="size-4 text-[#0F6E56]" />
{t("agents.title")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">{t("agents.hint")}</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
disabled={createCode.isPending}
onClick={() => createCode.mutate()}
>
{createCode.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
{t("agents.add")}
</Button>
</div>
{pairing ? (
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/50 p-3 text-sm dark:bg-[#0F6E56]/10">
<p className="font-medium">{t("agents.pairingTitle")}</p>
<p className="my-2 text-center font-mono text-2xl font-bold tracking-[0.3em] text-[#0F6E56]">
{pairing.code}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">{t("agents.pairingSteps")}</p>
</div>
) : null}
{agents.length === 0 ? (
<p className="py-2 text-center text-xs text-muted-foreground">{t("agents.empty")}</p>
) : (
<ul className="space-y-2">
{agents.map((a) => (
<li key={a.id} className="rounded-lg border border-border/70 bg-background p-3">
<div className="flex flex-wrap items-center gap-2">
{a.online ? (
<Wifi className="size-4 shrink-0 text-emerald-600" />
) : (
<WifiOff className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">{a.name}</span>
<span className={a.online ? "text-[11px] text-emerald-600" : "text-[11px] text-muted-foreground"}>
{a.online ? t("agents.online") : t("agents.offline")}
</span>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
disabled={revoke.isPending}
onClick={() => handleRevoke(a.id, a.name)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
{a.devices.length > 0 ? (
<ul className="mt-2 space-y-1 ps-6">
{a.devices.map((d) => (
<li key={d.id} className="flex items-center gap-2 text-xs">
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{d.displayName} <span className="opacity-60">({d.kind})</span>
</span>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
disabled={!a.online || testDevice.isPending}
onClick={() => testDevice.mutate(d.id)}
>
{t("agents.test")}
</Button>
</li>
))}
</ul>
) : a.online ? (
<p className="mt-1 ps-6 text-[11px] text-muted-foreground">{t("agents.noDevices")}</p>
) : null}
</li>
))}
</ul>
)}
{devices.length > 0 ? (
<div className="grid gap-4 border-t border-border/60 pt-3 sm:grid-cols-2">
<LabeledField label={t("agents.receiptVia")} htmlFor="receipt-device">
<select
id="receipt-device"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={receiptDeviceId}
onChange={(e) => setReceiptDeviceId(e.target.value)}
>
<option value="">{t("agents.useIpInstead")}</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("agents.kitchenVia")} htmlFor="kitchen-device">
<select
id="kitchen-device"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={kitchenDeviceId}
onChange={(e) => setKitchenDeviceId(e.target.value)}
>
<option value="">{t("agents.useIpInstead")}</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
</option>
))}
</select>
</LabeledField>
</div>
) : null}
</section>
<section className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">
@@ -145,6 +327,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
dir="ltr"
className="text-end"
/>
<DetectButton
cafeId={cafeId}
ports="9100"
onPick={(ip, port) => {
setReceiptIp(ip);
setReceiptPort(String(port));
}}
/>
</LabeledField>
<LabeledField label={t("port")} htmlFor="receipt-port">
<Input
@@ -164,6 +354,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
dir="ltr"
className="text-end"
/>
<DetectButton
cafeId={cafeId}
ports="9100"
onPick={(ip, port) => {
setKitchenIp(ip);
setKitchenPort(String(port));
}}
/>
</LabeledField>
<LabeledField label={t("port")} htmlFor="kitchen-port">
<Input
@@ -242,6 +440,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
dir="ltr"
className="text-end"
/>
<DetectButton
cafeId={cafeId}
ports={posDevicePort.trim() || "8088"}
onPick={(ip, port) => {
setPosDeviceIp(ip);
setPosDevicePort(String(port));
}}
/>
</LabeledField>
<LabeledField label={t("port")} htmlFor="pos-device-port">
<Input
@@ -268,3 +474,75 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
</Card>
);
}
/**
* "Auto-detect" affordance for an IP field: asks the online print agent to scan
* the café LAN for the given ports and lets the owner pick a found host (which
* fills the IP + port) instead of typing an address by hand.
*/
function DetectButton({
cafeId,
ports,
onPick,
}: {
cafeId: string;
ports: string;
onPick: (ip: string, port: number) => void;
}) {
const t = useTranslations("print");
const [results, setResults] = useState<ScannedDevice[] | null>(null);
const scan = useMutation({
mutationFn: () => scanNetwork(cafeId, ports),
onSuccess: (devices) => setResults(devices),
onError: (e) =>
notify.error(
e instanceof ApiClientError && e.code === "AGENT_OFFLINE"
? t("detectOffline")
: t("detectNone"),
),
});
return (
<div className="mt-1.5">
<button
type="button"
onClick={() => {
setResults(null);
scan.mutate();
}}
disabled={scan.isPending}
title={t("detectHint")}
className="inline-flex items-center gap-1.5 rounded-md border border-input px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-accent disabled:opacity-60"
>
{scan.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Radar className="size-3.5" />
)}
{scan.isPending ? t("detecting") : t("detect")}
</button>
{results &&
(results.length === 0 ? (
<p className="mt-1.5 text-xs text-muted-foreground">{t("detectNone")}</p>
) : (
<div className="mt-1.5 space-y-1">
{results.map((d) => (
<button
key={`${d.ip}:${d.port}`}
type="button"
dir="ltr"
onClick={() => {
onPick(d.ip, d.port);
setResults(null);
}}
className="block w-full rounded-md border border-border/70 bg-background px-2.5 py-1 text-start font-mono text-xs hover:border-primary hover:bg-primary/5"
>
{d.ip}:{d.port}
</button>
))}
</div>
))}
</div>
);
}
@@ -12,6 +12,7 @@ import {
type KitchenStation,
} from "@/lib/api/kitchen-stations";
import { testPrinter, printErrorMessage } from "@/lib/api/print";
import { listPrintAgents, deviceOptions } from "@/lib/api/print-agents";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -37,6 +38,14 @@ function StationForm({
const [name, setName] = useState(station?.name ?? "");
const [ip, setIp] = useState(station?.printerIp ?? "");
const [port, setPort] = useState(String(station?.printerPort ?? 9100));
const [deviceId, setDeviceId] = useState(station?.printDeviceId ?? "");
const { data: agents = [] } = useQuery({
queryKey: ["print-agents", cafeId],
queryFn: () => listPrintAgents(cafeId),
enabled: !!cafeId,
});
const devices = deviceOptions(agents);
const save = useMutation({
mutationFn: () => {
@@ -44,6 +53,7 @@ function StationForm({
name: name.trim(),
printerIp: ip.trim() || null,
printerPort: parseInt(port, 10) || 9100,
printDeviceId: deviceId || null,
};
return station
? updateKitchenStation(cafeId, station.id, body)
@@ -88,6 +98,23 @@ function StationForm({
/>
</LabeledField>
</div>
{devices.length > 0 ? (
<LabeledField label={t("agents.viaServer")} htmlFor="station-device">
<select
id="station-device"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
>
<option value="">{t("agents.useIpInstead")}</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.label}{d.online ? "" : ` (${t("agents.offline")})`}
</option>
))}
</select>
</LabeledField>
) : null}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onDone} disabled={save.isPending}>
{tCommon("cancel")}
@@ -16,7 +16,9 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
// z-[80]: a confirmation must sit above app overlays (the POS pay sheet is
// z-[60] and its busy overlay z-[70]); stays below toasts.
"fixed inset-0 z-[80] bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -33,7 +35,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"fixed left-[50%] top-[50%] z-[80] grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
@@ -14,6 +14,7 @@ export interface KitchenStation {
printerPort: number;
sortOrder: number;
categoryCount: number;
printDeviceId?: string | null;
}
export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]> {
@@ -22,7 +23,7 @@ export function fetchKitchenStations(cafeId: string): Promise<KitchenStation[]>
export function createKitchenStation(
cafeId: string,
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number }
body: { name: string; printerIp?: string | null; printerPort?: number; sortOrder?: number; printDeviceId?: string | null }
): Promise<KitchenStation> {
return apiPost<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations`, body);
}
@@ -30,7 +31,7 @@ export function createKitchenStation(
export function updateKitchenStation(
cafeId: string,
id: string,
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null }
body: { name?: string; printerIp?: string | null; printerPort?: number | null; sortOrder?: number | null; printDeviceId?: string | null }
): Promise<KitchenStation> {
return apiPatch<KitchenStation>(`/api/cafes/${cafeId}/kitchen-stations/${id}`, body);
}
+82
View File
@@ -0,0 +1,82 @@
import { apiGet, apiPost, apiDelete } from "@/lib/api/client";
/** A printer discovered & reported by a local print agent. */
export interface PrintAgentDevice {
id: string;
systemName: string;
displayName: string;
kind: string; // "usb" | "network" | "other"
lastSeenAt: string;
}
/** A local print bridge (cash-PC app) paired to the café. */
export interface PrintAgent {
id: string;
name: string;
branchId?: string | null;
online: boolean;
paired: boolean;
lastSeenAt?: string | null;
createdAt: string;
devices: PrintAgentDevice[];
}
export interface PairingCode {
agentId: string;
code: string;
expiresAt: string;
}
/** A flattened device option for a printer dropdown. */
export interface DeviceOption {
id: string;
label: string;
kind: string;
online: boolean;
}
export function listPrintAgents(cafeId: string): Promise<PrintAgent[]> {
return apiGet<PrintAgent[]>(`/api/cafes/${cafeId}/print-agents`);
}
export function createPairingCode(cafeId: string, name?: string): Promise<PairingCode> {
return apiPost<PairingCode>(`/api/cafes/${cafeId}/print-agents/pairing-code`, {
name: name?.trim() || null,
});
}
export function revokePrintAgent(cafeId: string, id: string): Promise<void> {
return apiDelete(`/api/cafes/${cafeId}/print-agents/${id}`);
}
export function testPrintDevice(cafeId: string, deviceId: string): Promise<unknown> {
return apiPost(`/api/cafes/${cafeId}/print-agents/devices/${deviceId}/test`, {});
}
/** A host found on the café LAN by an online agent's network scan. */
export interface ScannedDevice {
ip: string;
port: number;
kind: string; // "network-printer" | "pos-terminal" | "other"
}
/**
* Ask the café's online print agent(s) to scan the LAN for devices on the given
* comma-separated ports (e.g. "9100" for network printers, "8088" for terminals).
* Throws AGENT_OFFLINE if no agent is connected to do the scan.
*/
export function scanNetwork(cafeId: string, ports: string): Promise<ScannedDevice[]> {
return apiPost<ScannedDevice[]>(`/api/cafes/${cafeId}/print-agents/scan`, { ports });
}
/** Every device across all agents, for a printer-picker dropdown. */
export function deviceOptions(agents: PrintAgent[]): DeviceOption[] {
return agents.flatMap((a) =>
a.devices.map((d) => ({
id: d.id,
label: `${a.name} · ${d.displayName}`,
kind: d.kind,
online: a.online,
})),
);
}
@@ -40,6 +40,9 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit),
enabled: !!cafeId,
refetchInterval: 60_000,
// Keep polling even when the tab is in the background, so the unread count
// stays current if the live socket missed something — no refresh needed.
refetchIntervalInBackground: true,
});
const refresh = useCallback(() => {
@@ -94,6 +94,7 @@ export function useTabBadge() {
queryFn: () => fetchNotifications(cafeId!, false, 50),
enabled: !!cafeId,
refetchInterval: 60_000,
refetchIntervalInBackground: true, // update the tab badge even when unfocused
});
const unread = data?.unreadCount ?? 0;
+21
View File
@@ -165,3 +165,24 @@ export async function getPoisonedOps(): Promise<OutboxOp[]> {
const ops = await getOutboxOps();
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
}
function isPoisoned(o: OutboxOp): boolean {
return o.status === "failed" && o.attempts >= MAX_ATTEMPTS;
}
/** Count of ops still worth retrying (excludes poisoned) — drives the "pending" badge. */
export async function getActiveOutboxCount(): Promise<number> {
return (await getOutboxOps()).filter((o) => !isPoisoned(o)).length;
}
/** Count of poisoned ops — surfaced separately so they don't inflate "pending". */
export async function getFailedOutboxCount(): Promise<number> {
return (await getOutboxOps()).filter(isPoisoned).length;
}
/** Permanently drop poisoned ops (user-initiated "clear failed"). Returns how many. */
export async function discardFailedOps(): Promise<number> {
const poisoned = (await getOutboxOps()).filter(isPoisoned);
for (const o of poisoned) await removeOutboxOp(o.id);
return poisoned.length;
}
@@ -6,11 +6,14 @@ import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import {
enqueueOutboxOp,
getAllQueueItems,
getOutboxCount,
getQueueCount,
removeQueueItem,
} from "@/lib/offline/offline-db";
import { drainOutbox } from "@/lib/offline/outbox";
import {
drainOutbox,
getActiveOutboxCount,
getFailedOutboxCount,
} from "@/lib/offline/outbox";
function newId(prefix: string): string {
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
@@ -75,15 +78,18 @@ async function migrateLegacyQueue(): Promise<void> {
* - refresh server data once writes have synced.
*/
export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
const { setQueueCount, setFailedCount, setSyncing, setOnline } = useSyncQueueStore();
const queryClient = useQueryClient();
const syncLock = useRef(false);
const refreshCount = useCallback(async () => {
const n = (await getOutboxCount()) + (await getQueueCount());
// Pending = retryable ops only; poisoned (failed after max retries) are shown
// separately so they never inflate the badge or keep it stuck above zero.
const n = (await getActiveOutboxCount()) + (await getQueueCount());
setQueueCount(n);
setFailedCount(await getFailedOutboxCount());
return n;
}, [setQueueCount]);
}, [setQueueCount, setFailedCount]);
const syncQueue = useCallback(async () => {
if (syncLock.current) return;
@@ -54,10 +54,23 @@ export function useOrderAlerts() {
accessTokenFactory: () =>
(typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "",
})
.withAutomaticReconnect()
// Retry FOREVER (capped backoff). The default policy gives up after ~30s,
// leaving the connection dead until a page refresh → missed notifications.
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (ctx) =>
Math.min(30000, 1000 * 2 ** Math.min(ctx.previousRetryCount, 5)),
})
.build();
let stopped = false;
const joinCafe = () => connection.invoke("JoinCafe", cafeId).catch(() => {});
// On reconnect the server group membership is gone — re-join or we silently
// stop receiving notifications. Also catch up anything missed while down.
connection.onreconnected(() => {
void joinCafe();
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
});
const severityFor = (type: string) => {
if (type === "table_call_waiter") return notify.warning;
@@ -104,14 +117,36 @@ export function useOrderAlerts() {
void connection
.start()
.then(() => {
if (!stopped) return connection.invoke("JoinCafe", cafeId);
if (!stopped) return joinCafe();
})
.catch(() => {
// connection/auth failed — alerts simply won't fire; no UI breakage
});
// If the connection fully dropped (gave up, or the device slept), bring it
// back when the network returns or the tab is focused again.
const ensureConnected = () => {
if (stopped) return;
if (connection.state === signalR.HubConnectionState.Disconnected) {
void connection
.start()
.then(() => {
if (!stopped) return joinCafe();
})
.then(() => qc.invalidateQueries({ queryKey: ["notifications", cafeId] }))
.catch(() => {});
}
};
const onVisible = () => {
if (document.visibilityState === "visible") ensureConnected();
};
window.addEventListener("online", ensureConnected);
document.addEventListener("visibilitychange", onVisible);
return () => {
stopped = true;
window.removeEventListener("online", ensureConnected);
document.removeEventListener("visibilitychange", onVisible);
void connection.stop();
};
}, [cafeId, locale, qc]);
@@ -1,14 +1,17 @@
import { create } from "zustand";
interface SyncQueueState {
/** Number of items waiting to be synced */
/** Number of items waiting to be synced (retryable; excludes poisoned) */
queueCount: number;
/** Ops that exhausted retries and need attention (won't auto-sync) */
failedCount: number;
/** True while a sync pass is running */
isSyncing: boolean;
/** Mirrors navigator.onLine (updated client-side) */
isOnline: boolean;
setQueueCount: (n: number) => void;
setFailedCount: (n: number) => void;
setSyncing: (v: boolean) => void;
setOnline: (v: boolean) => void;
incrementQueue: () => void;
@@ -17,10 +20,12 @@ interface SyncQueueState {
export const useSyncQueueStore = create<SyncQueueState>((set) => ({
queueCount: 0,
failedCount: 0,
isSyncing: false,
isOnline: true, // assume online until client hydrates
setQueueCount: (n) => set({ queueCount: Math.max(0, n) }),
setFailedCount: (n) => set({ failedCount: Math.max(0, n) }),
setSyncing: (v) => set({ isSyncing: v }),
setOnline: (v) => set({ isOnline: v }),
incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })),