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,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("+", " "));
}