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
|
|
@ -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 } );
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
59
src/Yavsc.Api/Controllers/Business/InvoicesController.cs
Normal file
59
src/Yavsc.Api/Controllers/Business/InvoicesController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue