diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
index 35c79f8..6170444 100644
--- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs
+++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
@@ -96,7 +96,21 @@ public record RetailSaleInput(
/// Sprint 9: код промокода. Если задан — сервер найдёт active-
/// promotion с этим кодом, проверит период/MinSaleAmount/Scope и
/// применит скидку (PromotionDiscount/PromotionId/PromotionCode).
- string? PromotionCode = null);
+ string? PromotionCode = null,
+ /// Причина возврата ИНЫМ способом оплаты (не как в исходном чеке).
+ /// Обязательна, когда способ возврата отличается от способа оплаты чека-
+ /// основания; само изменение способа — только с правом администратора.
+ string? RefundReason = null);
+
+ private static string PaymentLabel(PaymentMethod m) => m switch
+ {
+ PaymentMethod.Cash => "наличные",
+ PaymentMethod.Card => "карта",
+ PaymentMethod.BankTransfer => "перевод",
+ PaymentMethod.Bonus => "бонусы",
+ PaymentMethod.Mixed => "смешанная",
+ _ => m.ToString(),
+ };
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
@@ -315,6 +329,16 @@ public async Task> Create([FromBody] RetailSaleInput
return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" });
if (refSale.Status != RetailSaleStatus.Posted)
return BadRequest(new { error = "Можно возвращать только из проведённого чека.", field = "referenceSaleId" });
+ // Способ возврата по умолчанию = способ оплаты исходного чека (как МойСклад).
+ // Иной способ — только с правом администратора и с указанием причины.
+ if (input.Payment != refSale.Payment)
+ {
+ var isAdmin = User.IsInRole("Admin") || User.IsInRole("SuperAdmin");
+ if (!isAdmin)
+ return StatusCode(403, new { error = $"Возврат способом «{PaymentLabel(input.Payment)}» отличается от оплаты чека («{PaymentLabel(refSale.Payment)}»). Нужно право администратора.", code = "refund-method-admin" });
+ if (string.IsNullOrWhiteSpace(input.RefundReason))
+ return BadRequest(new { error = "Укажите причину возврата иным способом оплаты.", field = "refundReason", code = "refund-reason-required" });
+ }
}
var sale = new RetailSale
@@ -329,7 +353,9 @@ public async Task> Create([FromBody] RetailSaleInput
Payment = input.Payment,
PaidCash = R(input.PaidCash),
PaidCard = R(input.PaidCard),
- Notes = input.Notes,
+ Notes = string.IsNullOrWhiteSpace(input.RefundReason)
+ ? input.Notes
+ : $"{input.Notes}{(string.IsNullOrWhiteSpace(input.Notes) ? "" : " · ")}Возврат иным способом: {input.RefundReason.Trim()}".Trim(),
IsReturn = input.IsReturn,
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
};
diff --git a/tests/food-market.IntegrationTests/RetailReturnParityTests.cs b/tests/food-market.IntegrationTests/RetailReturnParityTests.cs
new file mode 100644
index 0000000..29286f4
--- /dev/null
+++ b/tests/food-market.IntegrationTests/RetailReturnParityTests.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using FluentAssertions;
+using foodmarket.IntegrationTests.Support;
+using Xunit;
+
+namespace foodmarket.IntegrationTests;
+
+/// Паритет возврата покупателя (п.3): способ возврата по умолчанию =
+/// способ оплаты исходного чека; иной способ — только по праву администратора и
+/// с причиной.
+[Collection(ApiCollection.Name)]
+public class RetailReturnParityTests
+{
+ private readonly ApiFactory _factory;
+ public RetailReturnParityTests(ApiFactory factory) => _factory = factory;
+
+ private static string RandomBarcode()
+ => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
+
+ private async Task<(string saleId, string productId, ApiActor.Refs refs)> PostedCashSaleAsync(ApiActor api)
+ {
+ var refs = await api.LoadRefsAsync();
+ var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
+ // немного остатка
+ var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
+ {
+ date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
+ notes = "seed", lines = new[] { new { productId = p, quantity = 10m, unitCost = 50m } },
+ });
+ enter.EnsureSuccessStatusCode();
+ var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString();
+ (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
+
+ var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new
+ {
+ date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
+ payment = 0 /*Cash*/, paidCash = 100m, paidCard = 0m, isReturn = false,
+ lines = new[] { new { productId = p, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m, sortOrder = 0 } },
+ });
+ sale.StatusCode.Should().Be(HttpStatusCode.Created);
+ var saleId = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!;
+ (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
+ return (saleId, p, refs);
+ }
+
+ private object ReturnBody(string saleId, string productId, ApiActor.Refs refs, int payment, string? reason)
+ => new
+ {
+ date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
+ payment, paidCash = payment == 0 ? 100m : 0m, paidCard = payment == 1 ? 100m : 0m,
+ isReturn = true, referenceSaleId = saleId, refundReason = reason,
+ lines = new[] { new { productId, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m, sortOrder = 0 } },
+ };
+
+ [Fact]
+ public async Task Return_same_method_ok_different_method_needs_reason()
+ {
+ var api = new ApiActor(_factory.CreateClient());
+ await api.SignupAndLoginAsync($"ret-{Guid.NewGuid():N}"); // signup-пользователь = Admin
+ var (saleId, p, refs) = await PostedCashSaleAsync(api);
+
+ // Тот же способ (наличные) — ок.
+ var same = await api.Http.PostAsJsonAsync("/api/sales/retail", ReturnBody(saleId, p, refs, 0, null));
+ same.StatusCode.Should().Be(HttpStatusCode.Created, await same.Content.ReadAsStringAsync());
+
+ // Иной способ (карта) без причины — 400 (пользователь-админ проходит проверку права).
+ var noReason = await api.Http.PostAsJsonAsync("/api/sales/retail", ReturnBody(saleId, p, refs, 1, null));
+ noReason.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ (await noReason.Content.ReadAsStringAsync()).Should().Contain("причину");
+
+ // Иной способ с причиной — ок (админ + причина).
+ var withReason = await api.Http.PostAsJsonAsync("/api/sales/retail", ReturnBody(saleId, p, refs, 1, "покупатель просит на карту"));
+ withReason.StatusCode.Should().Be(HttpStatusCode.Created, await withReason.Content.ReadAsStringAsync());
+ }
+}