feat(billing): model invoice payment balances and settlement

Persist amounts due and paid, derive partial and full settlement, and protect balances during command updates. Add migration, focused tests and invoicing documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 19:32:22 +01:00
commit 5d03b2e477
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
13 changed files with 5434 additions and 2 deletions

View file

@ -1,8 +1,11 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Yavsc.ApiControllers;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Haircut;
@ -75,6 +78,14 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
Assert.NotEqual(0, created!.Id);
Assert.Equal("alice", created.ClientId);
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = await db.HairCutQueries.SingleAsync(q => q.Id == created.Id, TestContext.Current.CancellationToken);
command.SetPaymentAmounts(100m, 30m);
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
}
var getResponse = await http.GetAsync($"/api/v1/billing/Brush/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
@ -87,6 +98,15 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Brush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = await db.HairCutQueries.SingleAsync(q => q.Id == created.Id, TestContext.Current.CancellationToken);
Assert.Equal(100m, command.AmountDue);
Assert.Equal(30m, command.AmountPaid);
Assert.False(command.GetIsAcquitted());
}
var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
@ -111,6 +131,44 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
&& item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
}
[Fact]
public async Task Legacy_haircut_update_preserves_persisted_payment_amounts()
{
_fixture.ResetAndSeedHaircutGraph();
HairCutQuery payload;
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = await db.HairCutQueries.SingleAsync(TestContext.Current.CancellationToken);
command.SetPaymentAmounts(100m, 30m);
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
payload = Newtonsoft.Json.JsonConvert.DeserializeObject<HairCutQuery>(
Newtonsoft.Json.JsonConvert.SerializeObject(command))!;
}
Assert.Null(payload.AmountDue);
Assert.Equal(0m, payload.AmountPaid);
payload.AdditionalInfo = "Updated without payment amounts";
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var controller = new HairCutController(db, NullLoggerFactory.Instance);
var response = await controller.PutHairCutQuery(payload.Id, payload);
Assert.Equal(204, Assert.IsType<StatusCodeResult>(response).StatusCode);
}
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = await db.HairCutQueries.SingleAsync(q => q.Id == payload.Id, TestContext.Current.CancellationToken);
Assert.Equal(payload.AdditionalInfo, command.AdditionalInfo);
Assert.Equal(100m, command.AmountDue);
Assert.Equal(30m, command.AmountPaid);
Assert.False(command.GetIsAcquitted());
}
}
private async Task<long> GetPrestationIdAsync()
{
using var scope = _fixture.Services.CreateScope();