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

@ -89,6 +89,8 @@ namespace Yavsc.ApiControllers
{
return new ChallengeResult();
}
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null })
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
Response.ContentType = "text/x-tex";
return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true });
}
@ -103,6 +105,10 @@ namespace Yavsc.ApiControllers
return this.NotFound();
}
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice)
return Conflict(new { Error = "Create the invoice from a signed estimate first." });
if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName))
return Forbid();
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}

View file

@ -193,6 +193,8 @@ namespace Yavsc.Controllers
return BadRequest(ModelState);
}
}
if (_context.NominativeServiceCommands.Any(command => command.InvoiceEstimateId == id))
return Conflict(new { Error = "An invoiced estimate cannot be deleted." });
_context.Estimates.Remove(estimate);
_context.SaveChanges(User.GetUserId());

View file

@ -154,6 +154,9 @@ public class HairCutQueryApiController : Controller
return BadRequest(ModelState);
}
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
return Conflict(new { Error = "An invoiced command cannot change performer." });
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent;
@ -187,6 +190,8 @@ public class HairCutQueryApiController : Controller
return Forbid();
}
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -170,6 +170,8 @@ public class HairMultiCutQueryApiController : Controller
return BadRequest(ModelState);
}
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
return Conflict(new { Error = "An invoiced command cannot change performer." });
_context.RemoveRange(existing.Prestations);
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
@ -203,6 +205,8 @@ public class HairMultiCutQueryApiController : Controller
return Forbid();
}
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.RemoveRange(query.Prestations);
_context.HairMultiCutQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);

View file

@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Billing;
using Yavsc.Server.Helpers;
using Yavsc.Services;
namespace Yavsc.ApiControllers;
[Authorize, ApiController, Route(Constants.APIPrefix + "/invoices")]
[Produces("application/json")]
[ApiExplorerSettings(GroupName = "invoicing")]
public sealed class InvoicesController(IInvoicingService invoices, ILogger<InvoicesController> logger) : ControllerBase
{
/// <summary>Creates a frozen invoice from an estimate signed by both parties, or returns its existing invoice.</summary>
[HttpPost("from-estimate/{estimateId:long}")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public Task<ActionResult<InvoiceResponse>> Generate(long estimateId, CancellationToken ct)
=> ExecuteAsync(() => invoices.GenerateAsync(estimateId, User.GetUserId(), IsAdmin, ct), created: true);
/// <summary>Returns the invoice, balance and receipt history to its parties.</summary>
[HttpGet("{commandId:long}")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public Task<ActionResult<InvoiceResponse>> Get(long commandId, CancellationToken ct)
=> ExecuteAsync(() => invoices.GetAsync(commandId, User.GetUserId(), IsAdmin, ct));
/// <summary>Records an offline receipt attested by the performer, idempotently by reference.</summary>
[HttpPost("{commandId:long}/payments/offline")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public Task<ActionResult<InvoiceResponse>> RecordOffline(long commandId, RecordInvoicePaymentRequest request, CancellationToken ct)
=> ExecuteAsync(() => invoices.RecordOfflineAsync(commandId, request, User.GetUserId(), IsAdmin, ct));
/// <summary>Verifies a completed PayPal transaction server-side before recording its receipt.</summary>
[HttpPost("{commandId:long}/payments/paypal")]
[ProducesResponseType<InvoiceResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status502BadGateway)]
public Task<ActionResult<InvoiceResponse>> VerifyPayPal(long commandId, VerifyInvoicePayPalRequest request, CancellationToken ct)
=> ExecuteAsync(() => invoices.VerifyPayPalAsync(commandId, request.TransactionId, User.GetUserId(), IsAdmin, ct));
private bool IsAdmin => User.IsInRole(Constants.AdminGroupName);
private async Task<ActionResult<InvoiceResponse>> ExecuteAsync(Func<Task<InvoiceResponse>> action, bool created = false)
{
try
{
var invoice = await action();
return created
? CreatedAtAction(nameof(Get), new { commandId = invoice.CommandId }, invoice)
: Ok(invoice);
}
catch (InvoiceOperationException exception)
{
logger.LogWarning("Invoice operation rejected ({StatusCode}): {Reason}", exception.StatusCode, exception.Message);
return Problem(statusCode: exception.StatusCode, title: exception.Message);
}
}
}

View file

@ -162,6 +162,9 @@ public class RdvQueryApiController : Controller
return Forbid();
}
if (existing.InvoiceEstimateId.HasValue && existing.PerformerId != query.PerformerId)
return Conflict(new { Error = "An invoiced command cannot change performer." });
existing.ActivityCode = query.ActivityCode;
existing.PerformerId = query.PerformerId;
existing.Consent = query.Consent;
@ -224,6 +227,8 @@ public class RdvQueryApiController : Controller
return Forbid();
}
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.RdvQueries.Remove(query);
await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);