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>
196 lines
6.6 KiB
C#
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.");
|
|
}
|
|
}
|