fix(money): нормализовать date-only даты документов к UTC (Npgsql timestamptz)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

UI (DateField) шлёт openingDate/date как "YYYY-MM-DD" → DateTime Kind=Unspecified
→ Npgsql не пишет в timestamp with time zone → 500 при создании счёта/платежа/
корректировки. MoneyMath.AsUtc нормализует (Unspecified→UTC as-is дата,
Local→ToUniversalTime, Utc→без изменений); применено в MoneyAccounts/
MoneyTransactions/CounterpartyAdjustments на всех сайтах записи даты.

Интеграционные тесты раньше слали DateTime.UtcNow (Kind=Utc) и баг не ловили —
добавлен Account_create_accepts_date_only_opening_date_from_ui (строго
"2026-10-07"). Money integration 8/8 ✓, unit 25/25 ✓.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-07 22:29:38 +05:00
parent 7e3841ad48
commit 174cb563bf
5 changed files with 43 additions and 8 deletions

View file

@ -2,6 +2,7 @@
using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money; using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Money; using foodmarket.Infrastructure.Money;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
@ -71,7 +72,7 @@ public async Task<ActionResult<AdjustmentRow>> Create([FromBody] AdjustmentInput
{ {
OrganizationId = orgId, OrganizationId = orgId,
Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct), Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date, Date = MoneyMath.AsUtc(input.Date),
CounterpartyId = input.CounterpartyId, CounterpartyId = input.CounterpartyId,
Direction = input.Direction, Direction = input.Direction,
Amount = input.Amount, Amount = input.Amount,
@ -99,7 +100,7 @@ public async Task<ActionResult<AdjustmentRow>> Update(Guid id, [FromBody] Adjust
return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." }); return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." });
if (input.Xmin is not null && input.Xmin.Value != 0) if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value; _db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value;
a.Date = input.Date; a.Date = MoneyMath.AsUtc(input.Date);
a.CounterpartyId = input.CounterpartyId; a.CounterpartyId = input.CounterpartyId;
a.Direction = input.Direction; a.Direction = input.Direction;
a.Amount = input.Amount; a.Amount = input.Amount;

View file

@ -2,6 +2,7 @@
using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy; using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money; using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -149,7 +150,7 @@ public async Task<ActionResult<MoneyAccountRow>> Create([FromBody] MoneyAccountI
StoreId = input.StoreId, StoreId = input.StoreId,
CurrencyId = input.CurrencyId, CurrencyId = input.CurrencyId,
OpeningBalance = input.OpeningBalance, OpeningBalance = input.OpeningBalance,
OpeningDate = input.OpeningDate, OpeningDate = MoneyMath.AsUtc(input.OpeningDate),
IsActive = input.IsActive, IsActive = input.IsActive,
IsDefault = false, IsDefault = false,
}; };
@ -183,7 +184,7 @@ public async Task<ActionResult<MoneyAccountRow>> Update(Guid id, [FromBody] Mone
// OpeningBalance/OpeningDate после создания менять нельзя — ломает // OpeningBalance/OpeningDate после создания менять нельзя — ломает
// инвариант «CurrentBalance = Opening + Σ Posted». // инвариант «CurrentBalance = Opening + Σ Posted».
if (a.OpeningBalance != input.OpeningBalance || a.OpeningDate != input.OpeningDate) if (a.OpeningBalance != input.OpeningBalance || a.OpeningDate != MoneyMath.AsUtc(input.OpeningDate))
return Problem("Начальный остаток и дата после создания счёта не редактируются.", return Problem("Начальный остаток и дата после создания счёта не редактируются.",
statusCode: 409, type: "https://food-market.kz/errors/opening-balance-locked"); statusCode: 409, type: "https://food-market.kz/errors/opening-balance-locked");

View file

@ -163,7 +163,7 @@ public async Task<ActionResult<MoneyTxRow>> Create([FromBody] MoneyTxInput input
{ {
OrganizationId = orgId, OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct), Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date, Date = MoneyMath.AsUtc(input.Date),
Kind = input.Kind, Kind = input.Kind,
Status = MoneyTransactionStatus.Draft, Status = MoneyTransactionStatus.Draft,
AccountId = input.AccountId, AccountId = input.AccountId,
@ -204,7 +204,7 @@ public async Task<ActionResult<MoneyTxRow>> Update(Guid id, [FromBody] MoneyTxIn
if (input.Xmin is not null && input.Xmin.Value != 0) if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value; _db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value;
mt.Date = input.Date; mt.Date = MoneyMath.AsUtc(input.Date);
mt.AccountId = input.AccountId; mt.AccountId = input.AccountId;
mt.CounterpartyId = input.CounterpartyId; mt.CounterpartyId = input.CounterpartyId;
mt.ExpenseItemId = input.ExpenseItemId; mt.ExpenseItemId = input.ExpenseItemId;
@ -292,7 +292,7 @@ public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input,
{ {
OrganizationId = orgId, OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct), Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date, Date = MoneyMath.AsUtc(input.Date),
Kind = MoneyTransactionKind.TransferOut, Kind = MoneyTransactionKind.TransferOut,
Status = MoneyTransactionStatus.Draft, Status = MoneyTransactionStatus.Draft,
AccountId = from.Id, AccountId = from.Id,
@ -304,7 +304,7 @@ public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input,
{ {
OrganizationId = orgId, OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct), Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date, Date = MoneyMath.AsUtc(input.Date),
Kind = MoneyTransactionKind.TransferIn, Kind = MoneyTransactionKind.TransferIn,
Status = MoneyTransactionStatus.Draft, Status = MoneyTransactionStatus.Draft,
AccountId = to.Id, AccountId = to.Id,

View file

@ -51,4 +51,17 @@ public static decimal AccountBalance(decimal openingBalance, decimal inSum, deci
/// переплатив (payed + add ≤ total + ε).</summary> /// переплатив (payed + add ≤ total + ε).</summary>
public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total) public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total)
=> currentPayed + addSum <= total + Eps; => currentPayed + addSum <= total + Eps;
/// <summary>Нормализует дату документа к UTC перед записью в
/// <c>timestamp with time zone</c>. Date-only из UI (<c>YYYY-MM-DD</c>)
/// десериализуется с <see cref="DateTimeKind.Unspecified"/> — Npgsql такой
/// в timestamptz не пишет (кидает 500), поэтому помечаем как UTC (та же
/// календарная дата, полночь UTC). Полный ISO с <c>Z</c> (Kind=Utc) —
/// as-is; локальный — переводим в UTC.</summary>
public static DateTime AsUtc(DateTime d) => d.Kind switch
{
DateTimeKind.Utc => d,
DateTimeKind.Local => d.ToUniversalTime(),
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc),
};
} }

View file

@ -42,6 +42,26 @@ public async Task Tenant_B_cannot_see_tenant_A_money_accounts()
((int)direct.StatusCode).Should().Be(404); ((int)direct.StatusCode).Should().Be(404);
} }
[Fact]
public async Task Account_create_accepts_date_only_opening_date_from_ui()
{
// UI (DateField) шлёт openingDate как "YYYY-MM-DD" (Kind=Unspecified).
// Регресс: такой DateTime Npgsql не писал в timestamptz → 500. Проверяем
// что контроллер нормализует к UTC (MoneyMath.AsUtc) и создаёт счёт.
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"dateonly-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
using var resp = await api.Http.PostAsJsonAsync("/api/money/accounts", new
{
name = "Касса (date-only)", kind = Cash, bankName = (string?)null, accountNumber = (string?)null,
storeId = refs.StoreId, currencyId = refs.CurrencyId,
openingBalance = 50_000m, openingDate = "2026-10-07", // ← строго date-only
isActive = true, isDefault = false,
});
resp.IsSuccessStatusCode.Should().BeTrue($"date-only openingDate должен приниматься: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync()}");
(await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("currentBalance").GetDecimal().Should().Be(50_000m);
}
[Fact] [Fact]
public async Task Account_balance_invariant_opening_plus_posted_movements() public async Task Account_balance_invariant_opening_plus_posted_movements()
{ {