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

@ -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) };
}
}
}