books/backend/Books.Api/Domain/Customers/CustomerAggregate.cs
Nicolaj Hartmann 8096a19081 Audit v4: VAT calc, SAF-T compliance, security hardening, frontend quality
Backend (17 files):
- VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY),
  IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue
- SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback,
  credit note auto-numbering (§52)
- Security: BankingController CSRF state token + company auth check,
  attachment canonical path traversal check, discount 0-100% validation,
  deactivated product/customer update guard
- Quality: redact bank API logs, remove dead code (VatCalcService,
  PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding

Frontend (15 files):
- Fix double "kr." in AmountText and Dashboard Statistic components
- Fix UserSettings Switch defaultChecked desync with Form state
- Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank)
- Correct SKAT VAT deadline calculation per period type
- Add half-yearly/yearly VAT period options
- Guard console.error with import.meta.env.DEV
- Use shared formatDate in BankConnectionsTab
- Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union
- Migrate S25→U25, K25→I25 across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:38:52 +01:00

196 lines
6.6 KiB
C#

using Books.Api.Domain.Companies;
using Books.Api.Domain.Customers.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Customers;
public class CustomerAggregate(CustomerId id) : AggregateRoot<CustomerAggregate, CustomerId>(id),
IEmit<CustomerCreatedEvent>,
IEmit<CustomerUpdatedEvent>,
IEmit<CustomerDeactivatedEvent>,
IEmit<CustomerReactivatedEvent>
{
private bool _isCreated;
private bool _isActive = true;
private CustomerType _customerType;
public void Apply(CustomerCreatedEvent e)
{
_isCreated = true;
_customerType = e.CustomerType;
}
public void Apply(CustomerUpdatedEvent e) { }
public void Apply(CustomerDeactivatedEvent e) => _isActive = false;
public void Apply(CustomerReactivatedEvent e) => _isActive = true;
public void Create(
string companyId,
string customerNumber,
CustomerType customerType,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
string? email,
string? phone,
int paymentTermsDays,
string? defaultRevenueAccountId,
string subLedgerAccountId)
{
if (_isCreated)
throw new DomainException("CUSTOMER_EXISTS", "Customer already exists", "Kunden eksisterer allerede");
if (string.IsNullOrWhiteSpace(companyId))
throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er paakraevet");
if (string.IsNullOrWhiteSpace(customerNumber))
throw new DomainException("CUSTOMER_NUMBER_REQUIRED", "Customer number is required", "Kundenummer er paakraevet");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("CUSTOMER_NAME_REQUIRED", "Customer name is required", "Kundenavn er paakraevet");
if (string.IsNullOrWhiteSpace(subLedgerAccountId))
throw new DomainException("SUB_LEDGER_ACCOUNT_REQUIRED", "Sub-ledger account ID is required", "Debitor-underkonto er paakraevet");
// B2B customers require CVR
if (customerType == CustomerType.Business && string.IsNullOrWhiteSpace(cvr))
throw new DomainException("CVR_REQUIRED_FOR_BUSINESS",
"CVR is required for business customers",
"CVR-nummer er paakraevet for erhvervskunder");
// Validate CVR format if provided
if (!string.IsNullOrWhiteSpace(cvr))
{
if (!CvrValidator.HasValidFormat(cvr))
throw new DomainException("INVALID_CVR_FORMAT",
"CVR number must be exactly 8 digits",
"CVR-nummer skal vaere praecis 8 cifre");
if (!CvrValidator.IsValid(cvr))
throw new DomainException("INVALID_CVR_CHECKSUM",
"CVR number has invalid checksum",
"CVR-nummer har ugyldig kontrolsum");
}
if (paymentTermsDays < 0 || paymentTermsDays > 365)
throw new DomainException("INVALID_PAYMENT_TERMS",
"Payment terms must be between 0 and 365 days",
"Betalingsbetingelser skal vaere mellem 0 og 365 dage");
Emit(new CustomerCreatedEvent(
companyId,
customerNumber.Trim(),
customerType,
name.Trim(),
cvr?.Trim(),
address?.Trim(),
postalCode?.Trim(),
city?.Trim(),
country,
email?.Trim(),
phone?.Trim(),
paymentTermsDays,
defaultRevenueAccountId,
subLedgerAccountId));
}
public void Update(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
string? email,
string? phone,
int paymentTermsDays,
string? defaultRevenueAccountId)
{
EnsureCanModify();
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("CUSTOMER_NAME_REQUIRED", "Customer name is required", "Kundenavn er paakraevet");
// B2B customers still require CVR
if (_customerType == CustomerType.Business && string.IsNullOrWhiteSpace(cvr))
throw new DomainException("CVR_REQUIRED_FOR_BUSINESS",
"CVR is required for business customers",
"CVR-nummer er paakraevet for erhvervskunder");
// Validate CVR if provided
if (!string.IsNullOrWhiteSpace(cvr))
{
if (!CvrValidator.HasValidFormat(cvr))
throw new DomainException("INVALID_CVR_FORMAT",
"CVR number must be exactly 8 digits",
"CVR-nummer skal vaere praecis 8 cifre");
if (!CvrValidator.IsValid(cvr))
throw new DomainException("INVALID_CVR_CHECKSUM",
"CVR number has invalid checksum",
"CVR-nummer har ugyldig kontrolsum");
}
if (paymentTermsDays < 0 || paymentTermsDays > 365)
throw new DomainException("INVALID_PAYMENT_TERMS",
"Payment terms must be between 0 and 365 days",
"Betalingsbetingelser skal vaere mellem 0 og 365 dage");
Emit(new CustomerUpdatedEvent(
name.Trim(),
cvr?.Trim(),
address?.Trim(),
postalCode?.Trim(),
city?.Trim(),
country,
email?.Trim(),
phone?.Trim(),
paymentTermsDays,
defaultRevenueAccountId));
}
public void Deactivate()
{
EnsureCanModify();
if (!_isActive)
throw new DomainException("CUSTOMER_ALREADY_INACTIVE",
"Customer is already inactive",
"Kunden er allerede inaktiv");
Emit(new CustomerDeactivatedEvent());
}
public void Reactivate()
{
if (!_isCreated)
throw new DomainException("CUSTOMER_NOT_FOUND",
"Customer does not exist",
"Kunden findes ikke");
if (_isActive)
throw new DomainException("CUSTOMER_ALREADY_ACTIVE",
"Customer is already active",
"Kunden er allerede aktiv");
Emit(new CustomerReactivatedEvent());
}
private void EnsureCanModify()
{
if (!_isCreated)
throw new DomainException("CUSTOMER_NOT_FOUND",
"Customer does not exist",
"Kunden findes ikke");
if (!_isActive)
throw new DomainException("CUSTOMER_INACTIVE",
"Cannot modify an inactive customer. Reactivate first.",
"Kan ikke ændre en inaktiv kunde. Genaktiver først.");
}
}