using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Newtonsoft.Json; using System.Security.Claims; using Yavsc.Helpers; using Yavsc.ViewModels; using Yavsc.Models.Billing; 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; [Route("api/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; public BillingController( IAuthorizationService authorizationService, ILoggerFactory loggerFactory, IStringLocalizer SR, ApplicationDbContext context, IOptions googleSettings, IYavscMessageSender GCMSender, IBillingService billingService ) { _googleSettings=googleSettings.Value; this.authorizationService = authorizationService; dbContext = context; logger = loggerFactory.CreateLogger(); this._localizer = SR; _GCMSender=GCMSender; this.billingService=billingService; } [HttpGet("facture-{billingCode}-{id}.pdf"), Authorize] public async Task 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); 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 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 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 } ); } [HttpPost("prosign/{billingCode}/{id}")] public async Task 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"); estimate.ProviderValidationDate = DateTime.Now; 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 }); } [HttpGet("prosign/{billingCode}/{id}")] public async Task 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(AbstractFileSystemHelpers.UserBillsDirName, 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 CliSign(string billingCode, long id) { var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var estimate = dbContext.Estimates.Include( e=>e.Query ).Include(e=>e.Owner).Include(e=>e.Owner.Performer).Include(e=>e.Client) .FirstOrDefault( e=> e.Id == id && e.Query.ClientId == uid ); 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"); estimate.ClientValidationDate = DateTime.Now; dbContext.SaveChanges(User.GetUserId()); return Ok (new { ClientValidationDate = estimate.ClientValidationDate }); } [HttpGet("clisign/{billingCode}/{id}")] public async Task 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("pro", billingCode, id); FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename)); if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" }); return File(fi.OpenRead(), "application/x-pdf", filename); ; } /// /// Capture a signature for an estimate, in the JSON /// wire format produced by PostIt (see /// PostIt.Models.SignaturePadData). The legacy /// POST prosign / POST clisign endpoints /// take a PNG ; this one takes a /// JSON body so the capture happens entirely in-app on /// the client side, without a rasterisation step. /// /// 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 (Bill_tex.cshtml, /// Estimate_tex.cshtml) working until the /// migration commit regenerates PNGs from the JSON /// payload. The two flows share the /// table for storage but not /// the URL surface. /// [HttpPost("estimate/{id:long}/sign")] [Consumes("application/json")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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, }; // Disk write first: a disk failure shouldn't leave // a Signature row pointing at a file that doesn't // exist. The file helper throws on filesystem // problems and propagates here. FileReceivedInfo fi; try { fi = await User.ReceiveEstimateSignatureAsync(id, type, payload, token); } catch (Exception ex) { logger.LogError(ex, "estimate {Id}: signature file write failed", id); return BadRequest(new { Error = "file write failed", Detail = ex.Message }); } // Now persist the database row. The int[] is round- // tripped via Npgsql's native int[] mapping; the // migration (separate commit) introduces the column // and the index. var signature = new Signature { EstimateId = id, SignerId = userId, Type = type, CoordinateMax = payload.CoordinateMax, Strokes = payload.Strokes, CapturedAtUtc = payload.CapturedAtUtc, FilePath = Path.Combine(fi.DestDir, fi.FileName), }; dbContext.Signatures.Add(signature); // Bump the signer's quota. The Signature row's // SignerId is the IdentityUser.Id (a string), so we // look up by Id and not by username. var signer = await dbContext.Users .FirstOrDefaultAsync(u => u.Id == userId, token); if (signer is not null) { signer.DiskUsage += new FileInfo(signature.FilePath).Length; } try { await dbContext.SaveChangesAsync(token); } catch (Exception ex) { logger.LogError(ex, "estimate {Id}: signature db write failed", id); // Best-effort rollback: remove the file we wrote // so disk and db don't disagree. try { System.IO.File.Delete(signature.FilePath); } catch { /* swallow — the row will be re-orphaned, the user re-signs */ } 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, }); } } } /// /// JSON body of POST /api/bill/estimate/{id}/sign. The /// shape mirrors what PostIt sends; the signerUserId /// field disambiguates which side of the estimate signed /// because the bearer token belongs to the PostIt OAuth /// client, not the end user. /// public class SignatureSubmission { /// /// ApplicationUser.Id of the signer. Must equal /// Estimate.OwnerId for a Pro signature or /// Estimate.ClientId for a Client signature. /// public string SignerUserId { get; set; } /// /// Wire-format strokes. See /// PostIt.Models.SignaturePadData. /// public int[] Strokes { get; set; } = Array.Empty(); public int CoordinateMax { get; set; } = 10_000; /// /// Client-reported capture time. The server may override /// this with DateTime.UtcNow if the client is /// caught lying about clock skew, but the default is to /// trust the client. /// public DateTime? CapturedAtUtc { get; set; } }