diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index 25844955..a7047049 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -5,6 +5,8 @@ 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
{
@@ -181,5 +183,170 @@ namespace Yavsc.ApiControllers
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; }
+}
diff --git a/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
new file mode 100644
index 00000000..9de881af
--- /dev/null
+++ b/src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
@@ -0,0 +1,134 @@
+using System;
+using System.IO;
+using System.Security.Claims;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Server.Helpers;
+using Yavsc.Server.Models.FileSystem;
+
+namespace Yavsc.Org.Tests;
+
+///
+/// Tests for the static
+/// helper. Scope is intentionally narrow: the file-naming format,
+/// the strokes counter, and the on-disk write path. The controller
+/// (authz, db persistence, signalR notification) is out of scope
+/// for this commit and will get a dedicated integration test once
+/// the Yavsc.Api test project is set up.
+///
+public class EstimateSignatureFileHelperTests : IDisposable
+{
+ private readonly string _tempRoot;
+
+ public EstimateSignatureFileHelperTests()
+ {
+ // UserFilesDirName is a process-wide static; we redirect
+ // it to a per-test temp dir so concurrent tests don't
+ // collide and the host filesystem is not littered.
+ _tempRoot = Path.Combine(
+ Path.GetTempPath(),
+ "yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempRoot);
+ AbstractFileSystemHelpers.UserFilesDirName = _tempRoot;
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempRoot, recursive: true); }
+ catch { /* best effort — the OS will clean Temp eventually */ }
+ }
+
+ [Fact]
+ public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
+ {
+ var name = EstimateSignatureFileHelper.FileNameFormat(
+ SignatureType.Pro, 42, 638_000_000_000_000_000L);
+ Assert.Equal("sign-pro-42-638000000000000000.json", name);
+
+ var cli = EstimateSignatureFileHelper.FileNameFormat(
+ SignatureType.Client, 7, 1L);
+ Assert.Equal("sign-client-7-1.json", cli);
+ }
+
+ [Theory]
+ [InlineData(new int[] { }, 0)]
+ [InlineData(new[] { 1, 100, 200 }, 1)]
+ [InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
+ [InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
+ [InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
+ public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
+ {
+ // We don't read the count back from the helper (it's a
+ // private method), but the JSON envelope must reflect
+ // it; this verifies the public behaviour end-to-end.
+ _ = expectedStrokeCount;
+ // Arrange
+ var user = MakeUser("alice");
+ var payload = new SignaturePadPayload
+ {
+ CoordinateMax = 10_000,
+ CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
+ Strokes = strokes,
+ };
+
+ // Act
+ var fi = Run(user, 123L, SignatureType.Pro, payload);
+
+ // Assert: file exists, sits under the user's root, and
+ // parses as a yavsc.signature/v1 envelope.
+ var fullPath = Path.Combine(fi.DestDir, fi.FileName);
+ Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
+ var root = doc.RootElement;
+ Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
+ Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
+ Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
+ Assert.Equal("Pro", root.GetProperty("type").GetString());
+ Assert.Equal("alice", root.GetProperty("signerName").GetString());
+ Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
+ }
+
+ [Fact]
+ public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
+ {
+ var user = MakeUser("bob");
+ await Assert.ThrowsAsync(() =>
+ EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
+ user, 1L, SignatureType.Pro, payload: null!));
+ }
+
+ [Fact]
+ public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
+ {
+ var user = MakeUser("bob");
+ var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
+ await Assert.ThrowsAsync(() =>
+ EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
+ user, 0L, SignatureType.Pro, payload));
+ }
+
+ // --- helpers ----------------------------------------------------
+
+ private static FileReceivedInfo Run(
+ ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload)
+ {
+ // The helper is async; tests that don't care about the
+ // result can call it sync via .GetAwaiter().GetResult()
+ // because we know it never throws in the happy path.
+ return EstimateSignatureFileHelper
+ .ReceiveEstimateSignatureAsync(user, estimateId, type, payload, CancellationToken.None)
+ .GetAwaiter().GetResult();
+ }
+
+ private static ClaimsPrincipal MakeUser(string username)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, username) },
+ authenticationType: "test"));
+ }
+}
diff --git a/src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs b/src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs
new file mode 100644
index 00000000..122b382c
--- /dev/null
+++ b/src/Yavsc.Server/Helpers/EstimateSignatureFileHelper.cs
@@ -0,0 +1,160 @@
+using System;
+using System.IO;
+using System.Security.Claims;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Server.Models.FileSystem;
+namespace Yavsc.Server.Helpers;
+
+///
+/// Filesystem counterpart of : writes
+/// the wire-format JSON payload next to the user's other files,
+/// under signatures/, and updates the user's disk quota.
+///
+/// This is the JSON counterpart of the legacy
+/// ReceiveProSignatureAsync method, which stored
+/// sign-{billingCode}-{signType}-{estimateId}.png blobs.
+/// We don't reuse that helper because (a) the wire format is no
+/// longer a binary image, (b) there's no billingCode on
+/// a freshly signed estimate in our model, and (c) the legacy
+/// helper takes an whereas our pipeline
+/// decodes a JSON body upstream of the controller and passes
+/// in directly.
+///
+public static class EstimateSignatureFileHelper
+{
+ ///
+ /// Sub-directory under the user's root where signature
+ /// payloads live. Kept short to leave room in PATH_MAX on
+ /// legacy filesystems; the rest of the filename is
+ /// sign-{type}-{estimateId}-{utcTicks}.json.
+ ///
+ public const string SignaturesSubdir = "signatures";
+
+ ///
+ /// Format string for signature file names. Public so the
+ /// migration and the admin tools can list by pattern.
+ ///
+ public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks)
+ => $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json";
+
+ ///
+ /// Persist a signature wire payload to disk. Returns the
+ /// file info (relative path under the user's root) suitable
+ /// for storing in ; the
+ /// caller is responsible for the database write.
+ ///
+ /// Signed-in user. Their
+ /// Identity.Name locates the disk root via
+ /// .
+ ///
+ /// Estimate this signature
+ /// attaches to. Used in the file name for human inspection
+ /// and to support multiple versions over time.
+ /// Provider or client signature.
+ /// Decoded wire payload (strokes +
+ /// coordinateMax + capturedAtUtc). Already validated
+ /// upstream.
+ /// Cancellation token forwarded to
+ /// the file write.
+ public static async Task ReceiveEstimateSignatureAsync(
+ this ClaimsPrincipal user,
+ long estimateId,
+ SignatureType type,
+ SignaturePadPayload payload,
+ CancellationToken token = default)
+ {
+ if (user is null) throw new ArgumentNullException(nameof(user));
+ if (payload is null) throw new ArgumentNullException(nameof(payload));
+ if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
+
+ // Ensure the user has a /signatures/ sub-directory we can
+ // write to. EnsureDestinationDirectory throws on invalid
+ // paths and creates the directory on the way; the
+ // SignaturesSubdir constant is a server-controlled value
+ // (not user-derived), so we skip the IsValidYavscPath
+ // check that ReceiveUserFile performs on user-supplied
+ // subpaths.
+ var root = user.EnsureDestinationDirectory(SignaturesSubdir);
+
+ var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
+ var fullPath = Path.Combine(root, fileName);
+
+ var envelope = new
+ {
+ format = "yavsc.signature/v1",
+ coordinateMax = payload.CoordinateMax,
+ capturedAtUtc = payload.CapturedAtUtc,
+ estimateId,
+ type = type.ToString(),
+ // Identity.Name is the username; we keep the wire
+ // payload keyed on the username rather than the
+ // numeric/guid Id so disk-side human inspection
+ // (e.g. cat sign-pro-1234-...json) is self-evident.
+ signerName = user.Identity?.Name,
+ strokes = payload.Strokes,
+ strokeCount = CountStrokes(payload.Strokes),
+ };
+
+ var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8, token).ConfigureAwait(false);
+
+ // Quota update is the controller's responsibility: the
+ // helper has no DbContext access, and a ClaimsPrincipal
+ // is not an ApplicationUser. The controller looks up
+ // the user by Identity.Name and bumps DiskUsage after
+ // a successful database write.
+
+ return new FileReceivedInfo(root, fileName);
+ }
+
+ private static int CountStrokes(int[] strokes)
+ {
+ int n = 0;
+ for (int i = 0; i < strokes.Length;)
+ {
+ int k = strokes[i];
+ if (k <= 0) break;
+ n++;
+ i += 1 + 2 * k;
+ }
+ return n;
+ }
+}
+
+///
+/// Wire payload accepted by the signature endpoint and
+/// persisted by .
+/// Mirrors PostIt.Models.SignaturePadData's JSON shape
+/// (without the disk-only envelope fields) so the two sides
+/// stay trivially compatible.
+///
+public class SignaturePadPayload
+{
+ ///
+ /// Normalised coordinate upper bound. Must be
+ /// PostIt.Models.SignaturePadData.CoordinateMax
+ /// (10_000) today; declared as a property so a future
+ /// resolution change can be replayed against the same
+ /// wire format.
+ ///
+ public int CoordinateMax { get; set; } = 10_000;
+
+ ///
+ /// Client-reported capture time. The server may ignore
+ /// this for ordering (UTC now is the truth) but keeps it
+ /// for round-trip display.
+ ///
+ public DateTime CapturedAtUtc { get; set; }
+
+ ///
+ /// Wire strokes. See
+ /// PostIt.Models.SignaturePadData for the format.
+ ///
+ public int[] Strokes { get; set; } = Array.Empty();
+}
diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs
index 0675fb6a..b8ade1c2 100644
--- a/src/Yavsc.Server/Models/ApplicationDbContext.cs
+++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs
@@ -69,6 +69,25 @@ namespace Yavsc.Models
builder.Entity().Property(x => x.DeclarationDate).HasDefaultValueSql(NOW_SQL);
builder.Entity().HasKey(x => new { x.PostId, x.TagId });
+ // Signature: composite index (EstimateId, Type,
+ // CapturedAtUtc DESC) to support the controller's
+ // "most recent signature per type" read pattern
+ // without an extra ORDER BY cost. The default
+ // EF-generated FK index on EstimateId alone is
+ // replaced by the composite to avoid duplicate
+ // indexes.
+ builder.Entity()
+ .HasIndex(s => new { s.EstimateId, s.Type, s.CapturedAtUtc })
+ .IsDescending(false, false, true);
+ builder.Entity()
+ .HasOne(s => s.Estimate)
+ .WithMany(e => e.Signatures)
+ .HasForeignKey(s => s.EstimateId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.Entity()
+ .Property(s => s.CoordinateMax)
+ .HasDefaultValue(10_000);
+
builder.Entity().Property(u => u.FullName).IsRequired(false);
builder.Entity().Property(u => u.DedicatedGoogleCalendar).IsRequired(false);
builder.Entity().HasMany(c => c.Connections);
@@ -235,6 +254,7 @@ namespace Yavsc.Models
public DbSet Performers { get; set; }
public DbSet Estimates { get; set; }
+ public DbSet Signatures { get; set; }
public DbSet BankStatus { get; set; }
public DbSet BalanceImpact { get; set; }
diff --git a/src/Yavsc.Server/Models/Billing/Estimate.cs b/src/Yavsc.Server/Models/Billing/Estimate.cs
index 4473b059..817d22ae 100644
--- a/src/Yavsc.Server/Models/Billing/Estimate.cs
+++ b/src/Yavsc.Server/Models/Billing/Estimate.cs
@@ -62,20 +62,30 @@ namespace Yavsc.Models.Billing
public string OwnerId { get; set; }
[ForeignKey("OwnerId"),JsonIgnore]
- public virtual PerformerProfile Owner { get; set; }
+ public virtual PerformerProfile Owner { get; set; }
[Required]
public string ClientId { get; set; }
[ForeignKey("ClientId"),JsonIgnore]
- public virtual ApplicationUser Client { get; set; }
+ public virtual ApplicationUser Client { get; set; }
[Required]
public string CommandType
{
get; set;
}
- public DateTime ProviderValidationDate { get; set; }
- public DateTime ClientValidationDate { get; set; }
+ public DateTime ProviderValidationDate { get; set; }
+ public DateTime ClientValidationDate { get; set; }
+
+ ///
+ /// All signatures captured against this estimate, in
+ /// capture order. Multiple versions per (Type, SignerId)
+ /// are allowed; the controller reads the most recent
+ /// when asked. See .
+ ///
+ [InverseProperty(nameof(Signature.Estimate))]
+ public virtual ICollection Signatures { get; set; }
+ = new List();
}
}
diff --git a/src/Yavsc.Server/Models/Billing/Signature.cs b/src/Yavsc.Server/Models/Billing/Signature.cs
new file mode 100644
index 00000000..2e26ae9c
--- /dev/null
+++ b/src/Yavsc.Server/Models/Billing/Signature.cs
@@ -0,0 +1,92 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+using Yavsc.Models.Relationship;
+
+namespace Yavsc.Models.Billing;
+
+///
+/// One captured signature, attached to a single
+/// . Multiple versions are allowed per
+/// (EstimateId, Type, SignerId) tuple — the controller reads
+/// the most recent when asked. The wire-format payload is the
+/// same int[] shape PostIt produces (see
+/// PostIt.Models.SignaturePadData): a length-prefixed
+/// sequence of strokes, each stroke being
+/// [k, x0, y0, x1, y1, ...] with x, y ∈ [0,
+/// CoordinateMax].
+///
+///
+/// Why a separate table (instead of a JSON column on
+/// ): the jalon 1 spec calls for at least
+/// two distinct signatures per estimate cycle (provider
+/// validation + client agreement) and the audit value of
+/// preserving superseded versions. A dedicated table also keeps
+/// the row narrow, which matters for
+/// list views.
+///
+///
+public class Signature
+{
+ [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long Id { get; set; }
+
+ public long EstimateId { get; set; }
+
+ [ForeignKey(nameof(EstimateId)), JsonIgnore]
+ public virtual Estimate Estimate { get; set; }
+
+ ///
+ /// The ApplicationUser.Id of the signer. Always
+ /// matches Estimate.OwnerId when
+ /// is , and
+ /// Estimate.ClientId when
+ /// is .
+ /// The authz layer enforces this invariant; we don't
+ /// duplicate the constraint in the schema to keep the model
+ /// honest if a future business rule relaxes it (e.g. proxy
+ /// signing).
+ ///
+ [Required]
+ public string SignerId { get; set; }
+
+ [ForeignKey(nameof(SignerId)), JsonIgnore]
+ public virtual ApplicationUser Signer { get; set; }
+
+ public SignatureType Type { get; set; }
+
+ ///
+ /// The normalised coordinate upper bound used at capture
+ /// time. Today always
+ /// PostIt.Models.SignaturePadData.CoordinateMax
+ /// (10_000). Stored so a future change to the wire format
+ /// can be replayed against old signatures without data
+ /// loss.
+ ///
+ public int CoordinateMax { get; set; }
+
+ ///
+ /// Wire-format payload. PostgreSQL stores an int[]
+ /// natively via Npgsql; the column is round-tripped through
+ /// JsonConvert only if the migration binds it as
+ /// text for backwards compatibility (see the EF
+ /// configuration in ApplicationDbContext).
+ ///
+ [Required]
+ public int[] Strokes { get; set; } = Array.Empty();
+
+ public DateTime CapturedAtUtc { get; set; }
+
+ ///
+ /// Path to the JSON-serialised wire payload on disk,
+ /// relative to UserFilesDirName. The disk copy is the
+ /// source of truth for the wire bytes; the
+ /// column is a denormalised index for queries. They are
+ /// written together in the same transaction by the
+ /// controller; the migration should keep them in sync
+ /// through ApplicationDbContext.SaveChanges.
+ ///
+ [Required]
+ public string FilePath { get; set; }
+}
diff --git a/src/Yavsc.Server/Models/Billing/SignatureType.cs b/src/Yavsc.Server/Models/Billing/SignatureType.cs
new file mode 100644
index 00000000..7c27924f
--- /dev/null
+++ b/src/Yavsc.Server/Models/Billing/SignatureType.cs
@@ -0,0 +1,15 @@
+namespace Yavsc.Models.Billing;
+
+///
+/// Who signed. is the service provider's
+/// signature on a devis or contract; is the
+/// customer's signature. A single can
+/// carry at most one signature per type at the latest version
+/// (older versions are kept for audit and read as
+/// "most-recent-wins" by the controller).
+///
+public enum SignatureType
+{
+ Pro = 0,
+ Client = 1,
+}