yavsc/src/Yavsc.Api/Controllers/Business/BillingController.cs
Paul Schneider 97d0584292
Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on
  ProviderValidationDate so a provider's signed estimate leaves their
  list; PostIt refreshes the list on navigation pop-back.
- Title renames: "Devis en attente de signature fournisseur/client"
  on both the provider and client sides (HomePage, VM, tests).
- Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into
  Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent,
  Bill/Estimate templates, _ViewImports). Program.cs wires
  AddControllersWithViews + SiteSettings; FrontOfficeApiController
  injects IRazorViewEngine (cast to IViewEngine) with optional deps
  and exposes estimate.tex/estimate.pdf endpoints keyed by query id.
- PDF generation drives through the Estimate_pdf view (side-effect
  render) and compiles via the system lualatex, fed TeX on stdin (no
  intermediate .tex), PDF to the bills dir, aux artifacts cleaned so
  only the .pdf persists. Resolve the bills dir to an absolute path so
  lualatex's -output-directory and working directory agree regardless
  of the host cwd (fixes "I can't write on file estimate-1.log").
- PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync;
  Télécharger PDF/TeX buttons on the validation and command-detail
  pages via a StorageProvider save helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:22:01 +01:00

472 lines
20 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using System.Security.Claims;
using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.ViewModels;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Workflow;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
{
using Models;
using Services;
using Models.Messaging;
using Microsoft.Extensions.Options;
using Microsoft.EntityFrameworkCore;
using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers;
[Authorize]
[Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller
{
readonly ApplicationDbContext dbContext;
private readonly IStringLocalizer _localizer;
private readonly GoogleAuthSettings _googleSettings;
private readonly IYavscMessageSender _GCMSender;
private readonly IAuthorizationService authorizationService;
private readonly ILogger logger;
private readonly IBillingService billingService;
private readonly SiteSettings siteSettings;
public BillingController(
IAuthorizationService authorizationService,
ILoggerFactory loggerFactory,
IStringLocalizer<BillingController> SR,
ApplicationDbContext context,
IOptions<GoogleAuthSettings> googleSettings,
IYavscMessageSender GCMSender,
IBillingService billingService,
IOptions<SiteSettings> siteSettings
)
{
_googleSettings=googleSettings.Value;
this.authorizationService = authorizationService;
dbContext = context;
logger = loggerFactory.CreateLogger<BillingController>();
this._localizer = SR;
_GCMSender=GCMSender;
this.billingService=billingService;
this.siteSettings = siteSettings.Value;
}
[HttpGet("facture-{billingCode}-{id}.pdf"), Authorize]
public async Task<IActionResult> GetPdf(string billingCode, long id)
{
var bill = await billingService.GetBillAsync(billingCode, id);
if ( authorizationService.AuthorizeAsync(User, bill, new ReadPermission()).IsFaulted)
{
return new ChallengeResult();
}
var fi = bill.GetBillInfo(billingService, siteSettings);
if (!fi.Exists) return Ok(new { Error = "Not generated" });
return File(fi.OpenRead(), "application/x-pdf", fi.Name);
}
[HttpGet("facture-{billingCode}-{id}.tex"), Authorize]
public async Task<IActionResult> GetTex(string billingCode, long id)
{
var bill = await billingService.GetBillAsync(billingCode, id);
if (bill==null) {
logger.LogCritical ( $"# not found !! {id} in {billingCode}");
return this.NotFound();
}
logger.LogTrace(JsonConvert.SerializeObject(bill));
if (!(await authorizationService.AuthorizeAsync(User, bill, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
Response.ContentType = "text/x-tex";
return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true });
}
[HttpPost("genpdf/{billingCode}/{id}")]
public async Task<IActionResult> GeneratePdf(string billingCode, long id)
{
var bill = await billingService.GetBillAsync(billingCode, id);
if (bill==null) {
logger.LogCritical ( $"# not found !! {id} in {billingCode}");
return this.NotFound();
}
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}
/// <summary>
/// Lists ongoing service commands for the authenticated performer.
/// This endpoint is tailored for the PostIt provider homepage flow
/// ("Mes demandes en cours").
/// </summary>
[HttpGet("provider/ongoing")]
[Produces("application/json")]
public IActionResult GetProviderOngoingCommands()
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Unauthorized();
}
if (billingService.BillingMap.Count == 0)
{
WorkflowHelpers.ConfigureBillingService();
}
var allowedActivityCodes = dbContext.UserActivities
.AsNoTracking()
.Where(a => a.UserId == uid)
.Select(a => a.DoesCode)
.Distinct()
.ToList();
if (allowedActivityCodes.Count == 0)
{
return Ok(Array.Empty<object>());
}
var allowedBillingCodes = dbContext.CommandForm
.AsNoTracking()
.Where(form => allowedActivityCodes.Contains(form.ActivityCode))
.Select(form => form.ActionName)
.Where(actionName => !string.IsNullOrWhiteSpace(actionName))
.Distinct()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var fallbackToActivityFilteringOnly = allowedBillingCodes.Count == 0;
// Query only the command types allowed by the performer's declared
// activities; this avoids touching unrelated legacy slices.
var rdvCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Rdv)
? dbContext.Set<RdvQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var hairCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Brush)
? dbContext.Set<HairCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var hairMultiCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.MBrush)
? dbContext.Set<HairMultiCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var commands = rdvCommands
.Concat(hairCommands)
.Concat(hairMultiCommands)
.OrderByDescending(q => q.DateModified)
.ThenByDescending(q => q.Id)
.ToList();
var payload = commands
.Select(q => new
{
Id = q.Id,
BillingCode = ResolveBillingCode(q),
ActivityCode = q.ActivityCode,
PerformerId = q.PerformerId,
ClientId = q.ClientId,
Status = q.Status,
Description = q.Description,
EventDate = ResolveEventDate(q),
Reason = q is Models.Workflow.RdvQuery rdv ? rdv.Reason : string.Empty,
AdditionalInfo = q is Models.Haircut.HairCutQuery hc ? hc.AdditionalInfo : string.Empty,
Provisional = q.Provisional,
})
.Where(x => !string.IsNullOrWhiteSpace(x.BillingCode))
.ToList();
return Ok(payload);
}
[HttpPost("prosign/{billingCode}/{id}")]
public async Task<IActionResult> ProSign(string billingCode, long id)
{
var estimate = dbContext.Estimates.
Include(e=>e.Client).Include(e=>e.Client.DeviceDeclaration)
.Include(e=>e.Bill).Include(e=>e.Owner).Include(e=>e.Owner.Performer)
.FirstOrDefault(e=>e.Id == id);
if (estimate == null)
return new BadRequestResult();
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro", siteSettings);
estimate.ProviderValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
// Notify the client
var locstr = _localizer["EstimationMessageToClient"];
var yaev = new EstimationEvent(estimate,_localizer);
var regids = new [] { estimate.Client.Id };
bool gcmSent = false;
var grep = await _GCMSender.NotifyEstimateAsync(regids,yaev);
gcmSent = grep.success>0;
return Ok (new { ProviderValidationDate = estimate.ProviderValidationDate, GCMSent = gcmSent });
}
private string ResolveBillingCode(NominativeServiceCommand command)
{
var typeName = command.GetType().Name;
return billingService.BillingMap.TryGetValue(typeName, out var code)
? code
: string.Empty;
}
private static DateTime? ResolveEventDate(NominativeServiceCommand command)
=> command switch
{
Models.Workflow.RdvQuery rdv => rdv.EventDate,
Models.Haircut.HairCutQuery brush => brush.EventDate,
Models.Haircut.HairMultiCutQuery mbrush => mbrush.EventDate,
_ => null,
};
[HttpGet("prosign/{billingCode}/{id}")]
public async Task<IActionResult> GetProSign(string billingCode, long id)
{
// For authorization purpose
var estimate = dbContext.Estimates.FirstOrDefault(e=>e.Id == id);
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
[HttpPost("clisign/{billingCode}/{id}")]
public async Task<IActionResult> CliSign(string billingCode, long id)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var estimate = dbContext.Estimates
.Include(e => e.Owner)
.Include(e => e.Owner.Performer)
.Include(e => e.Client)
.FirstOrDefault(e => e.Id == id);
if (estimate is null)
{
return NotFound();
}
if (estimate.CommandId is null)
{
return new ChallengeResult();
}
var command = dbContext.Set<NominativeServiceCommand>()
.FirstOrDefault(c => c.Id == estimate.CommandId.Value);
if (command is null || command.ClientId != uid)
{
return new ChallengeResult();
}
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
estimate.ClientValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
}
[HttpGet("clisign/{billingCode}/{id}")]
public async Task<IActionResult> GetCliSign(string billingCode, long id)
{
// For authorization purpose
var estimate = dbContext.Estimates.FirstOrDefault(e=>e.Id == id);
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename);
}
/// <summary>
/// Capture a signature for an estimate, in the JSON
/// wire format produced by PostIt (see
/// <c>PostIt.Models.SignaturePadData</c>). The legacy
/// <c>POST prosign</c> / <c>POST clisign</c> endpoints
/// take a PNG <see cref="IFormFile"/>; this one takes a
/// JSON body so the capture happens entirely in-app on
/// the client side, without a rasterisation step.
///
/// <para>The route is intentionally a sibling of the
/// legacy endpoints, not a replacement: the legacy
/// PNG-based flow stays in place to keep the TeX
/// invoice templates (<c>Bill_tex.cshtml</c>,
/// <c>Estimate_tex.cshtml</c>) working until the
/// migration commit regenerates PNGs from the JSON
/// payload. The two flows share the
/// <see cref="Signature"/> table for storage but not
/// the URL surface.</para>
/// </summary>
[HttpPost("estimate/{id:long}/sign")]
[ValidateAntiForgeryToken]
[Consumes("application/json")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Sign(
[FromRoute] long id,
[FromBody] SignatureSubmission body,
CancellationToken token)
{
if (body is null) return BadRequest(new { Error = "missing body" });
if (body.Strokes is null) return BadRequest(new { Error = "missing strokes" });
if (string.IsNullOrEmpty(body.SignerUserId))
return BadRequest(new { Error = "missing signerUserId" });
var estimate = await dbContext.Estimates
.Include(e => e.Client)
.FirstOrDefaultAsync(e => e.Id == id, token);
if (estimate is null) return NotFound(new { Error = "estimate not found" });
// The signer is identified by userId in the body, not
// by the bearer token, because the OAuth scope we
// carry is for the API client (PostIt), not the end
// user. We trust the body's userId to match either
// Owner or Client, and reject everything else.
var userId = body.SignerUserId;
if (userId != estimate.OwnerId && userId != estimate.ClientId)
return Forbid();
// Map userId → type. The Pro/Client split is the
// same one the legacy prosign/clisign endpoints use;
// keeping the rule here means the Signature table
// and the legacy ProviderValidationDate/ClientValidationDate
// columns can co-exist without contradicting each other.
var type = userId == estimate.OwnerId
? SignatureType.Pro
: SignatureType.Client;
var payload = new SignaturePadPayload
{
CoordinateMax = body.CoordinateMax,
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
Strokes = body.Strokes,
};
// The Signature row is staged by the shared persister; we
// SaveChanges here so the signature write is one
// transaction. The Strokes column is the source of truth —
// there is no on-disk copy to roll back on failure.
var signature = await EstimateSignaturePersister.StageAsync(
dbContext, id, type, userId, payload, token);
try
{
await dbContext.SaveChangesAsync(token);
}
catch (Exception ex)
{
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
var location = Url.Action(nameof(Sign), new { id })
?? $"/api/bill/estimate/{id}/sign";
return Created(location, new
{
id = signature.Id,
estimateId = signature.EstimateId,
type = signature.Type.ToString(),
capturedAtUtc = signature.CapturedAtUtc,
coordinateMax = signature.CoordinateMax,
});
}
}
}
/// <summary>
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
/// field disambiguates which side of the estimate signed
/// because the bearer token belongs to the PostIt OAuth
/// client, not the end user.
/// </summary>
public class SignatureSubmission
{
/// <summary>
/// ApplicationUser.Id of the signer. Must equal
/// <c>Estimate.OwnerId</c> for a Pro signature or
/// <c>Estimate.ClientId</c> for a Client signature.
/// </summary>
public string SignerUserId { get; set; }
/// <summary>
/// Wire-format strokes. See
/// <c>PostIt.Models.SignaturePadData</c>.
/// </summary>
public int[] Strokes { get; set; } = Array.Empty<int>();
public int CoordinateMax { get; set; } = 10_000;
/// <summary>
/// Client-reported capture time. The server may override
/// this with <c>DateTime.UtcNow</c> if the client is
/// caught lying about clock skew, but the default is to
/// trust the client.
/// </summary>
public DateTime? CapturedAtUtc { get; set; }
}