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:
parent
ed51e62b29
commit
5d03b2e477
13 changed files with 5434 additions and 2 deletions
|
|
@ -20,6 +20,8 @@ namespace Yavsc.Billing
|
|||
|
||||
DateTime? ValidationDate { get; }
|
||||
|
||||
PaymentSummary? PaymentSummary { get; }
|
||||
|
||||
bool GetIsAcquitted ();
|
||||
|
||||
string GetFileBaseName (IBillingService service);
|
||||
|
|
|
|||
8
src/Yavsc.Abstract/Billing/PaymentStatus.cs
Normal file
8
src/Yavsc.Abstract/Billing/PaymentStatus.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Yavsc.Billing;
|
||||
|
||||
public enum PaymentStatus
|
||||
{
|
||||
Unpaid,
|
||||
PartiallyPaid,
|
||||
Paid
|
||||
}
|
||||
21
src/Yavsc.Abstract/Billing/PaymentSummary.cs
Normal file
21
src/Yavsc.Abstract/Billing/PaymentSummary.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
4867
src/Yavsc.Org/Migrations/20261019182518_InvoicePaymentAmounts.Designer.cs
generated
Normal file
4867
src/Yavsc.Org/Migrations/20261019182518_InvoicePaymentAmounts.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
209
src/test/Yavsc.Server.Tests/PaymentSummaryTests.cs
Normal file
209
src/test/Yavsc.Server.Tests/PaymentSummaryTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue