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:
parent
5d03b2e477
commit
2dcbcd35b3
37 changed files with 6686 additions and 12 deletions
84
src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs
Normal file
84
src/PostIt/PostIt.Tests/InvoicesApiClientTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue