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

@ -20,6 +20,8 @@ namespace Yavsc.Billing
DateTime? ValidationDate { get; }
PaymentSummary? PaymentSummary { get; }
bool GetIsAcquitted ();
string GetFileBaseName (IBillingService service);

View file

@ -0,0 +1,8 @@
namespace Yavsc.Billing;
public enum PaymentStatus
{
Unpaid,
PartiallyPaid,
Paid
}

View file

@ -0,0 +1,21 @@
namespace Yavsc.Billing;
public sealed class PaymentSummary
{
public PaymentSummary(decimal amountDue, decimal amountPaid)
{
ArgumentOutOfRangeException.ThrowIfNegative(amountDue);
ArgumentOutOfRangeException.ThrowIfNegative(amountPaid);
AmountDue = amountDue;
AmountPaid = amountPaid;
}
public decimal AmountDue { get; }
public decimal AmountPaid { get; }
public decimal AmountRemaining => Math.Max(0m, AmountDue - AmountPaid);
public bool IsAcquitted => AmountPaid >= AmountDue;
public PaymentStatus Status => IsAcquitted
? PaymentStatus.Paid
: AmountPaid > 0m ? PaymentStatus.PartiallyPaid : PaymentStatus.Unpaid;
}

View file

@ -89,6 +89,8 @@ namespace Yavsc.ApiControllers
}
_context.Entry(hairCutQuery).State = EntityState.Modified;
_context.Entry(hairCutQuery).Property(q => q.AmountDue).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.AmountPaid).IsModified = false;
try
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class InvoicePaymentAmounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "AmountDue",
table: "NominativeServiceCommands",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "AmountPaid",
table: "NominativeServiceCommands",
type: "numeric",
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AmountDue",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "AmountPaid",
table: "NominativeServiceCommands");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Yavsc.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("ProductVersion", "10.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -1454,6 +1454,14 @@ namespace Yavsc.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("AmountDue")
.HasColumnType("numeric");
b.Property<decimal>("AmountPaid")
.ValueGeneratedOnAdd()
.HasColumnType("numeric")
.HasDefaultValue(0m);
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text");

View file

@ -58,6 +58,10 @@ namespace Yavsc.Models
}
builder.UseIdentityByDefaultColumns();
builder.Entity<NominativeServiceCommand>()
.Property(command => command.AmountPaid)
.HasDefaultValue(0m);
builder.Entity<ResourceUsageRecord>(entity =>
{
entity.HasKey(r => r.Id);

View file

@ -80,9 +80,29 @@ namespace Yavsc.Models.Billing
public abstract System.Collections.Generic.List<IBillItem> GetBillItems();
/// <summary>
/// Null until an invoice total has been established; never inferred
/// from a provisional price or a payment gateway acknowledgement.
/// </summary>
public decimal? AmountDue { get; private set; }
public decimal AmountPaid { get; private set; }
[NotMapped]
public PaymentSummary? PaymentSummary => AmountDue.HasValue
? new PaymentSummary(AmountDue.Value, AmountPaid)
: null;
public void SetPaymentAmounts(decimal amountDue, decimal amountPaid)
{
var summary = new PaymentSummary(amountDue, amountPaid);
AmountDue = summary.AmountDue;
AmountPaid = summary.AmountPaid;
}
public bool GetIsAcquitted()
{
return Regularization?.IsOk() ?? false;
return PaymentSummary?.IsAcquitted ?? false;
}
public string GetFileBaseName(IBillingService billingService)

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();

View file

@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Workflow;
using Yavsc.Billing;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Payment;
using Yavsc.Models.Workflow;
using Yavsc.Services;
namespace Yavsc.Server.Tests;
public class PaymentSummaryTests
{
[Theory]
[InlineData(100, 0, 100, PaymentStatus.Unpaid, false)]
[InlineData(100, 30, 70, PaymentStatus.PartiallyPaid, false)]
[InlineData(100, 100, 0, PaymentStatus.Paid, true)]
[InlineData(100, 120, 0, PaymentStatus.Paid, true)]
[InlineData(0, 0, 0, PaymentStatus.Paid, true)]
public void Summary_distinguishes_partial_and_total_payment(
decimal due, decimal paid, decimal remaining, PaymentStatus status, bool acquitted)
{
var summary = new PaymentSummary(due, paid);
Assert.Equal(due, summary.AmountDue);
Assert.Equal(paid, summary.AmountPaid);
Assert.Equal(remaining, summary.AmountRemaining);
Assert.Equal(status, summary.Status);
Assert.Equal(acquitted, summary.IsAcquitted);
}
[Fact]
public void Summary_uses_exact_decimal_amounts()
{
var summary = new PaymentSummary(0.3m, 0.1m + 0.2m);
Assert.Equal(0m, summary.AmountRemaining);
Assert.True(summary.IsAcquitted);
Assert.False(new PaymentSummary(100m, 99.99m).IsAcquitted);
}
[Theory]
[InlineData(-1, 0, "amountDue")]
[InlineData(100, -1, "amountPaid")]
public void Negative_amounts_are_rejected(decimal due, decimal paid, string parameter)
{
var exception = Assert.Throws<ArgumentOutOfRangeException>(() => new PaymentSummary(due, paid));
Assert.Equal(parameter, exception.ParamName);
}
[Fact]
public void Gateway_success_without_known_amounts_does_not_acquit_the_bill()
{
IBillable command = new TestCommand
{
Regularization = new PayPalPayment { State = "SUCCESS" }
};
Assert.Null(command.PaymentSummary);
Assert.False(command.GetIsAcquitted());
}
[Fact]
public void Acquittal_and_document_name_follow_the_balance_not_gateway_success()
{
var command = new TestCommand
{
Id = 42,
Regularization = new PayPalPayment { State = "SUCCESS" }
};
var service = new TestBillingService();
command.SetPaymentAmounts(100m, 30m);
Assert.False(command.GetIsAcquitted());
Assert.Equal(PaymentStatus.PartiallyPaid, command.PaymentSummary!.Status);
Assert.Equal("facture-payment-summary-test-42", command.GetFileBaseName(service));
command.Regularization = null!;
command.SetPaymentAmounts(100m, 100m);
Assert.True(command.GetIsAcquitted());
Assert.Equal("facture-payment-summary-test-42-ack", command.GetFileBaseName(service));
}
[Fact]
public void Invalid_amount_update_leaves_previous_balance_unchanged()
{
var command = new TestCommand();
command.SetPaymentAmounts(100m, 30m);
Assert.Throws<ArgumentOutOfRangeException>(() => command.SetPaymentAmounts(50m, -1m));
Assert.Equal(100m, command.AmountDue);
Assert.Equal(30m, command.AmountPaid);
}
[Fact]
public async Task Amounts_and_derived_status_survive_reloading_the_command()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
long id;
using (var context = new ApplicationDbContext(options))
{
var command = new RdvQuery
{
ClientId = "client",
PerformerId = "provider",
ActivityCode = "test",
Reason = "Payment test",
UserCreated = "client",
UserModified = "client"
};
context.Add(command);
await context.SaveChangesAsync();
id = command.Id;
}
using (var context = new ApplicationDbContext(options))
{
var command = await context.RdvQueries.SingleAsync(q => q.Id == id, TestContext.Current.CancellationToken);
Assert.Null(command.AmountDue);
Assert.Equal(0m, command.AmountPaid);
Assert.False(command.GetIsAcquitted());
command.SetPaymentAmounts(100.50m, 30.25m);
await context.SaveChangesAsync();
}
using (var context = new ApplicationDbContext(options))
{
var command = await context.RdvQueries.SingleAsync(q => q.Id == id, TestContext.Current.CancellationToken);
Assert.Equal(100.50m, command.AmountDue);
Assert.Equal(30.25m, command.AmountPaid);
Assert.Equal(70.25m, command.PaymentSummary!.AmountRemaining);
Assert.Equal(PaymentStatus.PartiallyPaid, command.PaymentSummary.Status);
Assert.False(command.GetIsAcquitted());
command.SetPaymentAmounts(100.50m, 100.50m);
await context.SaveChangesAsync();
}
using (var context = new ApplicationDbContext(options))
{
var command = await context.RdvQueries.SingleAsync(q => q.Id == id, TestContext.Current.CancellationToken);
Assert.True(command.GetIsAcquitted());
Assert.Equal(0m, command.PaymentSummary!.AmountRemaining);
}
}
[Fact]
public void Command_amounts_cannot_be_set_by_request_json()
{
const string json = """{"AmountDue":100,"AmountPaid":100}""";
var newtonsoftCommand = Newtonsoft.Json.JsonConvert.DeserializeObject<RdvQuery>(json)!;
var systemTextCommand = System.Text.Json.JsonSerializer.Deserialize<RdvQuery>(json)!;
Assert.Null(newtonsoftCommand.AmountDue);
Assert.Equal(0m, newtonsoftCommand.AmountPaid);
Assert.Null(systemTextCommand.AmountDue);
Assert.Equal(0m, systemTextCommand.AmountPaid);
}
[Fact]
public void Payment_migration_adds_only_amounts_without_inventing_historical_payments()
{
var migration = new Yavsc.Migrations.InvoicePaymentAmounts();
Assert.Equal(2, migration.UpOperations.Count);
var columns = migration.UpOperations
.Cast<Microsoft.EntityFrameworkCore.Migrations.Operations.AddColumnOperation>()
.ToList();
Assert.All(columns, column => Assert.Equal("NominativeServiceCommands", column.Table));
var due = Assert.Single(columns, column => column.Name == "AmountDue");
Assert.True(due.IsNullable);
Assert.Null(due.DefaultValue);
var paid = Assert.Single(columns, column => column.Name == "AmountPaid");
Assert.False(paid.IsNullable);
Assert.Equal(0m, paid.DefaultValue);
Assert.Equal(2, migration.DownOperations.Count);
}
[Fact]
public void Payment_model_matches_the_postgresql_migration_snapshot()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql("Host=localhost;Database=model_comparison_only",
provider => provider.MigrationsAssembly("Yavsc.Org"))
.Options;
using var context = new ApplicationDbContext(options);
Assert.False(context.Database.HasPendingModelChanges());
}
private sealed class TestCommand : NominativeServiceCommand
{
public override long Id { get; set; }
public override string Description { get; set; } = "Test";
public override List<IBillItem> GetBillItems() => throw new NotSupportedException();
}
private sealed class TestBillingService : IBillingService
{
public Dictionary<string, string> BillingMap { get; } = new()
{
[nameof(TestCommand)] = "payment-summary-test"
};
public Task<IQuery> GetBillAsync(string billingCode, long queryId) => throw new NotSupportedException();
public Task<IUserSettings> GetPerformersSettingsAsync(string activityCode, string userId) => throw new NotSupportedException();
}
}