diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs
new file mode 100644
index 0000000..9fd9811
--- /dev/null
+++ b/src/food-market.shared/Pos/V1/SyncDtos.cs
@@ -0,0 +1,160 @@
+namespace foodmarket.Shared.Pos.V1;
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Контракты синхронизации сервер ↔ POS (Windows-касса food-market.pos).
+// Namespace `Pos.V1` — версионирование на уровне неймспейса: v2 рядом, без
+// breaking changes для v1. POS-клиент закрепляет за собой конкретную версию
+// через URL `/api/pos/v1/...` (плюс заголовок `X-Pos-Api-Version: 1` для дабл-чека).
+//
+// Все DTO — record'ы с required-полями: компилятор обяжет POS-разработчика
+// заполнить новые поля, если v1.X добавит обязательные. Опциональные поля —
+// nullable; default-значения избегаем чтобы baseline на проде совпадал с
+// баг-репортами «у меня поля null».
+// ──────────────────────────────────────────────────────────────────────────────
+
+/// Полная карточка товара для оффлайн-кассы. Включает только то, что
+/// нужно при пробивке: ID для FK, идентификаторы (имя/артикул/штрихкоды),
+/// единица измерения, упаковка (штучная/весовая/разливная), НДС и текущая
+/// розничная цена. Изображения и история — не передаём (вес sync-payload'а
+/// важен на медленных каналах KZ-периферии).
+public record ProductSyncDto
+{
+ public required Guid Id { get; init; }
+ public required string Name { get; init; }
+ public string? Article { get; init; }
+ /// Все штрихкоды товара (для поиска по сканеру). Первый — primary.
+ public required IReadOnlyList Barcodes { get; init; }
+ /// Код единицы измерения (шт., кг, л, м…), не FK — раскрывается на UI.
+ public required string UnitCode { get; init; }
+ /// Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).
+ public required int Packaging { get; init; }
+ public required decimal VatPercent { get; init; }
+ public required bool VatEnabled { get; init; }
+ public required bool IsMarked { get; init; }
+ /// true — товар архивный, на кассе не продаётся, оставлен для отчётов.
+ public required bool IsArchived { get; init; }
+ public DateTime UpdatedAt { get; init; }
+}
+
+/// Розничная цена товара. Один товар может иметь несколько цен
+/// (разные типы цен — обычная, для VIP, со скидкой); POS обычно использует
+/// «системный/главный» PriceType, его помечает IsSystem=true.
+public record PriceSyncDto
+{
+ public required Guid ProductId { get; init; }
+ public required Guid PriceTypeId { get; init; }
+ public required string PriceTypeName { get; init; }
+ public required bool IsSystem { get; init; }
+ public required decimal Amount { get; init; }
+ public required string CurrencyCode { get; init; }
+ public DateTime UpdatedAt { get; init; }
+}
+
+/// Текущий остаток на момент запроса. Не агрегируется на POS, просто
+/// показывается кассиру для подсказки/предупреждения «осталось 2 шт». Никаких
+/// reserve/available — это серверный учёт; на кассе только on-hand.
+public record StockSyncDto
+{
+ public required Guid ProductId { get; init; }
+ public required Guid StoreId { get; init; }
+ public required decimal Quantity { get; init; }
+ public DateTime AsOf { get; init; }
+}
+
+/// Контрагент (только покупатели — поставщики POS не нужны). Используется
+/// для лояльности/привязки чека к клиенту: ввёл телефон → нашёлся контакт.
+public record CounterpartySyncDto
+{
+ public required Guid Id { get; init; }
+ public required string Name { get; init; }
+ public string? Phone { get; init; }
+ public string? Email { get; init; }
+ public string? Bin { get; init; }
+ public string? Iin { get; init; }
+ public DateTime UpdatedAt { get; init; }
+}
+
+/// Ответ GET /api/pos/sync: все изменения после since.
+/// Клиент кеширует и шлёт её обратно в следующем
+/// since — серверная reference time, чтобы клок-дрейф кассы не пропустил
+/// записи. — id товаров, которые с last sync
+/// были архивированы (на POS нужно их пометить, не удалять историю продаж).
+public record PosSyncResponse
+{
+ public required DateTime ServerTime { get; init; }
+ public required IReadOnlyList Products { get; init; }
+ public required IReadOnlyList Prices { get; init; }
+ public required IReadOnlyList Stocks { get; init; }
+ public required IReadOnlyList Counterparties { get; init; }
+ public required IReadOnlyList DeletedProductIds { get; init; }
+}
+
+/// Одна продажа из оффлайн-кассы. POS пробил чек локально, теперь
+/// шлёт на сервер. ClientSaleId — Guid, который POS присвоил при
+/// пробитии; служит частью idempotency-ключа (если батч придёт повторно из-за
+/// сетевой ошибки, сервер вернёт прежний результат без двойного списания).
+/// Сумма к оплате уже посчитана POS'ом — сервер пересчитывает по своим
+/// ценам и сверяет; mismatch → строка попадает в `Failed` ответа.
+public record PosSaleDto
+{
+ public required Guid ClientSaleId { get; init; }
+ public required DateTime OccurredAt { get; init; }
+ public Guid? CustomerId { get; init; }
+ public Guid? CashierUserId { get; init; }
+ public required int Payment { get; init; } // PaymentMethod enum int (0=Cash,1=Card,2=BankTransfer,3=Bonus,99=Mixed)
+ public required decimal PaidCash { get; init; }
+ public required decimal PaidCard { get; init; }
+ public required IReadOnlyList Lines { get; init; }
+ public string? Notes { get; init; }
+}
+
+public record PosSaleLineDto
+{
+ public required Guid ProductId { get; init; }
+ public required decimal Quantity { get; init; }
+ public required decimal UnitPrice { get; init; }
+ public required decimal Discount { get; init; }
+ public decimal VatPercent { get; init; }
+}
+
+/// Батч продаж POS → сервер. — Guid,
+/// POS должен использовать СТАБИЛЬНЫЙ ключ на тот же набор продаж: повтор
+/// батча с тем же ключом гарантирует один и тот же результат без дублей
+/// в БД. (Внутри батча уникальность отдельных продаж обеспечивается
+/// PosSaleDto.ClientSaleId — он тоже идемпотентен.)
+public record PosSaleBatchDto
+{
+ public required Guid IdempotencyKey { get; init; }
+ public required IReadOnlyList Sales { get; init; }
+}
+
+/// Ответ на загрузку батча.
+/// — продажи, которые сервер реально провёл (Status=Posted)
+/// или нашёл уже проведённый по ClientSaleId (идемпотентность). С каждой —
+/// идентификатор серверной RetailSale.
+/// — продажи, которые отклонены (mismatch цен, отсутствие
+/// товара, нехватка остатка для проведения). POS должен показать кассиру и
+/// решить (перепробить с новой ценой, списать как акт расхождения).
+public record PosSaleBatchResponse
+{
+ public required Guid IdempotencyKey { get; init; }
+ public required IReadOnlyList Accepted { get; init; }
+ public required IReadOnlyList Failed { get; init; }
+ /// true — этот ответ построен по кешу прежнего батча с тем же
+ /// ключом, реальной обработки не было.
+ public required bool ReplayedFromCache { get; init; }
+}
+
+public record PosSaleAcceptedDto
+{
+ public required Guid ClientSaleId { get; init; }
+ public required Guid ServerSaleId { get; init; }
+ public required string ServerSaleNumber { get; init; }
+}
+
+public record PosSaleFailedDto
+{
+ public required Guid ClientSaleId { get; init; }
+ public required string Error { get; init; }
+ public string? Field { get; init; }
+}
diff --git a/tests/food-market.UnitTests/PosContractsTests.cs b/tests/food-market.UnitTests/PosContractsTests.cs
new file mode 100644
index 0000000..316692d
--- /dev/null
+++ b/tests/food-market.UnitTests/PosContractsTests.cs
@@ -0,0 +1,82 @@
+using System.Text.Json;
+using FluentAssertions;
+using foodmarket.Shared.Pos.V1;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// Контракт POS — public API: ломая его, мы ломаем все Windows-кассы
+/// в поле. Тестируем сериализацию round-trip + наличие required-полей.
+/// При попытке убрать поле или поменять тип компиляция этих тестов упадёт.
+public class PosContractsTests
+{
+ [Fact]
+ public void Product_sync_dto_round_trips()
+ {
+ var dto = new ProductSyncDto
+ {
+ Id = Guid.NewGuid(),
+ Name = "Молоко 1л",
+ Article = "M-001",
+ Barcodes = new[] { "1234567890123", "2345678901234" },
+ UnitCode = "л",
+ Packaging = 1,
+ VatPercent = 12m,
+ VatEnabled = true,
+ IsMarked = false,
+ IsArchived = false,
+ UpdatedAt = DateTime.UtcNow,
+ };
+ var json = JsonSerializer.Serialize(dto);
+ var back = JsonSerializer.Deserialize(json)!;
+ back.Should().BeEquivalentTo(dto);
+ }
+
+ [Fact]
+ public void Batch_dto_carries_idempotency_key()
+ {
+ var batch = new PosSaleBatchDto
+ {
+ IdempotencyKey = Guid.NewGuid(),
+ Sales = new[]
+ {
+ new PosSaleDto
+ {
+ ClientSaleId = Guid.NewGuid(),
+ OccurredAt = DateTime.UtcNow,
+ Payment = 0, PaidCash = 1000m, PaidCard = 0m,
+ Lines = new[]
+ {
+ new PosSaleLineDto
+ {
+ ProductId = Guid.NewGuid(), Quantity = 1m,
+ UnitPrice = 1000m, Discount = 0m, VatPercent = 12m,
+ },
+ },
+ },
+ },
+ };
+ var json = JsonSerializer.Serialize(batch);
+ var back = JsonSerializer.Deserialize(json)!;
+ back.IdempotencyKey.Should().Be(batch.IdempotencyKey);
+ back.Sales.Should().HaveCount(1);
+ back.Sales[0].Lines.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public void Sync_response_collects_all_groups()
+ {
+ var resp = new PosSyncResponse
+ {
+ ServerTime = DateTime.UtcNow,
+ Products = Array.Empty(),
+ Prices = Array.Empty(),
+ Stocks = Array.Empty(),
+ Counterparties = Array.Empty(),
+ DeletedProductIds = Array.Empty(),
+ };
+ var json = JsonSerializer.Serialize(resp);
+ var back = JsonSerializer.Deserialize(json)!;
+ back.Should().BeEquivalentTo(resp);
+ }
+}
diff --git a/tests/food-market.UnitTests/food-market.UnitTests.csproj b/tests/food-market.UnitTests/food-market.UnitTests.csproj
index 0152f53..bb3e72b 100644
--- a/tests/food-market.UnitTests/food-market.UnitTests.csproj
+++ b/tests/food-market.UnitTests/food-market.UnitTests.csproj
@@ -15,6 +15,7 @@
+