diff --git a/EpsonPrintService/EpsonPrintService.csproj b/EpsonPrintService/EpsonPrintService.csproj
index 505cace..c490f69 100644
--- a/EpsonPrintService/EpsonPrintService.csproj
+++ b/EpsonPrintService/EpsonPrintService.csproj
@@ -5,7 +5,7 @@
net8.0
enable
enable
- 1.0.12
+ 1.0.14
diff --git a/EpsonPrintService/PrintServerBootstrapper.cs b/EpsonPrintService/PrintServerBootstrapper.cs
index 043cd7d..40f5906 100644
--- a/EpsonPrintService/PrintServerBootstrapper.cs
+++ b/EpsonPrintService/PrintServerBootstrapper.cs
@@ -1,6 +1,7 @@
using Inspectron.Epson;
using Inspectron.Epson.PrintServer;
using Inspectron.Epson.PrintServer.ConfigurationSources;
+using Inspectron.Epson.PrintServer.Hooks;
using Inspectron.Epson.PrintServer.JobSources;
using Inspectron.Epson.PrintServer.JobStatusReporters;
using Inspectron.Epson.PrintServer.PrinterAssinment;
@@ -17,6 +18,7 @@ public class PrintServerBootstrapper
{
private readonly EpsonPrintServiceConfiguration _config;
private readonly CancellationTokenSource _cts;
+ private readonly List _arrivedHandlers = new();
private StandardKernel? _kernel;
public bool IsRunning { get; private set; }
@@ -27,6 +29,17 @@ public class PrintServerBootstrapper
_cts = cts;
}
+ ///
+ /// Register a handler that fires once per print job as it arrives from the job source,
+ /// before it's enqueued on the printer queue. Register all handlers before calling .
+ /// Handler exceptions are logged and swallowed; they never block a print.
+ ///
+ public void OnPrintJobArrived(PrintJobArrivedHandler handler)
+ {
+ if (IsRunning) throw new InvalidOperationException("Register hooks before StartAsync().");
+ _arrivedHandlers.Add(handler);
+ }
+
public async Task StartAsync()
{
_kernel = new StandardKernel();
@@ -54,6 +67,13 @@ public class PrintServerBootstrapper
}
else
{
+ // hardcoded discovery
+ // quick and dirty fix for testing without discovery - just bind a preconfigured discovery service with known printer IPs
+ // DO NOT REMOVE !!!
+ //var discovery = new PreconfiguredDiscoveryService();
+ //discovery.AddPrinter("192.168.1.124", "TM-T30III");
+ //_kernel.Bind().ToConstant(discovery).InSingletonScope();\
+
_kernel.Bind().To().InSingletonScope();
_kernel.Bind().To();
}
@@ -73,6 +93,10 @@ public class PrintServerBootstrapper
_kernel.Bind().To().InSingletonScope();
_kernel.Bind().To();
_kernel.Bind().ToSelf().InSingletonScope();
+ foreach (var handler in _arrivedHandlers)
+ {
+ _kernel.Bind().ToConstant(handler);
+ }
_kernel.Bind().To().InSingletonScope();
_kernel.Bind().ToSelf().InSingletonScope();
_kernel.Bind().ToSelf().InSingletonScope();
diff --git a/EpsonPrintService/PuduRobotClient.cs b/EpsonPrintService/PuduRobotClient.cs
new file mode 100644
index 0000000..fabc2a1
--- /dev/null
+++ b/EpsonPrintService/PuduRobotClient.cs
@@ -0,0 +1,118 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace EpsonPrintService;
+
+public sealed class PuduRobotClient
+{
+ private readonly HttpClient _http;
+ private readonly string _baseUrl;
+ private readonly string _username;
+ private readonly string _password;
+ private string? _token;
+
+ public PuduRobotClient(string baseUrl, string username, string password, HttpClient? http = null)
+ {
+ _baseUrl = baseUrl.TrimEnd('/');
+ _username = username;
+ _password = password;
+ _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
+ }
+
+ public async Task SendToTableAsync(string robotSn, string targetPoint, CancellationToken ct = default)
+ {
+ await EnsureTokenAsync(ct);
+ try
+ {
+ await PostAddCommandAsync(robotSn, targetPoint, ct);
+ }
+ catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ // Token expired — refresh once and retry.
+ _token = null;
+ await EnsureTokenAsync(ct);
+ await PostAddCommandAsync(robotSn, targetPoint, ct);
+ }
+ }
+
+ private async Task EnsureTokenAsync(CancellationToken ct)
+ {
+ if (_token is not null) return;
+
+ using var resp = await _http.PostAsJsonAsync(
+ $"{_baseUrl}/api/Auth/login",
+ new { username = _username, password = _password },
+ ct);
+ resp.EnsureSuccessStatusCode();
+
+ var bodyText = await resp.Content.ReadAsStringAsync(ct);
+ var token = ExtractToken(bodyText);
+ if (string.IsNullOrEmpty(token))
+ throw new InvalidOperationException($"Pudu login: no token returned. Body: {bodyText}");
+
+ _token = token;
+ }
+
+ private async Task PostAddCommandAsync(string robotSn, string targetPoint, CancellationToken ct)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/Robots/add-command")
+ {
+ Content = JsonContent.Create(new { Sn = robotSn, TargetPoint = targetPoint })
+ };
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+
+ using var resp = await _http.SendAsync(req, ct);
+ resp.EnsureSuccessStatusCode();
+ }
+
+ // Recursive search so we match the token regardless of wrapping (e.g. {"data":{"token":"..."}}).
+ // Mirrors the behavior of `sed -n 's/.*"token":"\([^"]*\)".*/\1/p'` in send_robot.sh.
+ private static string? ExtractToken(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ return FindTokenProperty(doc.RootElement);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string? FindTokenProperty(JsonElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (var prop in element.EnumerateObject())
+ {
+ if ((prop.Name.Equals("token", StringComparison.OrdinalIgnoreCase)
+ || prop.Name.Equals("accessToken", StringComparison.OrdinalIgnoreCase))
+ && prop.Value.ValueKind == JsonValueKind.String)
+ {
+ return prop.Value.GetString();
+ }
+ }
+ foreach (var prop in element.EnumerateObject())
+ {
+ var nested = FindTokenProperty(prop.Value);
+ if (nested is not null) return nested;
+ }
+ return null;
+
+ case JsonValueKind.Array:
+ foreach (var item in element.EnumerateArray())
+ {
+ var nested = FindTokenProperty(item);
+ if (nested is not null) return nested;
+ }
+ return null;
+
+ default:
+ return null;
+ }
+ }
+}
diff --git a/EpsonPrintService/robot_control.md b/EpsonPrintService/robot_control.md
new file mode 100644
index 0000000..bccb909
--- /dev/null
+++ b/EpsonPrintService/robot_control.md
@@ -0,0 +1,138 @@
+# Robot Control — Send a Robot to a Table
+
+Step-by-step guide to authorize against the `PuduControl.DataSync.API` and dispatch a robot (by serial number) to a specific table (by table name).
+
+## Prerequisites
+
+- API running locally at `http://localhost:5022` (see `Properties/launchSettings.json`).
+- Credentials: `admin` / `admin`.
+- Robot serial number (`Sn`) and the exact table name configured as a destination point (`TargetPoint`) in the robot's shop.
+- A REST client (`curl`, Postman, HTTPie, etc.).
+
+All endpoints return a uniform envelope:
+
+```json
+{ "success": true, "data": { ... }, "errorMessage": null }
+```
+
+## Step 1 — Authorize (obtain a JWT)
+
+`POST /api/Auth/login`
+
+Request body:
+
+```json
+{ "username": "admin", "password": "admin" }
+```
+
+`curl` example:
+
+```bash
+curl -s -X POST http://localhost:5022/api/Auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin"}'
+```
+
+Sample response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "message": "Login successful",
+ "username": "admin",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "expiresIn": 86400
+ },
+ "errorMessage": null
+}
+```
+
+Copy `data.token` — every subsequent call must include it as:
+
+```
+Authorization: Bearer
+```
+
+The token is valid for 24 hours.
+
+## Step 2 — Send the robot to a table
+
+`POST /api/Robots/add-command` (requires `Authorization` header)
+
+Request body:
+
+| Field | Type | Description |
+|---------------|--------|--------------------------------------------------|
+| `Sn` | string | Robot serial number |
+| `TargetPoint` | string | Table name (must exist in the robot's shop map) |
+
+```json
+{ "Sn": "ROBOT_SERIAL_HERE", "TargetPoint": "TABLE_NAME_HERE" }
+```
+
+`curl` example:
+
+```bash
+TOKEN="eyJhbGciOi..." # token from Step 1
+
+curl -s -X POST http://localhost:5022/api/Robots/add-command \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $TOKEN" \
+ -d '{"Sn":"ROBOT_SERIAL_HERE","TargetPoint":"TABLE_NAME_HERE"}'
+```
+
+Success response:
+
+```json
+{
+ "success": true,
+ "data": { "message": "Command added successfully." },
+ "errorMessage": null
+}
+```
+
+The command is persisted and picked up by the `DataSyncHosted` background service, which forwards it to the robot through the Pudu API.
+
+## One-shot script
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE_URL="http://localhost:5022"
+ROBOT_SN="$1"
+TABLE_NAME="$2"
+
+TOKEN=$(curl -s -X POST "$BASE_URL/api/Auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin"}' \
+ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+curl -s -X POST "$BASE_URL/api/Robots/add-command" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $TOKEN" \
+ -d "{\"Sn\":\"$ROBOT_SN\",\"TargetPoint\":\"$TABLE_NAME\"}"
+```
+
+Usage: `./send_robot.sh `
+
+## Troubleshooting
+
+- **401 Unauthorized** — token missing, expired, or malformed. Repeat Step 1.
+- **`You do not have permission to control this robot.`** — the logged-in user does not own the shop this robot belongs to. Assign the shop to the user via `POST /api/Robots/assign-shop` (admin only).
+- **Target point not reached / ignored** — verify `TargetPoint` matches a point name on the robot's map exactly (case-sensitive). Use `GET /api/Robots/shops/{shopId}` to list available points.
+- **Listing available robots** — `GET /api/Robots/shops/{shopId}/robots` returns robots (and their `Sn`) in a shop; `GET /api/Robots/shops` lists shops the user can access.
+
+## Helpful companion endpoints
+
+| Purpose | Method & Path |
+|-------------------------------|---------------------------------------------------|
+| List accessible shops | `GET /api/Robots/shops` |
+| List robots in a shop | `GET /api/Robots/shops/{shopId}/robots` |
+| Get shop layout / points | `GET /api/Robots/shops/{shopId}` |
+| Get robot status | `GET /api/Robots/shops/{shopId}/status` |
+| List queued commands | `POST /api/Robots/list-commands` |
+| Cancel a queued command | `POST /api/Robots/delete-command` |
+
+All of the above require the `Authorization: Bearer ` header.
diff --git a/EpsonPrintService/send_robot.sh b/EpsonPrintService/send_robot.sh
new file mode 100644
index 0000000..f96ff83
--- /dev/null
+++ b/EpsonPrintService/send_robot.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 2 ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+BASE_URL="${BASE_URL:-https://pudu-api.tes.gd}"
+USERNAME="${USERNAME:-admin}"
+PASSWORD="${PASSWORD:-admin}"
+ROBOT_SN="$1"
+TABLE_NAME="$2"
+
+TOKEN=$(curl -fsS -X POST "$BASE_URL/api/Auth/login" \
+ -H "Content-Type: application/json" \
+ -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
+ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
+
+if [[ -z "$TOKEN" ]]; then
+ echo "Login failed: could not extract token." >&2
+ exit 1
+fi
+
+curl -fsS -X POST "$BASE_URL/api/Robots/add-command" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $TOKEN" \
+ -d "{\"Sn\":\"$ROBOT_SN\",\"TargetPoint\":\"$TABLE_NAME\"}"
+echo
diff --git a/EpsonTest/Program.cs b/EpsonTest/Program.cs
index 125957b..d58c249 100644
--- a/EpsonTest/Program.cs
+++ b/EpsonTest/Program.cs
@@ -3,9 +3,28 @@ using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.FinalReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
+using Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
using System.Text.Json;
+// Non-interactive mode: `dotnet run -- [html|epson]`
+// receiptType: kitchen | final | invoice | bar | orderitems | moneyreturn (or 0-5)
+if (args.Length > 0)
+{
+ int rt = ParseReceiptTypeArg(args[0]);
+ if (rt < 0)
+ {
+ Console.WriteLine($"Unknown receipt type: {args[0]}");
+ return;
+ }
+
+ int pt = args.Length > 1 && args[1].Equals("epson", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
+ Console.WriteLine($"Running {GetReceiptTypeName(rt)} on {(pt == 0 ? "HTML" : "Epson")} printer...\n");
+ await RunTest(rt, pt);
+ Console.WriteLine("\nDone.");
+ return;
+}
+
while (true)
{
Console.Clear();
@@ -18,10 +37,11 @@ while (true)
"InvoiceReceipt",
"BarReceipt",
"OrderItems",
+ "MoneyReturnReceipt",
"Exit"
});
- if (receiptType == 5) break;
+ if (receiptType == 6) break;
var printerTarget = ShowMenu("Select printer target:", new[]
{
@@ -96,9 +116,21 @@ static string GetReceiptTypeName(int index) => index switch
2 => "InvoiceReceipt",
3 => "BarReceipt",
4 => "OrderItems",
+ 5 => "MoneyReturnReceipt",
_ => "Unknown"
};
+static int ParseReceiptTypeArg(string arg) => arg.ToLowerInvariant() switch
+{
+ "0" or "kitchen" or "kitchenreceipt" => 0,
+ "1" or "final" or "finalreceipt" => 1,
+ "2" or "invoice" or "invoicereceipt" => 2,
+ "3" or "bar" or "barreceipt" => 3,
+ "4" or "orderitems" => 4,
+ "5" or "moneyreturn" or "moneyreturnreceipt" or "refund" or "creditnote" => 5,
+ _ => -1
+};
+
static async Task RunTest(int receiptType, int printerTarget)
{
switch (receiptType)
@@ -108,6 +140,7 @@ static async Task RunTest(int receiptType, int printerTarget)
case 2: await RunInvoiceReceiptTest(printerTarget); break;
case 3: await RunBarReceiptTest(printerTarget); break;
case 4: await RunOrderItemsTest(printerTarget); break;
+ case 5: await RunMoneyReturnReceiptTest(printerTarget); break;
}
}
@@ -235,3 +268,29 @@ static async Task RunOrderItemsTest(int printerTarget)
await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
}
}
+
+static async Task RunMoneyReturnReceiptTest(int printerTarget)
+{
+ var receipt = TestHelpers.BuildSampleMoneyReturnReceipt();
+ var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true });
+
+ var converter = new MoneyReturnReceiptConverter(lineWidth: 48, bigFontLineWidth: 24);
+ var printCommands = converter.Convert(serializedReceipt);
+
+ TestHelpers.PrintCommandsToConsole(printCommands);
+
+ if (printerTarget == 0)
+ {
+ var printer = await TestHelpers.CreateHtmlPrinterAsync(path: @".\money_return_test.html");
+ await TestHelpers.PrintCommandsToHtmlPrinterAsync(
+ printer,
+ printCommands,
+ logoPath: "ristorante-klinglers.ch-logo_white_bg.png");
+ Console.WriteLine("Output written to: money_return_test.html");
+ }
+ else
+ {
+ var printer = await TestHelpers.CreateEpsonPrinterAsync();
+ await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands);
+ }
+}
diff --git a/EpsonTest/TestHelpers.cs b/EpsonTest/TestHelpers.cs
index 310febe..0209d9d 100644
--- a/EpsonTest/TestHelpers.cs
+++ b/EpsonTest/TestHelpers.cs
@@ -5,6 +5,7 @@ using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe;
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
+using Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
namespace EpsonTest;
@@ -462,6 +463,48 @@ public static class TestHelpers
};
}
+ // Mirrors money_return_receipt_data.json (restaurant refund payload with
+ // Unix-ms timestamps and per-rate VAT breakdown).
+ public static MoneyReturnReceipt BuildSampleMoneyReturnReceipt()
+ {
+ return new MoneyReturnReceipt
+ {
+ RestaurantName = "Gaumenfreuden",
+ RestaurantPhoneNumber = "043 810 48 48",
+ RestaurantAddressLine1 = "Seestrasse 11",
+ RestaurantAddressLine2 = "8810 Horgen",
+ RestaurantWebsite = "https://gaumen-freuden.ch",
+ RestaurantVatNumber = null,
+ ThanksMessage = "Thank you for your order!",
+ NoVAT = false,
+ RefundReceiptId = null,
+ RefundNumber = 1,
+ RefundTimestamp = 1783522075649,
+ OriginalTransactionNumber = 186,
+ OriginalPaymentTimestamp = 1783521986035,
+ OriginalPaymentMethod = "Cash",
+ Currency = "CHF",
+ RefundType = "Full",
+ TipAmount = 0.3m,
+ RefundedAmount = 11.0m,
+ RefundPaymentMethod = "Cash",
+ RefundedItems = new List
+ {
+ new() { Name = "Cappuccino", Size = "", Quantity = 1, Price = 5.8m, TaxAbbr = "A" },
+ new() { Name = "Kaffee Crème", Size = "", Quantity = 1, Price = 4.9m, TaxAbbr = "A" }
+ },
+ StandardRate = new TaxRateBreakdown
+ {
+ Rate = 8.1m,
+ TotalAmount = 10.7m,
+ TotalAmountNetto = 9.90m,
+ TotalAmountTax = 0.80m
+ },
+ ReducedRate = new TaxRateBreakdown { Rate = 0m, TotalAmount = 0m, TotalAmountNetto = 0m, TotalAmountTax = 0m },
+ SpecialRateForAccommodation = new TaxRateBreakdown { Rate = 0m, TotalAmount = 0m, TotalAmountNetto = 0m, TotalAmountTax = 0m }
+ };
+ }
+
#endregion
#region Print Helpers
@@ -498,7 +541,7 @@ public static class TestHelpers
public static async Task PrintCommandsToEpsonPrinterAsync(
EpsonPrinter printer,
IEnumerable commands,
- bool useDelay = true,
+ bool useDelay = false,
int delayMs = 200)
{
await printer.FeedLinesAsync(1);
diff --git a/Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs b/Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs
new file mode 100644
index 0000000..e9ad144
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs
@@ -0,0 +1,75 @@
+using System.Text.Json;
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.Queue;
+using FinalReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe.FinalReceipt;
+using InvoiceReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.InvoiceReceipt;
+using KitchenReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt.KitchenReceipt;
+using OrderItemsReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.OrderItems.OrderItemsReceipt;
+
+namespace Inspectron.Epson.PrintServer.Hooks;
+
+public delegate Task PrintJobArrivedHandler(PrintJobArrivedContext ctx, CancellationToken cancellationToken);
+
+public sealed class PrintJobArrivedContext
+{
+ private KitchenReceiptModel? _kitchen;
+ private FinalReceiptModel? _final;
+ private InvoiceReceiptModel? _invoice;
+ private OrderItemsReceiptModel? _orderItems;
+ private bool _kitchenParsed, _finalParsed, _invoiceParsed, _orderItemsParsed;
+
+ public PrintJob Job { get; }
+ public int ReceiptTypeId => Job.Document.ReceiptType;
+ public ReceiptConverterFactory.EReceiptType ReceiptType => (ReceiptConverterFactory.EReceiptType)Job.Document.ReceiptType;
+ public string ContentJson => Job.Document.Content;
+
+ public PrintJobArrivedContext(PrintJob job)
+ {
+ Job = job;
+ }
+
+ public KitchenReceiptModel? AsKitchen()
+ {
+ if (_kitchenParsed) return _kitchen;
+ _kitchenParsed = true;
+ if (ReceiptType is ReceiptConverterFactory.EReceiptType.WorkareaTicket
+ or ReceiptConverterFactory.EReceiptType.NextCourse)
+ {
+ _kitchen = JsonSerializer.Deserialize(ContentJson);
+ }
+ return _kitchen;
+ }
+
+ public FinalReceiptModel? AsFinal()
+ {
+ if (_finalParsed) return _final;
+ _finalParsed = true;
+ if (ReceiptType == ReceiptConverterFactory.EReceiptType.Receipt)
+ {
+ _final = JsonSerializer.Deserialize(ContentJson);
+ }
+ return _final;
+ }
+
+ public InvoiceReceiptModel? AsInvoice()
+ {
+ if (_invoiceParsed) return _invoice;
+ _invoiceParsed = true;
+ if (ReceiptType == ReceiptConverterFactory.EReceiptType.Invoice)
+ {
+ _invoice = JsonSerializer.Deserialize(ContentJson);
+ }
+ return _invoice;
+ }
+
+ public OrderItemsReceiptModel? AsOrderItems()
+ {
+ if (_orderItemsParsed) return _orderItems;
+ _orderItemsParsed = true;
+ if (ReceiptType == ReceiptConverterFactory.EReceiptType.OrdersOverview)
+ {
+ _orderItems = JsonSerializer.Deserialize(ContentJson);
+ }
+ return _orderItems;
+ }
+}
diff --git a/Inspectron.Epson/PrintServer/PrintLoop.cs b/Inspectron.Epson/PrintServer/PrintLoop.cs
index 7bf5074..6c24640 100644
--- a/Inspectron.Epson/PrintServer/PrintLoop.cs
+++ b/Inspectron.Epson/PrintServer/PrintLoop.cs
@@ -1,4 +1,5 @@
-using Inspectron.Epson.PrintServer.PrintServices;
+using Inspectron.Epson.PrintServer.Hooks;
+using Inspectron.Epson.PrintServer.PrintServices;
using Inspectron.Epson.Queue;
using Microsoft.Extensions.Logging;
@@ -9,17 +10,19 @@ public class PrintLoop
private readonly global::Inspectron.Epson.Queue.PrintServer _printServer;
private readonly IPrintService _printService;
private readonly IPrintJobSource _jobSource;
-
+ private readonly IReadOnlyList _arrivedHandlers;
+
private readonly ILogger _logger;
private CancellationTokenSource? _cancellationSource;
private CancellationToken _cancellationToken;
- public PrintLoop(global::Inspectron.Epson.Queue.PrintServer printServer,IPrintService printService, IPrintJobSource jobSource, ILogger logger)
+ public PrintLoop(global::Inspectron.Epson.Queue.PrintServer printServer, IPrintService printService, IPrintJobSource jobSource, ILogger logger, IEnumerable? arrivedHandlers = null)
{
_printServer = printServer;
_printService = printService;
_jobSource = jobSource;
-
+ _arrivedHandlers = arrivedHandlers?.ToList() ?? (IReadOnlyList)Array.Empty();
+
_logger = logger;
}
@@ -35,7 +38,23 @@ public class PrintLoop
while (!_cancellationSource!.Token.IsCancellationRequested)
{
var job = await _jobSource.GetNextJobAsync(_cancellationToken);
-
+
+ if (_arrivedHandlers.Count > 0)
+ {
+ var ctx = new PrintJobArrivedContext(job);
+ foreach (var handler in _arrivedHandlers)
+ {
+ try
+ {
+ await handler(ctx, _cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Print job arrived hook threw (job {JobId})", job.JobId);
+ }
+ }
+ }
+
_printServer.SubmitJob(job.IP, job);
}
}
diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/Models.cs b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/Models.cs
new file mode 100644
index 0000000..43ef754
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/Models.cs
@@ -0,0 +1,97 @@
+using System.Text.Json.Serialization;
+
+namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
+
+///
+/// Data model for a refund / credit note ("money return") receipt.
+/// Matches the refund payload (see money_return_receipt_data.json / data (1).json):
+/// restaurant header info, original transaction reference, refunded items and the
+/// per-rate VAT breakdown. Timestamps are Unix epoch milliseconds.
+///
+public class MoneyReturnReceipt
+{
+ /// Base64-encoded restaurant logo (optional). Rendered by the print pipeline, not the text converter.
+ public string? RestaurantLogo { get; set; }
+
+ public string RestaurantName { get; set; }
+ public string RestaurantPhoneNumber { get; set; }
+ public string RestaurantAddressLine1 { get; set; }
+ public string RestaurantAddressLine2 { get; set; }
+ public string RestaurantWebsite { get; set; }
+
+ /// VAT registration number. Note the misspelled JSON key ("Restauant...").
+ [JsonPropertyName("RestauantVATNumber")]
+ public string? RestaurantVatNumber { get; set; }
+
+ public string ThanksMessage { get; set; }
+
+ /// When true the sale is not subject to VAT and the tax table is replaced by a note.
+ public bool NoVAT { get; set; }
+
+ public long? RefundReceiptId { get; set; }
+
+ /// Credit note number (shown as "Credit note No. {RefundNumber}").
+ public long RefundNumber { get; set; }
+
+ /// When the refund was issued, as Unix epoch milliseconds.
+ public long RefundTimestamp { get; set; }
+
+ /// Number of the original transaction/invoice being refunded ("Orig. invoice No.").
+ public long OriginalTransactionNumber { get; set; }
+
+ /// When the original payment was made, as Unix epoch milliseconds ("Orig. date").
+ public long OriginalPaymentTimestamp { get; set; }
+
+ /// Method the original payment was made with (e.g. "Cash").
+ public string OriginalPaymentMethod { get; set; }
+
+ public string Currency { get; set; }
+
+ /// Refund type, e.g. "Full" or "Partial" (rendered as "Full refund").
+ public string RefundType { get; set; }
+
+ /// Tip amount included in the refund. Shown as "incl. tip" when non-zero.
+ public decimal TipAmount { get; set; }
+
+ public List RefundedItems { get; set; } = new List();
+
+ /// Total refunded amount (positive). Rendered negative on the receipt.
+ public decimal RefundedAmount { get; set; }
+
+ /// Standard-rate VAT bucket. Note the misspelled JSON key ("Standart...").
+ [JsonPropertyName("StandartRate")]
+ public TaxRateBreakdown StandardRate { get; set; }
+
+ public TaxRateBreakdown ReducedRate { get; set; }
+
+ public TaxRateBreakdown SpecialRateForAccommodation { get; set; }
+
+ /// Method the refund was paid out with (e.g. "Cash").
+ public string RefundPaymentMethod { get; set; }
+
+ public string TerminalReceiptData { get; set; }
+ public string WebPaymentReceiptData { get; set; }
+}
+
+public class RefundedItem
+{
+ public string Name { get; set; }
+ public string Size { get; set; }
+ public int Quantity { get; set; }
+ public decimal Price { get; set; }
+ public string TaxAbbr { get; set; }
+}
+
+public class TaxRateBreakdown
+{
+ public decimal Rate { get; set; }
+
+ /// Gross amount at this rate.
+ public decimal TotalAmount { get; set; }
+
+ /// Net amount at this rate.
+ public decimal TotalAmountNetto { get; set; }
+
+ /// Tax amount at this rate.
+ public decimal TotalAmountTax { get; set; }
+}
diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/MoneyReturnReceiptConverter.cs b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/MoneyReturnReceiptConverter.cs
new file mode 100644
index 0000000..80e2d42
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/MoneyReturnReceiptConverter.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+
+namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
+
+public class MoneyReturnReceiptConverter : IReceiptConverter
+{
+ private readonly int _lineWidth;
+ private readonly int _bigFontLineWidth;
+
+ public MoneyReturnReceiptConverter(int lineWidth, int bigFontLineWidth)
+ {
+ _lineWidth = lineWidth;
+ _bigFontLineWidth = bigFontLineWidth;
+ }
+
+ public List Convert(string jsonContent)
+ {
+ var receipt = JsonSerializer.Deserialize(jsonContent);
+ var converter = new ReceiptConverter(_lineWidth, _bigFontLineWidth);
+ return converter.ConvertToPrintCommands(receipt);
+ }
+}
diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/ReceiptConverter.cs b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/ReceiptConverter.cs
new file mode 100644
index 0000000..bd21217
--- /dev/null
+++ b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/ReceiptConverter.cs
@@ -0,0 +1,154 @@
+namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
+
+///
+/// Builds the print command list for a refund / credit note receipt.
+/// Layout follows money_return_receipt.jpg: restaurant header, "Refund / Credit note"
+/// title, credit note + original transaction reference block, the negative credit total,
+/// the (negated) per-rate VAT breakdown and the thank-you footer.
+///
+public class ReceiptConverter
+{
+ private readonly int _lineWidth;
+ private readonly int _bigFontLineWidth;
+
+ public ReceiptConverter(int lineWidth = 48, int bigFontLineWidth = 24)
+ {
+ _lineWidth = lineWidth;
+ _bigFontLineWidth = bigFontLineWidth;
+ }
+
+ public List ConvertToPrintCommands(MoneyReturnReceipt receipt)
+ {
+ var commands = new List();
+
+ // Header - Restaurant info
+ commands.Add(new PrintCommand(Center(receipt.RestaurantName, false)));
+ commands.Add(new PrintCommand(Center(receipt.RestaurantAddressLine1, false)));
+ commands.Add(new PrintCommand(Center(receipt.RestaurantAddressLine2, false)));
+ commands.Add(new PrintCommand(Center(receipt.RestaurantPhoneNumber, false)));
+ if (!string.IsNullOrWhiteSpace(receipt.RestaurantWebsite))
+ commands.Add(new PrintCommand(Center(receipt.RestaurantWebsite, false)));
+ commands.Add(new PrintCommand(""));
+ commands.Add(new PrintCommand(""));
+
+ // Title between separators
+ commands.Add(new PrintCommand(new string('-', _lineWidth)));
+ commands.Add(new PrintCommand(Center("Refund / Credit note", false), isBold: true));
+ commands.Add(new PrintCommand(new string('-', _lineWidth)));
+ commands.Add(new PrintCommand(""));
+
+ // Credit note number + issue date
+ commands.Add(new PrintCommand(Justify(
+ $"Credit note No. {receipt.RefundNumber}",
+ FormatTimestamp(receipt.RefundTimestamp))));
+ commands.Add(new PrintCommand(""));
+
+ // Original transaction reference block
+ commands.Add(new PrintCommand(Justify("Orig. invoice No.:", receipt.OriginalTransactionNumber.ToString())));
+ commands.Add(new PrintCommand(Justify("Orig. date:", FormatTimestamp(receipt.OriginalPaymentTimestamp))));
+ commands.Add(new PrintCommand(Justify("Orig. payment method:", receipt.OriginalPaymentMethod ?? "")));
+ if (!string.IsNullOrEmpty(receipt.RefundType))
+ commands.Add(new PrintCommand(Justify("Type:", $"{receipt.RefundType} refund")));
+ commands.Add(new PrintCommand(""));
+
+ commands.Add(new PrintCommand(new string('-', _lineWidth)));
+ commands.Add(new PrintCommand(""));
+
+ // Included tip
+ if (receipt.TipAmount != 0)
+ {
+ commands.Add(new PrintCommand($"incl. tip {receipt.Currency}: {receipt.TipAmount:F2}".PadLeft(_lineWidth)));
+ commands.Add(new PrintCommand(""));
+ }
+
+ // Credit total (refund is shown as a negative amount), big + bold, centered
+ string creditLine = $"Credit: {-receipt.RefundedAmount:F2} {receipt.Currency}";
+ commands.Add(new PrintCommand(Center(creditLine, true), true, true));
+ commands.Add(new PrintCommand(""));
+
+ // Refund payment method line (e.g. "Cash: -11.00 CHF")
+ commands.Add(new PrintCommand($"{receipt.RefundPaymentMethod}: {-receipt.RefundedAmount:F2} {receipt.Currency}".PadLeft(_lineWidth)));
+ commands.Add(new PrintCommand(""));
+
+ // VAT breakdown (amounts negated for the refund)
+ if (receipt.NoVAT)
+ {
+ commands.Add(new PrintCommand("Not subject to value added tax"));
+ }
+ else
+ {
+ var rates = new List<(string Category, TaxRateBreakdown Rate)>();
+ AddIfPresent(rates, "A", receipt.StandardRate);
+ AddIfPresent(rates, "B", receipt.ReducedRate);
+ AddIfPresent(rates, "C", receipt.SpecialRateForAccommodation);
+
+ if (rates.Count > 0)
+ {
+ commands.Add(new PrintCommand("VAT %".PadRight(_lineWidth / 4)
+ + "Gross".PadLeft(_lineWidth / 4)
+ + "Net".PadLeft(_lineWidth / 4)
+ + "VAT".PadLeft(_lineWidth / 4)));
+
+ foreach (var (category, rate) in rates)
+ {
+ commands.Add(new PrintCommand(
+ ($"{category}:" + $"{rate.Rate}%".PadLeft(5)).PadRight(_lineWidth / 4)
+ + $"{-rate.TotalAmount:F2} {receipt.Currency}".PadLeft(_lineWidth / 4)
+ + $"{-rate.TotalAmountNetto:F2} {receipt.Currency}".PadLeft(_lineWidth / 4)
+ + $"{-rate.TotalAmountTax:F2} {receipt.Currency}".PadLeft(_lineWidth / 4)));
+ }
+ }
+ }
+
+ commands.Add(new PrintCommand(""));
+
+ // VAT registration number (if provided)
+ if (!string.IsNullOrWhiteSpace(receipt.RestaurantVatNumber))
+ {
+ commands.Add(new PrintCommand(Center(receipt.RestaurantVatNumber, false)));
+ }
+
+ commands.Add(new PrintCommand(""));
+ commands.Add(new PrintCommand(""));
+
+ // Footer
+ commands.Add(new PrintCommand(Center(receipt.ThanksMessage, false)));
+
+ return commands;
+ }
+
+ private static void AddIfPresent(List<(string, TaxRateBreakdown)> rates, string category, TaxRateBreakdown? rate)
+ {
+ if (rate != null && (rate.Rate != 0 || rate.TotalAmount != 0))
+ rates.Add((category, rate));
+ }
+
+ private static string FormatTimestamp(long unixMilliseconds)
+ {
+ return DateTimeOffset.FromUnixTimeMilliseconds(unixMilliseconds).LocalDateTime.ToString("HH:mm dd.MM.yyyy");
+ }
+
+ private string Justify(string left, string right)
+ {
+ left ??= "";
+ right ??= "";
+
+ int spaces = _lineWidth - left.Length - right.Length;
+ if (spaces < 1) spaces = 1;
+
+ return left + new string(' ', spaces) + right;
+ }
+
+ private string Center(string text, bool isBigFont)
+ {
+ int effectiveLineWidth = isBigFont ? _bigFontLineWidth : _lineWidth;
+
+ if (string.IsNullOrEmpty(text) || text.Length >= effectiveLineWidth)
+ return text;
+
+ int totalPadding = effectiveLineWidth - text.Length;
+ int leftPadding = totalPadding / 2;
+
+ return new string(' ', leftPadding) + text;
+ }
+}
diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs b/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs
index 5bde730..8c65f0d 100644
--- a/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs
+++ b/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs
@@ -20,6 +20,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory
EReceiptType.NextCourse => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
EReceiptType.OrdersOverview => new OrderItemsReceiptConverter(48, 24),
EReceiptType.Invoice => new InvoiceReceiptConverter(48, 24),
+ EReceiptType.MoneyReturn => new MoneyReturnReceipt.MoneyReturnReceiptConverter(48, 24),
_ => throw new ArgumentException($"Unknown receipt type: {receiptType}")
};
}
@@ -30,6 +31,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory
WorkareaTicket,
NextCourse,
OrdersOverview,
- Invoice
+ Invoice,
+ MoneyReturn
}
}
diff --git a/data (1).json b/data (1).json
new file mode 100644
index 0000000..19aa5b0
--- /dev/null
+++ b/data (1).json
@@ -0,0 +1,57 @@
+{
+ "RestaurantName":"Gaumenfreuden",
+ "RestaurantPhoneNumber":"043 810 48 48",
+ "RestaurantAddressLine1":"Seestrasse 11",
+ "RestaurantAddressLine2":"8810 Horgen",
+ "RestaurantWebsite":"https://gaumen-freuden.ch",
+ "RestauantVATNumber":null,
+ "ThanksMessage":"Thank you for your order!",
+ "NoVAT":false,
+ "RefundReceiptId":null,
+ "RefundNumber":1,
+ "RefundTimestamp":1783522075649,
+ "OriginalTransactionNumber":186,
+ "OriginalPaymentTimestamp":1783521986035,
+ "OriginalPaymentMethod":"Cash",
+ "Currency":"CHF",
+ "RefundType":"Full",
+ "TipAmount":0.3,
+ "RefundedItems":[
+ {
+ "Name":"Cappuccino",
+ "Size":"",
+ "Quantity":1,
+ "Price":5.8,
+ "TaxAbbr":"A"
+ },
+ {
+ "Name":"Kaffee Cr\u00E8me",
+ "Size":"",
+ "Quantity":1,
+ "Price":4.9,
+ "TaxAbbr":"A"
+ }
+ ],
+ "RefundedAmount":11.0,
+ "StandartRate":{
+ "Rate":8.1,
+ "TotalAmount":10.7,
+ "TotalAmountNetto":9.90,
+ "TotalAmountTax":0.80
+ },
+ "ReducedRate":{
+ "Rate":0,
+ "TotalAmount":0,
+ "TotalAmountNetto":0,
+ "TotalAmountTax":0
+ },
+ "SpecialRateForAccommodation":{
+ "Rate":0,
+ "TotalAmount":0,
+ "TotalAmountNetto":0,
+ "TotalAmountTax":0
+ },
+ "RefundPaymentMethod":"Cash",
+ "TerminalReceiptData":null,
+ "WebPaymentReceiptData":null
+}
\ No newline at end of file
diff --git a/global.json b/global.json
deleted file mode 100644
index 74ce97c..0000000
--- a/global.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "sdk": {
- "version": "8.0.419",
- "rollForward": "latestPatch"
- }
-}
diff --git a/money_return_receipt.jpg b/money_return_receipt.jpg
new file mode 100644
index 0000000..c07432c
Binary files /dev/null and b/money_return_receipt.jpg differ
diff --git a/money_return_receipt_data.json b/money_return_receipt_data.json
new file mode 100644
index 0000000..a3fcac1
--- /dev/null
+++ b/money_return_receipt_data.json
@@ -0,0 +1,58 @@
+{
+ "RestaurantLogo":null,
+ "RestaurantName":"Gaumenfreuden",
+ "RestaurantPhoneNumber":"043 810 48 48",
+ "RestaurantAddressLine1":"Seestrasse 11",
+ "RestaurantAddressLine2":"8810 Horgen",
+ "RestaurantWebsite":"https://gaumen-freuden.ch",
+ "RestauantVATNumber":null,
+ "ThanksMessage":"Thank you for your order!",
+ "NoVAT":false,
+ "RefundReceiptId":null,
+ "RefundNumber":1,
+ "RefundTimestamp":1783522075649,
+ "OriginalTransactionNumber":186,
+ "OriginalPaymentTimestamp":1783521986035,
+ "OriginalPaymentMethod":"Cash",
+ "Currency":"CHF",
+ "RefundType":"Full",
+ "TipAmount":0.3,
+ "RefundedItems":[
+ {
+ "Name":"Cappuccino",
+ "Size":"",
+ "Quantity":1,
+ "Price":5.8,
+ "TaxAbbr":"A"
+ },
+ {
+ "Name":"Kaffee Crème",
+ "Size":"",
+ "Quantity":1,
+ "Price":4.9,
+ "TaxAbbr":"A"
+ }
+ ],
+ "RefundedAmount":11.0,
+ "StandartRate":{
+ "Rate":8.1,
+ "TotalAmount":10.7,
+ "TotalAmountNetto":9.90,
+ "TotalAmountTax":0.80
+ },
+ "ReducedRate":{
+ "Rate":0,
+ "TotalAmount":0,
+ "TotalAmountNetto":0,
+ "TotalAmountTax":0
+ },
+ "SpecialRateForAccommodation":{
+ "Rate":0,
+ "TotalAmount":0,
+ "TotalAmountNetto":0,
+ "TotalAmountTax":0
+ },
+ "RefundPaymentMethod":"Cash",
+ "TerminalReceiptData":null,
+ "WebPaymentReceiptData":null
+}