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:
Paul Schneider 2026-10-19 20:29:08 +01:00
commit 2dcbcd35b3
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
37 changed files with 6686 additions and 12 deletions

View file

@ -147,6 +147,8 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (query.ClientId != uid)
return new ChallengeResult();
if (query.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.HairCutQueries.Remove(query);
await _context.SaveChangesAsync();
return await Index();

View file

@ -158,6 +158,8 @@ namespace Yavsc.Controllers
public async Task<IActionResult> DeleteConfirmed(long id)
{
Project project = await _context.Project.SingleAsync(m => m.Id == id);
if (project.InvoiceEstimateId.HasValue)
return Conflict(new { Error = "An invoiced command cannot be deleted." });
_context.Project.Remove(project);
await _context.SaveChangesAsync();
return RedirectToAction("Index");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class SignedEstimateInvoicing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "InvoiceClientId",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceCurrency",
table: "NominativeServiceCommands",
type: "character varying(3)",
maxLength: 3,
nullable: true);
migrationBuilder.AddColumn<long>(
name: "InvoiceEstimateId",
table: "NominativeServiceCommands",
type: "bigint",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceLinesJson",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoicePerformerId",
table: "NominativeServiceCommands",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "InvoicedAtUtc",
table: "NominativeServiceCommands",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "PaymentRevision",
table: "NominativeServiceCommands",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateTable(
name: "InvoicePayments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CommandId = table.Column<long>(type: "bigint", nullable: false),
Source = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ReferenceScope = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
Reference = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
ReceivedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RecordedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RecordedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InvoicePayments", x => x.Id);
table.ForeignKey(
name: "FK_InvoicePayments_NominativeServiceCommands_CommandId",
column: x => x.CommandId,
principalTable: "NominativeServiceCommands",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NominativeServiceCommands_InvoiceEstimateId",
table: "NominativeServiceCommands",
column: "InvoiceEstimateId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_InvoicePayments_CommandId",
table: "InvoicePayments",
column: "CommandId");
migrationBuilder.CreateIndex(
name: "IX_InvoicePayments_Source_ReferenceScope_Reference",
table: "InvoicePayments",
columns: new[] { "Source", "ReferenceScope", "Reference" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_NominativeServiceCommands_Estimates_InvoiceEstimateId",
table: "NominativeServiceCommands",
column: "InvoiceEstimateId",
principalTable: "Estimates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NominativeServiceCommands_Estimates_InvoiceEstimateId",
table: "NominativeServiceCommands");
migrationBuilder.DropTable(
name: "InvoicePayments");
migrationBuilder.DropIndex(
name: "IX_NominativeServiceCommands_InvoiceEstimateId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceClientId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceCurrency",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceEstimateId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoiceLinesJson",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoicePerformerId",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "InvoicedAtUtc",
table: "NominativeServiceCommands");
migrationBuilder.DropColumn(
name: "PaymentRevision",
table: "NominativeServiceCommands");
}
}
}

View file

@ -1442,6 +1442,61 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN");
});
modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<long>("CommandId")
.HasColumnType("bigint");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<DateTimeOffset>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("RecordedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Reference")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("ReferenceScope")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.HasKey("Id");
b.HasIndex("CommandId");
b.HasIndex("Source", "ReferenceScope", "Reference")
.IsUnique();
b.ToTable("InvoicePayments");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.Property<long>("Id")
@ -1484,9 +1539,34 @@ namespace Yavsc.Migrations
.HasMaxLength(34)
.HasColumnType("character varying(34)");
b.Property<string>("InvoiceClientId")
.HasColumnType("text");
b.Property<string>("InvoiceCurrency")
.HasMaxLength(3)
.HasColumnType("character varying(3)");
b.Property<long?>("InvoiceEstimateId")
.HasColumnType("bigint");
b.Property<string>("InvoiceLinesJson")
.HasColumnType("text");
b.Property<string>("InvoicePerformerId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("InvoicedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PaymentId")
.HasColumnType("text");
b.Property<Guid>("PaymentRevision")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000000"));
b.Property<string>("PerformerId")
.IsRequired()
.HasColumnType("text");
@ -1514,6 +1594,9 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId");
b.HasIndex("InvoiceEstimateId")
.IsUnique();
b.HasIndex("PaymentId");
b.HasIndex("PerformerId");
@ -3953,6 +4036,17 @@ namespace Yavsc.Migrations
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.InvoicePayment", b =>
{
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
.WithMany()
.HasForeignKey("CommandId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Command");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
@ -3967,6 +4061,11 @@ namespace Yavsc.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Billing.Estimate", null)
.WithMany()
.HasForeignKey("InvoiceEstimateId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Yavsc.Models.Payment.PayPalPayment", "Regularization")
.WithMany()
.HasForeignKey("PaymentId");