yavsc/src/Yavsc.Api/Controllers/Business/BillingController.cs

385 lines
16 KiB
C#
Raw Normal View History

2023-03-19 17:57:55 +00:00
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
2019-08-21 17:23:58 +01:00
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using System.Security.Claims;
using Yavsc.Helpers;
using Yavsc.ViewModels;
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture Adds a new JSON-bodied signature endpoint as a sibling of the legacy PNG-based prosign/clisign routes. The legacy flow stays intact: the TeX invoice templates (Bill_tex.cshtml, Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png files the old endpoints write, and the new endpoint writes to a distinct /signatures/ tree under UserFilesDirName. A future migration commit will regenerate PNGs from the JSON payload and decommission the PNG flow. Scope - New Signature entity (Yavsc.Server/Models/Billing/Signature.cs) with FK to Estimate, FK to ApplicationUser (Signer), Type (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple versions per (EstimateId, Type) are allowed; the controller reads the most recent. - New Estimate.Signatures nav collection (InverseProperty) so the composite index covers both sides of the relation. - New DbSet<Signature> Signatures + composite index (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext OnModelCreating. DeleteBehavior.Cascade on Estimate deletion cleans up signatures automatically. - New EstimateSignatureFileHelper (Server/Helpers) with ReceiveEstimateSignatureAsync(user, estimateId, type, payload). Writes a yavsc.signature/v1 JSON envelope to UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json. Quota update lives in the controller, not the helper, because the helper has no DbContext access. - New endpoint POST /api/bill/estimate/{id:long}/sign on BillingController. Authz is body-driven (the bearer token is the PostIt OAuth client, not the end user, so signerUserId is in the JSON body, validated against Estimate.OwnerId/ClientId). Returns 201 Created with the new Signature's metadata. Plumbing - SignatureSubmission (body type) lives next to BillingController in the same file — small enough to keep colocated. - The legacy prosign/clisign routes are untouched. They keep the IFormFile PNG contract; the new endpoint is the JSON counterpart. Tests - New EstimateSignatureFileHelperTests in Yavsc.Org.Tests (8 tests, all green): filename format incl. lowercase type and ticks, envelope v1 round-trip (parsed via JsonDocument, not text matching), null payload rejected, non-positive estimateId rejected. Disk side effects are isolated to a per-test temp root via AbstractFileSystemHelpers.UserFilesDirName. - Yavsc.Org.Tests full suite: 29/29 green. - PostIt.Tests: 57/57 green (untouched by this commit). - Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests all compile clean. Out of scope - EF migration: the Signatures table doesn't exist in the database yet. The migration is intentionally a separate commit so the generated SQL can be reviewed against the composite index and the int[] column type before it touches any prod database. Until the migration lands, the new endpoint will 500 on SaveChanges; the [DEV] button in PostIt is the only call site, so this is acceptable. - SignalR handler that opens the signature page on a 'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
using Yavsc.Models.Billing;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
{
using Models;
using Services;
2016-11-09 14:03:57 +01:00
using Models.Messaging;
2023-03-19 17:57:55 +00:00
using Microsoft.Extensions.Options;
using Microsoft.EntityFrameworkCore;
2025-02-14 00:20:35 +00:00
using Yavsc.ViewModels.Auth;
2025-02-17 23:56:28 +00:00
using Yavsc.Server.Helpers;
2017-06-08 00:26:29 +02:00
[Route("api/bill"), Authorize]
public class BillingController : Controller
2016-11-07 19:34:56 +01:00
{
2020-10-09 19:35:39 +01:00
readonly ApplicationDbContext dbContext;
private readonly IStringLocalizer _localizer;
private readonly GoogleAuthSettings _googleSettings;
private readonly IYavscMessageSender _GCMSender;
private readonly IAuthorizationService authorizationService;
2016-11-09 14:03:57 +01:00
2017-06-08 00:26:29 +02:00
2020-10-09 19:35:39 +01:00
private readonly ILogger logger;
private readonly IBillingService billingService;
2016-11-09 14:03:57 +01:00
2017-06-08 00:26:29 +02:00
public BillingController(
IAuthorizationService authorizationService,
ILoggerFactory loggerFactory,
2025-08-31 18:27:53 +01:00
IStringLocalizer<BillingController> SR,
2017-03-05 20:29:02 +01:00
ApplicationDbContext context,
IOptions<GoogleAuthSettings> googleSettings,
2019-05-08 01:35:10 +01:00
IYavscMessageSender GCMSender,
2017-06-08 00:26:29 +02:00
IBillingService billingService
2017-03-05 20:29:02 +01:00
)
2016-11-09 14:03:57 +01:00
{
2017-03-05 20:29:02 +01:00
_googleSettings=googleSettings.Value;
this.authorizationService = authorizationService;
2016-11-09 14:03:57 +01:00
dbContext = context;
2017-06-08 00:26:29 +02:00
logger = loggerFactory.CreateLogger<BillingController>();
2017-02-22 22:56:03 +01:00
this._localizer = SR;
2017-03-05 20:29:02 +01:00
_GCMSender=GCMSender;
2017-06-08 00:26:29 +02:00
this.billingService=billingService;
2016-11-09 14:03:57 +01:00
}
2017-06-08 11:05:57 +02:00
[HttpGet("facture-{billingCode}-{id}.pdf"), Authorize]
2017-06-08 00:26:29 +02:00
public async Task<IActionResult> GetPdf(string billingCode, long id)
2026-07-11 03:26:13 +01:00
{
2017-06-08 00:26:29 +02:00
var bill = await billingService.GetBillAsync(billingCode, id);
2025-02-23 20:23:23 +00:00
if ( authorizationService.AuthorizeAsync(User, bill, new ReadPermission()).IsFaulted)
2016-11-07 19:34:56 +01:00
{
return new ChallengeResult();
}
2026-07-11 03:26:13 +01:00
var fi = bill.GetBillInfo(billingService);
2016-11-09 14:03:57 +01:00
if (!fi.Exists) return Ok(new { Error = "Not generated" });
2026-07-11 03:26:13 +01:00
return File(fi.OpenRead(), "application/x-pdf", fi.Name);
2016-11-09 14:03:57 +01:00
}
2017-06-08 11:05:57 +02:00
[HttpGet("facture-{billingCode}-{id}.tex"), Authorize]
2017-06-08 00:26:29 +02:00
public async Task<IActionResult> GetTex(string billingCode, long id)
2016-11-09 14:03:57 +01:00
{
2017-06-08 00:26:29 +02:00
var bill = await billingService.GetBillAsync(billingCode, id);
2017-06-08 11:05:57 +02:00
if (bill==null) {
2017-06-08 17:09:06 +02:00
logger.LogCritical ( $"# not found !! {id} in {billingCode}");
2023-03-19 17:57:55 +00:00
return this.NotFound();
2017-06-08 11:05:57 +02:00
}
2023-03-19 17:57:55 +00:00
logger.LogTrace(JsonConvert.SerializeObject(bill));
2017-06-08 00:26:29 +02:00
2025-02-23 20:23:23 +00:00
if (!(await authorizationService.AuthorizeAsync(User, bill, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
2016-11-09 14:03:57 +01:00
Response.ContentType = "text/x-tex";
return ViewComponent("Bill",new object[] {  billingCode, bill , OutputFormat.LaTeX, true });
2016-11-09 14:03:57 +01:00
}
2017-06-08 00:26:29 +02:00
[HttpPost("genpdf/{billingCode}/{id}")]
public async Task<IActionResult> GeneratePdf(string billingCode, long id)
2016-11-09 14:03:57 +01:00
{
2017-06-08 17:09:06 +02:00
var bill = await billingService.GetBillAsync(billingCode, id);
2026-07-11 03:26:13 +01:00
2017-06-08 17:09:06 +02:00
if (bill==null) {
logger.LogCritical ( $"# not found !! {id} in {billingCode}");
2023-03-19 17:57:55 +00:00
return this.NotFound();
}
logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString());
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
2016-11-07 19:34:56 +01:00
}
2017-06-08 00:26:29 +02:00
[HttpPost("prosign/{billingCode}/{id}")]
public async Task<IActionResult> ProSign(string billingCode, long id)
{
2017-02-22 22:56:03 +01:00
var estimate = dbContext.Estimates.
2019-05-24 20:17:24 +01:00
Include(e=>e.Client).Include(e=>e.Client.DeviceDeclaration)
2017-02-22 22:56:03 +01:00
.Include(e=>e.Bill).Include(e=>e.Owner).Include(e=>e.Owner.Performer)
.FirstOrDefault(e=>e.Id == id);
if (estimate == null)
return new BadRequestResult();
2025-02-23 20:23:23 +00:00
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
2023-03-19 17:57:55 +00:00
2026-07-11 03:26:13 +01:00
{
return new ChallengeResult();
}
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
2026-05-28 22:18:26 +01:00
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro");
2026-07-11 03:26:13 +01:00
estimate.ProviderValidationDate = DateTime.UtcNow;
2017-02-23 03:10:30 +01:00
dbContext.SaveChanges(User.GetUserId());
// Notify the client
2017-02-22 22:56:03 +01:00
var locstr = _localizer["EstimationMessageToClient"];
var yaev = new EstimationEvent(estimate,_localizer);
2026-07-11 03:26:13 +01:00
2019-05-24 20:17:24 +01:00
var regids = new [] { estimate.Client.Id };
bool gcmSent = false;
2018-05-04 10:45:45 +02:00
var grep = await _GCMSender.NotifyEstimateAsync(regids,yaev);
gcmSent = grep.success>0;
return Ok (new { ProviderValidationDate = estimate.ProviderValidationDate, GCMSent = gcmSent });
}
2017-06-08 00:26:29 +02:00
[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);
2025-02-23 20:23:23 +00:00
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
2026-07-11 03:26:13 +01:00
{
return new ChallengeResult();
}
2019-08-21 17:23:58 +01:00
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
2018-03-26 19:27:29 +02:00
FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
2023-03-19 17:57:55 +00:00
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
2017-06-08 00:26:29 +02:00
[HttpPost("clisign/{billingCode}/{id}")]
public async Task<IActionResult> CliSign(string billingCode, long id)
{
2023-03-19 17:57:55 +00:00
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var estimate = dbContext.Estimates.Include( e=>e.Query
2017-02-22 22:56:03 +01:00
).Include(e=>e.Owner).Include(e=>e.Owner.Performer).Include(e=>e.Client)
.FirstOrDefault( e=> e.Id == id && e.Query.ClientId == uid );
2025-02-23 20:23:23 +00:00
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
2026-05-28 22:18:26 +01:00
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli");
2026-07-11 03:26:13 +01:00
estimate.ClientValidationDate = DateTime.UtcNow;
2017-02-23 03:10:30 +01:00
dbContext.SaveChanges(User.GetUserId());
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
}
2017-06-08 00:26:29 +02:00
[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);
2025-02-23 20:23:23 +00:00
if (!(await authorizationService.AuthorizeAsync(User, estimate, new ReadPermission())).Succeeded)
{
return new ChallengeResult();
}
2026-07-11 03:26:13 +01:00
2019-08-21 17:23:58 +01:00
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
2018-03-26 19:27:29 +02:00
FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
2023-03-19 17:57:55 +00:00
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture Adds a new JSON-bodied signature endpoint as a sibling of the legacy PNG-based prosign/clisign routes. The legacy flow stays intact: the TeX invoice templates (Bill_tex.cshtml, Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png files the old endpoints write, and the new endpoint writes to a distinct /signatures/ tree under UserFilesDirName. A future migration commit will regenerate PNGs from the JSON payload and decommission the PNG flow. Scope - New Signature entity (Yavsc.Server/Models/Billing/Signature.cs) with FK to Estimate, FK to ApplicationUser (Signer), Type (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple versions per (EstimateId, Type) are allowed; the controller reads the most recent. - New Estimate.Signatures nav collection (InverseProperty) so the composite index covers both sides of the relation. - New DbSet<Signature> Signatures + composite index (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext OnModelCreating. DeleteBehavior.Cascade on Estimate deletion cleans up signatures automatically. - New EstimateSignatureFileHelper (Server/Helpers) with ReceiveEstimateSignatureAsync(user, estimateId, type, payload). Writes a yavsc.signature/v1 JSON envelope to UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json. Quota update lives in the controller, not the helper, because the helper has no DbContext access. - New endpoint POST /api/bill/estimate/{id:long}/sign on BillingController. Authz is body-driven (the bearer token is the PostIt OAuth client, not the end user, so signerUserId is in the JSON body, validated against Estimate.OwnerId/ClientId). Returns 201 Created with the new Signature's metadata. Plumbing - SignatureSubmission (body type) lives next to BillingController in the same file — small enough to keep colocated. - The legacy prosign/clisign routes are untouched. They keep the IFormFile PNG contract; the new endpoint is the JSON counterpart. Tests - New EstimateSignatureFileHelperTests in Yavsc.Org.Tests (8 tests, all green): filename format incl. lowercase type and ticks, envelope v1 round-trip (parsed via JsonDocument, not text matching), null payload rejected, non-positive estimateId rejected. Disk side effects are isolated to a per-test temp root via AbstractFileSystemHelpers.UserFilesDirName. - Yavsc.Org.Tests full suite: 29/29 green. - PostIt.Tests: 57/57 green (untouched by this commit). - Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests all compile clean. Out of scope - EF migration: the Signatures table doesn't exist in the database yet. The migration is intentionally a separate commit so the generated SQL can be reviewed against the composite index and the int[] column type before it touches any prod database. Until the migration lands, the new endpoint will 500 on SaveChanges; the [DEV] button in PostIt is the only call site, so this is acceptable. - SignalR handler that opens the signature page on a 'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
/// <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]
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture Adds a new JSON-bodied signature endpoint as a sibling of the legacy PNG-based prosign/clisign routes. The legacy flow stays intact: the TeX invoice templates (Bill_tex.cshtml, Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png files the old endpoints write, and the new endpoint writes to a distinct /signatures/ tree under UserFilesDirName. A future migration commit will regenerate PNGs from the JSON payload and decommission the PNG flow. Scope - New Signature entity (Yavsc.Server/Models/Billing/Signature.cs) with FK to Estimate, FK to ApplicationUser (Signer), Type (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple versions per (EstimateId, Type) are allowed; the controller reads the most recent. - New Estimate.Signatures nav collection (InverseProperty) so the composite index covers both sides of the relation. - New DbSet<Signature> Signatures + composite index (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext OnModelCreating. DeleteBehavior.Cascade on Estimate deletion cleans up signatures automatically. - New EstimateSignatureFileHelper (Server/Helpers) with ReceiveEstimateSignatureAsync(user, estimateId, type, payload). Writes a yavsc.signature/v1 JSON envelope to UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json. Quota update lives in the controller, not the helper, because the helper has no DbContext access. - New endpoint POST /api/bill/estimate/{id:long}/sign on BillingController. Authz is body-driven (the bearer token is the PostIt OAuth client, not the end user, so signerUserId is in the JSON body, validated against Estimate.OwnerId/ClientId). Returns 201 Created with the new Signature's metadata. Plumbing - SignatureSubmission (body type) lives next to BillingController in the same file — small enough to keep colocated. - The legacy prosign/clisign routes are untouched. They keep the IFormFile PNG contract; the new endpoint is the JSON counterpart. Tests - New EstimateSignatureFileHelperTests in Yavsc.Org.Tests (8 tests, all green): filename format incl. lowercase type and ticks, envelope v1 round-trip (parsed via JsonDocument, not text matching), null payload rejected, non-positive estimateId rejected. Disk side effects are isolated to a per-test temp root via AbstractFileSystemHelpers.UserFilesDirName. - Yavsc.Org.Tests full suite: 29/29 green. - PostIt.Tests: 57/57 green (untouched by this commit). - Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests all compile clean. Out of scope - EF migration: the Signatures table doesn't exist in the database yet. The migration is intentionally a separate commit so the generated SQL can be reviewed against the composite index and the int[] column type before it touches any prod database. Until the migration lands, the new endpoint will 500 on SaveChanges; the [DEV] button in PostIt is the only call site, so this is acceptable. - SignalR handler that opens the signature page on a 'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
[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,
};
// 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 });
}
2026-07-04 16:06:02 +01:00
// Find-or-add: the (EstimateId, Type) pair is
// unique, so a second POST for the same side of the
// estimate replaces the previous signature. EF
// translates this into a single UPDATE when the
// row exists and an INSERT otherwise; the unique
// index in ApplicationDbContext is the
// database-level guarantee that the contract
// holds if two requests race.
var signature = await dbContext.Signatures
.FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
if (signature is null)
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture Adds a new JSON-bodied signature endpoint as a sibling of the legacy PNG-based prosign/clisign routes. The legacy flow stays intact: the TeX invoice templates (Bill_tex.cshtml, Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png files the old endpoints write, and the new endpoint writes to a distinct /signatures/ tree under UserFilesDirName. A future migration commit will regenerate PNGs from the JSON payload and decommission the PNG flow. Scope - New Signature entity (Yavsc.Server/Models/Billing/Signature.cs) with FK to Estimate, FK to ApplicationUser (Signer), Type (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple versions per (EstimateId, Type) are allowed; the controller reads the most recent. - New Estimate.Signatures nav collection (InverseProperty) so the composite index covers both sides of the relation. - New DbSet<Signature> Signatures + composite index (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext OnModelCreating. DeleteBehavior.Cascade on Estimate deletion cleans up signatures automatically. - New EstimateSignatureFileHelper (Server/Helpers) with ReceiveEstimateSignatureAsync(user, estimateId, type, payload). Writes a yavsc.signature/v1 JSON envelope to UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json. Quota update lives in the controller, not the helper, because the helper has no DbContext access. - New endpoint POST /api/bill/estimate/{id:long}/sign on BillingController. Authz is body-driven (the bearer token is the PostIt OAuth client, not the end user, so signerUserId is in the JSON body, validated against Estimate.OwnerId/ClientId). Returns 201 Created with the new Signature's metadata. Plumbing - SignatureSubmission (body type) lives next to BillingController in the same file — small enough to keep colocated. - The legacy prosign/clisign routes are untouched. They keep the IFormFile PNG contract; the new endpoint is the JSON counterpart. Tests - New EstimateSignatureFileHelperTests in Yavsc.Org.Tests (8 tests, all green): filename format incl. lowercase type and ticks, envelope v1 round-trip (parsed via JsonDocument, not text matching), null payload rejected, non-positive estimateId rejected. Disk side effects are isolated to a per-test temp root via AbstractFileSystemHelpers.UserFilesDirName. - Yavsc.Org.Tests full suite: 29/29 green. - PostIt.Tests: 57/57 green (untouched by this commit). - Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests all compile clean. Out of scope - EF migration: the Signatures table doesn't exist in the database yet. The migration is intentionally a separate commit so the generated SQL can be reviewed against the composite index and the int[] column type before it touches any prod database. Until the migration lands, the new endpoint will 500 on SaveChanges; the [DEV] button in PostIt is the only call site, so this is acceptable. - SignalR handler that opens the signature page on a 'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
{
2026-07-04 16:06:02 +01:00
signature = new Signature
{
EstimateId = id,
SignerId = userId,
Type = type,
};
dbContext.Signatures.Add(signature);
}
else
{
// Roll the signer's quota back by the size of
// the file we're about to orphan: the old
// FilePath is no longer referenced once we
// overwrite FilePath below.
try
{
var orphan = new FileInfo(signature.FilePath);
if (orphan.Exists)
{
var signerForOrphan = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signerForOrphan is not null)
signerForOrphan.DiskUsage =
Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
}
}
catch { /* best effort — the file is being replaced anyway */ }
}
signature.SignerId = userId;
signature.CoordinateMax = payload.CoordinateMax;
signature.Strokes = payload.Strokes;
signature.CapturedAtUtc = payload.CapturedAtUtc;
signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture Adds a new JSON-bodied signature endpoint as a sibling of the legacy PNG-based prosign/clisign routes. The legacy flow stays intact: the TeX invoice templates (Bill_tex.cshtml, Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png files the old endpoints write, and the new endpoint writes to a distinct /signatures/ tree under UserFilesDirName. A future migration commit will regenerate PNGs from the JSON payload and decommission the PNG flow. Scope - New Signature entity (Yavsc.Server/Models/Billing/Signature.cs) with FK to Estimate, FK to ApplicationUser (Signer), Type (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple versions per (EstimateId, Type) are allowed; the controller reads the most recent. - New Estimate.Signatures nav collection (InverseProperty) so the composite index covers both sides of the relation. - New DbSet<Signature> Signatures + composite index (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext OnModelCreating. DeleteBehavior.Cascade on Estimate deletion cleans up signatures automatically. - New EstimateSignatureFileHelper (Server/Helpers) with ReceiveEstimateSignatureAsync(user, estimateId, type, payload). Writes a yavsc.signature/v1 JSON envelope to UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json. Quota update lives in the controller, not the helper, because the helper has no DbContext access. - New endpoint POST /api/bill/estimate/{id:long}/sign on BillingController. Authz is body-driven (the bearer token is the PostIt OAuth client, not the end user, so signerUserId is in the JSON body, validated against Estimate.OwnerId/ClientId). Returns 201 Created with the new Signature's metadata. Plumbing - SignatureSubmission (body type) lives next to BillingController in the same file — small enough to keep colocated. - The legacy prosign/clisign routes are untouched. They keep the IFormFile PNG contract; the new endpoint is the JSON counterpart. Tests - New EstimateSignatureFileHelperTests in Yavsc.Org.Tests (8 tests, all green): filename format incl. lowercase type and ticks, envelope v1 round-trip (parsed via JsonDocument, not text matching), null payload rejected, non-positive estimateId rejected. Disk side effects are isolated to a per-test temp root via AbstractFileSystemHelpers.UserFilesDirName. - Yavsc.Org.Tests full suite: 29/29 green. - PostIt.Tests: 57/57 green (untouched by this commit). - Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests all compile clean. Out of scope - EF migration: the Signatures table doesn't exist in the database yet. The migration is intentionally a separate commit so the generated SQL can be reviewed against the composite index and the int[] column type before it touches any prod database. Until the migration lands, the new endpoint will 500 on SaveChanges; the [DEV] button in PostIt is the only call site, so this is acceptable. - SignalR handler that opens the signature page on a 'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
// 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,
});
}
2016-11-07 19:34:56 +01:00
}
2018-05-04 10:45:45 +02:00
}
feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture Adds a new JSON-bodied signature endpoint as a sibling of the legacy PNG-based prosign/clisign routes. The legacy flow stays intact: the TeX invoice templates (Bill_tex.cshtml, Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png files the old endpoints write, and the new endpoint writes to a distinct /signatures/ tree under UserFilesDirName. A future migration commit will regenerate PNGs from the JSON payload and decommission the PNG flow. Scope - New Signature entity (Yavsc.Server/Models/Billing/Signature.cs) with FK to Estimate, FK to ApplicationUser (Signer), Type (Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes (native Npgsql mapping), CapturedAtUtc, FilePath. Multiple versions per (EstimateId, Type) are allowed; the controller reads the most recent. - New Estimate.Signatures nav collection (InverseProperty) so the composite index covers both sides of the relation. - New DbSet<Signature> Signatures + composite index (EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext OnModelCreating. DeleteBehavior.Cascade on Estimate deletion cleans up signatures automatically. - New EstimateSignatureFileHelper (Server/Helpers) with ReceiveEstimateSignatureAsync(user, estimateId, type, payload). Writes a yavsc.signature/v1 JSON envelope to UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json. Quota update lives in the controller, not the helper, because the helper has no DbContext access. - New endpoint POST /api/bill/estimate/{id:long}/sign on BillingController. Authz is body-driven (the bearer token is the PostIt OAuth client, not the end user, so signerUserId is in the JSON body, validated against Estimate.OwnerId/ClientId). Returns 201 Created with the new Signature's metadata. Plumbing - SignatureSubmission (body type) lives next to BillingController in the same file — small enough to keep colocated. - The legacy prosign/clisign routes are untouched. They keep the IFormFile PNG contract; the new endpoint is the JSON counterpart. Tests - New EstimateSignatureFileHelperTests in Yavsc.Org.Tests (8 tests, all green): filename format incl. lowercase type and ticks, envelope v1 round-trip (parsed via JsonDocument, not text matching), null payload rejected, non-positive estimateId rejected. Disk side effects are isolated to a per-test temp root via AbstractFileSystemHelpers.UserFilesDirName. - Yavsc.Org.Tests full suite: 29/29 green. - PostIt.Tests: 57/57 green (untouched by this commit). - Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests all compile clean. Out of scope - EF migration: the Signatures table doesn't exist in the database yet. The migration is intentionally a separate commit so the generated SQL can be reviewed against the composite index and the int[] column type before it touches any prod database. Until the migration lands, the new endpoint will 500 on SaveChanges; the [DEV] button in PostIt is the only call site, so this is acceptable. - SignalR handler that opens the signature page on a 'devis received' push — commit 4.
2026-07-04 15:47:55 +01:00
/// <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; }
}