feat(invoicing): connect signed estimates and verified payment receipts

Freeze invoice totals and parties, record offline receipts and verify completed PayPal transactions. Protect idempotence and concurrent balance updates; add migration, API client, documentation and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 20:29:08 +01:00
commit 2dcbcd35b3
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
37 changed files with 6686 additions and 12 deletions

View file

@ -0,0 +1,84 @@
using System.Net.Http;
using Yavsc.Api.Client;
using Yavsc.Billing;
namespace PostIt.Tests;
public sealed class InvoicesApiClientTests
{
[Fact]
public async Task Client_maps_invoice_operations_and_forwards_cancellation()
{
var api = new RecordingApi();
var client = new InvoicesApiClient(api, () => "https://business.example/api/v1/");
var ct = TestContext.Current.CancellationToken;
await client.GenerateAsync(7, ct);
Assert.Equal(HttpMethod.Post, api.Method);
Assert.Equal("https://business.example/api/v1/invoices/from-estimate/7", api.Path);
Assert.Null(api.Body);
Assert.Equal(ct, api.Cancellation);
await client.GetAsync(42, ct);
Assert.Equal(HttpMethod.Get, api.Method);
Assert.Equal("https://business.example/api/v1/invoices/42", api.Path);
var receipt = new RecordInvoicePaymentRequest
{
Reference = "bank-1", Amount = 30m, Currency = "EUR",
ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(-1)
};
await client.RecordOfflineAsync(42, receipt, ct);
Assert.Equal(HttpMethod.Post, api.Method);
Assert.Equal("https://business.example/api/v1/invoices/42/payments/offline", api.Path);
Assert.Same(receipt, api.Body);
await client.VerifyPayPalAsync(42, "TX-1", ct);
Assert.Equal(HttpMethod.Post, api.Method);
Assert.Equal("https://business.example/api/v1/invoices/42/payments/paypal", api.Path);
Assert.Equal("TX-1", Assert.IsType<VerifyInvoicePayPalRequest>(api.Body).TransactionId);
Assert.Equal(ct, api.Cancellation);
}
[Fact]
public async Task Invalid_arguments_fail_before_transport()
{
var api = new RecordingApi();
var client = new InvoicesApiClient(api, () => "https://business.example/api/v1/");
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.GenerateAsync(0, ct));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.GetAsync(-1, ct));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.RecordOfflineAsync(42, null!, ct));
await Assert.ThrowsAsync<ArgumentException>(() => client.VerifyPayPalAsync(42, "", ct));
Assert.Null(api.Path);
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? Method { get; private set; }
public string? Path { get; private set; }
public object? Body { get; private set; }
public CancellationToken Cancellation { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Method = method;
Path = path;
Body = body;
Cancellation = ct;
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -34,6 +34,7 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var invoicesClient = new InvoicesApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl);
@ -104,6 +105,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton(invoicesClient);
services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(administrationClient);

View file

@ -0,0 +1,38 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Billing;
/// <summary>A frozen invoice line copied from the signed estimate.</summary>
public sealed record InvoiceLineResponse(string Name, string Description, int Count, decimal UnitaryCost);
/// <summary>An invoice and its cumulative settlement balance.</summary>
public sealed record InvoiceResponse(
long CommandId, long EstimateId, string Reference, string ClientId, string PerformerId,
DateTimeOffset CreatedAtUtc, string Currency, decimal AmountDue, decimal AmountPaid,
decimal AmountRemaining, string PaymentStatus, bool IsAcquitted,
IReadOnlyList<InvoiceLineResponse> Lines, IReadOnlyList<InvoicePaymentResponse> Payments);
/// <summary>An immutable recorded receipt, not an instruction to debit the client.</summary>
public sealed record InvoicePaymentResponse(
long Id, string Source, string Reference, decimal Amount, string Currency,
DateTimeOffset ReceivedAtUtc, DateTimeOffset RecordedAtUtc, string RecordedBy);
/// <summary>A receipt attested by the invoice's performer.</summary>
public sealed record RecordInvoicePaymentRequest
{
[Required, MaxLength(128)]
public required string Reference { get; init; }
public required decimal Amount { get; init; }
[Required, StringLength(3, MinimumLength = 3)]
public required string Currency { get; init; }
public required DateTimeOffset ReceivedAtUtc { get; init; }
}
/// <summary>A PayPal transaction identifier to verify on the server; no client amount is trusted.</summary>
public sealed record VerifyInvoicePayPalRequest
{
[Required, MaxLength(128)]
public required string TransactionId { get; init; }
}

View file

@ -0,0 +1,50 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Billing;
namespace Yavsc.Api.Client;
public sealed class InvoicesApiClient
{
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public InvoicesApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
}
public Task<InvoiceResponse> GenerateAsync(long estimateId, CancellationToken ct = default)
=> CallAsync(HttpMethod.Post, $"from-estimate/{PositiveId(estimateId)}", null, ct);
public Task<InvoiceResponse> GetAsync(long commandId, CancellationToken ct = default)
=> CallAsync(HttpMethod.Get, $"{PositiveId(commandId)}", null, ct);
public Task<InvoiceResponse> RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct = default)
=> CallAsync(HttpMethod.Post, $"{PositiveId(commandId)}/payments/offline",
request ?? throw new ArgumentNullException(nameof(request)), ct);
public Task<InvoiceResponse> VerifyPayPalAsync(long commandId, string transactionId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(transactionId);
return CallAsync(HttpMethod.Post, $"{PositiveId(commandId)}/payments/paypal",
new VerifyInvoicePayPalRequest { TransactionId = transactionId }, ct);
}
private Task<InvoiceResponse> CallAsync(HttpMethod method, string path, object? body, CancellationToken ct)
{
var baseAddress = _businessBaseAddress();
ArgumentException.ThrowIfNullOrWhiteSpace(baseAddress);
return _api.CallAsync<InvoiceResponse>(method, new Uri(new Uri(baseAddress, UriKind.Absolute), $"invoices/{path}").ToString(),
body: body, ct: ct);
}
private static long PositiveId(long id)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id);
return id;
}
}

View file

@ -89,6 +89,8 @@ namespace Yavsc.ApiControllers
{
return new ChallengeResult();
}
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null })
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
Response.ContentType = "text/x-tex";
return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true });
}
@ -103,6 +105,10 @@ namespace Yavsc.ApiControllers
return this.NotFound();
}
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice)
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName))
return Forbid();
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}

View file

@ -193,6 +193,8 @@ namespace Yavsc.Controllers
return BadRequest(ModelState);
}
}
if (_context.NominativeServiceCommands.Any(command => command.InvoiceEstimateId == id))
return Conflict(new { Error = "An invoiced estimate cannot be deleted." });
_context.Estimates.Remove(estimate);
_context.SaveChanges(User.GetUserId());

View file

@ -154,6 +154,9 @@ public class HairCutQueryApiController : Controller
return BadRequest(ModelState);
}
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
return Conflict(new { Error = "An invoiced command cannot change performer." });
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent;
@ -187,6 +190,8 @@ public class HairCutQueryApiController : Controller
return Forbid();
}
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -170,6 +170,8 @@ public class HairMultiCutQueryApiController : Controller
return BadRequest(ModelState);
}
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
return Conflict(new { Error = "An invoiced command cannot change performer." });
_context.RemoveRange(existing.Prestations);
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
@ -203,6 +205,8 @@ public class HairMultiCutQueryApiController : Controller
return Forbid();
}
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.RemoveRange(query.Prestations);
_context.HairMultiCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Billing;
using Yavsc.Server.Helpers;
using Yavsc.Services;
namespace Yavsc.ApiControllers;
[Authorize, ApiController, Route(Constants.APIPrefix + "/invoices")]
[Produces("application/json")]
[ApiExplorerSettings(GroupName = "invoicing")]
public sealed class InvoicesController(IInvoicingService invoices, ILogger<InvoicesController> logger) : ControllerBase
{
/// <summary>Creates a frozen invoice from an estimate signed by both parties, or returns its existing invoice.</summary>
[HttpPost("from-estimate/{estimateId:long}")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public Task<ActionResult<InvoiceResponse>> Generate(long estimateId, CancellationToken ct)
=> ExecuteAsync(() => invoices.GenerateAsync(estimateId, User.GetUserId(), IsAdmin, ct), created: true);
/// <summary>Returns the invoice, balance and receipt history to its parties.</summary>
[HttpGet("{commandId:long}")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public Task<ActionResult<InvoiceResponse>> Get(long commandId, CancellationToken ct)
=> ExecuteAsync(() => invoices.GetAsync(commandId, User.GetUserId(), IsAdmin, ct));
/// <summary>Records an offline receipt attested by the performer, idempotently by reference.</summary>
[HttpPost("{commandId:long}/payments/offline")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public Task<ActionResult<InvoiceResponse>> RecordOffline(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct)
=> ExecuteAsync(() => invoices.RecordOfflineAsync(commandId, request, User.GetUserId(), IsAdmin, ct));
/// <summary>Verifies a completed PayPal transaction server-side before recording its receipt.</summary>
[HttpPost("{commandId:long}/payments/paypal")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status502BadGateway)]
public Task<ActionResult<InvoiceResponse>> VerifyPayPal(long commandId, VerifyInvoicePayPalRequest request, CancellationToken ct)
=> ExecuteAsync(() => invoices.VerifyPayPalAsync(commandId, request.TransactionId, User.GetUserId(), IsAdmin, ct));
private bool IsAdmin => User.IsInRole(Constants.AdminGroupName);
private async Task<ActionResult<InvoiceResponse>> ExecuteAsync(Func<Task<InvoiceResponse>> action, bool created = false)
{
try
{
var invoice = await action();
return created
? CreatedAtAction(nameof(Get), new { commandId = invoice.CommandId }, invoice)
: Ok(invoice);
}
catch (InvoiceOperationException exception)
{
logger.LogWarning("Invoice operation rejected ({StatusCode}): {Reason}", exception.StatusCode, exception.Message);
return Problem(statusCode: exception.StatusCode, title: exception.Message);
}
}
}

View file

@ -162,6 +162,9 @@ public class RdvQueryApiController : Controller
return Forbid();
}
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
return Conflict(new { Error = "An invoiced command cannot change performer." });
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent;
@ -224,6 +227,8 @@ public class RdvQueryApiController : Controller
return Forbid();
}
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.RdvQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -88,9 +88,22 @@ namespace Yavsc.ApiControllers
return BadRequest();
}
var persisted = await _context.HairCutQueries.AsNoTracking().SingleOrDefaultAsync(q => q.Id == id);
if (persisted is null) return NotFound();
if (persisted.InvoiceEstimateId.HasValue
&& (persisted.ClientId != hairCutQuery.ClientId || persisted.PerformerId != hairCutQuery.PerformerId))
return Conflict(new { Error = "An invoiced command cannot change parties." });
_context.Entry(hairCutQuery).State = EntityState.Modified;
_context.Entry(hairCutQuery).Property(q => q.PaymentRevision).OriginalValue = persisted.PaymentRevision;
_context.Entry(hairCutQuery).Property(q => q.AmountDue).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.AmountPaid).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.InvoiceEstimateId).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.InvoicedAtUtc).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.InvoiceCurrency).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.InvoiceClientId).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.InvoicePerformerId).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.InvoiceLinesJson).IsModified = false;
_context.Entry(hairCutQuery).Property(q => q.PaymentRevision).IsModified = false;
try
{
@ -206,6 +219,8 @@ namespace Yavsc.ApiControllers
return NotFound();
}
if (hairCutQuery.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(hairCutQuery);
await _context.SaveChangesAsync();

View file

@ -0,0 +1,51 @@
@baseUrl = https://localhost:5001/api/v1
@providerToken = replace-with-provider-access-token
@clientToken = replace-with-client-access-token
@estimateId = 1
@commandId = 1
@paypalTransactionId = replace-with-completed-paypal-transaction-id
### Create an invoice from an estimate with both persisted signatures (performer)
POST {{baseUrl}}/invoices/from-estimate/{{estimateId}}
Authorization: Bearer {{providerToken}}
### Read its frozen lines, balance and receipt history (either party)
GET {{baseUrl}}/invoices/{{commandId}}
Authorization: Bearer {{clientToken}}
### Record an offline receipt (performer); replay the exact body to test idempotence
POST {{baseUrl}}/invoices/{{commandId}}/payments/offline
Authorization: Bearer {{providerToken}}
Content-Type: application/json
{
"reference": "bank-transfer-2026-001",
"amount": 30.00,
"currency": "EUR",
"receivedAtUtc": "2026-10-19T12:00:00Z"
}
### Verify an already captured PayPal transaction (either party); not a checkout token
POST {{baseUrl}}/invoices/{{commandId}}/payments/paypal
Authorization: Bearer {{clientToken}}
Content-Type: application/json
{
"transactionId": "{{paypalTransactionId}}"
}
### Missing invoice -> 404
GET {{baseUrl}}/invoices/999999999
Authorization: Bearer {{providerToken}}
### A client cannot attest an offline receipt -> 403
POST {{baseUrl}}/invoices/{{commandId}}/payments/offline
Authorization: Bearer {{clientToken}}
Content-Type: application/json
{
"reference": "not-authorized",
"amount": 30.00,
"currency": "EUR",
"receivedAtUtc": "2026-10-19T12:00:00Z"
}

View file

@ -72,6 +72,7 @@ internal class Program
// the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
services.AddPodcasts(builder.Configuration);
services.AddInvoicing(builder.Configuration);
// accepts any access token issued by identity server
services.AddAuthentication("Bearer")

View file

@ -0,0 +1,17 @@
using Yavsc.Services;
namespace Yavsc.Api.Services;
public static class InvoicingServiceCollectionExtensions
{
public static IServiceCollection AddInvoicing(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IInvoicingService, InvoicingService>();
services.Configure<PayPalSettings>(configuration.GetSection("Authentication:PayPal"));
services.AddHttpClient<IPayPalTransactionVerifier, PayPalTransactionVerifier>(client =>
client.Timeout = TimeSpan.FromSeconds(30));
services.AddOpenApi("invoicing", options =>
options.ShouldInclude = description => description.GroupName == "invoicing");
return services;
}
}

View file

@ -147,6 +147,8 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (query.ClientId != uid)
return new ChallengeResult();
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync();
return await Index();

View file

@ -158,6 +158,8 @@ namespace Yavsc.Controllers
public async Task<IActionResult> DeleteConfirmed(long id)
{
Project project = await _context.Project.SingleAsync(m => m.Id == id);
if (project.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.Project.Remove(project);
await _context.SaveChangesAsync();
return RedirectToAction("Index");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class SignedEstimateInvoicing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "InvoiceClientId",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceCurrency",
table: "NominativeServiceCommands",
type: "character varying(3)",
maxLength: 3,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "InvoiceEstimateId",
table: "NominativeServiceCommands",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceLinesJson",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoicePerformerId",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "InvoicedAtUtc",
table: "NominativeServiceCommands",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "PaymentRevision",
table: "NominativeServiceCommands",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateTable(
name: "InvoicePayments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CommandId = table.Column<long>(type: "bigint", nullable: false),
Source = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ReferenceScope = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
Reference = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
ReceivedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RecordedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RecordedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoicePayments", x => x.Id);
table.ForeignKey(
name: "FK_InvoicePayments_NominativeServiceCommands_CommandId",
column: x => x.CommandId,
principalTable: "NominativeServiceCommands",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommands_InvoiceEstimateId",
table: "NominativeServiceCommands",
column: "InvoiceEstimateId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_InvoicePayments_CommandId",
table: "InvoicePayments",
column: "CommandId");
migrationBuilder.CreateIndex(
name: "IX_InvoicePayments_Source_ReferenceScope_Reference",
table: "InvoicePayments",
columns: new[] { "Source", "ReferenceScope", "Reference" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommands_Estimates_InvoiceEstimateId",
table: "NominativeServiceCommands",
column: "InvoiceEstimateId",
principalTable: "Estimates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommands_Estimates_InvoiceEstimateId",
table: "NominativeServiceCommands");
migrationBuilder.DropTable(
name: "InvoicePayments");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommands_InvoiceEstimateId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceClientId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceCurrency",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceEstimateId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceLinesJson",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoicePerformerId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoicedAtUtc",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "PaymentRevision",
table: "NominativeServiceCommands");
}
}
}

View file

@ -1442,6 +1442,61 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN");
});
modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<long>("CommandId")
.HasColumnType("bigint");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<DateTimeOffset>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecordedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Reference")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ReferenceScope")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.HasKey("Id");
b.HasIndex("CommandId");
b.HasIndex("Source", "ReferenceScope", "Reference")
.IsUnique();
b.ToTable("InvoicePayments");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.Property<long>("Id")
@ -1484,9 +1539,34 @@ namespace Yavsc.Migrations
.HasMaxLength(34)
.HasColumnType("character varying(34)");
b.Property<string>("InvoiceClientId")
.HasColumnType("text");
b.Property<string>("InvoiceCurrency")
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<long?>("InvoiceEstimateId")
.HasColumnType("bigint");
b.Property<string>("InvoiceLinesJson")
.HasColumnType("text");
b.Property<string>("InvoicePerformerId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("InvoicedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<Guid>("PaymentRevision")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000"));
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
@ -1514,6 +1594,9 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId");
b.HasIndex("InvoiceEstimateId")
.IsUnique();
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
@ -3953,6 +4036,17 @@ namespace Yavsc.Migrations
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b =>
{
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
.WithMany()
.HasForeignKey("CommandId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Command");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
@ -3967,6 +4061,11 @@ namespace Yavsc.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Billing.Estimate", null)
.WithMany()
.HasForeignKey("InvoiceEstimateId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");

View file

@ -18,6 +18,12 @@ namespace Yavsc.Helpers
string total = query.GetBillItems().Addition().ToString("C", CultureInfo.CurrentUICulture);
string bill = string.Join("\n", query.GetBillItems().Select(l=> $"{l.Name} {l.Description} : {l.UnitaryCost} € " + ((l.Count != 1) ? "*"+l.Count.ToString() : ""))) +
$"\n\nTotal: {total}";
if (query.PaymentSummary is { } payment)
{
bill += $"\nPaid: {payment.AmountPaid.ToString("C", CultureInfo.CurrentUICulture)}"
+ $"\nRemaining: {payment.AmountRemaining.ToString("C", CultureInfo.CurrentUICulture)}"
+ $"\nStatus: {payment.Status}";
}
return bill;
}

View file

@ -10,6 +10,7 @@ using Yavsc.ViewModels.PayPal;
using Yavsc.Models;
using Yavsc.Models.Payment;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
namespace Yavsc.Helpers
{
@ -72,7 +73,7 @@ namespace Yavsc.Helpers
public static SetExpressCheckoutResponseType CreatePayment(this HttpRequest request, string controllerName, NominativeServiceCommand query, string intent = "sale", ILogger logger = null)
{
var items = query.GetBillItems();
var total = items.Addition().ToString("F2");
var total = items.Addition().ToString("F2", CultureInfo.InvariantCulture);
var coreq = new SetExpressCheckoutReq {};
var urls = request.GetPaymentUrls(controllerName, query.Id.ToString());
@ -89,7 +90,7 @@ namespace Yavsc.Helpers
},
PaymentDetailsItem = new List <PaymentDetailsItemType> (
items.Select(i => new PaymentDetailsItemType {
Amount = new BasicAmountType { currencyID = CurrencyCodeType.EUR, value = i.UnitaryCost.ToString("F2") },
Amount = new BasicAmountType { currencyID = CurrencyCodeType.EUR, value = i.UnitaryCost.ToString("F2", CultureInfo.InvariantCulture) },
Name = i.Name,
Quantity = i.Count,
Description = i.Description

View file

@ -117,7 +117,8 @@ namespace Yavsc.Helpers
((db, id) =>
{
var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id);
query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId);
if (!query.InvoiceEstimateId.HasValue)
query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId);
return query;
}));

View file

@ -61,6 +61,22 @@ namespace Yavsc.Models
builder.Entity<NominativeServiceCommand>()
.Property(command => command.AmountPaid)
.HasDefaultValue(0m);
builder.Entity<NominativeServiceCommand>()
.Property(command => command.PaymentRevision)
.HasDefaultValue(Guid.Empty);
builder.Entity<NominativeServiceCommand>()
.HasOne<Estimate>().WithMany()
.HasForeignKey(command => command.InvoiceEstimateId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<NominativeServiceCommand>()
.HasIndex(command => command.InvoiceEstimateId).IsUnique();
builder.Entity<InvoicePayment>()
.HasIndex(payment => new { payment.Source, payment.ReferenceScope, payment.Reference })
.IsUnique();
builder.Entity<InvoicePayment>()
.HasOne(payment => payment.Command).WithMany()
.HasForeignKey(payment => payment.CommandId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<ResourceUsageRecord>(entity =>
{
@ -358,6 +374,7 @@ namespace Yavsc.Models
public DbSet<BlogTag> TagsDomain { get; set; }
public DbSet<EstimateTemplate> EstimateTemplates { get; set; }
public DbSet<InvoicePayment> InvoicePayments { get; set; }
public DbSet<Contact> Contact { get; set; }

View file

@ -0,0 +1,28 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace Yavsc.Models.Billing;
public sealed class InvoicePayment
{
public long Id { get; set; }
public long CommandId { get; set; }
[ForeignKey(nameof(CommandId)), JsonIgnore, Newtonsoft.Json.JsonIgnore]
public NominativeServiceCommand Command { get; set; } = null!;
[MaxLength(16)]
public string Source { get; set; } = "";
[MaxLength(450)]
public string ReferenceScope { get; set; } = "";
[MaxLength(128)]
public string Reference { get; set; } = "";
public decimal Amount { get; set; }
[MaxLength(3)]
public string Currency { get; set; } = "";
public DateTimeOffset ReceivedAtUtc { get; set; }
public DateTimeOffset RecordedAtUtc { get; set; }
[MaxLength(450)]
public string RecordedBy { get; set; } = "";
}

View file

@ -88,6 +88,50 @@ namespace Yavsc.Models.Billing
public decimal AmountPaid { get; private set; }
public long? InvoiceEstimateId { get; private set; }
public DateTimeOffset? InvoicedAtUtc { get; private set; }
[MaxLength(3)]
public string? InvoiceCurrency { get; private set; }
public string? InvoiceClientId { get; private set; }
public string? InvoicePerformerId { get; private set; }
[JsonIgnore, System.Text.Json.Serialization.JsonIgnore]
public string? InvoiceLinesJson { get; private set; }
[ConcurrencyCheck]
public Guid PaymentRevision { get; private set; }
public void EstablishInvoice(long estimateId, string currency, IReadOnlyList<InvoiceLineResponse> lines)
{
ArgumentNullException.ThrowIfNull(lines);
if (InvoiceEstimateId.HasValue || AmountDue.HasValue || AmountPaid != 0m)
throw new InvalidOperationException("A balance or invoice already exists for this command.");
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(estimateId);
if (currency != "EUR" || lines.Count == 0
|| lines.Any(line => line.Count <= 0 || decimal.Round(line.UnitaryCost, 2) != line.UnitaryCost))
throw new ArgumentException("An invoice requires EUR lines with positive quantities.");
var due = lines.Sum(line => checked(line.Count * line.UnitaryCost));
SetPaymentAmounts(due, 0m);
InvoiceEstimateId = estimateId;
InvoiceCurrency = currency;
InvoiceClientId = ClientId;
InvoicePerformerId = PerformerId;
var now = DateTimeOffset.UtcNow;
InvoicedAtUtc = new DateTimeOffset(now.UtcTicks - now.UtcTicks % 10, TimeSpan.Zero);
InvoiceLinesJson = System.Text.Json.JsonSerializer.Serialize(lines);
}
public List<IBillItem> GetInvoiceBillItems()
{
if (InvoiceLinesJson is null || InvoiceCurrency is null)
throw new InvalidOperationException("The invoice has no frozen lines.");
var lines = System.Text.Json.JsonSerializer.Deserialize<List<InvoiceLineResponse>>(InvoiceLinesJson)
?? throw new InvalidOperationException("The invoice lines are invalid.");
return lines.Select(line => (IBillItem)new CommandLine
{
Name = line.Name, Description = line.Description,
Count = line.Count, UnitaryCost = line.UnitaryCost, Currency = InvoiceCurrency
}).ToList();
}
[NotMapped]
public PaymentSummary? PaymentSummary => AmountDue.HasValue
? new PaymentSummary(AmountDue.Value, AmountPaid)
@ -95,9 +139,12 @@ namespace Yavsc.Models.Billing
public void SetPaymentAmounts(decimal amountDue, decimal amountPaid)
{
if (InvoiceEstimateId.HasValue && AmountDue != amountDue)
throw new InvalidOperationException("An established invoice total cannot be changed.");
var summary = new PaymentSummary(amountDue, amountPaid);
AmountDue = summary.AmountDue;
AmountPaid = summary.AmountPaid;
PaymentRevision = Guid.NewGuid();
}
public bool GetIsAcquitted()

View file

@ -60,6 +60,7 @@ namespace Yavsc.Models.Haircut
public override List<IBillItem> GetBillItems()
{
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
string longhairsuffix = " (cheveux longs)";
string halflonghairsuffix = " (cheveux mi-longs)";
string shorthairsuffix = " (cheveux courts)";

View file

@ -39,6 +39,7 @@ namespace Yavsc.Models.Haircut
public override List<IBillItem> GetBillItems()
{
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
throw new NotImplementedException();
}
}

View file

@ -44,6 +44,7 @@ namespace Yavsc.Server.Models.IT
}
public override List<IBillItem> GetBillItems()
{
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
return bill;
}
@ -62,4 +63,3 @@ namespace Yavsc.Server.Models.IT
}
}

View file

@ -71,6 +71,7 @@ namespace Yavsc.Models.Workflow
public override List<IBillItem> GetBillItems()
{
if (InvoiceEstimateId.HasValue) return GetInvoiceBillItems();
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,191 @@
#nullable enable
using Microsoft.EntityFrameworkCore;
using Yavsc.Billing;
using Yavsc.Models;
using Yavsc.Models.Billing;
namespace Yavsc.Services;
public interface IInvoicingService
{
Task<InvoiceResponse> GenerateAsync(long estimateId, string actorId, bool administrator, CancellationToken ct);
Task<InvoiceResponse> GetAsync(long commandId, string actorId, bool administrator, CancellationToken ct);
Task<InvoiceResponse> RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request, string actorId, bool administrator, CancellationToken ct);
Task<InvoiceResponse> VerifyPayPalAsync(long commandId, string transactionId, string actorId, bool administrator, CancellationToken ct);
}
public sealed class InvoiceOperationException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
public sealed class InvoicingService(ApplicationDbContext db, IPayPalTransactionVerifier payPal) : IInvoicingService
{
public async Task<InvoiceResponse> GenerateAsync(long estimateId, string actorId, bool administrator, CancellationToken ct)
{
var estimate = await db.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.SingleOrDefaultAsync(e => e.Id == estimateId, ct)
?? throw new InvoiceOperationException(404, "Estimate not found.");
RequirePerformer(actorId, estimate.OwnerId, administrator);
if (!estimate.CommandId.HasValue)
throw new InvoiceOperationException(409, "The estimate must be attached to a command.");
var command = await db.NominativeServiceCommands.SingleOrDefaultAsync(q => q.Id == estimate.CommandId, ct)
?? throw new InvoiceOperationException(409, "The estimate's command no longer exists.");
if (command.InvoiceEstimateId == estimateId)
{
RequirePerformer(actorId, command.InvoicePerformerId!, administrator);
return await ResponseAsync(command, ct);
}
if (command.InvoiceEstimateId.HasValue || command.AmountDue.HasValue || command.AmountPaid != 0m)
throw new InvoiceOperationException(409, "This command already has an invoice or a balance requiring reconciliation.");
if (command.ClientId != estimate.ClientId || command.PerformerId != estimate.OwnerId)
throw new InvoiceOperationException(409, "The estimate and command must have the same client and performer.");
if (estimate.ProviderValidationDate == default || estimate.ClientValidationDate == default
|| estimate.SignaturePro?.SignerId != estimate.OwnerId || estimate.SignatureClient?.SignerId != estimate.ClientId
|| estimate.SignaturePro.Strokes.Length == 0 || estimate.SignatureClient.Strokes.Length == 0)
throw new InvoiceOperationException(409, "Both parties must have signed and validated the estimate.");
if (estimate.Bill.Count == 0 || estimate.Bill.Any(line => line.Currency != "EUR" || line.Count <= 0
|| decimal.Round(line.UnitaryCost, 2) != line.UnitaryCost))
throw new InvoiceOperationException(409, "The estimate requires EUR lines with positive quantities and prices in whole cents.");
var lines = estimate.Bill.Select(line => new InvoiceLineResponse(
line.Name, line.Description, line.Count, line.UnitaryCost)).ToList();
decimal due;
try
{
due = lines.Sum(line => checked(line.Count * line.UnitaryCost));
}
catch (OverflowException)
{
throw new InvoiceOperationException(409, "The estimate total is too large.");
}
if (due < 0m)
throw new InvoiceOperationException(409, "The invoice total cannot be negative.");
command.EstablishInvoice(estimateId, "EUR", lines);
await SaveAsync(actorId, ct);
return await ResponseAsync(command, ct);
}
public async Task<InvoiceResponse> GetAsync(long commandId, string actorId, bool administrator, CancellationToken ct)
{
var command = await LoadAsync(commandId, actorId, administrator, ct);
return await ResponseAsync(command, ct);
}
public async Task<InvoiceResponse> RecordOfflineAsync(long commandId, RecordInvoicePaymentRequest request,
string actorId, bool administrator, CancellationToken ct)
{
var command = await LoadAsync(commandId, actorId, administrator, ct);
RequirePerformer(actorId, command.InvoicePerformerId!, administrator);
if (string.IsNullOrWhiteSpace(request.Reference) || request.Reference.Length > 128
|| request.Reference != request.Reference.Trim() || request.Amount <= 0m
|| decimal.Round(request.Amount, 2) != request.Amount
|| request.Currency != command.InvoiceCurrency || request.ReceivedAtUtc == default
|| request.ReceivedAtUtc > DateTimeOffset.UtcNow)
throw new InvoiceOperationException(400, "A positive receipt, invoice currency, reference and non-future receipt date are required.");
return await RecordAsync(command, "Offline", command.InvoicePerformerId!, request.Reference, request.Amount,
request.Currency, request.ReceivedAtUtc.ToUniversalTime(), actorId, ct);
}
public async Task<InvoiceResponse> VerifyPayPalAsync(long commandId, string transactionId,
string actorId, bool administrator, CancellationToken ct)
{
var command = await LoadAsync(commandId, actorId, administrator, ct);
if (string.IsNullOrWhiteSpace(transactionId) || transactionId.Length > 128 || transactionId != transactionId.Trim())
throw new InvoiceOperationException(400, "A PayPal transaction identifier is required.");
var receipt = await payPal.VerifyAsync(transactionId, command.GetInvoiceId(), command.InvoiceCurrency!, ct);
return await RecordAsync(command, "PayPal", "", receipt.TransactionId, receipt.Amount,
receipt.Currency, receipt.ReceivedAtUtc, actorId, ct);
}
private async Task<InvoiceResponse> RecordAsync(NominativeServiceCommand command, string source, string scope,
string reference, decimal amount, string currency, DateTimeOffset receivedAtUtc, string actorId, CancellationToken ct)
{
// PostgreSQL persists microseconds; normalize before comparing a replay.
receivedAtUtc = new DateTimeOffset(receivedAtUtc.UtcTicks - receivedAtUtc.UtcTicks % 10, TimeSpan.Zero);
var existing = await db.InvoicePayments.SingleOrDefaultAsync(
payment => payment.Source == source && payment.ReferenceScope == scope && payment.Reference == reference, ct);
if (existing is not null)
{
if (existing.CommandId != command.Id || existing.Amount != amount || existing.Currency != currency
|| existing.ReceivedAtUtc != receivedAtUtc)
throw new InvoiceOperationException(409, "The receipt reference is already used with different details.");
return await ResponseAsync(command, ct);
}
var payments = await db.InvoicePayments.Where(payment => payment.CommandId == command.Id).ToListAsync(ct);
var total = payments.Sum(payment => payment.Amount);
if (command.AmountPaid != total)
throw new InvoiceOperationException(409, "The recorded balance requires reconciliation with the receipt history.");
try
{
command.SetPaymentAmounts(command.AmountDue!.Value, checked(total + amount));
}
catch (OverflowException)
{
throw new InvoiceOperationException(400, "The cumulative payment amount is too large.");
}
db.InvoicePayments.Add(new InvoicePayment
{
CommandId = command.Id, Source = source, ReferenceScope = scope, Reference = reference,
Amount = amount, Currency = currency, ReceivedAtUtc = receivedAtUtc,
RecordedAtUtc = DateTimeOffset.UtcNow, RecordedBy = actorId
});
await SaveAsync(actorId, ct);
return await ResponseAsync(command, ct);
}
private async Task<NominativeServiceCommand> LoadAsync(long commandId, string actorId, bool administrator, CancellationToken ct)
{
var command = await db.NominativeServiceCommands.SingleOrDefaultAsync(q => q.Id == commandId, ct)
?? throw new InvoiceOperationException(404, "Invoice not found.");
if (!command.InvoiceEstimateId.HasValue)
throw new InvoiceOperationException(404, "Invoice not found.");
if (!administrator && actorId != command.InvoiceClientId && actorId != command.InvoicePerformerId)
throw new InvoiceOperationException(403, "Only the invoice parties may access it.");
return command;
}
private async Task<InvoiceResponse> ResponseAsync(NominativeServiceCommand command, CancellationToken ct)
{
var payments = await db.InvoicePayments.AsNoTracking().Where(payment => payment.CommandId == command.Id)
.OrderBy(payment => payment.Id).ToListAsync(ct);
var summary = new PaymentSummary(command.AmountDue
?? throw new InvalidOperationException("An established invoice must have a balance."),
payments.Sum(payment => payment.Amount));
var lines = command.GetInvoiceBillItems().Select(line =>
new InvoiceLineResponse(line.Name, line.Description, line.Count, line.UnitaryCost)).ToList();
return new InvoiceResponse(command.Id, command.InvoiceEstimateId!.Value, command.GetInvoiceId(),
command.InvoiceClientId!, command.InvoicePerformerId!, command.InvoicedAtUtc!.Value, command.InvoiceCurrency!,
summary.AmountDue, summary.AmountPaid, summary.AmountRemaining, summary.Status.ToString(), summary.IsAcquitted,
lines, payments.Select(payment => new InvoicePaymentResponse(
payment.Id, payment.Source, payment.Reference, payment.Amount, payment.Currency,
payment.ReceivedAtUtc, payment.RecordedAtUtc, payment.RecordedBy)).ToList());
}
private async Task SaveAsync(string actorId, CancellationToken ct)
{
try
{
await db.SaveChangesAsync(actorId, ct);
}
catch (DbUpdateConcurrencyException)
{
throw new InvoiceOperationException(409, "The invoice changed concurrently. Retry the same request.");
}
catch (DbUpdateException exception) when (exception.InnerException is
System.Data.Common.DbException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation })
{
throw new InvoiceOperationException(409, "The invoice or receipt reference already exists. Retry the same request.");
}
}
private static void RequirePerformer(string actorId, string performerId, bool administrator)
{
if (!administrator && actorId != performerId)
throw new InvoiceOperationException(403, "Only the performer may perform this operation.");
}
}

View file

@ -0,0 +1,77 @@
#nullable enable
using System.Globalization;
using Microsoft.Extensions.Options;
namespace Yavsc.Services;
public sealed record VerifiedPayPalReceipt(string TransactionId, decimal Amount, string Currency, DateTimeOffset ReceivedAtUtc);
public interface IPayPalTransactionVerifier
{
Task<VerifiedPayPalReceipt> VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct);
}
public sealed class PayPalTransactionVerifier(HttpClient http, IOptions<PayPalSettings> options) : IPayPalTransactionVerifier
{
public async Task<VerifiedPayPalReceipt> VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct)
{
var settings = options.Value;
var accounts = settings.Accounts?.Where(account => account.ApiUsername == settings.MerchantAccountUserName).ToArray();
if (accounts is not { Length: 1 })
throw new InvoiceOperationException(503, "PayPal transaction verification requires one matching merchant account.");
var credentials = accounts[0];
if (string.IsNullOrWhiteSpace(credentials.ApiUsername)
|| string.IsNullOrWhiteSpace(credentials.ApiPassword) || string.IsNullOrWhiteSpace(credentials.Signature)
|| string.IsNullOrWhiteSpace(settings.MerchantAccountId) || settings.Mode is not ("production" or "sandbox"))
throw new InvoiceOperationException(503, "PayPal transaction verification is not configured.");
var endpoint = settings.Mode == "production"
? "https://api-3t.paypal.com/nvp" : "https://api-3t.sandbox.paypal.com/nvp";
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["METHOD"] = "GetTransactionDetails", ["VERSION"] = "124.0",
["USER"] = credentials.ApiUsername, ["PWD"] = credentials.ApiPassword,
["SIGNATURE"] = credentials.Signature, ["TRANSACTIONID"] = transactionId
});
string body;
try
{
using var response = await http.PostAsync(endpoint, content, ct);
if (!response.IsSuccessStatusCode)
throw new InvoiceOperationException(502, "PayPal transaction verification failed.");
body = await response.Content.ReadAsStringAsync(ct);
}
catch (HttpRequestException)
{
throw new InvoiceOperationException(502, "PayPal is unavailable. No receipt was recorded.");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new InvoiceOperationException(504, "PayPal verification timed out. No receipt was recorded.");
}
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in body.Split('&'))
{
var parts = pair.Split('=', 2);
if (parts.Length != 2 || !fields.TryAdd(Decode(parts[0]), Decode(parts[1])))
throw new InvoiceOperationException(502, "PayPal returned an invalid verification response.");
}
string? Field(string name) => fields.GetValueOrDefault(name);
if (Field("ACK") is not ("Success" or "SuccessWithWarning"))
throw new InvoiceOperationException(502, "PayPal did not verify the transaction.");
if (Field("TRANSACTIONID") != transactionId || Field("INVNUM") != invoiceReference
|| Field("RECEIVERID") != settings.MerchantAccountId || Field("CURRENCYCODE") != currency
|| Field("PAYMENTSTATUS") != "Completed")
throw new InvoiceOperationException(409, "The PayPal receipt is not a completed payment for this invoice, currency and merchant.");
if (!decimal.TryParse(Field("AMT"), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var amount)
|| amount <= 0m || decimal.Round(amount, 2) != amount
|| !DateTimeOffset.TryParse(Field("ORDERTIME"), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var receivedAt) || receivedAt == default || receivedAt > DateTimeOffset.UtcNow)
throw new InvoiceOperationException(502, "PayPal returned an invalid receipt amount or date.");
return new VerifiedPayPalReceipt(transactionId, amount, currency, receivedAt.ToUniversalTime());
}
private static string Decode(string value) => Uri.UnescapeDataString(value.Replace("+", " "));
}

View file

@ -78,6 +78,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddInvoicing(builder.Configuration);
builder.Services.AddSingleton<RecordingMessageDelivery>();
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Yavsc.ApiControllers;
using Yavsc.Billing;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Haircut;
@ -140,6 +141,15 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = await db.HairCutQueries.SingleAsync(TestContext.Current.CancellationToken);
var estimate = new Yavsc.Models.Billing.Estimate
{
CommandId = command.Id, ClientId = command.ClientId, OwnerId = command.PerformerId,
CommandType = "Brush", Title = "Invoice test", Description = "Invoice test",
Bill = [], AttachedFiles = [], AttachedGraphics = []
};
db.Estimates.Add(estimate);
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
command.EstablishInvoice(estimate.Id, "EUR", [new InvoiceLineResponse("Service", "Service", 1, 100m)]);
command.SetPaymentAmounts(100m, 30m);
await db.SaveChangesAsync("alice", TestContext.Current.CancellationToken);
payload = Newtonsoft.Json.JsonConvert.DeserializeObject<HairCutQuery>(
@ -166,6 +176,11 @@ public sealed class HairCutQueryApiControllerTests : IClassFixture<ApiWebServerF
Assert.Equal(100m, command.AmountDue);
Assert.Equal(30m, command.AmountPaid);
Assert.False(command.GetIsAcquitted());
Assert.NotNull(command.InvoiceEstimateId);
Assert.Equal("EUR", command.InvoiceCurrency);
Assert.Equal("alice", command.InvoiceClientId);
Assert.Equal("alice", command.InvoicePerformerId);
Assert.Equal(100m, command.GetBillItems().Sum(item => item.Count * item.UnitaryCost));
}
}

View file

@ -0,0 +1,449 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class InvoiceApiTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateTimeOffset Received = new(2026, 1, 2, 12, 0, 0, TimeSpan.Zero);
private HttpClient Client(string? subject = "alice")
{
var http = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
}) { BaseAddress = new Uri(fixture.BaseAddress) };
if (subject is not null)
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, "api"));
return http;
}
private (long Estimate, long Command) Seed(bool providerSignature = true, bool clientSignature = true, bool reset = true)
{
if (reset) fixture.ResetAndSeedActivityGraph();
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = new RdvQuery
{
ClientId = "bob", PerformerId = "alice", ActivityCode = "dev",
Consent = true, Reason = "Invoice test", Location = db.Locations.Single(),
EventDate = DateTime.UtcNow.AddDays(1), UserCreated = "alice", UserModified = "alice"
};
db.Add(command);
db.SaveChanges();
var estimate = new Estimate
{
CommandId = command.Id, ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
Title = "Signed estimate", Description = "Invoice test",
AttachedFiles = [], AttachedGraphics = [],
ProviderValidationDate = DateTime.UtcNow, ClientValidationDate = DateTime.UtcNow,
Bill =
[
new CommandLine { Name = "Service", Description = "Service", Count = 2, UnitaryCost = 60m },
new CommandLine { Name = "Discount", Description = "Discount", Count = 1, UnitaryCost = -20m }
]
};
db.Add(estimate);
db.SaveChanges();
if (providerSignature) db.Signatures.Add(Signature(estimate.Id, SignatureType.Pro, "alice"));
if (clientSignature) db.Signatures.Add(Signature(estimate.Id, SignatureType.Client, "bob"));
db.SaveChanges();
return (estimate.Id, command.Id);
}
private static Signature Signature(long estimateId, SignatureType type, string signer)
=> new()
{
EstimateId = estimateId, Type = type, SignerId = signer, CoordinateMax = 10_000,
CapturedAtUtc = DateTime.UtcNow, Strokes = [2, 0, 0, 100, 100]
};
private static RecordInvoicePaymentRequest Receipt(string reference, decimal amount = 30m)
=> new() { Reference = reference, Amount = amount, Currency = "EUR", ReceivedAtUtc = Received };
private static async Task<InvoiceResponse> InvoiceAsync(HttpResponseMessage response, HttpStatusCode expected)
{
var body = await response.Content.ReadAsStringAsync(Ct);
Assert.True(response.StatusCode == expected, $"{response.StatusCode}: {body}");
return (await response.Content.ReadFromJsonAsync<InvoiceResponse>(Ct))!;
}
[Fact]
public async Task Signed_estimate_creates_a_frozen_unpaid_invoice_and_generation_is_idempotent()
{
var (estimateId, commandId) = Seed();
using var http = Client();
using var created = await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct);
var invoice = await InvoiceAsync(created, HttpStatusCode.Created);
Assert.EndsWith($"/api/v1/invoices/{commandId}", created.Headers.Location!.ToString());
Assert.Equal(commandId, invoice.CommandId);
Assert.Equal(estimateId, invoice.EstimateId);
Assert.Equal("bob", invoice.ClientId);
Assert.Equal("alice", invoice.PerformerId);
Assert.Equal(100m, invoice.AmountDue);
Assert.Equal(0m, invoice.AmountPaid);
Assert.Equal(100m, invoice.AmountRemaining);
Assert.Equal("Unpaid", invoice.PaymentStatus);
Assert.False(invoice.IsAcquitted);
Assert.Empty(invoice.Payments);
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var line = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId).Bill.First();
line.UnitaryCost = 999m;
db.SaveChanges();
var command = db.RdvQueries.Single(q => q.Id == commandId);
Assert.Equal(100m, command.GetBillItems().Sum(item => item.Count * item.UnitaryCost));
}
var repeated = await InvoiceAsync(
await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
Assert.Equal(invoice.CreatedAtUtc, repeated.CreatedAtUtc);
Assert.Equal(100m, repeated.AmountDue);
Assert.Equal(invoice.Lines.ToArray(), repeated.Lines.ToArray());
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Dates_without_both_persisted_signatures_cannot_generate_an_invoice(bool provider, bool client)
{
var (estimateId, commandId) = Seed(provider, client);
using var http = Client();
var response = await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Null(db.RdvQueries.Single(q => q.Id == commandId).AmountDue);
}
[Theory]
[InlineData("wrong-signer")]
[InlineData("missing-date")]
[InlineData("wrong-client")]
[InlineData("wrong-performer")]
[InlineData("standalone")]
[InlineData("negative-total")]
[InlineData("wrong-currency")]
[InlineData("invalid-quantity")]
[InlineData("fractional-cent")]
[InlineData("empty-signature")]
public async Task Inconsistent_estimates_are_rejected(string fault)
{
var (estimateId, _) = Seed();
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = db.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(e => e.Id == estimateId);
switch (fault)
{
case "wrong-signer": estimate.SignatureClient!.SignerId = "alice"; break;
case "missing-date": estimate.ClientValidationDate = default; break;
case "wrong-client": estimate.ClientId = "alice"; break;
case "wrong-performer": db.RdvQueries.Single().PerformerId = "bob"; break;
case "standalone": estimate.CommandId = null; break;
case "negative-total": estimate.Bill.ForEach(line => line.UnitaryCost = -1m); break;
case "wrong-currency": estimate.Bill[0].Currency = "USD"; break;
case "invalid-quantity": estimate.Bill[0].Count = 0; break;
case "fractional-cent": estimate.Bill[0].UnitaryCost = 0.001m; break;
case "empty-signature": estimate.SignatureClient!.Strokes = []; break;
}
db.SaveChanges();
}
using var http = Client();
Assert.Equal(HttpStatusCode.Conflict,
(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct)).StatusCode);
}
[Fact]
public async Task Receipts_accumulate_idempotently_until_the_invoice_is_fully_paid()
{
var (estimateId, commandId) = Seed();
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
var path = $"/api/v1/invoices/{commandId}/payments/offline";
var partial = await InvoiceAsync(await http.PostAsJsonAsync(path, Receipt("bank-1"), Ct), HttpStatusCode.OK);
Assert.Equal(30m, partial.AmountPaid);
Assert.Equal(70m, partial.AmountRemaining);
Assert.Equal("PartiallyPaid", partial.PaymentStatus);
Assert.False(partial.IsAcquitted);
Assert.Single(partial.Payments);
using (var partialScope = fixture.Services.CreateScope())
{
var partialDb = partialScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var partialCommand = partialDb.RdvQueries.Single(q => q.Id == commandId);
Assert.Contains("Status: PartiallyPaid", partialCommand.GetBillText());
Assert.DoesNotContain("Status: Paid", partialCommand.GetBillText());
}
var replay = await InvoiceAsync(await http.PostAsJsonAsync(path, Receipt("bank-1"), Ct), HttpStatusCode.OK);
Assert.Equal(30m, replay.AmountPaid);
Assert.Single(replay.Payments);
Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync(path, Receipt("bank-1", 31m), Ct)).StatusCode);
var total = await InvoiceAsync(await http.PostAsJsonAsync(path, Receipt("cash-2", 70m), Ct), HttpStatusCode.OK);
Assert.Equal(100m, total.AmountPaid);
Assert.Equal(0m, total.AmountRemaining);
Assert.True(total.IsAcquitted);
Assert.Equal("Paid", total.PaymentStatus);
Assert.Equal(2, total.Payments.Count);
using var client = Client("bob");
var read = await InvoiceAsync(await client.GetAsync($"/api/v1/invoices/{commandId}", Ct), HttpStatusCode.OK);
Assert.Equal(100m, read.AmountPaid);
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = db.RdvQueries.Single(q => q.Id == commandId);
Assert.Equal(100m, command.AmountPaid);
Assert.True(command.GetIsAcquitted());
}
[Theory]
[InlineData("negative")]
[InlineData("zero")]
[InlineData("currency")]
[InlineData("date")]
[InlineData("future")]
[InlineData("fractional-cent")]
public async Task Invalid_receipts_do_not_change_the_invoice(string fault)
{
var (estimateId, commandId) = Seed();
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
var request = fault switch
{
"negative" => Receipt("bad") with { Amount = -1m },
"zero" => Receipt("bad") with { Amount = 0m },
"currency" => Receipt("bad") with { Currency = "USD" },
"date" => Receipt("bad") with { ReceivedAtUtc = default },
"future" => Receipt("bad") with { ReceivedAtUtc = DateTimeOffset.UtcNow.AddDays(1) },
_ => Receipt("bad") with { Amount = 0.001m }
};
Assert.Equal(HttpStatusCode.BadRequest,
(await http.PostAsJsonAsync($"/api/v1/invoices/{commandId}/payments/offline", request, Ct)).StatusCode);
var invoice = await InvoiceAsync(await http.GetAsync($"/api/v1/invoices/{commandId}", Ct), HttpStatusCode.OK);
Assert.Equal(0m, invoice.AmountPaid);
Assert.Empty(invoice.Payments);
}
[Fact]
public async Task Only_performer_can_generate_or_attest_and_only_parties_can_read_or_verify()
{
var (estimateId, commandId) = Seed();
using var provider = Client();
using var client = Client("bob");
using var outsider = Client("mallory");
using var anonymous = Client(null);
var generate = $"/api/v1/invoices/from-estimate/{estimateId}";
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync(generate, null, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync(generate, null, Ct)).StatusCode);
await InvoiceAsync(await provider.PostAsync(generate, null, Ct), HttpStatusCode.Created);
Assert.Equal(HttpStatusCode.Forbidden,
(await client.PostAsJsonAsync($"/api/v1/invoices/{commandId}/payments/offline", Receipt("bank-1"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await outsider.GetAsync($"/api/v1/invoices/{commandId}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await outsider.PostAsJsonAsync(
$"/api/v1/invoices/{commandId}/payments/paypal", new { transactionId = "TX-1" }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await provider.GetAsync("/api/v1/invoices/999999999", Ct)).StatusCode);
}
[Fact]
public async Task Unconfigured_paypal_never_creates_a_receipt()
{
var (estimateId, commandId) = Seed();
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
var response = await http.PostAsJsonAsync($"/api/v1/invoices/{commandId}/payments/paypal", new { transactionId = "TX-1" }, Ct);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var invoice = await InvoiceAsync(await http.GetAsync($"/api/v1/invoices/{commandId}", Ct), HttpStatusCode.OK);
Assert.Empty(invoice.Payments);
Assert.Equal(0m, invoice.AmountPaid);
}
[Fact]
public async Task Verified_paypal_receipts_combine_with_offline_payments_without_double_counting()
{
var (estimateId, commandId) = Seed();
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
await InvoiceAsync(await http.PostAsJsonAsync(
$"/api/v1/invoices/{commandId}/payments/offline", Receipt("bank-1"), Ct), HttpStatusCode.OK);
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var service = new InvoicingService(db, new VerifiedPayPalStub());
var invoice = await service.VerifyPayPalAsync(commandId, "TX-1", "bob", false, Ct);
Assert.True(invoice.IsAcquitted);
Assert.Equal(100m, invoice.AmountPaid);
Assert.Equal(2, invoice.Payments.Count);
}
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var service = new InvoicingService(db, new VerifiedPayPalStub());
var invoice = await service.VerifyPayPalAsync(commandId, "TX-1", "bob", false, Ct);
Assert.Equal(100m, invoice.AmountPaid);
Assert.Equal(2, invoice.Payments.Count);
}
}
[Fact]
public async Task A_receipt_reference_cannot_be_reused_for_another_invoice()
{
var first = Seed();
var second = Seed(reset: false);
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{first.Estimate}", null, Ct), HttpStatusCode.Created);
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{second.Estimate}", null, Ct), HttpStatusCode.Created);
await InvoiceAsync(await http.PostAsJsonAsync(
$"/api/v1/invoices/{first.Command}/payments/offline", Receipt("bank-1"), Ct), HttpStatusCode.OK);
Assert.Equal(HttpStatusCode.Conflict, (await http.PostAsJsonAsync(
$"/api/v1/invoices/{second.Command}/payments/offline", Receipt("bank-1"), Ct)).StatusCode);
using (var scope = fixture.Services.CreateScope())
{
var service = new InvoicingService(scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(), new VerifiedPayPalStub());
await service.VerifyPayPalAsync(first.Command, "TX-1", "bob", false, Ct);
}
using (var scope = fixture.Services.CreateScope())
{
var service = new InvoicingService(scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(), new VerifiedPayPalStub());
var error = await Assert.ThrowsAsync<InvoiceOperationException>(
() => service.VerifyPayPalAsync(second.Command, "TX-1", "bob", false, Ct));
Assert.Equal(409, error.StatusCode);
}
}
[Fact]
public async Task Concurrent_receipts_are_atomic_and_the_losing_request_can_be_retried()
{
var (estimateId, commandId) = Seed();
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
using var scope = fixture.Services.CreateScope();
var pause = new SavePause();
var options = new DbContextOptionsBuilder<ApplicationDbContext>(
scope.ServiceProvider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())
.AddInterceptors(pause).Options;
await using var stale = new ApplicationDbContext(options);
var staleService = new InvoicingService(stale, new VerifiedPayPalStub());
var first = staleService.RecordOfflineAsync(commandId, Receipt("bank-1"), "alice", false, Ct);
try
{
await pause.Started.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct);
using var otherScope = fixture.Services.CreateScope();
var otherService = new InvoicingService(otherScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(), new VerifiedPayPalStub());
var other = await otherService.RecordOfflineAsync(commandId, Receipt("bank-2", 40m), "alice", false, Ct);
Assert.Equal(40m, other.AmountPaid);
}
finally
{
pause.Resume.TrySetResult();
}
var conflict = await Assert.ThrowsAsync<InvoiceOperationException>(() => first);
Assert.Equal(409, conflict.StatusCode);
using (var checkScope = fixture.Services.CreateScope())
{
var db = checkScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Single(db.InvoicePayments);
Assert.Equal(40m, db.RdvQueries.Single(q => q.Id == commandId).AmountPaid);
}
var retry = await InvoiceAsync(await http.PostAsJsonAsync(
$"/api/v1/invoices/{commandId}/payments/offline", Receipt("bank-1"), Ct), HttpStatusCode.OK);
Assert.Equal(70m, retry.AmountPaid);
Assert.Equal(2, retry.Payments.Count);
}
[Fact]
public async Task Invoiced_commands_and_estimates_cannot_be_deleted()
{
var (estimateId, commandId) = Seed();
using var provider = Client();
using var client = Client("bob");
await InvoiceAsync(await provider.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
Assert.Equal(HttpStatusCode.Conflict, (await client.DeleteAsync($"/api/v1/billing/Rdv/{commandId}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await provider.DeleteAsync($"/api/v1/estimate/{estimateId}", Ct)).StatusCode);
}
[Fact]
public async Task Legacy_document_generation_cannot_bypass_invoice_establishment()
{
var (_, commandId) = Seed();
using var provider = Client();
Assert.Equal(HttpStatusCode.Conflict,
(await provider.PostAsync($"/api/v1/bill/genpdf/Rdv/{commandId}", null, Ct)).StatusCode);
}
[Fact]
public async Task Invoice_total_cannot_be_overwritten_and_overpayments_keep_the_actual_amount()
{
var (estimateId, commandId) = Seed();
using var http = Client();
await InvoiceAsync(await http.PostAsync($"/api/v1/invoices/from-estimate/{estimateId}", null, Ct), HttpStatusCode.Created);
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var command = db.RdvQueries.Single(q => q.Id == commandId);
Assert.Throws<InvalidOperationException>(() => command.SetPaymentAmounts(10m, 0m));
Assert.Equal(100m, command.AmountDue);
}
var request = Receipt("bank-overpaid", 120m) with { ReceivedAtUtc = Received.AddTicks(1234567) };
var invoice = await InvoiceAsync(await http.PostAsJsonAsync(
$"/api/v1/invoices/{commandId}/payments/offline", request, Ct), HttpStatusCode.OK);
Assert.Equal(120m, invoice.AmountPaid);
Assert.Equal(0m, invoice.AmountRemaining);
Assert.True(invoice.IsAcquitted);
var replay = await InvoiceAsync(await http.PostAsJsonAsync(
$"/api/v1/invoices/{commandId}/payments/offline", request, Ct), HttpStatusCode.OK);
Assert.Equal(120m, replay.AmountPaid);
Assert.Single(replay.Payments);
}
[Fact]
public async Task Openapi_documents_the_invoice_routes()
{
using var admin = Client();
admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
TestTokenIssuer.Issue("alice", "api", extraClaims:
[new System.Security.Claims.Claim(Constants.RoleClaimType, Constants.AdminGroupName)]));
var response = await admin.GetAsync("/openapi/invoicing.json", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync(Ct);
Assert.Contains("/api/v1/invoices/from-estimate/{estimateId}", json);
Assert.Contains("/api/v1/invoices/{commandId}/payments/paypal", json);
Assert.Contains("/api/v1/invoices/{commandId}/payments/offline", json);
}
private sealed class SavePause : SaveChangesInterceptor
{
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
Started.TrySetResult();
await Resume.Task.WaitAsync(cancellationToken);
return result;
}
}
private sealed class VerifiedPayPalStub : IPayPalTransactionVerifier
{
public Task<VerifiedPayPalReceipt> VerifyAsync(string transactionId, string invoiceReference, string currency, CancellationToken ct)
{
Assert.StartsWith("RdvQuery/", invoiceReference);
Assert.Equal("EUR", currency);
return Task.FromResult(new VerifiedPayPalReceipt(transactionId, 70m, currency, Received));
}
}
}

View file

@ -0,0 +1,153 @@
using System.Globalization;
using System.Net;
using Microsoft.Extensions.Options;
using Yavsc.Services;
namespace Yavsc.Server.Tests;
public sealed class PayPalTransactionVerifierTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static Dictionary<string, string> Completed() => new()
{
["ACK"] = "Success", ["TRANSACTIONID"] = "TX-1", ["INVNUM"] = "RdvQuery/42",
["RECEIVERID"] = "merchant-id", ["CURRENCYCODE"] = "EUR", ["PAYMENTSTATUS"] = "Completed",
["AMT"] = "30.25", ["ORDERTIME"] = "2026-01-02T12:00:00Z"
};
private static PayPalSettings Settings(string mode = "sandbox") => new()
{
Mode = mode, MerchantAccountUserName = "test-api-user", MerchantAccountId = "merchant-id",
Accounts =
[
new PayPalSettings.ClassicPayPalAccountApiCredential
{
ApiUsername = "test-api-user", ApiPassword = "test-password", Signature = "test-signature"
}
]
};
private static string Encode(Dictionary<string, string> fields)
=> string.Join("&", fields.Select(field => $"{Uri.EscapeDataString(field.Key)}={Uri.EscapeDataString(field.Value)}"));
[Theory]
[InlineData("sandbox", "api-3t.sandbox.paypal.com")]
[InlineData("production", "api-3t.paypal.com")]
public async Task Completed_receipt_is_verified_with_server_credentials_and_invariant_amount(string mode, string host)
{
using var handler = new Handler(Encode(Completed()));
using var http = new HttpClient(handler);
var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings(mode)));
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
var receipt = await verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct);
Assert.Equal(30.25m, receipt.Amount);
Assert.Equal("EUR", receipt.Currency);
Assert.Equal("TX-1", receipt.TransactionId);
Assert.Equal(TimeSpan.Zero, receipt.ReceivedAtUtc.Offset);
Assert.Equal(host, handler.Uri!.Host);
Assert.Equal("https", handler.Uri.Scheme);
Assert.Contains("METHOD=GetTransactionDetails", handler.Body);
Assert.Contains("TRANSACTIONID=TX-1", handler.Body);
Assert.Contains("USER=test-api-user", handler.Body);
Assert.Contains("PWD=test-password", handler.Body);
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
}
[Theory]
[InlineData("PAYMENTSTATUS", "Pending", 409)]
[InlineData("PAYMENTSTATUS", "Refunded", 409)]
[InlineData("PAYMENTSTATUS", "Reversed", 409)]
[InlineData("TRANSACTIONID", "TX-2", 409)]
[InlineData("INVNUM", "RdvQuery/43", 409)]
[InlineData("RECEIVERID", "another-merchant", 409)]
[InlineData("CURRENCYCODE", "USD", 409)]
[InlineData("ACK", "Failure", 502)]
[InlineData("AMT", "-1", 502)]
[InlineData("AMT", "0", 502)]
[InlineData("AMT", "30,25", 502)]
[InlineData("AMT", "0.001", 502)]
[InlineData("ORDERTIME", "not-a-date", 502)]
[InlineData("ORDERTIME", "2999-01-01T12:00:00Z", 502)]
public async Task Unverified_or_mismatched_transactions_are_rejected(string field, string value, int status)
{
var fields = Completed();
fields[field] = value;
using var handler = new Handler(Encode(fields));
using var http = new HttpClient(handler);
var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings()));
var exception = await Assert.ThrowsAsync<InvoiceOperationException>(
() => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct));
Assert.Equal(status, exception.StatusCode);
}
[Theory]
[InlineData("")]
[InlineData("ACK=Success&ACK=Success")]
[InlineData("ACK=Success&malformed")]
public async Task Malformed_responses_do_not_confirm_receipts(string body)
{
using var handler = new Handler(body);
using var http = new HttpClient(handler);
var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings()));
var exception = await Assert.ThrowsAsync<InvoiceOperationException>(
() => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct));
Assert.Equal(502, exception.StatusCode);
}
[Fact]
public async Task Missing_configuration_fails_before_any_network_call()
{
using var handler = new Handler(Encode(Completed()));
using var http = new HttpClient(handler);
var verifier = new PayPalTransactionVerifier(http, Options.Create(new PayPalSettings()));
var exception = await Assert.ThrowsAsync<InvoiceOperationException>(
() => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct));
Assert.Equal(503, exception.StatusCode);
Assert.Null(handler.Uri);
}
[Fact]
public async Task Http_failure_does_not_confirm_a_receipt()
{
using var handler = new Handler("unavailable", HttpStatusCode.ServiceUnavailable);
using var http = new HttpClient(handler);
var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings()));
var exception = await Assert.ThrowsAsync<InvoiceOperationException>(
() => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", Ct));
Assert.Equal(502, exception.StatusCode);
}
[Fact]
public async Task Caller_cancellation_is_not_converted_into_a_payment_failure()
{
using var handler = new Handler(Encode(Completed()));
using var http = new HttpClient(handler);
var verifier = new PayPalTransactionVerifier(http, Options.Create(Settings()));
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(Ct);
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => verifier.VerifyAsync("TX-1", "RdvQuery/42", "EUR", cancellation.Token));
}
private sealed class Handler(string body, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler
{
public Uri? Uri { get; private set; }
public string? Body { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
Uri = request.RequestUri;
Body = await request.Content!.ReadAsStringAsync(ct);
return new HttpResponseMessage(status) { Content = new StringContent(body) };
}
}
}