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