Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:
Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess
Commands & Handlers:
- Full CQRS command structure for all domains
Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories
GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries
Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)
Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00
|
|
|
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");
|
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
|
|
|
|
|
|
|
|
if (!_isActive)
|
|
|
|
|
throw new DomainException("CUSTOMER_INACTIVE",
|
|
|
|
|
"Cannot modify an inactive customer. Reactivate first.",
|
|
|
|
|
"Kan ikke ændre en inaktiv kunde. Genaktiver først.");
|
Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:
Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess
Commands & Handlers:
- Full CQRS command structure for all domains
Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories
GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries
Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)
Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00
|
|
|
}
|
|
|
|
|
}
|