books/backend/Books.Api/Domain/Invoices/InvoiceLine.cs

125 lines
3.8 KiB
C#
Raw Normal View History

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
using Books.Api.Domain;
namespace Books.Api.Domain.Invoices;
/// <summary>
/// Value object representing a single line on an invoice.
/// Contains product/service description, quantity, pricing, and VAT information.
/// </summary>
public sealed record InvoiceLine
{
/// <summary>
/// Line number for ordering (1-based).
/// </summary>
public int LineNumber { get; init; }
/// <summary>
/// Description of the product or service.
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Quantity (can be decimal for services billed by hour).
/// </summary>
public decimal Quantity { get; init; }
/// <summary>
/// Unit of measurement (e.g., "stk", "timer", "kg").
/// </summary>
public string? Unit { get; init; }
/// <summary>
/// Price per unit excluding VAT.
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// Discount percentage (0-100).
/// </summary>
public decimal DiscountPercent { get; init; }
/// <summary>
/// VAT code (e.g., "U25" for 25%, "UEU" for EU sales, "UEXP" for export).
/// Uses existing VatCode definitions.
/// </summary>
public string VatCode { get; init; } = "U25";
/// <summary>
/// Revenue account to credit when invoice is sent.
/// If null, uses customer's default revenue account.
/// </summary>
public string? AccountId { get; init; }
/// <summary>
/// Calculated: UnitPrice * Quantity * (1 - DiscountPercent/100)
/// </summary>
public decimal AmountExVat => Math.Round(UnitPrice * Quantity * (1 - DiscountPercent / 100m), 2);
/// <summary>
/// Calculated VAT amount based on VatCode.
/// </summary>
public decimal AmountVat => Math.Round(AmountExVat * GetVatRate(), 2);
/// <summary>
/// Calculated: AmountExVat + AmountVat
/// </summary>
public decimal AmountTotal => AmountExVat + AmountVat;
/// <summary>
/// Gets the VAT rate for this line based on VatCode.
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
/// Delegates to the canonical VatCodes.GetRate() to ensure consistency.
/// </summary>
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
private decimal GetVatRate()
{
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
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.
/// </summary>
public static InvoiceLine Create(
int lineNumber,
string description,
decimal quantity,
decimal unitPrice,
string? vatCode = null,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0)
{
if (lineNumber < 1)
throw new ArgumentException("Line number must be at least 1", nameof(lineNumber));
if (string.IsNullOrWhiteSpace(description))
throw new ArgumentException("Description is required", nameof(description));
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));
if (unitPrice < 0)
throw new ArgumentException("Unit price cannot be negative", nameof(unitPrice));
if (discountPercent < 0 || discountPercent > 100)
throw new ArgumentException("Discount must be between 0 and 100", nameof(discountPercent));
return new InvoiceLine
{
LineNumber = lineNumber,
Description = description.Trim(),
Quantity = quantity,
UnitPrice = unitPrice,
VatCode = vatCode ?? "U25",
AccountId = accountId,
Unit = unit,
DiscountPercent = discountPercent
};
}
}