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