books/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs

135 lines
5 KiB
C#
Raw Normal View History

using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using Books.Api.Domain.JournalEntryDrafts;
using Books.Api.Domain.JournalEntryDrafts.Events;
using EventFlow.Aggregates;
using EventFlow.PostgreSql.ReadStores.Attributes;
using EventFlow.ReadStores;
namespace Books.Api.EventFlow.ReadModels;
[Table("journal_entry_draft_read_models")]
public class JournalEntryDraftReadModel : IReadModel,
IAmReadModelFor<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftCreatedEvent>,
IAmReadModelFor<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftUpdatedEvent>,
IAmReadModelFor<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftPostedEvent>,
IAmReadModelFor<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftDiscardedEvent>
{
[PostgreSqlReadModelIdentityColumn]
public string AggregateId { get; set; } = string.Empty;
public DateTimeOffset CreateTime { get; set; }
public DateTimeOffset UpdatedTime { get; set; }
[PostgreSqlReadModelVersionColumn]
public int LastAggregateSequenceNumber { get; set; }
// Business fields
public string CompanyId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
/// <summary>
/// Bilagsnummer - unique document number per company (required by Bogføringsloven § 7)
/// </summary>
public string VoucherNumber { get; set; } = string.Empty;
/// <summary>
/// Bilagsdato - the date of the transaction/document (e.g., invoice date)
/// </summary>
public DateTime? DocumentDate { get; set; }
public string? Description { get; set; }
public string? FiscalYearId { get; set; }
/// <summary>
/// JSON array of posting lines with VAT codes
/// </summary>
public string Lines { get; set; } = "[]";
/// <summary>
/// JSON array of attachment IDs (bilag references, required by Bogføringsloven § 6)
/// </summary>
public string AttachmentIds { get; set; } = "[]";
public string Status { get; set; } = "active";
public string? TransactionId { get; set; }
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset? PostedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
/// <summary>
/// Full AI extraction data stored as JSON string.
/// Contains vendor CVR, amounts, VAT, due date, payment reference, line items, etc.
/// </summary>
public string? ExtractionData { get; set; }
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftCreatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
AggregateId = domainEvent.AggregateIdentity.Value;
CreateTime = domainEvent.Timestamp;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
CompanyId = e.CompanyId;
Name = e.Name;
VoucherNumber = e.VoucherNumber;
CreatedBy = e.CreatedBy;
Status = "active";
Lines = "[]";
AttachmentIds = "[]";
ExtractionData = e.ExtractionData;
return Task.CompletedTask;
}
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftUpdatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
if (e.Name != null)
Name = e.Name;
DocumentDate = e.DocumentDate?.ToDateTime(TimeOnly.MinValue);
Description = e.Description;
FiscalYearId = e.FiscalYearId;
Lines = JsonSerializer.Serialize(e.Lines);
AttachmentIds = JsonSerializer.Serialize(e.AttachmentIds);
return Task.CompletedTask;
}
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftPostedEvent> domainEvent,
CancellationToken cancellationToken)
{
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Status = "posted";
TransactionId = domainEvent.AggregateEvent.TransactionId;
Full product audit: fix security, compliance, UX, and wire broken features Security (Phase 1): - Add authentication middleware on /graphql endpoint - Filter company queries by user access (prevent IDOR) - Add role-based authorization on mutations (owner/accountant) - Reduce API key cache TTL from 24h to 5 minutes - Hide exception details in production GraphQL errors - Fix RBAC in frontend companyStore (was hardcoded) Wiring broken features (Phase 2): - Wire Kassekladde submit/void/copy to GraphQL mutations - Wire Kontooversigt account creation to createAccount mutation - Wire Settings save to updateCompany mutation - Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations - Replace Momsindberetning mock data with real useVatReport query - Remove Dashboard hardcoded percentages and fake VAT deadline - Fix Kreditnotaer invoice selector to use real data - Fix mutation retry from 1 to 0 (prevent duplicate operations) Accounting compliance (Phase 3): - Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate - Add fiscal year boundary enforcement (status, date range checks) - Add PostedAt timestamp to posted events (Bogføringsloven §7) - Add account number uniqueness check within company - Add fiscal year overlap and gap checks - Add sequential invoice auto-numbering - Fix InvoiceLine VAT rate to use canonical VatCodes - Fix SAF-T account type mapping (financial → Expense) - Add DraftLine validation (cannot have both debit and credit > 0) UX improvements (Phase 4): - Fix Danish character encoding across 15+ files (ø, æ, å) - Deploy DemoDataDisclaimer on pages with mock/incomplete data - Adopt PageHeader component universally across all pages - Standardize active/inactive filtering to Switch pattern - Fix dead buttons in Header (Help, Notifications) - Remove hardcoded mock data from Settings - Fix Sidebar controlled state and Kontooversigt navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00
PostedAt = domainEvent.AggregateEvent.PostedAt;
return Task.CompletedTask;
}
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<JournalEntryDraftAggregate, JournalEntryDraftId, JournalEntryDraftDiscardedEvent> domainEvent,
CancellationToken cancellationToken)
{
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Status = "discarded";
return Task.CompletedTask;
}
}