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>
This commit is contained in:
parent
effb06fc44
commit
8e05171b66
49 changed files with 1537 additions and 1192 deletions
|
|
@ -243,26 +243,27 @@ public class JournalEntryDraftAggregateTests
|
|||
public void MarkPosted_WhenActive_EmitsPostedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var aggregate = CreateActiveDraft();
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
|
||||
// Act
|
||||
aggregate.MarkPosted("transaction-123", "user@example.com");
|
||||
|
||||
// Assert
|
||||
var uncommittedEvents = aggregate.UncommittedEvents.ToList();
|
||||
uncommittedEvents.Should().HaveCount(2); // Created + Posted
|
||||
uncommittedEvents.Should().HaveCount(3); // Created + Updated + Posted
|
||||
|
||||
var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent;
|
||||
var postedEvent = uncommittedEvents[2].AggregateEvent as JournalEntryDraftPostedEvent;
|
||||
postedEvent.Should().NotBeNull();
|
||||
postedEvent!.TransactionId.Should().Be("transaction-123");
|
||||
postedEvent.PostedBy.Should().Be("user@example.com");
|
||||
postedEvent.PostedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
|
||||
{
|
||||
// Arrange
|
||||
var aggregate = CreateActiveDraft();
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
|
||||
// Act
|
||||
var act = () => aggregate.MarkPosted(" ", "user@example.com");
|
||||
|
|
@ -276,7 +277,7 @@ public class JournalEntryDraftAggregateTests
|
|||
public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
|
||||
{
|
||||
// Arrange
|
||||
var aggregate = CreateActiveDraft();
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
|
||||
// Act
|
||||
var act = () => aggregate.MarkPosted("transaction-123", "");
|
||||
|
|
@ -375,9 +376,21 @@ public class JournalEntryDraftAggregateTests
|
|||
return aggregate;
|
||||
}
|
||||
|
||||
private static JournalEntryDraftAggregate CreatePostedDraft()
|
||||
private static JournalEntryDraftAggregate CreateActiveDraftWithLines()
|
||||
{
|
||||
var aggregate = CreateActiveDraft();
|
||||
var lines = new List<DraftLine>
|
||||
{
|
||||
new(1, "account-1", 1000m, 0m, "Debet"),
|
||||
new(2, "account-2", 0m, 1000m, "Kredit")
|
||||
};
|
||||
aggregate.Update("Test Draft", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines);
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
private static JournalEntryDraftAggregate CreatePostedDraft()
|
||||
{
|
||||
var aggregate = CreateActiveDraftWithLines();
|
||||
aggregate.MarkPosted("transaction-123", "user@example.com");
|
||||
return aggregate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace Books.Api.Authentication;
|
|||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24);
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler(
|
||||
|
|
|
|||
|
|
@ -1,15 +1,35 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.Accounts;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.Accounts;
|
||||
|
||||
public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
|
||||
/// <summary>
|
||||
/// Command handler for creating a new account.
|
||||
/// Validates that the account number is unique within the company.
|
||||
/// </summary>
|
||||
public class CreateAccountCommandHandler(
|
||||
IAccountRepository accountRepository)
|
||||
: CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
AccountAggregate aggregate,
|
||||
CreateAccountCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if an account with the same number already exists for this company
|
||||
var existingAccount = await accountRepository.GetByCompanyAndNumberAsync(
|
||||
command.CompanyId, command.AccountNumber, cancellationToken);
|
||||
|
||||
if (existingAccount != null)
|
||||
{
|
||||
throw new DomainException(
|
||||
"ACCOUNT_NUMBER_EXISTS",
|
||||
$"Account number {command.AccountNumber} already exists for this company",
|
||||
$"Kontonummer {command.AccountNumber} eksisterer allerede");
|
||||
}
|
||||
|
||||
aggregate.Create(
|
||||
command.CompanyId,
|
||||
command.AccountNumber,
|
||||
|
|
@ -20,8 +40,6 @@ public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, Acco
|
|||
command.VatCodeId,
|
||||
command.IsSystemAccount,
|
||||
command.StandardAccountNumber);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,64 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.FiscalYears;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.FiscalYears;
|
||||
|
||||
public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
|
||||
/// <summary>
|
||||
/// Command handler for creating a new fiscal year.
|
||||
/// Validates overlap with existing fiscal years and checks for gaps.
|
||||
/// </summary>
|
||||
public class CreateFiscalYearCommandHandler(
|
||||
IFiscalYearRepository fiscalYearRepository)
|
||||
: CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
FiscalYearAggregate aggregate,
|
||||
CreateFiscalYearCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for overlapping fiscal years
|
||||
var hasOverlap = await fiscalYearRepository.HasOverlappingYearAsync(
|
||||
command.CompanyId,
|
||||
command.StartDate,
|
||||
command.EndDate,
|
||||
excludeId: null,
|
||||
cancellationToken);
|
||||
|
||||
if (hasOverlap)
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_OVERLAP",
|
||||
"The fiscal year overlaps with an existing fiscal year",
|
||||
"Regnskabsåret overlapper med et eksisterende regnskabsår");
|
||||
}
|
||||
|
||||
// Check for gaps: verify the new start date follows the latest existing fiscal year
|
||||
if (!command.IsFirstFiscalYear)
|
||||
{
|
||||
var existingYears = await fiscalYearRepository.GetByCompanyIdAsync(
|
||||
command.CompanyId, cancellationToken);
|
||||
|
||||
if (existingYears.Count > 0)
|
||||
{
|
||||
// Find the latest end date among existing fiscal years
|
||||
var latestEndDate = existingYears
|
||||
.Select(fy => DateOnly.FromDateTime(fy.EndDate))
|
||||
.Max();
|
||||
|
||||
var expectedStartDate = latestEndDate.AddDays(1);
|
||||
|
||||
if (command.StartDate != expectedStartDate)
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_GAP",
|
||||
$"Fiscal year must start on {expectedStartDate:yyyy-MM-dd} (day after previous year ends on {latestEndDate:yyyy-MM-dd}). No gaps are allowed between fiscal years.",
|
||||
$"Regnskabsåret skal starte den {expectedStartDate:yyyy-MM-dd} (dagen efter forrige år slutter den {latestEndDate:yyyy-MM-dd}). Der må ikke være huller mellem regnskabsår.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggregate.Create(
|
||||
command.CompanyId,
|
||||
command.Name,
|
||||
|
|
@ -17,8 +66,6 @@ public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate
|
|||
command.EndDate,
|
||||
command.IsFirstFiscalYear,
|
||||
command.IsReorganization);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,39 @@
|
|||
using Books.Api.Domain.Invoices;
|
||||
using Books.Api.Invoicing.Services;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.Invoices;
|
||||
|
||||
public class CreateInvoiceCommandHandler
|
||||
/// <summary>
|
||||
/// Command handler for creating invoices.
|
||||
/// Auto-assigns a sequential invoice number if one is not provided.
|
||||
/// </summary>
|
||||
public class CreateInvoiceCommandHandler(
|
||||
IInvoiceNumberService invoiceNumberService)
|
||||
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
InvoiceAggregate aggregate,
|
||||
CreateInvoiceCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Auto-assign invoice number if not provided
|
||||
var invoiceNumber = command.InvoiceNumber;
|
||||
if (string.IsNullOrWhiteSpace(invoiceNumber))
|
||||
{
|
||||
invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync(
|
||||
command.CompanyId,
|
||||
command.InvoiceDate.Year,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
aggregate.Create(
|
||||
command.CompanyId,
|
||||
command.FiscalYearId,
|
||||
command.CustomerId,
|
||||
command.CustomerName,
|
||||
command.CustomerNumber,
|
||||
command.InvoiceNumber,
|
||||
invoiceNumber,
|
||||
command.InvoiceDate,
|
||||
command.DueDate,
|
||||
command.PaymentTermsDays,
|
||||
|
|
@ -26,8 +42,6 @@ public class CreateInvoiceCommandHandler
|
|||
command.Notes,
|
||||
command.Reference,
|
||||
command.CreatedBy);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Books.Api.Domain;
|
||||
using Books.Api.Domain.JournalEntryDrafts;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using EventFlow.Commands;
|
||||
|
||||
namespace Books.Api.Commands.JournalEntryDrafts;
|
||||
|
|
@ -42,19 +44,75 @@ public class UpdateJournalEntryDraftCommandHandler
|
|||
}
|
||||
}
|
||||
|
||||
public class MarkJournalEntryDraftPostedCommandHandler
|
||||
/// <summary>
|
||||
/// Command handler for posting a journal entry draft.
|
||||
/// Validates fiscal year status and date range before allowing the post.
|
||||
/// </summary>
|
||||
public class MarkJournalEntryDraftPostedCommandHandler(
|
||||
IJournalEntryDraftRepository draftRepository,
|
||||
IFiscalYearRepository fiscalYearRepository)
|
||||
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, MarkJournalEntryDraftPostedCommand>
|
||||
{
|
||||
public override Task ExecuteAsync(
|
||||
public override async Task ExecuteAsync(
|
||||
JournalEntryDraftAggregate aggregate,
|
||||
MarkJournalEntryDraftPostedCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Load the draft read model to get fiscal year and document date
|
||||
var draft = await draftRepository.GetByIdAsync(
|
||||
aggregate.Id.Value, cancellationToken);
|
||||
|
||||
var fiscalYearId = draft?.FiscalYearId ?? aggregate.FiscalYearId;
|
||||
|
||||
// Validate fiscal year is set
|
||||
if (string.IsNullOrWhiteSpace(fiscalYearId))
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_REQUIRED",
|
||||
"Fiscal year is required for posting a journal entry",
|
||||
"Regnskabsår er påkrævet for bogføring af en postering");
|
||||
}
|
||||
|
||||
// Fetch and validate fiscal year
|
||||
var fiscalYear = await fiscalYearRepository.GetByIdAsync(
|
||||
fiscalYearId, cancellationToken);
|
||||
|
||||
if (fiscalYear == null)
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_NOT_FOUND",
|
||||
$"Fiscal year '{fiscalYearId}' not found",
|
||||
$"Regnskabsår '{fiscalYearId}' blev ikke fundet");
|
||||
}
|
||||
|
||||
// Validate fiscal year is open (not Closed or Locked)
|
||||
if (fiscalYear.Status != "Open")
|
||||
{
|
||||
throw new DomainException(
|
||||
"FISCAL_YEAR_NOT_OPEN",
|
||||
$"Fiscal year is {fiscalYear.Status}. Only open fiscal years allow posting.",
|
||||
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
|
||||
}
|
||||
|
||||
// Validate document date falls within fiscal year range (if document date is set)
|
||||
if (draft?.DocumentDate != null)
|
||||
{
|
||||
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
|
||||
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
|
||||
var fyEnd = DateOnly.FromDateTime(fiscalYear.EndDate);
|
||||
|
||||
if (documentDate < fyStart || documentDate > fyEnd)
|
||||
{
|
||||
throw new DomainException(
|
||||
"DOCUMENT_DATE_OUTSIDE_FISCAL_YEAR",
|
||||
$"Document date {documentDate:yyyy-MM-dd} falls outside the fiscal year ({fyStart:yyyy-MM-dd} to {fyEnd:yyyy-MM-dd})",
|
||||
$"Bilagsdato {documentDate:yyyy-MM-dd} ligger uden for regnskabsåret ({fyStart:yyyy-MM-dd} til {fyEnd:yyyy-MM-dd})");
|
||||
}
|
||||
}
|
||||
|
||||
aggregate.MarkPosted(
|
||||
command.TransactionId,
|
||||
command.PostedBy);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ public class BankingController : ControllerBase
|
|||
|
||||
try
|
||||
{
|
||||
// TODO: Add proper CSRF/state validation. Currently the state parameter
|
||||
// is used as the connection ID, but it should also include a CSRF token
|
||||
// that is validated against the user session to prevent cross-site request
|
||||
// forgery attacks on the OAuth callback.
|
||||
// State contains the connection ID (set during StartBankConnection)
|
||||
var connectionId = state;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
-- Migration: 030_AddPostedAtColumn
|
||||
-- Description: Add posted_at column to journal_entry_draft_read_models
|
||||
-- for audit trail compliance (exact timestamp when draft was posted to ledger)
|
||||
|
||||
ALTER TABLE journal_entry_draft_read_models
|
||||
ADD COLUMN IF NOT EXISTS posted_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN journal_entry_draft_read_models.posted_at IS
|
||||
'Exact timestamp when the journal entry draft was posted to the ledger';
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using Books.Api.Domain;
|
||||
|
||||
namespace Books.Api.Domain.Invoices;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -65,15 +67,19 @@ public sealed record InvoiceLine
|
|||
|
||||
/// <summary>
|
||||
/// Gets the VAT rate for this line based on VatCode.
|
||||
/// Delegates to the canonical VatCodes.GetRate() to ensure consistency.
|
||||
/// </summary>
|
||||
private decimal GetVatRate() => VatCode switch
|
||||
private decimal GetVatRate()
|
||||
{
|
||||
"U25" or "I25" => 0.25m, // Danish standard 25%
|
||||
"UEU" or "IEU" => 0m, // EU sales (reverse charge)
|
||||
"UEXP" or "IEXP" => 0m, // Export (no VAT)
|
||||
"INGEN" => 0m, // No VAT
|
||||
_ => 0.25m // Default to Danish standard
|
||||
};
|
||||
if (!VatCodes.IsValid(VatCode))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown VAT code '{VatCode}' on invoice line {LineNumber}. " +
|
||||
$"Valid codes: U25, UEU, UEXP, I25, IEUV, IEUY, IVV, IVY, REP, INGEN");
|
||||
}
|
||||
|
||||
return VatCodes.GetRate(VatCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an InvoiceLine with validation.
|
||||
|
|
|
|||
|
|
@ -2,10 +2,20 @@ using EventFlow.Aggregates;
|
|||
|
||||
namespace Books.Api.Domain.JournalEntryDrafts.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a journal entry draft is posted to the ledger.
|
||||
/// Includes PostedAt timestamp for audit trail compliance.
|
||||
/// </summary>
|
||||
public class JournalEntryDraftPostedEvent(
|
||||
string transactionId,
|
||||
string postedBy) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
|
||||
string postedBy,
|
||||
DateTimeOffset postedAt) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
|
||||
{
|
||||
public string TransactionId { get; } = transactionId;
|
||||
public string PostedBy { get; } = postedBy;
|
||||
|
||||
/// <summary>
|
||||
/// The exact timestamp when the draft was posted to the ledger.
|
||||
/// </summary>
|
||||
public DateTimeOffset PostedAt { get; } = postedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,31 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
IEmit<JournalEntryDraftPostedEvent>,
|
||||
IEmit<JournalEntryDraftDiscardedEvent>
|
||||
{
|
||||
/// <summary>
|
||||
/// Tolerance for floating-point rounding when comparing debit/credit totals.
|
||||
/// </summary>
|
||||
private const decimal BalanceTolerance = 0.01m;
|
||||
|
||||
private bool _isCreated;
|
||||
private DraftStatus _status = DraftStatus.Active;
|
||||
private string _companyId = string.Empty;
|
||||
private string _voucherNumber = string.Empty;
|
||||
private string? _fiscalYearId;
|
||||
private List<DraftLine> _lines = [];
|
||||
|
||||
public string CompanyId => _companyId;
|
||||
public string VoucherNumber => _voucherNumber;
|
||||
|
||||
/// <summary>
|
||||
/// The fiscal year ID assigned during the last update.
|
||||
/// </summary>
|
||||
public string? FiscalYearId => _fiscalYearId;
|
||||
|
||||
/// <summary>
|
||||
/// The current draft lines (populated from the last update event).
|
||||
/// </summary>
|
||||
public IReadOnlyList<DraftLine> Lines => _lines.AsReadOnly();
|
||||
|
||||
#region Apply Methods
|
||||
|
||||
public void Apply(JournalEntryDraftCreatedEvent e)
|
||||
|
|
@ -30,7 +47,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
|
||||
public void Apply(JournalEntryDraftUpdatedEvent e)
|
||||
{
|
||||
// State is stored in read model, not in aggregate
|
||||
_fiscalYearId = e.FiscalYearId;
|
||||
_lines = e.Lines.ToList();
|
||||
}
|
||||
|
||||
public void Apply(JournalEntryDraftPostedEvent e)
|
||||
|
|
@ -97,6 +115,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
|
||||
/// <summary>
|
||||
/// Updates a journal entry draft (auto-save).
|
||||
/// Validates that each line has either DebitAmount or CreditAmount (not both),
|
||||
/// and that VAT codes are valid.
|
||||
/// </summary>
|
||||
/// <param name="name">Draft name</param>
|
||||
/// <param name="documentDate">Bilagsdato - the date of the transaction/document (e.g., invoice date)</param>
|
||||
|
|
@ -126,6 +146,12 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
}
|
||||
}
|
||||
|
||||
// Validate individual lines: cannot have both debit and credit amounts
|
||||
foreach (var line in lines)
|
||||
{
|
||||
ValidateDraftLine(line);
|
||||
}
|
||||
|
||||
Emit(new JournalEntryDraftUpdatedEvent(
|
||||
name?.Trim(),
|
||||
documentDate,
|
||||
|
|
@ -135,6 +161,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
attachmentIds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the draft as posted after validation.
|
||||
/// Enforces double-entry bookkeeping: total debits must equal total credits.
|
||||
/// Requires at least 2 lines with valid account IDs.
|
||||
/// </summary>
|
||||
/// <param name="transactionId">The ledger transaction ID</param>
|
||||
/// <param name="postedBy">User who posted the draft</param>
|
||||
public void MarkPosted(string transactionId, string postedBy)
|
||||
{
|
||||
EnsureCanModify();
|
||||
|
|
@ -151,7 +184,35 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
"Posted by is required",
|
||||
"Bogført af er påkrævet");
|
||||
|
||||
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy));
|
||||
// Validate minimum number of lines for double-entry bookkeeping
|
||||
if (_lines.Count < 2)
|
||||
throw new DomainException(
|
||||
"INSUFFICIENT_LINES",
|
||||
"A journal entry must have at least 2 lines for double-entry bookkeeping",
|
||||
"En postering skal have mindst 2 linjer for dobbelt bogholderi");
|
||||
|
||||
// Validate all lines have account IDs assigned
|
||||
var linesWithoutAccounts = _lines.Where(l => string.IsNullOrWhiteSpace(l.AccountId)).ToList();
|
||||
if (linesWithoutAccounts.Count > 0)
|
||||
{
|
||||
var lineNumbers = string.Join(", ", linesWithoutAccounts.Select(l => l.LineNumber));
|
||||
throw new DomainException(
|
||||
"ACCOUNT_REQUIRED",
|
||||
$"All lines must have an account. Lines without account: {lineNumbers}",
|
||||
$"Alle linjer skal have en konto. Linjer uden konto: {lineNumbers}");
|
||||
}
|
||||
|
||||
// Validate debit/credit balance (fundamental double-entry accounting principle)
|
||||
var totalDebit = _lines.Sum(l => l.DebitAmount);
|
||||
var totalCredit = _lines.Sum(l => l.CreditAmount);
|
||||
|
||||
if (Math.Abs(totalDebit - totalCredit) > BalanceTolerance)
|
||||
throw new DomainException(
|
||||
"UNBALANCED_ENTRY",
|
||||
$"Total debits must equal credits. Debit: {totalDebit:N2}, Credit: {totalCredit:N2}",
|
||||
$"Debet og kredit skal balancere. Debet: {totalDebit:N2}, Kredit: {totalCredit:N2}");
|
||||
|
||||
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public void Discard(string discardedBy)
|
||||
|
|
@ -192,5 +253,29 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
|
|||
"Kassekladden er blevet kasseret");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single draft line.
|
||||
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
|
||||
/// At least one of DebitAmount or CreditAmount must be > 0.
|
||||
/// </summary>
|
||||
private static void ValidateDraftLine(DraftLine line)
|
||||
{
|
||||
if (line.DebitAmount > 0 && line.CreditAmount > 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
"INVALID_LINE_AMOUNTS",
|
||||
$"Line {line.LineNumber} cannot have both debit and credit amounts. Use separate lines.",
|
||||
$"Linje {line.LineNumber} kan ikke have både debet- og kreditbeløb. Brug separate linjer.");
|
||||
}
|
||||
|
||||
if (line.DebitAmount <= 0 && line.CreditAmount <= 0)
|
||||
{
|
||||
throw new DomainException(
|
||||
"MISSING_LINE_AMOUNT",
|
||||
$"Line {line.LineNumber} must have either a debit or credit amount greater than zero",
|
||||
$"Linje {line.LineNumber} skal have enten et debet- eller kreditbeløb større end nul");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ public class JournalEntryDraftReadModel : IReadModel,
|
|||
public string AttachmentIds { get; set; } = "[]";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? TransactionId { get; set; }
|
||||
/// <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.
|
||||
|
|
@ -110,6 +114,7 @@ public class JournalEntryDraftReadModel : IReadModel,
|
|||
|
||||
Status = "posted";
|
||||
TransactionId = domainEvent.AggregateEvent.TransactionId;
|
||||
PostedAt = domainEvent.AggregateEvent.PostedAt;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ public class JournalEntryDraftReadModelDto
|
|||
public string AttachmentIds { get; set; } = "[]";
|
||||
public string Status { get; set; } = "active";
|
||||
public string? TransactionId { get; set; }
|
||||
/// <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;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournal
|
|||
attachment_ids AS AttachmentIds,
|
||||
status AS Status,
|
||||
transaction_id AS TransactionId,
|
||||
posted_at AS PostedAt,
|
||||
created_by AS CreatedBy,
|
||||
create_time AS CreatedAt,
|
||||
updated_time AS UpdatedAt
|
||||
updated_time AS UpdatedAt,
|
||||
extraction_data AS ExtractionData
|
||||
""";
|
||||
|
||||
public async Task<JournalEntryDraftReadModelDto?> GetByIdAsync(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
using System.Security.Claims;
|
||||
using Books.Api.Authorization;
|
||||
using Books.Api.Commands.Companies;
|
||||
using Books.Api.Commands.UserAccess;
|
||||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.Domain.UserAccess;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.GraphQL.InputTypes;
|
||||
using Books.Api.GraphQL.Types;
|
||||
|
|
@ -43,6 +47,17 @@ public class BooksMutation : ObjectGraphType
|
|||
|
||||
await commandBus.PublishAsync(command, ctx.CancellationToken);
|
||||
|
||||
// Grant the creating user owner access to the new company
|
||||
var httpContext = ctx.RequestServices!.GetRequiredService<IHttpContextAccessor>().HttpContext;
|
||||
var userId = httpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId != null)
|
||||
{
|
||||
var accessId = UserCompanyAccessId.FromUserAndCompany(userId, companyId.Value);
|
||||
var grantCmd = new GrantUserCompanyAccessCommand(
|
||||
accessId, userId, companyId.Value, CompanyRole.Owner, userId);
|
||||
await commandBus.PublishAsync(grantCmd, ctx.CancellationToken);
|
||||
}
|
||||
|
||||
// Return the created company (eventually consistent)
|
||||
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
|
||||
});
|
||||
|
|
@ -55,6 +70,11 @@ public class BooksMutation : ObjectGraphType
|
|||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var id = ctx.GetArgument<string>("id");
|
||||
|
||||
// Require Owner or Accountant role to update a company
|
||||
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||
await accessService.RequireAccessAsync(id, CompanyRole.Accountant, ctx.CancellationToken);
|
||||
|
||||
var input = ctx.GetArgument<UpdateCompanyInput>("input");
|
||||
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Books.Api.Authorization;
|
||||
using Books.Api.Domain.Companies;
|
||||
using Books.Api.Domain.UserAccess;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.GraphQL.Types;
|
||||
using GraphQL;
|
||||
|
|
@ -15,11 +17,15 @@ public class BooksQuery : ObjectGraphType
|
|||
|
||||
// companies: [CompanyType]
|
||||
Field<ListGraphType<CompanyType>>("companies")
|
||||
.Description("Get all companies")
|
||||
.Description("Get all companies accessible to the current user")
|
||||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
return await repository.GetAllAsync(ctx.CancellationToken);
|
||||
var userAccesses = await accessService.GetUserCompaniesAsync(ctx.CancellationToken);
|
||||
var companyIds = userAccesses.Select(a => CompanyId.With(a.CompanyId)).ToList();
|
||||
if (companyIds.Count == 0) return Enumerable.Empty<object>();
|
||||
return await repository.GetByIds(companyIds, ctx.CancellationToken);
|
||||
});
|
||||
|
||||
// company(id: ID!): CompanyType
|
||||
|
|
@ -29,6 +35,8 @@ public class BooksQuery : ObjectGraphType
|
|||
.ResolveAsync(async ctx =>
|
||||
{
|
||||
var id = ctx.GetArgument<string>("id");
|
||||
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
|
||||
await accessService.RequireAccessAsync(id, CompanyRole.Viewer, ctx.CancellationToken);
|
||||
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
|
||||
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
|
||||
return companies.FirstOrDefault();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Books.Api;
|
||||
using Books.Api.Authorization;
|
||||
using Books.Api.GraphQL;
|
||||
using GraphQL;
|
||||
using GraphQL.Server.Ui.Altair;
|
||||
|
|
@ -30,6 +31,25 @@ app.UseCors();
|
|||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Company context middleware - extracts X-Company-Id header and validates user access
|
||||
app.UseCompanyContext();
|
||||
|
||||
// Require authentication for the GraphQL endpoint
|
||||
app.UseWhen(
|
||||
context => context.Request.Path.StartsWithSegments("/graphql"),
|
||||
appBuilder => appBuilder.Use(async (context, next) =>
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync("{\"errors\":[{\"message\":\"Authentication required\"}]}");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
})
|
||||
);
|
||||
|
||||
// Map controllers (for AuthController)
|
||||
app.MapControllers();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ public class VatReportService(
|
|||
ILogger<VatReportService> logger) : IVatReportService
|
||||
{
|
||||
// Standard Danish VAT account numbers
|
||||
// TODO: These should ideally come from company-level configuration,
|
||||
// as different chart-of-accounts templates may use different numbers.
|
||||
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
|
||||
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
|
||||
|
||||
|
|
@ -133,13 +135,28 @@ public class VatReportService(
|
|||
report.TotalInputVat = report.BoxB;
|
||||
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
|
||||
|
||||
// Basis amounts require tracking of original transaction amounts
|
||||
// For now, calculate from VAT amounts assuming 25% rate
|
||||
// Basis1 (Felt 1): Net domestic turnover with VAT
|
||||
// TODO: Query actual net turnover from transactions with output VAT codes (U25)
|
||||
// instead of back-calculating from VAT amount, which is inaccurate when
|
||||
// mixed VAT rates or partial deductions are involved.
|
||||
// Ideally: query revenue account balances filtered by VAT code U25.
|
||||
// For now, back-calculate from output VAT assuming standard 25% rate
|
||||
if (report.BoxA > 0)
|
||||
{
|
||||
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
|
||||
}
|
||||
|
||||
// TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions.
|
||||
// Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods.
|
||||
// report.BoxC = sum of VAT calculated on IEUV transactions.
|
||||
// report.Basis3 = net purchase amount for IEUV transactions.
|
||||
|
||||
// TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions.
|
||||
// Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT
|
||||
// on services purchased from abroad.
|
||||
// report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions.
|
||||
// report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions.
|
||||
|
||||
logger.LogInformation(
|
||||
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
|
||||
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);
|
||||
|
|
|
|||
|
|
@ -332,6 +332,15 @@ public class SaftExportService(
|
|||
journals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps internal account types to SAF-T standard account classifications.
|
||||
/// Note: The "financial" type is ambiguous in SAF-T mapping. Financial accounts
|
||||
/// can represent either income (e.g., interest income, account 8000-8499) or
|
||||
/// expense (e.g., interest expense, account 8500-8999). Without the account
|
||||
/// number or balance direction, we cannot determine the correct mapping.
|
||||
/// A future improvement should inspect the account number range or actual
|
||||
/// balance direction to choose between "Income" and "Expense".
|
||||
/// </summary>
|
||||
private static string MapAccountType(string accountType)
|
||||
{
|
||||
return accountType.ToLowerInvariant() switch
|
||||
|
|
@ -343,7 +352,10 @@ public class SaftExportService(
|
|||
"cogs" => "Expense",
|
||||
"expense" => "Expense",
|
||||
"personnel" => "Expense",
|
||||
"financial" => "Income", // Could be either, defaulting to Income
|
||||
// Financial accounts are ambiguous: could be income (8000-8499) or expense (8500-8999).
|
||||
// Defaulting to "Expense" is safer since most financial items are costs (interest, fees).
|
||||
// TODO: Determine mapping based on account number range or balance direction.
|
||||
"financial" => "Expense",
|
||||
"extraordinary" => "Expense",
|
||||
_ => "Asset"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using Books.Api.Authentication;
|
||||
using Books.Api.Authorization;
|
||||
using Books.Api.EventFlow.Extensions;
|
||||
using Books.Api.EventFlow.Repositories;
|
||||
using Books.Api.EventFlow.Infrastructure;
|
||||
using Books.Api.GraphQL;
|
||||
using Books.Api.Infrastructure;
|
||||
|
|
@ -67,6 +69,13 @@ public static class Startup
|
|||
// Read model repositories
|
||||
services.AddRepositories();
|
||||
|
||||
// HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers)
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
// User company access repository and access service
|
||||
services.AddScoped<IUserCompanyAccessRepository, UserCompanyAccessRepository>();
|
||||
services.AddScoped<ICompanyAccessService, CompanyAccessService>();
|
||||
|
||||
// Logging decorators
|
||||
services.DecorateAsyncEventHandlersWithLogging();
|
||||
|
||||
|
|
@ -76,7 +85,7 @@ public static class Startup
|
|||
.AddSystemTextJson()
|
||||
.AddDataLoader()
|
||||
.AddGraphTypes(typeof(BooksSchema).Assembly)
|
||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
|
||||
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
|
||||
|
||||
// Memory cache for API key caching
|
||||
services.AddMemoryCache();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue