From 1f75c5d791494976a4a873e89181182cc4ce3eab Mon Sep 17 00:00:00 2001 From: Nicolaj Hartmann Date: Fri, 30 Jan 2026 22:19:42 +0100 Subject: [PATCH] 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 --- .beads/issues.jsonl | 1 + .../AiBookkeeper/AiBookkeeperClientTests.cs | 365 ++++++ .../ChartOfAccountsProviderTests.cs | 220 ++++ .../AiBookkeeper/ToonFormatConverterTests.cs | 214 ++++ .../Domain/ApiKeyIntegrationTests.cs | 282 +++++ .../Domain/BankConnectionAggregateTests.cs | 485 ++++++++ .../Domain/JournalEntryDraftAggregateTests.cs | 393 ++++++ .../Domain/VatCalculationServiceTests.cs | 265 ++++ .../GraphQL/AccountGraphQLTests.cs | 819 ++++++++++++ .../ChartOfAccountsInitializerTests.cs | 475 +++++++ .../GraphQL/FiscalYearGraphQLTests.cs | 1102 +++++++++++++++++ .../GraphQL/JournalEntryDraftGraphQLTests.cs | 787 ++++++++++++ .../Books.Api.Tests/Helpers/CvrGenerator.cs | 51 + .../IntegrationTestCollection.cs | 12 + .../TestAuthenticationHandler.cs | 44 + .../DocumentProcessingIntegrationTests.cs | 260 ++++ .../Integration/EnableBankingClientTests.cs | 100 ++ .../Integration/ReadModelRepopulationTests.cs | 419 +++++++ .../Invoicing/InvoicePdfGeneratorTests.cs | 484 ++++++++ .../Reporting/VatReportServiceTests.cs | 368 ++++++ .../AiBookkeeper/AccountMappingService.cs | 126 ++ .../AiBookkeeper/AiBookkeeperClient.cs | 340 +++++ .../AiBookkeeper/AiBookkeeperOptions.cs | 24 + .../AiBookkeeper/BankTransactionMatcher.cs | 96 ++ .../AiBookkeeper/ChartOfAccountsDto.cs | 23 + .../AiBookkeeper/ChartOfAccountsProvider.cs | 36 + .../AiBookkeeper/IAccountMappingService.cs | 54 + .../AiBookkeeper/IAiBookkeeperClient.cs | 246 ++++ .../AiBookkeeper/IBankTransactionMatcher.cs | 25 + .../AiBookkeeper/IChartOfAccountsProvider.cs | 17 + .../AiBookkeeper/StubAiBookkeeperClient.cs | 22 + .../AiBookkeeper/ToonFormatConverter.cs | 115 ++ .../Authorization/CompanyAccessService.cs | 130 ++ .../Books.Api/Authorization/CompanyContext.cs | 82 ++ .../Authorization/CompanyContextMiddleware.cs | 72 ++ .../Banking/BankTransactionSyncJob.cs | 221 ++++ .../Books.Api/Banking/EnableBankingClient.cs | 399 ++++++ .../Books.Api/Banking/IEnableBankingClient.cs | 110 ++ .../Accounts/AccountCommandHandlers.cs | 67 + .../Commands/Accounts/AccountCommands.cs | 51 + .../Attachments/AttachmentCommandHandlers.cs | 55 + .../Attachments/AttachmentCommands.cs | 58 + .../BankConnectionCommandHandlers.cs | 124 ++ .../BankConnections/BankConnectionCommands.cs | 91 ++ .../Customers/CustomerCommandHandlers.cs | 78 ++ .../Commands/Customers/CustomerCommands.cs | 70 ++ .../FiscalYears/FiscalYearCommandHandlers.cs | 71 ++ .../FiscalYears/FiscalYearCommands.cs | 57 + .../Commands/Invoices/CreditNoteCommands.cs | 77 ++ .../Invoices/InvoiceCommandHandlers.cs | 211 ++++ .../Commands/Invoices/InvoiceCommands.cs | 144 +++ .../JournalEntryDraftCommandHandlers.cs | 73 ++ .../JournalEntryDraftCommands.cs | 77 ++ .../Commands/Orders/OrderCommandHandlers.cs | 136 ++ .../Commands/Orders/OrderCommands.cs | 136 ++ .../Products/ProductCommandHandlers.cs | 73 ++ .../Commands/Products/ProductCommands.cs | 60 + .../UserCompanyAccessCommandHandlers.cs | 43 + .../UserAccess/UserCompanyAccessCommands.cs | 33 + .../Controllers/AttachmentController.cs | 269 ++++ .../Controllers/BankingController.cs | 105 ++ .../DocumentProcessingController.cs | 455 +++++++ .../Migrations/003_AccountConstraints.sql | 5 + .../Migrations/004_FiscalYearAuditFields.sql | 14 + .../Migrations/005_UserCompanyAccess.sql | 33 + .../Migrations/006_JournalEntryDrafts.sql | 30 + .../Migrations/007_BankConnections.sql | 34 + .../007_JournalEntryDraftCompliance.sql | 45 + .../008_FixJournalEntryDraftColumns.sql | 20 + .../Migrations/009_BankTransactions.sql | 63 + .../010_SyncFiscalYearsToLedger.sql | 28 + .../Database/Migrations/011_Customers.sql | 84 ++ .../Database/Migrations/012_Invoices.sql | 86 ++ .../Database/Migrations/013_CreditNotes.sql | 84 ++ .../Migrations/014_PaymentAllocations.sql | 104 ++ .../015_AddStandardAccountNumber.sql | 17 + .../Migrations/016_CompanyBankDetails.sql | 15 + .../Migrations/017_BankConnectionArchive.sql | 10 + .../018_InvoiceTypeConsolidation.sql | 140 +++ .../Migrations/019_FixInvoiceLinesColumn.sql | 6 + .../Database/Migrations/020_Products.sql | 29 + .../Migrations/021_ProductEanManufacturer.sql | 14 + .../022_DropPeriodNameUniqueConstraint.sql | 11 + .../023_ResyncFiscalYearsToLedger.sql | 25 + .../024_CleanOrphanIdempotencyKeys.sql | 17 + .../Database/Migrations/025_Orders.sql | 92 ++ .../Migrations/026_DocumentContentHashes.sql | 27 + .../Database/Migrations/027_Attachments.sql | 36 + .../Migrations/028_DraftExtractionData.sql | 8 + .../029_FixExtractionDataColumnType.sql | 5 + .../Domain/Accounts/AccountAggregate.cs | 106 ++ .../Books.Api/Domain/Accounts/AccountId.cs | 31 + .../Books.Api/Domain/Accounts/AccountType.cs | 35 + .../Accounts/Events/AccountCreatedEvent.cs | 28 + .../Events/AccountDeactivatedEvent.cs | 5 + .../Events/AccountReactivatedEvent.cs | 5 + .../Accounts/Events/AccountUpdatedEvent.cs | 15 + .../Domain/Attachments/AttachmentAggregate.cs | 211 ++++ .../Domain/Attachments/AttachmentId.cs | 13 + .../Attachments/Events/AttachmentEvents.cs | 80 ++ .../Domain/BankConnections/BankAccountInfo.cs | 12 + .../BankConnectionAggregate.cs | 274 ++++ .../BankConnections/BankConnectionId.cs | 8 + .../Events/BankConnectionEvents.cs | 97 ++ .../Domain/Companies/CvrValidator.cs | 41 + .../Events/CompanyBankDetailsUpdatedEvent.cs | 17 + .../Domain/Customers/CustomerAggregate.cs | 191 +++ .../Books.Api/Domain/Customers/CustomerId.cs | 29 + .../Domain/Customers/CustomerType.cs | 14 + .../Customers/Events/CustomerCreatedEvent.cs | 35 + .../Events/CustomerDeactivatedEvent.cs | 5 + .../Events/CustomerReactivatedEvent.cs | 5 + .../Customers/Events/CustomerUpdatedEvent.cs | 27 + .../Events/FiscalYearClosedEvent.cs | 8 + .../Events/FiscalYearCreatedEvent.cs | 15 + .../Events/FiscalYearLockedEvent.cs | 8 + .../Events/FiscalYearReopenedEvent.cs | 8 + .../Events/OpeningBalancePostedEvent.cs | 5 + .../Domain/FiscalYears/FiscalYearAggregate.cs | 144 +++ .../Domain/FiscalYears/FiscalYearId.cs | 31 + .../Domain/FiscalYears/FiscalYearStatus.cs | 14 + .../Invoices/Events/InvoiceCreatedEvent.cs | 64 + .../Events/InvoiceCreditAppliedEvent.cs | 64 + .../Invoices/Events/InvoiceLineAddedEvent.cs | 27 + .../Events/InvoiceLineRemovedEvent.cs | 12 + .../Events/InvoiceLineUpdatedEvent.cs | 27 + .../Events/InvoicePaymentReceivedEvent.cs | 32 + .../Invoices/Events/InvoiceSentEvent.cs | 26 + .../Invoices/Events/InvoiceVoidedEvent.cs | 20 + .../Domain/Invoices/InvoiceAggregate.cs | 472 +++++++ .../Books.Api/Domain/Invoices/InvoiceId.cs | 30 + .../Books.Api/Domain/Invoices/InvoiceLine.cs | 118 ++ .../Domain/Invoices/InvoiceStatus.cs | 149 +++ .../Books.Api/Domain/Invoices/InvoiceType.cs | 57 + .../Domain/JournalEntryDrafts/DraftLine.cs | 15 + .../Domain/JournalEntryDrafts/DraftStatus.cs | 22 + .../Events/JournalEntryDraftCreatedEvent.cs | 28 + .../Events/JournalEntryDraftDiscardedEvent.cs | 9 + .../Events/JournalEntryDraftPostedEvent.cs | 11 + .../Events/JournalEntryDraftUpdatedEvent.cs | 29 + .../JournalEntryDraftAggregate.cs | 196 +++ .../JournalEntryDrafts/JournalEntryDraftId.cs | 8 + .../VatCalculationService.cs | 221 ++++ .../Orders/Events/OrderCancelledEvent.cs | 17 + .../Orders/Events/OrderCompletedEvent.cs | 14 + .../Orders/Events/OrderConfirmedEvent.cs | 21 + .../Domain/Orders/Events/OrderCreatedEvent.cs | 36 + .../Orders/Events/OrderLineAddedEvent.cs | 28 + .../Orders/Events/OrderLineRemovedEvent.cs | 11 + .../Orders/Events/OrderLineUpdatedEvent.cs | 28 + .../Orders/Events/OrderLinesInvoicedEvent.cs | 39 + .../Events/OrderRevertedToDraftEvent.cs | 16 + .../Domain/Orders/Events/OrderUpdatedEvent.cs | 18 + .../Books.Api/Domain/Orders/OrderAggregate.cs | 411 ++++++ backend/Books.Api/Domain/Orders/OrderId.cs | 28 + backend/Books.Api/Domain/Orders/OrderLine.cs | 157 +++ .../Books.Api/Domain/Orders/OrderStatus.cs | 86 ++ .../Products/Events/ProductCreatedEvent.cs | 27 + .../Events/ProductDeactivatedEvent.cs | 5 + .../Events/ProductReactivatedEvent.cs | 5 + .../Products/Events/ProductUpdatedEvent.cs | 25 + .../Domain/Products/ProductAggregate.cs | 134 ++ .../Books.Api/Domain/Products/ProductId.cs | 29 + .../Domain/UserAccess/CompanyRole.cs | 56 + .../Events/UserCompanyAccessEvents.cs | 40 + .../UserAccess/UserCompanyAccessAggregate.cs | 102 ++ .../Domain/UserAccess/UserCompanyAccessId.cs | 27 + backend/Books.Api/Domain/VatCode.cs | 88 ++ .../Infrastructure/DapperConfiguration.cs | 140 +++ .../ISingleAggregateReadModelPopulator.cs | 30 + .../SingleAggregateReadModelPopulator.cs | 189 +++ .../EventFlow/ReadModels/AccountReadModel.cs | 101 ++ .../ReadModels/AccountReadModelDto.cs | 26 + .../ReadModels/AccountReadModelLocator.cs | 16 + .../ReadModels/AttachmentReadModel.cs | 112 ++ .../ReadModels/AttachmentReadModelDto.cs | 38 + .../ReadModels/AttachmentReadModelLocator.cs | 21 + .../ReadModels/BankConnectionReadModel.cs | 165 +++ .../ReadModels/BankConnectionReadModelDto.cs | 20 + .../BankConnectionReadModelLocator.cs | 16 + .../ReadModels/BankTransactionDto.cs | 65 + .../EventFlow/ReadModels/CustomerReadModel.cs | 114 ++ .../ReadModels/CustomerReadModelDto.cs | 28 + .../ReadModels/CustomerReadModelLocator.cs | 16 + .../ReadModels/FiscalYearReadModel.cs | 117 ++ .../ReadModels/FiscalYearReadModelDto.cs | 28 + .../ReadModels/FiscalYearReadModelLocator.cs | 16 + .../EventFlow/ReadModels/InvoiceReadModel.cs | 357 ++++++ .../ReadModels/InvoiceReadModelDto.cs | 75 ++ .../ReadModels/InvoiceReadModelLocator.cs | 20 + .../ReadModels/JournalEntryDraftReadModel.cs | 129 ++ .../JournalEntryDraftReadModelDto.cs | 60 + .../JournalEntryDraftReadModelLocator.cs | 16 + .../EventFlow/ReadModels/OrderReadModel.cs | 294 +++++ .../EventFlow/ReadModels/OrderReadModelDto.cs | 110 ++ .../ReadModels/OrderReadModelLocator.cs | 20 + .../EventFlow/ReadModels/ProductReadModel.cs | 105 ++ .../ReadModels/ProductReadModelDto.cs | 24 + .../ReadModels/ProductReadModelLocator.cs | 16 + .../ReadModels/UserCompanyAccessReadModel.cs | 91 ++ .../UserCompanyAccessReadModelDto.cs | 27 + .../Repositories/AccountRepository.cs | 95 ++ .../Repositories/AttachmentRepository.cs | 97 ++ .../Repositories/BankConnectionRepository.cs | 130 ++ .../Repositories/BankTransactionRepository.cs | 245 ++++ .../Repositories/CustomerRepository.cs | 113 ++ .../Repositories/DocumentHashRepository.cs | 63 + .../Repositories/FiscalYearRepository.cs | 110 ++ .../Repositories/IAccountRepository.cs | 13 + .../Repositories/IAttachmentRepository.cs | 12 + .../Repositories/IBankConnectionRepository.cs | 16 + .../IBankTransactionRepository.cs | 74 ++ .../Repositories/ICustomerRepository.cs | 14 + .../Repositories/IDocumentHashRepository.cs | 40 + .../Repositories/IFiscalYearRepository.cs | 13 + .../Repositories/IInvoiceRepository.cs | 69 ++ .../IJournalEntryDraftRepository.cs | 25 + .../ILedgerTransactionRepository.cs | 19 + .../Repositories/IManufacturerRepository.cs | 14 + .../Repositories/IOrderNumberService.cs | 16 + .../Repositories/IOrderRepository.cs | 50 + .../Repositories/IProductRepository.cs | 11 + .../IUserCompanyAccessRepository.cs | 56 + .../Repositories/IVoucherNumberService.cs | 21 + .../Repositories/InvoiceRepository.cs | 271 ++++ .../JournalEntryDraftRepository.cs | 82 ++ .../LedgerTransactionRepository.cs | 29 + .../Repositories/ManufacturerRepository.cs | 50 + .../Repositories/OrderNumberService.cs | 54 + .../EventFlow/Repositories/OrderRepository.cs | 192 +++ .../Repositories/ProductRepository.cs | 81 ++ .../UserCompanyAccessRepository.cs | 173 +++ .../Repositories/VoucherNumberService.cs | 54 + .../Subscribers/ChartOfAccountsInitializer.cs | 90 ++ .../Subscribers/StandardDanishAccounts.cs | 221 ++++ .../GraphQL/GraphQLContextExtensions.cs | 86 ++ .../InputTypes/BankConnectionInputTypes.cs | 85 ++ .../InputTypes/CreateAccountInputType.cs | 41 + .../InputTypes/CreateFiscalYearInputType.cs | 39 + .../InputTypes/CreateJournalEntryInputType.cs | 52 + .../GraphQL/InputTypes/CustomerInputTypes.cs | 80 ++ .../GraphQL/InputTypes/InvoiceInputTypes.cs | 195 +++ .../InputTypes/JournalEntryDraftInputTypes.cs | 83 ++ .../GraphQL/InputTypes/OrderInputTypes.cs | 174 +++ .../InputTypes/PaymentMatchingInputTypes.cs | 25 + .../GraphQL/InputTypes/ProductInputTypes.cs | 71 ++ .../InputTypes/UpdateAccountInputType.cs | 25 + .../UpdateCompanyBankDetailsInputType.cs | 27 + .../InputTypes/UserCompanyAccessInputTypes.cs | 65 + .../GraphQL/Scalars/DateOnlyGraphType.cs | 54 + .../GraphQL/Types/AccountBalanceType.cs | 51 + .../Books.Api/GraphQL/Types/AccountType.cs | 27 + .../GraphQL/Types/AccountTypeEnumType.cs | 23 + .../Books.Api/GraphQL/Types/AttachmentType.cs | 37 + .../GraphQL/Types/BankConnectionType.cs | 92 ++ .../GraphQL/Types/BankTransactionType.cs | 113 ++ .../Books.Api/GraphQL/Types/CustomerType.cs | 41 + .../GraphQL/Types/FiscalYearStatusEnumType.cs | 17 + .../Books.Api/GraphQL/Types/FiscalYearType.cs | 29 + .../GraphQL/Types/InvoicePdfResultType.cs | 28 + .../Books.Api/GraphQL/Types/InvoiceType.cs | 116 ++ .../GraphQL/Types/JournalEntryDraftType.cs | 54 + .../GraphQL/Types/JournalEntryResultType.cs | 23 + backend/Books.Api/GraphQL/Types/OrderType.cs | 113 ++ .../GraphQL/Types/PaymentMatchingTypes.cs | 96 ++ .../Books.Api/GraphQL/Types/ProductType.cs | 28 + .../GraphQL/Types/SaftExportResultType.cs | 22 + .../GraphQL/Types/UserCompanyAccessType.cs | 49 + .../Books.Api/GraphQL/Types/VatReportType.cs | 39 + .../FileStorage/IFileStorageService.cs | 63 + .../FileStorage/LocalFileStorageService.cs | 138 +++ .../Invoicing/Pdf/IInvoicePdfGenerator.cs | 14 + .../Invoicing/Pdf/IInvoicePdfService.cs | 33 + .../Books.Api/Invoicing/Pdf/InvoicePdfData.cs | 99 ++ .../Invoicing/Pdf/InvoicePdfGenerator.cs | 355 ++++++ .../Invoicing/Pdf/InvoicePdfService.cs | 154 +++ .../Services/CustomerSubLedgerService.cs | 73 ++ .../Services/DocumentPostingService.cs | 336 +++++ .../Services/ICustomerSubLedgerService.cs | 29 + .../Services/IDocumentPostingService.cs | 99 ++ .../Services/IInvoiceNumberService.cs | 26 + .../Services/IInvoicePostingService.cs | 109 ++ .../Services/IPaymentMatchingService.cs | 143 +++ .../Services/InvoiceNumberService.cs | 53 + .../Services/InvoicePostingService.cs | 568 +++++++++ .../Services/PaymentMatchingService.cs | 618 +++++++++ .../Ledger/LedgerAccountSyncSubscriber.cs | 137 ++ .../Books.Api/Ledger/LedgerJobsExtensions.cs | 41 + .../Books.Api/Ledger/LedgerOutboxConsumer.cs | 196 +++ .../Ledger/LedgerPeriodSyncSubscriber.cs | 90 ++ .../Books.Api/Reporting/IVatReportService.cs | 21 + backend/Books.Api/Reporting/VatReportDto.cs | 54 + .../Books.Api/Reporting/VatReportService.cs | 161 +++ backend/Books.Api/Saft/Models/SaftModels.cs | 182 +++ .../Saft/Services/ISaftExportService.cs | 22 + .../Saft/Services/SaftExportService.cs | 391 ++++++ .../Books.Api/Saft/Services/SaftXmlBuilder.cs | 341 +++++ .../Faktura5216698_7e153f92.pdf | Bin 0 -> 97584 bytes .../Faktura5216698_fd933f8a.pdf | Bin 0 -> 97584 bytes .../Invoice-MVPAIAKP-0002_39db6245.pdf | Bin 0 -> 26274 bytes .../Microsoft E1 - E0800QGJD1_e4c59320.pdf | Bin 0 -> 205805 bytes ...5_07.830_67dd64034768185bed93_7b1c77d4.pdf | Bin 0 -> 55797 bytes .../fibia_receipt_72147924_09a4770d.pdf | Bin 0 -> 187202 bytes .../invoice-F175490_0a1c36e0.pdf | Bin 0 -> 75817 bytes .../invoice-F175490_1b126666.pdf | Bin 0 -> 75817 bytes .../parking_receipt_2582441883_b78d8034.pdf | Bin 0 -> 52371 bytes .../Books.E2E.Tests/Actions/ActionContext.cs | 42 + .../Books.E2E.Tests/Actions/ActionsBuilder.cs | 20 + .../Actions/FluentExtensions.cs | 153 +++ .../Books.E2E.Tests/Actions/GivenActions.cs | 71 ++ .../Books.E2E.Tests/Actions/ThenAssertions.cs | 195 +++ .../Books.E2E.Tests/Actions/WhenActions.cs | 167 +++ .../Books.E2E.Tests/Books.E2E.Tests.csproj | 30 + .../Infrastructure/E2EConfiguration.cs | 47 + .../Infrastructure/E2ETestBase.cs | 67 + .../Infrastructure/E2ETestCollection.cs | 13 + .../Infrastructure/E2ETestFixture.cs | 60 + .../Infrastructure/PlaywrightFixture.cs | 59 + backend/Books.E2E.Tests/Pages/BasePage.cs | 81 ++ .../Pages/CompanySetupWizardPage.cs | 203 +++ .../Books.E2E.Tests/Pages/CustomersPage.cs | 109 ++ .../Books.E2E.Tests/Pages/DashboardPage.cs | 44 + backend/Books.E2E.Tests/Pages/InvoicesPage.cs | 149 +++ .../Pages/Modals/AddOrderLineModalPage.cs | 178 +++ .../Pages/Modals/CancelOrderModalPage.cs | 87 ++ .../Pages/Modals/ConvertToInvoiceModalPage.cs | 166 +++ .../Pages/Modals/CreateOrderModalPage.cs | 145 +++ .../Books.E2E.Tests/Pages/OrderDrawerPage.cs | 203 +++ backend/Books.E2E.Tests/Pages/OrdersPage.cs | 189 +++ backend/Books.E2E.Tests/Pages/SidebarPage.cs | 154 +++ .../Books.E2E.Tests/TestData/CvrGenerator.cs | 47 + .../TestData/TestDataGenerator.cs | 103 ++ .../CompanySetup/CompanySetupWizardTests.cs | 195 +++ .../Navigation/SidebarNavigationTests.cs | 175 +++ .../Tests/Orders/CreateOrderTests.cs | 108 ++ .../Tests/Orders/OrderLineTests.cs | 213 ++++ .../Tests/Orders/OrderWorkflowTests.cs | 315 +++++ .../Tests/Orders/OrdersPageTests.cs | 142 +++ backend/Books.E2E.Tests/xunit.runner.json | 5 + 339 files changed, 33378 insertions(+) create mode 100644 backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs create mode 100644 backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs create mode 100644 backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs create mode 100644 backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs create mode 100644 backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs create mode 100644 backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs create mode 100644 backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs create mode 100644 backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs create mode 100644 backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs create mode 100644 backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs create mode 100644 backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs create mode 100644 backend/Books.Api.Tests/Helpers/CvrGenerator.cs create mode 100644 backend/Books.Api.Tests/Infrastructure/IntegrationTestCollection.cs create mode 100644 backend/Books.Api.Tests/Infrastructure/TestAuthenticationHandler.cs create mode 100644 backend/Books.Api.Tests/Integration/DocumentProcessingIntegrationTests.cs create mode 100644 backend/Books.Api.Tests/Integration/EnableBankingClientTests.cs create mode 100644 backend/Books.Api.Tests/Integration/ReadModelRepopulationTests.cs create mode 100644 backend/Books.Api.Tests/Invoicing/InvoicePdfGeneratorTests.cs create mode 100644 backend/Books.Api.Tests/Reporting/VatReportServiceTests.cs create mode 100644 backend/Books.Api/AiBookkeeper/AccountMappingService.cs create mode 100644 backend/Books.Api/AiBookkeeper/AiBookkeeperClient.cs create mode 100644 backend/Books.Api/AiBookkeeper/AiBookkeeperOptions.cs create mode 100644 backend/Books.Api/AiBookkeeper/BankTransactionMatcher.cs create mode 100644 backend/Books.Api/AiBookkeeper/ChartOfAccountsDto.cs create mode 100644 backend/Books.Api/AiBookkeeper/ChartOfAccountsProvider.cs create mode 100644 backend/Books.Api/AiBookkeeper/IAccountMappingService.cs create mode 100644 backend/Books.Api/AiBookkeeper/IAiBookkeeperClient.cs create mode 100644 backend/Books.Api/AiBookkeeper/IBankTransactionMatcher.cs create mode 100644 backend/Books.Api/AiBookkeeper/IChartOfAccountsProvider.cs create mode 100644 backend/Books.Api/AiBookkeeper/StubAiBookkeeperClient.cs create mode 100644 backend/Books.Api/AiBookkeeper/ToonFormatConverter.cs create mode 100644 backend/Books.Api/Authorization/CompanyAccessService.cs create mode 100644 backend/Books.Api/Authorization/CompanyContext.cs create mode 100644 backend/Books.Api/Authorization/CompanyContextMiddleware.cs create mode 100644 backend/Books.Api/Banking/BankTransactionSyncJob.cs create mode 100644 backend/Books.Api/Banking/EnableBankingClient.cs create mode 100644 backend/Books.Api/Banking/IEnableBankingClient.cs create mode 100644 backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/Accounts/AccountCommands.cs create mode 100644 backend/Books.Api/Commands/Attachments/AttachmentCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/Attachments/AttachmentCommands.cs create mode 100644 backend/Books.Api/Commands/BankConnections/BankConnectionCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/BankConnections/BankConnectionCommands.cs create mode 100644 backend/Books.Api/Commands/Customers/CustomerCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/Customers/CustomerCommands.cs create mode 100644 backend/Books.Api/Commands/FiscalYears/FiscalYearCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/FiscalYears/FiscalYearCommands.cs create mode 100644 backend/Books.Api/Commands/Invoices/CreditNoteCommands.cs create mode 100644 backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/Invoices/InvoiceCommands.cs create mode 100644 backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommands.cs create mode 100644 backend/Books.Api/Commands/Orders/OrderCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/Orders/OrderCommands.cs create mode 100644 backend/Books.Api/Commands/Products/ProductCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/Products/ProductCommands.cs create mode 100644 backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommandHandlers.cs create mode 100644 backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommands.cs create mode 100644 backend/Books.Api/Controllers/AttachmentController.cs create mode 100644 backend/Books.Api/Controllers/BankingController.cs create mode 100644 backend/Books.Api/Controllers/DocumentProcessingController.cs create mode 100644 backend/Books.Api/Database/Migrations/003_AccountConstraints.sql create mode 100644 backend/Books.Api/Database/Migrations/004_FiscalYearAuditFields.sql create mode 100644 backend/Books.Api/Database/Migrations/005_UserCompanyAccess.sql create mode 100644 backend/Books.Api/Database/Migrations/006_JournalEntryDrafts.sql create mode 100644 backend/Books.Api/Database/Migrations/007_BankConnections.sql create mode 100644 backend/Books.Api/Database/Migrations/007_JournalEntryDraftCompliance.sql create mode 100644 backend/Books.Api/Database/Migrations/008_FixJournalEntryDraftColumns.sql create mode 100644 backend/Books.Api/Database/Migrations/009_BankTransactions.sql create mode 100644 backend/Books.Api/Database/Migrations/010_SyncFiscalYearsToLedger.sql create mode 100644 backend/Books.Api/Database/Migrations/011_Customers.sql create mode 100644 backend/Books.Api/Database/Migrations/012_Invoices.sql create mode 100644 backend/Books.Api/Database/Migrations/013_CreditNotes.sql create mode 100644 backend/Books.Api/Database/Migrations/014_PaymentAllocations.sql create mode 100644 backend/Books.Api/Database/Migrations/015_AddStandardAccountNumber.sql create mode 100644 backend/Books.Api/Database/Migrations/016_CompanyBankDetails.sql create mode 100644 backend/Books.Api/Database/Migrations/017_BankConnectionArchive.sql create mode 100644 backend/Books.Api/Database/Migrations/018_InvoiceTypeConsolidation.sql create mode 100644 backend/Books.Api/Database/Migrations/019_FixInvoiceLinesColumn.sql create mode 100644 backend/Books.Api/Database/Migrations/020_Products.sql create mode 100644 backend/Books.Api/Database/Migrations/021_ProductEanManufacturer.sql create mode 100644 backend/Books.Api/Database/Migrations/022_DropPeriodNameUniqueConstraint.sql create mode 100644 backend/Books.Api/Database/Migrations/023_ResyncFiscalYearsToLedger.sql create mode 100644 backend/Books.Api/Database/Migrations/024_CleanOrphanIdempotencyKeys.sql create mode 100644 backend/Books.Api/Database/Migrations/025_Orders.sql create mode 100644 backend/Books.Api/Database/Migrations/026_DocumentContentHashes.sql create mode 100644 backend/Books.Api/Database/Migrations/027_Attachments.sql create mode 100644 backend/Books.Api/Database/Migrations/028_DraftExtractionData.sql create mode 100644 backend/Books.Api/Database/Migrations/029_FixExtractionDataColumnType.sql create mode 100644 backend/Books.Api/Domain/Accounts/AccountAggregate.cs create mode 100644 backend/Books.Api/Domain/Accounts/AccountId.cs create mode 100644 backend/Books.Api/Domain/Accounts/AccountType.cs create mode 100644 backend/Books.Api/Domain/Accounts/Events/AccountCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/Accounts/Events/AccountDeactivatedEvent.cs create mode 100644 backend/Books.Api/Domain/Accounts/Events/AccountReactivatedEvent.cs create mode 100644 backend/Books.Api/Domain/Accounts/Events/AccountUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs create mode 100644 backend/Books.Api/Domain/Attachments/AttachmentId.cs create mode 100644 backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs create mode 100644 backend/Books.Api/Domain/BankConnections/BankAccountInfo.cs create mode 100644 backend/Books.Api/Domain/BankConnections/BankConnectionAggregate.cs create mode 100644 backend/Books.Api/Domain/BankConnections/BankConnectionId.cs create mode 100644 backend/Books.Api/Domain/BankConnections/Events/BankConnectionEvents.cs create mode 100644 backend/Books.Api/Domain/Companies/CvrValidator.cs create mode 100644 backend/Books.Api/Domain/Companies/Events/CompanyBankDetailsUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/Customers/CustomerAggregate.cs create mode 100644 backend/Books.Api/Domain/Customers/CustomerId.cs create mode 100644 backend/Books.Api/Domain/Customers/CustomerType.cs create mode 100644 backend/Books.Api/Domain/Customers/Events/CustomerCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/Customers/Events/CustomerDeactivatedEvent.cs create mode 100644 backend/Books.Api/Domain/Customers/Events/CustomerReactivatedEvent.cs create mode 100644 backend/Books.Api/Domain/Customers/Events/CustomerUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/Events/FiscalYearClosedEvent.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/Events/FiscalYearCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/Events/FiscalYearLockedEvent.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/Events/FiscalYearReopenedEvent.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/Events/OpeningBalancePostedEvent.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/FiscalYearAggregate.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/FiscalYearId.cs create mode 100644 backend/Books.Api/Domain/FiscalYears/FiscalYearStatus.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceCreditAppliedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceLineAddedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceLineRemovedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceLineUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoicePaymentReceivedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceSentEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/Events/InvoiceVoidedEvent.cs create mode 100644 backend/Books.Api/Domain/Invoices/InvoiceAggregate.cs create mode 100644 backend/Books.Api/Domain/Invoices/InvoiceId.cs create mode 100644 backend/Books.Api/Domain/Invoices/InvoiceLine.cs create mode 100644 backend/Books.Api/Domain/Invoices/InvoiceStatus.cs create mode 100644 backend/Books.Api/Domain/Invoices/InvoiceType.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/DraftLine.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/DraftStatus.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftDiscardedEvent.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftId.cs create mode 100644 backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderCancelledEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderCompletedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderConfirmedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderLineAddedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderLineRemovedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderLineUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderLinesInvoicedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderRevertedToDraftEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/Events/OrderUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/Orders/OrderAggregate.cs create mode 100644 backend/Books.Api/Domain/Orders/OrderId.cs create mode 100644 backend/Books.Api/Domain/Orders/OrderLine.cs create mode 100644 backend/Books.Api/Domain/Orders/OrderStatus.cs create mode 100644 backend/Books.Api/Domain/Products/Events/ProductCreatedEvent.cs create mode 100644 backend/Books.Api/Domain/Products/Events/ProductDeactivatedEvent.cs create mode 100644 backend/Books.Api/Domain/Products/Events/ProductReactivatedEvent.cs create mode 100644 backend/Books.Api/Domain/Products/Events/ProductUpdatedEvent.cs create mode 100644 backend/Books.Api/Domain/Products/ProductAggregate.cs create mode 100644 backend/Books.Api/Domain/Products/ProductId.cs create mode 100644 backend/Books.Api/Domain/UserAccess/CompanyRole.cs create mode 100644 backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs create mode 100644 backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs create mode 100644 backend/Books.Api/Domain/UserAccess/UserCompanyAccessId.cs create mode 100644 backend/Books.Api/Domain/VatCode.cs create mode 100644 backend/Books.Api/EventFlow/Infrastructure/DapperConfiguration.cs create mode 100644 backend/Books.Api/EventFlow/Infrastructure/ISingleAggregateReadModelPopulator.cs create mode 100644 backend/Books.Api/EventFlow/Infrastructure/SingleAggregateReadModelPopulator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/AccountReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/AccountReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/AccountReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/AttachmentReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/BankTransactionDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/CustomerReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/CustomerReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/CustomerReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/InvoiceReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/OrderReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/OrderReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/OrderReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/ProductReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/ProductReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/ProductReadModelLocator.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModel.cs create mode 100644 backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModelDto.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/AccountRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/AttachmentRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/BankConnectionRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/BankTransactionRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/CustomerRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/DocumentHashRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/FiscalYearRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IAccountRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IBankConnectionRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IBankTransactionRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/ICustomerRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IDocumentHashRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IFiscalYearRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IInvoiceRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IJournalEntryDraftRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/ILedgerTransactionRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IManufacturerRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IOrderNumberService.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IOrderRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IProductRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IUserCompanyAccessRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/IVoucherNumberService.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/InvoiceRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/LedgerTransactionRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/ManufacturerRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/OrderNumberService.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/OrderRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/ProductRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/UserCompanyAccessRepository.cs create mode 100644 backend/Books.Api/EventFlow/Repositories/VoucherNumberService.cs create mode 100644 backend/Books.Api/EventFlow/Subscribers/ChartOfAccountsInitializer.cs create mode 100644 backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs create mode 100644 backend/Books.Api/GraphQL/GraphQLContextExtensions.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/BankConnectionInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/CreateAccountInputType.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/CreateFiscalYearInputType.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/CreateJournalEntryInputType.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/CustomerInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/InvoiceInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/JournalEntryDraftInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/OrderInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/PaymentMatchingInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/ProductInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/UpdateAccountInputType.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/UpdateCompanyBankDetailsInputType.cs create mode 100644 backend/Books.Api/GraphQL/InputTypes/UserCompanyAccessInputTypes.cs create mode 100644 backend/Books.Api/GraphQL/Scalars/DateOnlyGraphType.cs create mode 100644 backend/Books.Api/GraphQL/Types/AccountBalanceType.cs create mode 100644 backend/Books.Api/GraphQL/Types/AccountType.cs create mode 100644 backend/Books.Api/GraphQL/Types/AccountTypeEnumType.cs create mode 100644 backend/Books.Api/GraphQL/Types/AttachmentType.cs create mode 100644 backend/Books.Api/GraphQL/Types/BankConnectionType.cs create mode 100644 backend/Books.Api/GraphQL/Types/BankTransactionType.cs create mode 100644 backend/Books.Api/GraphQL/Types/CustomerType.cs create mode 100644 backend/Books.Api/GraphQL/Types/FiscalYearStatusEnumType.cs create mode 100644 backend/Books.Api/GraphQL/Types/FiscalYearType.cs create mode 100644 backend/Books.Api/GraphQL/Types/InvoicePdfResultType.cs create mode 100644 backend/Books.Api/GraphQL/Types/InvoiceType.cs create mode 100644 backend/Books.Api/GraphQL/Types/JournalEntryDraftType.cs create mode 100644 backend/Books.Api/GraphQL/Types/JournalEntryResultType.cs create mode 100644 backend/Books.Api/GraphQL/Types/OrderType.cs create mode 100644 backend/Books.Api/GraphQL/Types/PaymentMatchingTypes.cs create mode 100644 backend/Books.Api/GraphQL/Types/ProductType.cs create mode 100644 backend/Books.Api/GraphQL/Types/SaftExportResultType.cs create mode 100644 backend/Books.Api/GraphQL/Types/UserCompanyAccessType.cs create mode 100644 backend/Books.Api/GraphQL/Types/VatReportType.cs create mode 100644 backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs create mode 100644 backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs create mode 100644 backend/Books.Api/Invoicing/Pdf/IInvoicePdfGenerator.cs create mode 100644 backend/Books.Api/Invoicing/Pdf/IInvoicePdfService.cs create mode 100644 backend/Books.Api/Invoicing/Pdf/InvoicePdfData.cs create mode 100644 backend/Books.Api/Invoicing/Pdf/InvoicePdfGenerator.cs create mode 100644 backend/Books.Api/Invoicing/Pdf/InvoicePdfService.cs create mode 100644 backend/Books.Api/Invoicing/Services/CustomerSubLedgerService.cs create mode 100644 backend/Books.Api/Invoicing/Services/DocumentPostingService.cs create mode 100644 backend/Books.Api/Invoicing/Services/ICustomerSubLedgerService.cs create mode 100644 backend/Books.Api/Invoicing/Services/IDocumentPostingService.cs create mode 100644 backend/Books.Api/Invoicing/Services/IInvoiceNumberService.cs create mode 100644 backend/Books.Api/Invoicing/Services/IInvoicePostingService.cs create mode 100644 backend/Books.Api/Invoicing/Services/IPaymentMatchingService.cs create mode 100644 backend/Books.Api/Invoicing/Services/InvoiceNumberService.cs create mode 100644 backend/Books.Api/Invoicing/Services/InvoicePostingService.cs create mode 100644 backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs create mode 100644 backend/Books.Api/Ledger/LedgerAccountSyncSubscriber.cs create mode 100644 backend/Books.Api/Ledger/LedgerJobsExtensions.cs create mode 100644 backend/Books.Api/Ledger/LedgerOutboxConsumer.cs create mode 100644 backend/Books.Api/Ledger/LedgerPeriodSyncSubscriber.cs create mode 100644 backend/Books.Api/Reporting/IVatReportService.cs create mode 100644 backend/Books.Api/Reporting/VatReportDto.cs create mode 100644 backend/Books.Api/Reporting/VatReportService.cs create mode 100644 backend/Books.Api/Saft/Models/SaftModels.cs create mode 100644 backend/Books.Api/Saft/Services/ISaftExportService.cs create mode 100644 backend/Books.Api/Saft/Services/SaftExportService.cs create mode 100644 backend/Books.Api/Saft/Services/SaftXmlBuilder.cs create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Faktura5216698_7e153f92.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Faktura5216698_fd933f8a.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Invoice-MVPAIAKP-0002_39db6245.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Microsoft E1 - E0800QGJD1_e4c59320.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Wolt+_2025-03-21_13_05_07.830_67dd64034768185bed93_7b1c77d4.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/fibia_receipt_72147924_09a4770d.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/invoice-F175490_0a1c36e0.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/invoice-F175490_1b126666.pdf create mode 100644 backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/parking_receipt_2582441883_b78d8034.pdf create mode 100644 backend/Books.E2E.Tests/Actions/ActionContext.cs create mode 100644 backend/Books.E2E.Tests/Actions/ActionsBuilder.cs create mode 100644 backend/Books.E2E.Tests/Actions/FluentExtensions.cs create mode 100644 backend/Books.E2E.Tests/Actions/GivenActions.cs create mode 100644 backend/Books.E2E.Tests/Actions/ThenAssertions.cs create mode 100644 backend/Books.E2E.Tests/Actions/WhenActions.cs create mode 100644 backend/Books.E2E.Tests/Books.E2E.Tests.csproj create mode 100644 backend/Books.E2E.Tests/Infrastructure/E2EConfiguration.cs create mode 100644 backend/Books.E2E.Tests/Infrastructure/E2ETestBase.cs create mode 100644 backend/Books.E2E.Tests/Infrastructure/E2ETestCollection.cs create mode 100644 backend/Books.E2E.Tests/Infrastructure/E2ETestFixture.cs create mode 100644 backend/Books.E2E.Tests/Infrastructure/PlaywrightFixture.cs create mode 100644 backend/Books.E2E.Tests/Pages/BasePage.cs create mode 100644 backend/Books.E2E.Tests/Pages/CompanySetupWizardPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/CustomersPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/DashboardPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/InvoicesPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/Modals/AddOrderLineModalPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/Modals/CancelOrderModalPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/Modals/ConvertToInvoiceModalPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/Modals/CreateOrderModalPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/OrderDrawerPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/OrdersPage.cs create mode 100644 backend/Books.E2E.Tests/Pages/SidebarPage.cs create mode 100644 backend/Books.E2E.Tests/TestData/CvrGenerator.cs create mode 100644 backend/Books.E2E.Tests/TestData/TestDataGenerator.cs create mode 100644 backend/Books.E2E.Tests/Tests/CompanySetup/CompanySetupWizardTests.cs create mode 100644 backend/Books.E2E.Tests/Tests/Navigation/SidebarNavigationTests.cs create mode 100644 backend/Books.E2E.Tests/Tests/Orders/CreateOrderTests.cs create mode 100644 backend/Books.E2E.Tests/Tests/Orders/OrderLineTests.cs create mode 100644 backend/Books.E2E.Tests/Tests/Orders/OrderWorkflowTests.cs create mode 100644 backend/Books.E2E.Tests/Tests/Orders/OrdersPageTests.cs create mode 100644 backend/Books.E2E.Tests/xunit.runner.json diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6b8f3ea..458fd2e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"id":"books-0rs","title":"fix whitescreen at http://localhost:3000","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:15:47.598939+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:16:09.572428+01:00"} {"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"} {"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"} {"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"} diff --git a/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs b/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs new file mode 100644 index 0000000..9b3d77e --- /dev/null +++ b/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs @@ -0,0 +1,365 @@ +using System.Net; +using System.Text; +using Books.Api.AiBookkeeper; +using AwesomeAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; + +namespace Books.Api.Tests.AiBookkeeper; + +/// +/// Unit tests for AiBookkeeperClient. +/// Tests JSON parsing and suggestion generation from extraction data. +/// +[Trait("Category", "Unit")] +public class AiBookkeeperClientTests +{ + [Fact] + public async Task ProcessDocument_WithExtractionButNoSuggestion_GeneratesSuggestionFromExtraction() + { + // Arrange + var json = """ + { + "success": true, + "extraction": { + "documentType": "invoice", + "vendor": { "name": "Test Leverandør", "cvr": "12345678" }, + "invoiceNumber": "INV-001", + "date": "2024-01-15", + "totalAmount": 1250.00, + "amountExVat": 1000.00, + "vatAmount": 250.00, + "currency": "DKK" + } + } + """; + + var client = CreateClientWithResponse(json); + var chartOfAccounts = CreateChartOfAccounts(); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + chartOfAccounts); + + // Assert + result.Success.Should().BeTrue(); + result.Extraction.Should().NotBeNull(); + result.Extraction!.Vendor.Should().Be("Test Leverandør"); + result.Extraction.TotalAmount.Should().Be(1250.00m); + result.Extraction.AmountExVat.Should().Be(1000.00m); + result.Extraction.VatAmount.Should().Be(250.00m); + + // Suggestion should be generated + result.Suggestion.Should().NotBeNull(); + result.Suggestion!.Description.Should().Be("Test Leverandør"); + result.Suggestion.Confidence.Should().Be(0.7m); + result.Suggestion.Lines.Should().HaveCount(3); + + // Expense line (debit amountExVat) - Erhvervsstyrelsen standard 1610 + var expenseLine = result.Suggestion.Lines[0]; + expenseLine.StandardAccountNumber.Should().Be("1610"); + expenseLine.DebitAmount.Should().Be(1000.00m); + expenseLine.CreditAmount.Should().Be(0m); + expenseLine.VatCode.Should().Be("I25"); // 25% VAT detected + + // VAT line (debit vatAmount) - Erhvervsstyrelsen standard 7680 + var vatLine = result.Suggestion.Lines[1]; + vatLine.StandardAccountNumber.Should().Be("7680"); + vatLine.DebitAmount.Should().Be(250.00m); + vatLine.CreditAmount.Should().Be(0m); + + // Creditor line (credit totalAmount) - Erhvervsstyrelsen standard 7350 + var creditorLine = result.Suggestion.Lines[2]; + creditorLine.StandardAccountNumber.Should().Be("7350"); + creditorLine.DebitAmount.Should().Be(0m); + creditorLine.CreditAmount.Should().Be(1250.00m); + } + + [Fact] + public async Task ProcessDocument_WithNestedTotals_ExtractsTotalsCorrectly() + { + // Arrange - AI service returns totals as nested object + var json = """ + { + "success": true, + "extraction": { + "documentType": "invoice", + "vendor": { "name": "Nested Totals Vendor" }, + "totals": { + "grandTotal": 625.00, + "subtotal": 500.00, + "vatTotal": 125.00 + } + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert + result.Extraction.Should().NotBeNull(); + result.Extraction!.TotalAmount.Should().Be(625.00m); + result.Extraction.AmountExVat.Should().Be(500.00m); + result.Extraction.VatAmount.Should().Be(125.00m); + + // Suggestion should be generated with correct amounts + result.Suggestion.Should().NotBeNull(); + result.Suggestion!.Lines.Should().HaveCount(3); + result.Suggestion.Lines[0].DebitAmount.Should().Be(500.00m); // Expense + result.Suggestion.Lines[1].DebitAmount.Should().Be(125.00m); // VAT + result.Suggestion.Lines[2].CreditAmount.Should().Be(625.00m); // Creditor + } + + [Fact] + public async Task ProcessDocument_WithoutVat_GeneratesTwoLineEntry() + { + // Arrange - No VAT in the extraction + var json = """ + { + "success": true, + "extraction": { + "documentType": "receipt", + "vendor": { "name": "No VAT Vendor" }, + "totalAmount": 500.00, + "amountExVat": 500.00, + "vatAmount": 0 + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert - Should only have 2 lines (no VAT line) + result.Suggestion.Should().NotBeNull(); + result.Suggestion!.Lines.Should().HaveCount(2); + + // Expense line - Erhvervsstyrelsen standard 1610 + result.Suggestion.Lines[0].StandardAccountNumber.Should().Be("1610"); + result.Suggestion.Lines[0].DebitAmount.Should().Be(500.00m); + result.Suggestion.Lines[0].VatCode.Should().BeNull(); + + // Creditor line - Erhvervsstyrelsen standard 7350 + result.Suggestion.Lines[1].StandardAccountNumber.Should().Be("7350"); + result.Suggestion.Lines[1].CreditAmount.Should().Be(500.00m); + } + + [Fact] + public async Task ProcessDocument_WithOnlyTotalAmount_UsesSameAmountForExpense() + { + // Arrange - Only total amount, no breakdown + var json = """ + { + "success": true, + "extraction": { + "documentType": "receipt", + "vendor": { "name": "Simple Receipt" }, + "totalAmount": 299.00 + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert + result.Suggestion.Should().NotBeNull(); + result.Suggestion!.Lines.Should().HaveCount(2); + + // Expense line uses total (no VAT breakdown) + result.Suggestion.Lines[0].DebitAmount.Should().Be(299.00m); + + // Creditor line + result.Suggestion.Lines[1].CreditAmount.Should().Be(299.00m); + } + + [Fact] + public async Task ProcessDocument_WithNoTotalAmount_DoesNotGenerateSuggestion() + { + // Arrange - No amounts in extraction + var json = """ + { + "success": true, + "extraction": { + "documentType": "unknown", + "vendor": { "name": "Unknown Document" } + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert - No suggestion generated (can't book without amounts) + result.Extraction.Should().NotBeNull(); + result.Suggestion.Should().BeNull(); + } + + [Fact] + public async Task ProcessDocument_WithExistingSuggestion_UsesProvidedSuggestion() + { + // Arrange - AI provides its own suggestion + var json = """ + { + "success": true, + "extraction": { + "documentType": "invoice", + "vendor": { "name": "Test Vendor" }, + "totalAmount": 1000.00 + }, + "suggestedBooking": { + "description": "AI Suggested Description", + "confidence": 0.95, + "lines": [ + { "standardAccountNumber": "7320", "accountName": "Software", "debit": 800.00, "credit": 0 }, + { "standardAccountNumber": "6320", "accountName": "Moms", "debit": 200.00, "credit": 0 }, + { "standardAccountNumber": "6930", "accountName": "Kreditor", "debit": 0, "credit": 1000.00 } + ] + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert - Should use AI's suggestion, not generate our own + result.Suggestion.Should().NotBeNull(); + result.Suggestion!.Description.Should().Be("AI Suggested Description"); + result.Suggestion.Confidence.Should().Be(0.95m); + result.Suggestion.Lines[0].StandardAccountNumber.Should().Be("7320"); + } + + [Fact] + public async Task ProcessDocument_WithNoVendorName_UsesDefaultDescription() + { + // Arrange - No vendor name + var json = """ + { + "success": true, + "extraction": { + "documentType": "receipt", + "totalAmount": 150.00 + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert + result.Suggestion.Should().NotBeNull(); + result.Suggestion!.Description.Should().Be("Udgift"); + } + + [Fact] + public async Task ProcessDocument_GeneratedSuggestion_IsBalanced() + { + // Arrange + var json = """ + { + "success": true, + "extraction": { + "documentType": "invoice", + "vendor": { "name": "Balance Test" }, + "totalAmount": 1875.50, + "amountExVat": 1500.40, + "vatAmount": 375.10 + } + } + """; + + var client = CreateClientWithResponse(json); + + // Act + var result = await client.ProcessDocumentAsync( + new MemoryStream([1, 2, 3]), + "test.pdf", + "application/pdf", + CreateChartOfAccounts()); + + // Assert - Total debits should equal total credits + result.Suggestion.Should().NotBeNull(); + var totalDebits = result.Suggestion!.Lines.Sum(l => l.DebitAmount); + var totalCredits = result.Suggestion.Lines.Sum(l => l.CreditAmount); + totalDebits.Should().Be(totalCredits); + totalDebits.Should().Be(1875.50m); + } + + private static AiBookkeeperClient CreateClientWithResponse(string jsonResponse) + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonResponse, Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri("http://localhost") + }; + + return new AiBookkeeperClient(httpClient, NullLogger.Instance); + } + + private static ChartOfAccountsDto CreateChartOfAccounts() + { + return new ChartOfAccountsDto + { + CompanyId = "test-company", + Accounts = + [ + new AiAccountDto { AccountNumber = "1000", Name = "Vareforbrug", AccountType = "cogs", VatCodeId = "I25" }, + new AiAccountDto { AccountNumber = "6320", Name = "Indgaaende moms", AccountType = "asset", VatCodeId = null }, + new AiAccountDto { AccountNumber = "6930", Name = "Leverandoerer", AccountType = "liability", VatCodeId = null } + ] + }; + } +} diff --git a/backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs b/backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs new file mode 100644 index 0000000..3ee8b15 --- /dev/null +++ b/backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs @@ -0,0 +1,220 @@ +using Books.Api.AiBookkeeper; +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Moq; +using AwesomeAssertions; + +namespace Books.Api.Tests.AiBookkeeper; + +/// +/// Unit tests for ChartOfAccountsProvider. +/// Verifies that accounts are filtered and sorted correctly. +/// +[Trait("Category", "Unit")] +public class ChartOfAccountsProviderTests +{ + private readonly Mock _accountRepository; + private readonly ChartOfAccountsProvider _sut; + + public ChartOfAccountsProviderTests() + { + _accountRepository = new Mock(); + _sut = new ChartOfAccountsProvider(_accountRepository.Object); + } + + [Fact] + public async Task GetChartOfAccountsAsync_ReturnsOnlyExpenseTypeAccounts() + { + // Arrange + var accounts = new List + { + CreateAccount("1000", "Bank", "asset", null), + CreateAccount("2000", "Leverandørgæld", "liability", null), + CreateAccount("3000", "Egenkapital", "equity", null), + CreateAccount("4000", "Omsætning", "income", null), + CreateAccount("5000", "Vareforbrug", "cogs", "I25"), + CreateAccount("6000", "Løn", "personnel", "INGEN"), + CreateAccount("7000", "Kontorudgifter", "expense", "I25"), + CreateAccount("8000", "Renteudgifter", "financial", null) + }; + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(accounts); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert - Only expense-type accounts should be included + result.Accounts.Should().HaveCount(4); + result.Accounts.Select(a => a.AccountType).Should().OnlyContain(t => + t == "cogs" || t == "personnel" || t == "expense" || t == "financial"); + } + + [Fact] + public async Task GetChartOfAccountsAsync_ExcludesAssetAccounts() + { + // Arrange + var accounts = new List + { + CreateAccount("1000", "Bank", "asset", null), + CreateAccount("1200", "Varelager", "asset", null), + CreateAccount("7000", "Kontorudgifter", "expense", "I25") + }; + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(accounts); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert + result.Accounts.Should().HaveCount(1); + result.Accounts.Should().NotContain(a => a.AccountType == "asset"); + } + + [Fact] + public async Task GetChartOfAccountsAsync_ExcludesLiabilityAccounts() + { + // Arrange + var accounts = new List + { + CreateAccount("2000", "Leverandørgæld", "liability", null), + CreateAccount("2100", "Skyldig moms", "liability", null), + CreateAccount("7000", "Kontorudgifter", "expense", "I25") + }; + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(accounts); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert + result.Accounts.Should().HaveCount(1); + result.Accounts.Should().NotContain(a => a.AccountType == "liability"); + } + + [Fact] + public async Task GetChartOfAccountsAsync_ExcludesIncomeAccounts() + { + // Arrange + var accounts = new List + { + CreateAccount("4000", "Omsætning DK", "income", "U25"), + CreateAccount("4100", "Omsætning EU", "income", "U25EU"), + CreateAccount("7000", "Kontorudgifter", "expense", "I25") + }; + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(accounts); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert + result.Accounts.Should().HaveCount(1); + result.Accounts.Should().NotContain(a => a.AccountType == "income"); + } + + [Fact] + public async Task GetChartOfAccountsAsync_SortsAccountsByNumber() + { + // Arrange + var accounts = new List + { + CreateAccount("7200", "IT-udgifter", "expense", "I25"), + CreateAccount("5000", "Vareforbrug", "cogs", "I25"), + CreateAccount("7100", "Kontorudgifter", "expense", "I25"), + CreateAccount("6000", "Løn", "personnel", null) + }; + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(accounts); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert + var accountNumbers = result.Accounts.Select(a => a.AccountNumber).ToList(); + accountNumbers.Should().BeInAscendingOrder(); + accountNumbers.Should().Equal(["5000", "6000", "7100", "7200"]); + } + + [Fact] + public async Task GetChartOfAccountsAsync_ReturnsCorrectCompanyId() + { + // Arrange + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync("company-42", It.IsAny())) + .ReturnsAsync([]); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-42"); + + // Assert + result.CompanyId.Should().Be("company-42"); + } + + [Fact] + public async Task GetChartOfAccountsAsync_MapsAllAccountProperties() + { + // Arrange + var accounts = new List + { + new() + { + Id = "acc-1", + CompanyId = "company-1", + AccountNumber = "7320", + Name = "Software abonnementer", + AccountType = "expense", + VatCodeId = "I25", + StandardAccountNumber = "11400", + IsActive = true, + IsSystemAccount = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + } + }; + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(accounts); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert + result.Accounts.Should().HaveCount(1); + var account = result.Accounts[0]; + account.AccountNumber.Should().Be("7320"); + account.Name.Should().Be("Software abonnementer"); + account.AccountType.Should().Be("expense"); + account.VatCodeId.Should().Be("I25"); + account.StandardAccountNumber.Should().Be("11400"); + } + + [Fact] + public async Task GetChartOfAccountsAsync_HandlesEmptyAccountList() + { + // Arrange + _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + // Act + var result = await _sut.GetChartOfAccountsAsync("company-1"); + + // Assert + result.Accounts.Should().BeEmpty(); + result.CompanyId.Should().Be("company-1"); + } + + private static AccountReadModelDto CreateAccount(string number, string name, string type, string? vatCode) + { + return new AccountReadModelDto + { + Id = $"account-{number}", + CompanyId = "company-1", + AccountNumber = number, + Name = name, + AccountType = type, + VatCodeId = vatCode, + IsActive = true, + IsSystemAccount = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + } +} diff --git a/backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs b/backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs new file mode 100644 index 0000000..55b4219 --- /dev/null +++ b/backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs @@ -0,0 +1,214 @@ +using Books.Api.AiBookkeeper; +using AwesomeAssertions; + +namespace Books.Api.Tests.AiBookkeeper; + +/// +/// Unit tests for ToonFormatConverter. +/// Verifies that ChartOfAccountsDto is correctly converted to .toon format. +/// +[Trait("Category", "Unit")] +public class ToonFormatConverterTests +{ + [Fact] + public void ConvertToToon_EuGoodsAccount_HasEuRegion() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("2050", "EU-erhvervelser varer", "cogs", "IEUV")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert - Account 2050 should have region EU + result.Should().Contain("2050,EU-erhvervelser varer,Variable omkostninger,IEUV,EU,,"); + } + + [Fact] + public void ConvertToToon_EuServicesAccount_HasEuRegion() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("2100", "EU-erhvervelser ydelser", "cogs", "IEUY")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert - Account 2100 should have region EU + result.Should().Contain("2100,EU-erhvervelser ydelser,Variable omkostninger,IEUY,EU,,"); + } + + [Fact] + public void ConvertToToon_WorldGoodsAccount_HasWorldRegion() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("2150", "Varekøb verden", "cogs", "IVV")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert - Account 2150 should have region WORLD + result.Should().Contain("2150,Varekøb verden,Variable omkostninger,IVV,WORLD,,"); + } + + [Fact] + public void ConvertToToon_WorldServicesAccount_HasWorldRegion() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("2200", "Ydelseskøb verden", "cogs", "IVY")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert - Account 2200 should have region WORLD + result.Should().Contain("2200,Ydelseskøb verden,Variable omkostninger,IVY,WORLD,,"); + } + + [Fact] + public void ConvertToToon_DomesticAccount_HasEmptyRegion() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("7320", "Køb af software", "expense", "I25")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert - Account 7320 should have empty region (available for all regions) + result.Should().Contain("7320,Køb af software,Administrationsomkostninger,I25,,,"); + } + + [Fact] + public void ConvertToToon_AccountWithNoVatCode_HasEmptyRegion() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("7000", "Kontorartikler", "expense", null)); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert - Account with no VAT code should have empty region + result.Should().Contain("7000,Kontorartikler,Administrationsomkostninger,,,,"); + } + + [Fact] + public void ConvertToToon_MixedAccounts_CorrectRegionsAssigned() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("2000", "Vareforbrug", "cogs", "I25"), + CreateAccount("2050", "EU-erhvervelser varer", "cogs", "IEUV"), + CreateAccount("2150", "Varekøb verden", "cogs", "IVV"), + CreateAccount("7320", "Køb af software", "expense", "I25")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert + result.Should().Contain("2000,Vareforbrug,Variable omkostninger,I25,,,"); // Empty region + result.Should().Contain("2050,EU-erhvervelser varer,Variable omkostninger,IEUV,EU,,"); // EU + result.Should().Contain("2150,Varekøb verden,Variable omkostninger,IVV,WORLD,,"); // WORLD + result.Should().Contain("7320,Køb af software,Administrationsomkostninger,I25,,,"); // Empty region + } + + [Fact] + public void ConvertToToon_ContainsMetaSection() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("7000", "Kontorartikler", "expense", "I25")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert + result.Should().Contain("meta:"); + result.Should().Contain("source: Books API"); + result.Should().Contain("organizationId: company-1"); + result.Should().Contain("accountType: expense"); + result.Should().Contain("totalAccounts: 1"); + } + + [Fact] + public void ConvertToToon_ContainsAccountsHeader() + { + // Arrange + var chartOfAccounts = CreateChartOfAccounts( + CreateAccount("7000", "Kontorartikler", "expense", "I25"), + CreateAccount("7100", "Porto", "expense", "I25")); + + // Act + var result = ToonFormatConverter.ConvertToToon(chartOfAccounts); + + // Assert + result.Should().Contain("accounts[2]{number,name,category,vatCode,region,vatRubric,suggestions}:"); + } + + [Fact] + public void MapCategory_ExpenseAccount_ReturnsAdministrationsomkostninger() + { + var result = ToonFormatConverter.MapCategory("expense"); + result.Should().Be("Administrationsomkostninger"); + } + + [Fact] + public void MapCategory_CogsAccount_ReturnsVariableOmkostninger() + { + var result = ToonFormatConverter.MapCategory("cogs"); + result.Should().Be("Variable omkostninger"); + } + + [Fact] + public void MapCategory_PersonnelAccount_ReturnsLønomkostninger() + { + var result = ToonFormatConverter.MapCategory("personnel"); + result.Should().Be("Lønomkostninger"); + } + + [Fact] + public void MapCategory_FinancialAccount_ReturnsRenteudgifter() + { + var result = ToonFormatConverter.MapCategory("financial"); + result.Should().Be("Renteudgifter"); + } + + [Fact] + public void GenerateSuggestions_IncludesWordsFromName() + { + var result = ToonFormatConverter.GenerateSuggestions("Køb af software", "7320"); + result.Should().Contain("køb"); + result.Should().Contain("software"); + result.Should().Contain("7320"); + } + + [Fact] + public void GenerateSuggestions_ExcludesShortWords() + { + var result = ToonFormatConverter.GenerateSuggestions("IT og software", "7320"); + result.Should().NotContain("og"); // "og" has only 2 characters + result.Should().Contain("software"); + } + + private static ChartOfAccountsDto CreateChartOfAccounts(params AiAccountDto[] accounts) + { + return new ChartOfAccountsDto + { + CompanyId = "company-1", + Accounts = accounts.ToList() + }; + } + + private static AiAccountDto CreateAccount(string number, string name, string type, string? vatCode) + { + return new AiAccountDto + { + AccountNumber = number, + Name = name, + AccountType = type, + VatCodeId = vatCode + }; + } +} diff --git a/backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs b/backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs new file mode 100644 index 0000000..0784f6a --- /dev/null +++ b/backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs @@ -0,0 +1,282 @@ +using Books.Api.Commands.ApiKeys; +using Books.Api.Domain.ApiKeys; +using Books.Api.EventFlow.Repositories; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Books.Api.Tests.Domain; + +/// +/// Integration tests for ApiKey domain operations. +/// Tests the ApiKeyAggregate via CommandBus since ApiKeys are not exposed via GraphQL. +/// +[Trait("Category", "Integration")] +public class ApiKeyIntegrationTests(TestWebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + public async Task CreateApiKey_CreatesKeySuccessfully() + { + // Arrange + var apiKeyId = ApiKeyId.New; + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}"))); + var companyId = $"company-{Guid.NewGuid():N}"; + + var command = new CreateApiKeyCommand( + apiKeyId, + "Test API Key", + keyHash, + companyId, + "test-user"); + + // Act + await CommandBus.PublishAsync(command, CancellationToken.None); + + // Assert + var apiKeys = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, 1); + + apiKeys.Should().ContainSingle(); + apiKeys.First().Name.Should().Be("Test API Key"); + apiKeys.First().CompanyId.Should().Be(companyId); + apiKeys.First().IsActive.Should().BeTrue(); + } + + [Fact] + public async Task CreateApiKey_FailsForDuplicate() + { + // Arrange + var apiKeyId = ApiKeyId.New; + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}"))); + var companyId = $"company-{Guid.NewGuid():N}"; + + var command = new CreateApiKeyCommand( + apiKeyId, + "First API Key", + keyHash, + companyId, + "test-user"); + + await CommandBus.PublishAsync(command, CancellationToken.None); + + // Wait for first key to be created + await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, 1); + + // Act - Try to create with same ID + var duplicateCommand = new CreateApiKeyCommand( + apiKeyId, // Same ID + "Duplicate API Key", + keyHash, + companyId, + "test-user"); + + var act = async () => await CommandBus.PublishAsync(duplicateCommand, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .Where(e => e.Message.Contains("APIKEY_EXISTS") || + (e.InnerException != null && e.InnerException.Message.Contains("APIKEY_EXISTS")) || + e.Message.Contains("already exists")); + } + + [Fact] + public async Task RevokeApiKey_RevokesSuccessfully() + { + // Arrange + var apiKeyId = ApiKeyId.New; + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}"))); + var companyId = $"company-{Guid.NewGuid():N}"; + + await CommandBus.PublishAsync(new CreateApiKeyCommand( + apiKeyId, "Key To Revoke", keyHash, companyId, "test-user"), CancellationToken.None); + + await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, 1); + + // Act + var revokeCommand = new RevokeApiKeyCommand(apiKeyId, "admin-user"); + await CommandBus.PublishAsync(revokeCommand, CancellationToken.None); + + // Assert + var revokedKey = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var keys = await repo.GetByCompanyIdAsync(companyId); + var key = keys.FirstOrDefault(k => k.Id == apiKeyId.Value); + return key?.IsActive == false ? key : null; + }); + + revokedKey.Should().NotBeNull(); + revokedKey!.IsActive.Should().BeFalse(); + revokedKey.RevokedBy.Should().Be("admin-user"); + } + + [Fact] + public async Task RevokeApiKey_FailsForAlreadyRevoked() + { + // Arrange + var apiKeyId = ApiKeyId.New; + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}"))); + var companyId = $"company-{Guid.NewGuid():N}"; + + await CommandBus.PublishAsync(new CreateApiKeyCommand( + apiKeyId, "Key To Double Revoke", keyHash, companyId, "test-user"), CancellationToken.None); + + await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, 1); + + // Revoke first time + await CommandBus.PublishAsync(new RevokeApiKeyCommand(apiKeyId, "admin-user"), CancellationToken.None); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var keys = await repo.GetByCompanyIdAsync(companyId); + var key = keys.FirstOrDefault(k => k.Id == apiKeyId.Value); + return key?.IsActive == false ? key : null; + }); + + // Act - Try to revoke again + var act = async () => await CommandBus.PublishAsync( + new RevokeApiKeyCommand(apiKeyId, "admin-user"), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .Where(e => e.Message.Contains("APIKEY_REVOKED") || + (e.InnerException != null && e.InnerException.Message.Contains("APIKEY_REVOKED")) || + e.Message.Contains("already revoked")); + } + + [Fact] + public async Task GetByCompanyId_ReturnsKeysForCompany() + { + // Arrange + var companyId = $"company-{Guid.NewGuid():N}"; + var otherCompanyId = $"company-{Guid.NewGuid():N}"; + + // Create keys for our company + for (var i = 0; i < 3; i++) + { + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"key-{i}-{Guid.NewGuid()}"))); + await CommandBus.PublishAsync(new CreateApiKeyCommand( + ApiKeyId.New, $"Key {i}", keyHash, companyId, "test-user"), CancellationToken.None); + } + + // Create key for other company + var otherKeyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"other-key-{Guid.NewGuid()}"))); + await CommandBus.PublishAsync(new CreateApiKeyCommand( + ApiKeyId.New, "Other Key", otherKeyHash, otherCompanyId, "test-user"), CancellationToken.None); + + // Act + var keys = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, 3); + + // Assert + keys.Should().HaveCount(3); + keys.Should().AllSatisfy(k => k.CompanyId.Should().Be(companyId)); + } + + [Fact] + public async Task GetByIdForValidation_ReturnsActiveKey() + { + // Arrange + var apiKeyId = ApiKeyId.New; + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"validation-key-{Guid.NewGuid()}"))); + var companyId = $"company-{Guid.NewGuid():N}"; + + await CommandBus.PublishAsync(new CreateApiKeyCommand( + apiKeyId, "Validation Key", keyHash, companyId, "test-user"), CancellationToken.None); + + // Act + var validationDto = await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdForValidationAsync(apiKeyId.Value); + }); + + // Assert + validationDto.Should().NotBeNull(); + validationDto!.ApiKeyId.Should().Be(apiKeyId.Value); + validationDto.Name.Should().Be("Validation Key"); + validationDto.KeyHash.Should().Be(keyHash); + validationDto.CompanyId.Should().Be(companyId); + validationDto.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task GetByIdForValidation_ReturnsNull_ForRevokedKey() + { + // Arrange + var apiKeyId = ApiKeyId.New; + var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"revoked-validation-key-{Guid.NewGuid()}"))); + var companyId = $"company-{Guid.NewGuid():N}"; + + await CommandBus.PublishAsync(new CreateApiKeyCommand( + apiKeyId, "Revoked Validation Key", keyHash, companyId, "test-user"), CancellationToken.None); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdForValidationAsync(apiKeyId.Value); + }); + + // Revoke the key + await CommandBus.PublishAsync(new RevokeApiKeyCommand(apiKeyId, "admin-user"), CancellationToken.None); + + // Wait for revocation + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var keys = await repo.GetByCompanyIdAsync(companyId); + var key = keys.FirstOrDefault(k => k.Id == apiKeyId.Value); + return key?.IsActive == false ? key : null; + }); + + // Act + var repo = GetService(); + var validationDto = await repo.GetByIdForValidationAsync(apiKeyId.Value); + + // Assert - Revoked keys should not be returned for validation + validationDto.Should().BeNull(); + } + + [Fact] + public async Task GetByIdForValidation_ReturnsNull_ForNonExistentKey() + { + // Arrange + var nonExistentId = ApiKeyId.New; + + // Act + var repo = GetService(); + var validationDto = await repo.GetByIdForValidationAsync(nonExistentId.Value); + + // Assert + validationDto.Should().BeNull(); + } +} diff --git a/backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs b/backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs new file mode 100644 index 0000000..979431b --- /dev/null +++ b/backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs @@ -0,0 +1,485 @@ +using Books.Api.Domain; +using Books.Api.Domain.BankConnections; +using Books.Api.Domain.BankConnections.Events; +using AwesomeAssertions; + +namespace Books.Api.Tests.Domain; + +/// +/// Unit tests for BankConnectionAggregate domain logic. +/// Tests aggregate behavior without EventFlow infrastructure. +/// +[Trait("Category", "Unit")] +public class BankConnectionAggregateTests +{ + #region Initiate Tests + + [Fact] + public void Initiate_WithValidData_EmitsInitiatedEvent() + { + // Arrange + var aggregate = new BankConnectionAggregate(BankConnectionId.New); + + // Act + aggregate.Initiate("company-123", "Danske Bank", "auth-456", "https://callback.url", "state-789"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().ContainSingle(); + + var initiatedEvent = uncommittedEvents[0].AggregateEvent as BankConnectionInitiatedEvent; + initiatedEvent.Should().NotBeNull(); + initiatedEvent!.CompanyId.Should().Be("company-123"); + initiatedEvent.AspspName.Should().Be("Danske Bank"); + initiatedEvent.AuthorizationId.Should().Be("auth-456"); + initiatedEvent.RedirectUrl.Should().Be("https://callback.url"); + initiatedEvent.State.Should().Be("state-789"); + } + + [Fact] + public void Initiate_WithEmptyAspspName_ThrowsDomainException() + { + // Arrange + var aggregate = new BankConnectionAggregate(BankConnectionId.New); + + // Act + var act = () => aggregate.Initiate("company-123", " ", "auth-456", "https://callback.url", "state-789"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "ASPSP_NAME_REQUIRED"); + } + + [Fact] + public void Initiate_WhenAlreadyInitiated_ThrowsDomainException() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + + // Act + var act = () => aggregate.Initiate("company-123", "Nordea", "auth-999", "https://other.url", "state-111"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_ALREADY_INITIATED"); + } + + #endregion + + #region Establish Tests + + [Fact] + public void Establish_WhenInitiated_EmitsEstablishedEvent() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + var accounts = new List + { + new("acc-1", "DK1234567890123456", "DKK", "Lønkonto"), + new("acc-2", "DK9876543210987654", "DKK", "Opsparingskonto") + }; + var validUntil = DateTimeOffset.UtcNow.AddDays(90); + + // Act + aggregate.Establish("session-123", validUntil, accounts); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(2); // Initiated + Established + + var establishedEvent = uncommittedEvents[1].AggregateEvent as BankConnectionEstablishedEvent; + establishedEvent.Should().NotBeNull(); + establishedEvent!.SessionId.Should().Be("session-123"); + establishedEvent.ValidUntil.Should().Be(validUntil); + establishedEvent.Accounts.Should().HaveCount(2); + } + + [Fact] + public void Establish_WhenNotInitiated_ThrowsDomainException() + { + // Arrange + var aggregate = new BankConnectionAggregate(BankConnectionId.New); + var accounts = new List { new("acc-1", "DK1234", "DKK", null) }; + + // Act + var act = () => aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_NOT_INITIATED"); + } + + [Fact] + public void Establish_WithEmptySessionId_ThrowsDomainException() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + var accounts = new List { new("acc-1", "DK1234", "DKK", null) }; + + // Act + var act = () => aggregate.Establish(" ", DateTimeOffset.UtcNow.AddDays(90), accounts); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "SESSION_ID_REQUIRED"); + } + + [Fact] + public void Establish_WithNoAccounts_ThrowsDomainException() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + var accounts = new List(); + + // Act + var act = () => aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "NO_ACCOUNTS_FOUND"); + } + + [Fact] + public void Establish_WhenAlreadyEstablished_ThrowsDomainException() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + var accounts = new List { new("acc-1", "DK1234", "DKK", null) }; + + // Act + var act = () => aggregate.Establish("session-456", DateTimeOffset.UtcNow.AddDays(90), accounts); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_ALREADY_ESTABLISHED"); + } + + #endregion + + #region Fail Tests + + [Fact] + public void Fail_WhenInitiated_EmitsFailedEvent() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + + // Act + aggregate.Fail("User cancelled authorization"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(2); // Initiated + Failed + + var failedEvent = uncommittedEvents[1].AggregateEvent as BankConnectionFailedEvent; + failedEvent.Should().NotBeNull(); + failedEvent!.Reason.Should().Be("User cancelled authorization"); + } + + [Fact] + public void Fail_WhenNotInitiated_ThrowsDomainException() + { + // Arrange + var aggregate = new BankConnectionAggregate(BankConnectionId.New); + + // Act + var act = () => aggregate.Fail("Some reason"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_NOT_INITIATED"); + } + + #endregion + + #region Disconnect Tests + + [Fact] + public void Disconnect_WhenEstablished_EmitsDisconnectedEvent() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + + // Act + aggregate.Disconnect("User requested disconnection"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(3); // Initiated + Established + Disconnected + + var disconnectedEvent = uncommittedEvents[2].AggregateEvent as BankConnectionDisconnectedEvent; + disconnectedEvent.Should().NotBeNull(); + disconnectedEvent!.Reason.Should().Be("User requested disconnection"); + } + + [Fact] + public void Disconnect_WhenInitiated_EmitsDisconnectedEvent() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + + // Act + aggregate.Disconnect("User cancelled flow"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(2); // Initiated + Disconnected + + var disconnectedEvent = uncommittedEvents[1].AggregateEvent as BankConnectionDisconnectedEvent; + disconnectedEvent.Should().NotBeNull(); + disconnectedEvent!.Reason.Should().Be("User cancelled flow"); + } + + [Fact] + public void Disconnect_WhenAlreadyDisconnected_Idempotent_DoesNothing() + { + // Arrange + var aggregate = CreateDisconnectedConnection(); + var initialEventCount = aggregate.UncommittedEvents.Count(); + + // Act + aggregate.Disconnect("Trying again"); + + // Assert + aggregate.UncommittedEvents.Count().Should().Be(initialEventCount); + } + + #endregion + + #region LinkBankAccount Tests + + [Fact] + public void LinkBankAccount_WhenEstablished_EmitsLinkedEvent() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + var importDate = new DateOnly(2023, 1, 1); + + // Act + aggregate.LinkBankAccount("acc-1", "ledger-1000", importDate); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + var linkedEvent = uncommittedEvents.Last().AggregateEvent as BankAccountLinkedEvent; + + linkedEvent.Should().NotBeNull(); + linkedEvent!.BankAccountId.Should().Be("acc-1"); + linkedEvent.LinkedAccountId.Should().Be("ledger-1000"); + linkedEvent.ImportFromDate.Should().Be(importDate); + } + + [Fact] + public void LinkBankAccount_WhenNotEstablished_ThrowsDomainException() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + + // Act + var act = () => aggregate.LinkBankAccount("acc-1", "ledger-1000"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_NOT_ACTIVE"); + } + + [Fact] + public void LinkBankAccount_WithUnknownAccountId_ThrowsDomainException() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + + // Act + var act = () => aggregate.LinkBankAccount("unknown-acc", "ledger-1000"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_ACCOUNT_NOT_FOUND"); + } + + #endregion + + #region ReInitiate Tests + + [Fact] + public void ReInitiate_WhenDisconnected_EmitsReInitiatedEvent() + { + // Arrange + var aggregate = CreateDisconnectedConnection(); + + // Act + aggregate.ReInitiate("new-auth", "url", "new-state"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + var event_ = uncommittedEvents.Last().AggregateEvent as BankConnectionReInitiatedEvent; + + event_.Should().NotBeNull(); + event_!.AuthorizationId.Should().Be("new-auth"); + event_.State.Should().Be("new-state"); + } + + [Fact] + public void ReInitiate_WhenActive_ThrowsDomainException() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + + // Act + var act = () => aggregate.ReInitiate("auth", "url", "state"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_STILL_ACTIVE"); + } + + #endregion + + #region Archive Tests + + [Fact] + public void Archive_WhenDisconnected_EmitsArchivedEvent() + { + // Arrange + var aggregate = CreateDisconnectedConnection(); + + // Act + aggregate.Archive(); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + var event_ = uncommittedEvents.Last().AggregateEvent as BankConnectionArchivedEvent; + + event_.Should().NotBeNull(); + } + + [Fact] + public void Archive_WhenActive_ThrowsDomainException() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + + // Act + var act = () => aggregate.Archive(); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_STILL_ACTIVE"); + } + + #endregion + + #region Refresh Tests + + [Fact] + public void Refresh_WhenEstablished_EmitsRefreshedEvent() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + var newValidUntil = DateTimeOffset.UtcNow.AddDays(180); + + // Act + aggregate.Refresh("new-session-456", newValidUntil); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(3); // Initiated + Established + Refreshed + + var refreshedEvent = uncommittedEvents[2].AggregateEvent as BankConnectionRefreshedEvent; + refreshedEvent.Should().NotBeNull(); + refreshedEvent!.NewSessionId.Should().Be("new-session-456"); + refreshedEvent.ValidUntil.Should().Be(newValidUntil); + } + + [Fact] + public void Refresh_WhenNotEstablished_ThrowsDomainException() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + + // Act + var act = () => aggregate.Refresh("new-session-456", DateTimeOffset.UtcNow.AddDays(90)); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_NOT_ACTIVE"); + } + + [Fact] + public void Refresh_WhenDisconnected_ThrowsDomainException() + { + // Arrange + var aggregate = CreateDisconnectedConnection(); + + // Act + var act = () => aggregate.Refresh("new-session-456", DateTimeOffset.UtcNow.AddDays(90)); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "BANK_CONNECTION_NOT_ACTIVE"); + } + + #endregion + + #region IsActive Property Tests + + [Fact] + public void IsActive_WhenEstablishedAndNotExpired_ReturnsTrue() + { + // Arrange + var aggregate = CreateEstablishedConnection(); + + // Assert + aggregate.IsActive.Should().BeTrue(); + } + + [Fact] + public void IsActive_WhenNotEstablished_ReturnsFalse() + { + // Arrange + var aggregate = CreateInitiatedConnection(); + + // Assert + aggregate.IsActive.Should().BeFalse(); + } + + [Fact] + public void IsActive_WhenDisconnected_ReturnsFalse() + { + // Arrange + var aggregate = CreateDisconnectedConnection(); + + // Assert + aggregate.IsActive.Should().BeFalse(); + } + + #endregion + + #region Helper Methods + + private static BankConnectionAggregate CreateInitiatedConnection() + { + var aggregate = new BankConnectionAggregate(BankConnectionId.New); + aggregate.Initiate("company-123", "Danske Bank", "auth-456", "https://callback.url", "state-789"); + return aggregate; + } + + private static BankConnectionAggregate CreateEstablishedConnection() + { + var aggregate = CreateInitiatedConnection(); + var accounts = new List + { + new("acc-1", "DK1234567890123456", "DKK", "Lønkonto") + }; + aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts); + return aggregate; + } + + private static BankConnectionAggregate CreateDisconnectedConnection() + { + var aggregate = CreateEstablishedConnection(); + aggregate.Disconnect("User requested disconnection"); + return aggregate; + } + + #endregion +} diff --git a/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs b/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs new file mode 100644 index 0000000..0870032 --- /dev/null +++ b/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs @@ -0,0 +1,393 @@ +using Books.Api.Domain; +using Books.Api.Domain.JournalEntryDrafts; +using Books.Api.Domain.JournalEntryDrafts.Events; +using AwesomeAssertions; + +namespace Books.Api.Tests.Domain; + +/// +/// Unit tests for JournalEntryDraftAggregate domain logic. +/// Tests aggregate behavior without EventFlow infrastructure. +/// +[Trait("Category", "Unit")] +public class JournalEntryDraftAggregateTests +{ + #region Create Tests + + [Fact] + public void Create_WithValidData_EmitsCreatedEvent() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + aggregate.Create("company-123", "Test Draft", "user@example.com", "K-0001"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().ContainSingle(); + + var createdEvent = uncommittedEvents[0].AggregateEvent as JournalEntryDraftCreatedEvent; + createdEvent.Should().NotBeNull(); + createdEvent!.CompanyId.Should().Be("company-123"); + createdEvent.Name.Should().Be("Test Draft"); + createdEvent.CreatedBy.Should().Be("user@example.com"); + createdEvent.VoucherNumber.Should().Be("K-0001"); + } + + [Fact] + public void Create_TrimsWhitespace() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + aggregate.Create(" company-123 ", " Trimmed Name ", "user@example.com", " K-0002 "); + + // Assert + var createdEvent = aggregate.UncommittedEvents.First().AggregateEvent as JournalEntryDraftCreatedEvent; + createdEvent!.CompanyId.Should().Be("company-123"); + createdEvent.Name.Should().Be("Trimmed Name"); + createdEvent.VoucherNumber.Should().Be("K-0002"); + } + + [Fact] + public void Create_WithEmptyName_ThrowsDomainException() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + var act = () => aggregate.Create("company-123", " ", "user@example.com", "K-0001"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_NAME_REQUIRED"); + } + + [Fact] + public void Create_WithEmptyCompanyId_ThrowsDomainException() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + var act = () => aggregate.Create("", "Test Draft", "user@example.com", "K-0001"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "COMPANY_ID_REQUIRED"); + } + + [Fact] + public void Create_WithEmptyCreatedBy_ThrowsDomainException() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + var act = () => aggregate.Create("company-123", "Test Draft", " ", "K-0001"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "CREATED_BY_REQUIRED"); + } + + [Fact] + public void Create_WithEmptyVoucherNumber_ThrowsDomainException() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + var act = () => aggregate.Create("company-123", "Test Draft", "user@example.com", " "); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "VOUCHER_NUMBER_REQUIRED"); + } + + [Fact] + public void Create_WhenAlreadyCreated_ThrowsDomainException() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + aggregate.Create("company-123", "First Draft", "user@example.com", "K-0001"); + + // Act + var act = () => aggregate.Create("company-123", "Second Draft", "user@example.com", "K-0002"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_ALREADY_EXISTS"); + } + + #endregion + + #region Update Tests + + [Fact] + public void Update_WhenActive_EmitsUpdatedEvent() + { + // Arrange + var aggregate = CreateActiveDraft(); + var lines = new List + { + new(1, "account-1", 1000m, 0m, "Debet"), + new(2, "account-2", 0m, 1000m, "Kredit") + }; + + // Act + aggregate.Update("New Name", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(2); // Created + Updated + + var updatedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftUpdatedEvent; + updatedEvent.Should().NotBeNull(); + updatedEvent!.Name.Should().Be("New Name"); + updatedEvent.DocumentDate.Should().Be(DateOnly.FromDateTime(DateTime.Today)); + updatedEvent.Description.Should().Be("Description"); + updatedEvent.FiscalYearId.Should().Be("fiscalyear-1"); + updatedEvent.Lines.Should().HaveCount(2); + } + + [Fact] + public void Update_WithVatCode_IncludesVatCodeInEvent() + { + // Arrange + var aggregate = CreateActiveDraft(); + var lines = new List + { + new(1, "account-1", 1000m, 0m, "Salg", "U25"), + new(2, "account-2", 0m, 1000m, "Moms", "I25") + }; + + // Act + aggregate.Update("Draft with VAT", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + var updatedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftUpdatedEvent; + updatedEvent.Should().NotBeNull(); + updatedEvent!.Lines[0].VatCode.Should().Be("U25"); + updatedEvent.Lines[1].VatCode.Should().Be("I25"); + } + + [Fact] + public void Update_WithInvalidVatCode_ThrowsDomainException() + { + // Arrange + var aggregate = CreateActiveDraft(); + var lines = new List + { + new(1, "account-1", 1000m, 0m, "Test", "INVALID_CODE") + }; + + // Act + var act = () => aggregate.Update("Name", null, null, null, lines); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "INVALID_VAT_CODE"); + } + + [Fact] + public void Update_WhenNotCreated_ThrowsDomainException() + { + // Arrange + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + + // Act + var act = () => aggregate.Update("Name", null, null, null, []); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_NOT_FOUND"); + } + + [Fact] + public void Update_WhenPosted_ThrowsDomainException() + { + // Arrange + var aggregate = CreatePostedDraft(); + + // Act + var act = () => aggregate.Update("New Name", null, null, null, []); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_ALREADY_POSTED"); + } + + [Fact] + public void Update_WhenDiscarded_ThrowsDomainException() + { + // Arrange + var aggregate = CreateDiscardedDraft(); + + // Act + var act = () => aggregate.Update("New Name", null, null, null, []); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_ALREADY_DISCARDED"); + } + + #endregion + + #region MarkPosted Tests + + [Fact] + public void MarkPosted_WhenActive_EmitsPostedEvent() + { + // Arrange + var aggregate = CreateActiveDraft(); + + // Act + aggregate.MarkPosted("transaction-123", "user@example.com"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(2); // Created + Posted + + var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent; + postedEvent.Should().NotBeNull(); + postedEvent!.TransactionId.Should().Be("transaction-123"); + postedEvent.PostedBy.Should().Be("user@example.com"); + } + + [Fact] + public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException() + { + // Arrange + var aggregate = CreateActiveDraft(); + + // Act + var act = () => aggregate.MarkPosted(" ", "user@example.com"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "TRANSACTION_ID_REQUIRED"); + } + + [Fact] + public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException() + { + // Arrange + var aggregate = CreateActiveDraft(); + + // Act + var act = () => aggregate.MarkPosted("transaction-123", ""); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "POSTED_BY_REQUIRED"); + } + + [Fact] + public void MarkPosted_WhenAlreadyPosted_ThrowsDomainException() + { + // Arrange + var aggregate = CreatePostedDraft(); + + // Act + var act = () => aggregate.MarkPosted("transaction-456", "user@example.com"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_ALREADY_POSTED"); + } + + #endregion + + #region Discard Tests + + [Fact] + public void Discard_WhenActive_EmitsDiscardedEvent() + { + // Arrange + var aggregate = CreateActiveDraft(); + + // Act + aggregate.Discard("user@example.com"); + + // Assert + var uncommittedEvents = aggregate.UncommittedEvents.ToList(); + uncommittedEvents.Should().HaveCount(2); // Created + Discarded + + var discardedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftDiscardedEvent; + discardedEvent.Should().NotBeNull(); + discardedEvent!.DiscardedBy.Should().Be("user@example.com"); + } + + [Fact] + public void Discard_WithEmptyDiscardedBy_ThrowsDomainException() + { + // Arrange + var aggregate = CreateActiveDraft(); + + // Act + var act = () => aggregate.Discard(" "); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DISCARDED_BY_REQUIRED"); + } + + [Fact] + public void Discard_WhenAlreadyDiscarded_ThrowsDomainException() + { + // Arrange + var aggregate = CreateDiscardedDraft(); + + // Act + var act = () => aggregate.Discard("user@example.com"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_ALREADY_DISCARDED"); + } + + [Fact] + public void Discard_WhenPosted_ThrowsDomainException() + { + // Arrange + var aggregate = CreatePostedDraft(); + + // Act + var act = () => aggregate.Discard("user@example.com"); + + // Assert + act.Should().Throw() + .Where(e => e.Code == "DRAFT_ALREADY_POSTED"); + } + + #endregion + + #region Helper Methods + + private static JournalEntryDraftAggregate CreateActiveDraft() + { + var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New()); + aggregate.Create("company-123", "Test Draft", "user@example.com", "K-0001"); + return aggregate; + } + + private static JournalEntryDraftAggregate CreatePostedDraft() + { + var aggregate = CreateActiveDraft(); + aggregate.MarkPosted("transaction-123", "user@example.com"); + return aggregate; + } + + private static JournalEntryDraftAggregate CreateDiscardedDraft() + { + var aggregate = CreateActiveDraft(); + aggregate.Discard("user@example.com"); + return aggregate; + } + + #endregion +} diff --git a/backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs b/backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs new file mode 100644 index 0000000..f05ad45 --- /dev/null +++ b/backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs @@ -0,0 +1,265 @@ +using Books.Api.Domain; +using Books.Api.Domain.JournalEntryDrafts; +using AwesomeAssertions; + +namespace Books.Api.Tests.Domain; + +/// +/// Unit tests for VatCalculationService. +/// Tests Danish VAT calculation logic for SKAT compliance. +/// +[Trait("Category", "Unit")] +public class VatCalculationServiceTests +{ + private readonly VatCalculationService _sut = new(); + private const string InputVatAccount = "5610"; // Købsmoms (indgående moms) + private const string OutputVatAccount = "5611"; // Salgsmoms (udgående moms) + + #region Sales (U25) Tests + + [Fact] + public void CalculateVat_SalesWithU25_Exclusive_CalculatesCorrectVat() + { + // Arrange - Sale of 1000 kr excl. VAT + var lines = new List + { + new(1, "1000", 0m, 1000m, "Salg af varer", "U25") // Credit = sales revenue + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + result.VatLines.Should().HaveCount(1); + var vatLine = result.VatLines[0]; + vatLine.CreditAmount.Should().Be(250m); // 25% of 1000 + vatLine.DebitAmount.Should().Be(0m); + vatLine.AccountId.Should().Be(OutputVatAccount); + vatLine.VatCode.Should().Be("U25"); + + result.VatSummary.Should().HaveCount(1); + result.VatSummary[0].BaseAmount.Should().Be(1000m); + result.VatSummary[0].VatAmount.Should().Be(250m); + result.VatSummary[0].IsInputVat.Should().BeFalse(); + } + + [Fact] + public void CalculateVat_SalesWithU25_Inclusive_ExtractsCorrectVat() + { + // Arrange - Sale of 1250 kr incl. VAT (1000 + 250 VAT) + var lines = new List + { + new(1, "1000", 0m, 1250m, "Salg inkl moms", "U25") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Inclusive, InputVatAccount, OutputVatAccount); + + // Assert + var vatLine = result.VatLines[0]; + vatLine.CreditAmount.Should().Be(250m); // 1250 * 0.25 / 1.25 = 250 + vatLine.DebitAmount.Should().Be(0m); + + result.VatSummary[0].BaseAmount.Should().Be(1000m); // 1250 - 250 + result.VatSummary[0].VatAmount.Should().Be(250m); + } + + #endregion + + #region Purchases (I25) Tests + + [Fact] + public void CalculateVat_PurchaseWithI25_Exclusive_CalculatesCorrectVat() + { + // Arrange - Purchase of 1000 kr excl. VAT + var lines = new List + { + new(1, "2000", 1000m, 0m, "Køb af varer", "I25") // Debit = expense + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + result.VatLines.Should().HaveCount(1); + var vatLine = result.VatLines[0]; + vatLine.DebitAmount.Should().Be(250m); // 25% of 1000 - debit because it's an asset (receivable) + vatLine.CreditAmount.Should().Be(0m); + vatLine.AccountId.Should().Be(InputVatAccount); + vatLine.VatCode.Should().Be("I25"); + + result.VatSummary[0].IsInputVat.Should().BeTrue(); + } + + [Fact] + public void CalculateVat_PurchaseWithI25_Inclusive_ExtractsCorrectVat() + { + // Arrange - Purchase of 1250 kr incl. VAT + var lines = new List + { + new(1, "2000", 1250m, 0m, "Køb inkl moms", "I25") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Inclusive, InputVatAccount, OutputVatAccount); + + // Assert + var vatLine = result.VatLines[0]; + vatLine.DebitAmount.Should().Be(250m); + vatLine.CreditAmount.Should().Be(0m); + + result.VatSummary[0].BaseAmount.Should().Be(1000m); + result.VatSummary[0].VatAmount.Should().Be(250m); + } + + #endregion + + #region No VAT Tests + + [Fact] + public void CalculateVat_NoVatCode_ReturnsNoVatLines() + { + // Arrange + var lines = new List + { + new(1, "1000", 0m, 1000m, "Momsfrit salg") // No VAT code + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + result.VatLines.Should().BeEmpty(); + result.VatSummary.Should().BeEmpty(); + result.TotalVatAmount.Should().Be(0m); + } + + [Fact] + public void CalculateVat_WithINGEN_ReturnsNoVatLines() + { + // Arrange + var lines = new List + { + new(1, "1000", 0m, 1000m, "Momsfrit", "INGEN") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + result.VatLines.Should().BeEmpty(); + } + + [Fact] + public void CalculateVat_WithUEU_ReturnsNoVatLines() + { + // Arrange - EU sale (no VAT charged) + var lines = new List + { + new(1, "1000", 0m, 10000m, "EU-salg af varer", "UEU") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert - UEU has 0% rate, so no VAT line generated + result.VatLines.Should().BeEmpty(); + } + + #endregion + + #region Multiple Lines Tests + + [Fact] + public void CalculateVat_MultipleLines_AggregatesByVatCode() + { + // Arrange + var lines = new List + { + new(1, "1000", 0m, 1000m, "Salg 1", "U25"), + new(2, "1000", 0m, 2000m, "Salg 2", "U25"), + new(3, "2000", 500m, 0m, "Køb 1", "I25") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + result.VatLines.Should().HaveCount(3); + + // Check VAT summary aggregates correctly + result.VatSummary.Should().HaveCount(2); // U25 and I25 + + var u25Summary = result.VatSummary.First(s => s.VatCode == "U25"); + u25Summary.BaseAmount.Should().Be(3000m); // 1000 + 2000 + u25Summary.VatAmount.Should().Be(750m); // 25% of 3000 + + var i25Summary = result.VatSummary.First(s => s.VatCode == "I25"); + i25Summary.BaseAmount.Should().Be(500m); + i25Summary.VatAmount.Should().Be(125m); // 25% of 500 + } + + #endregion + + #region Rounding Tests + + [Fact] + public void CalculateVat_OddAmount_RoundsCorrectly() + { + // Arrange - Amount that results in rounding + var lines = new List + { + new(1, "1000", 0m, 100.01m, "Salg med øreafrunding", "U25") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + // 100.01 * 0.25 = 25.0025, rounded to 25.00 + result.VatLines[0].CreditAmount.Should().Be(25.00m); + } + + [Fact] + public void CalculateVat_InclusiveOddAmount_RoundsCorrectly() + { + // Arrange - 125.01 incl VAT + var lines = new List + { + new(1, "1000", 0m, 125.01m, "Salg inkl moms", "U25") + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Inclusive, InputVatAccount, OutputVatAccount); + + // Assert + // 125.01 * 0.25 / 1.25 = 25.002, rounded to 25.00 + result.VatLines[0].CreditAmount.Should().Be(25.00m); + result.VatSummary[0].BaseAmount.Should().Be(100.01m); // 125.01 - 25.00 + } + + #endregion + + #region Total VAT Tests + + [Fact] + public void CalculateVat_MixedInputOutput_CalculatesTotalCorrectly() + { + // Arrange - Sales and purchases + var lines = new List + { + new(1, "1000", 0m, 1000m, "Salg", "U25"), // Output VAT: 250 credit + new(2, "2000", 400m, 0m, "Køb", "I25") // Input VAT: 100 debit + }; + + // Act + var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount); + + // Assert + // Total = debit (input VAT asset) - credit (output VAT liability) + // = 100 - 250 = -150 (negative means net VAT payable to SKAT) + result.TotalVatAmount.Should().Be(-150m); + } + + #endregion +} diff --git a/backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs b/backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs new file mode 100644 index 0000000..2a479a6 --- /dev/null +++ b/backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs @@ -0,0 +1,819 @@ +using Books.Api.EventFlow.Repositories; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Books.Api.Tests.GraphQL; + +/// +/// Integration tests for Account GraphQL operations. +/// Each test class runs with its own isolated database. +/// +[Trait("Category", "Integration")] +public class AccountGraphQLTests(TestWebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + public async Task Query_Accounts_FailsWithoutCompanyHeader() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + // Note: We're NOT setting X-Company-Id header + + // Act - query accounts without X-Company-Id header + var response = await graphqlClient.QueryAsync(""" + query { + accounts(companyId: "nonexistent") { + id + name + } + } + """); + + // Assert - Should fail with authorization error + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("No company selected"); + } + + [Fact] + public async Task Mutation_CreateAccount_CreatesAccountSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // First create a company (which doesn't auto-create accounts in this test) + var companyId = await CreateCompanyAsync(graphqlClient, "Account Test Company"); + + // Act - Create an account + var response = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + companyId + accountNumber + name + accountType + isActive + isSystemAccount + } + } + """, + new + { + input = new + { + companyId, + accountNumber = "9999", + name = "Test Konto", + accountType = "EXPENSE", + description = "En testkonto" + } + }); + + // Assert + response.EnsureNoErrors(); + response.Data.Should().NotBeNull(); + response.Data!.CreateAccount.Should().NotBeNull(); + response.Data.CreateAccount!.AccountNumber.Should().Be("9999"); + response.Data.CreateAccount.Name.Should().Be("Test Konto"); + response.Data.CreateAccount.AccountType.Should().Be("expense"); + response.Data.CreateAccount.IsActive.Should().BeTrue(); + response.Data.CreateAccount.IsSystemAccount.Should().BeFalse(); + } + + [Fact] + public async Task Mutation_UpdateAccount_UpdatesAccountSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Update Account Test"); + + // Create an account + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { id name } + } + """, + new + { + input = new + { + companyId, + accountNumber = "8888", + name = "Original Name", + accountType = "EXPENSE" + } + }); + + createResponse.EnsureNoErrors(); + var accountId = createResponse.Data!.CreateAccount!.Id; + + // Wait for eventual consistency + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(accountId); + }); + + // Act - Update the account + var updateResponse = await graphqlClient.MutateAsync(""" + mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) { + updateAccount(id: $id, input: $input) { + id + name + description + } + } + """, + new + { + id = accountId, + input = new + { + name = "Updated Name", + description = "Updated description" + } + }); + + // Assert + updateResponse.EnsureNoErrors(); + + var updatedAccount = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var account = await repo.GetByIdAsync(accountId); + return account?.Name == "Updated Name" ? account : null; + }); + + updatedAccount.Should().NotBeNull(); + updatedAccount!.Name.Should().Be("Updated Name"); + updatedAccount.Description.Should().Be("Updated description"); + } + + [Fact] + public async Task Mutation_DeactivateAccount_DeactivatesAccountSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Deactivate Account Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { id isActive } + } + """, + new + { + input = new + { + companyId, + accountNumber = "7777", + name = "Deactivate Test", + accountType = "EXPENSE" + } + }); + + createResponse.EnsureNoErrors(); + var accountId = createResponse.Data!.CreateAccount!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(accountId); + }); + + // Act + var deactivateResponse = await graphqlClient.MutateAsync(""" + mutation DeactivateAccount($id: ID!) { + deactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = accountId }); + + // Assert + deactivateResponse.EnsureNoErrors(); + + var deactivatedAccount = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var account = await repo.GetByIdAsync(accountId); + return account?.IsActive == false ? account : null; + }); + + deactivatedAccount.Should().NotBeNull(); + deactivatedAccount!.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task Mutation_ReactivateAccount_ReactivatesAccountSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Reactivate Account Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + accountNumber = "6666", + name = "Reactivate Test", + accountType = "EXPENSE" + } + }); + + createResponse.EnsureNoErrors(); + var accountId = createResponse.Data!.CreateAccount!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(accountId); + }); + + // Deactivate first + await graphqlClient.MutateAsync(""" + mutation { deactivateAccount(id: $id) { id } } + """.Replace("$id", $"\"{accountId}\"")); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var account = await repo.GetByIdAsync(accountId); + return account?.IsActive == false ? account : null; + }); + + // Act + var reactivateResponse = await graphqlClient.MutateAsync(""" + mutation ReactivateAccount($id: ID!) { + reactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = accountId }); + + // Assert + reactivateResponse.EnsureNoErrors(); + + var reactivatedAccount = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var account = await repo.GetByIdAsync(accountId); + return account?.IsActive == true ? account : null; + }); + + reactivatedAccount.Should().NotBeNull(); + reactivatedAccount!.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task Query_ActiveAccounts_ExcludesDeactivatedAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Active Accounts Test"); + + // Wait for standard accounts to be created + var initialAccounts = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, 50, timeout: TimeSpan.FromSeconds(30)); // At least 50 standard accounts + + var initialActiveCount = initialAccounts.Count(a => a.IsActive); + + // Create a custom account + var customAccountId = await CreateAccountAsync(graphqlClient, companyId, "5553", "Custom To Deactivate"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(customAccountId); + }); + + // Deactivate the custom account + await graphqlClient.MutateAsync( + $"mutation {{ deactivateAccount(id: \"{customAccountId}\") {{ id }} }}"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var account = await repo.GetByIdAsync(customAccountId); + return account?.IsActive == false ? account : null; + }); + + // Act + var response = await graphqlClient.QueryAsync(""" + query ActiveAccounts($companyId: ID!) { + activeAccounts(companyId: $companyId) { + id + name + isActive + } + } + """, + new { companyId }); + + // Assert + response.EnsureNoErrors(); + // Should have initial active accounts (deactivated one is excluded) + response.Data!.ActiveAccounts.Should().HaveCount(initialActiveCount); + response.Data.ActiveAccounts.Should().AllSatisfy(a => a.IsActive.Should().BeTrue()); + response.Data.ActiveAccounts.Should().NotContain(a => a.Id == customAccountId); + } + + [Fact] + public async Task Mutation_CreateAccount_FailsWithInvalidAccountNumber() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Invalid Number Test"); + + // Act - Try to create account with invalid number (too short) + var response = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + } + } + """, + new + { + input = new + { + companyId, + accountNumber = "12", // Invalid - must be 4-10 digits + name = "Invalid Account", + accountType = "EXPENSE" + } + }); + + // Assert - Domain validation error is wrapped by GraphQL + response.HasErrors.Should().BeTrue(); + // Check that the error is related to account validation (could be in message or extensions) + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + var hasAccountError = response.Errors!.Any(e => + e.Message.Contains("INVALID_ACCOUNT_NUMBER") || + e.Message.Contains("createAccount") || + errorDetails.Contains("INVALID_ACCOUNT_NUMBER") || + errorDetails.Contains("4-10 digits")); + hasAccountError.Should().BeTrue("Expected an error related to invalid account number"); + } + + [Fact] + public async Task Mutation_UpdateAccount_FailsForSystemAccount() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "System Account Update Test"); + + // Wait for chart of accounts to be initialized + var systemAccount = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var accounts = await repo.GetByCompanyIdAsync(companyId); + return accounts.FirstOrDefault(a => a.IsSystemAccount); + }, timeout: TimeSpan.FromSeconds(30)); + + systemAccount.Should().NotBeNull("Expected at least one system account from chart initialization"); + + // Act - Try to update a system account + var response = await graphqlClient.MutateAsync(""" + mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) { + updateAccount(id: $id, input: $input) { + id + name + } + } + """, + new + { + id = systemAccount!.Id, + input = new { name = "Attempting to rename system account" } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("SYSTEM_ACCOUNT_READONLY")) || + errorDetails.Contains("SYSTEM_ACCOUNT_READONLY") || + errorDetails.Contains("System accounts cannot be modified")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_DeactivateAccount_FailsForSystemAccount() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "System Account Deactivate Test"); + + // Wait for chart of accounts to be initialized + var systemAccount = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var accounts = await repo.GetByCompanyIdAsync(companyId); + return accounts.FirstOrDefault(a => a.IsSystemAccount); + }, timeout: TimeSpan.FromSeconds(30)); + + systemAccount.Should().NotBeNull("Expected at least one system account from chart initialization"); + + // Act - Try to deactivate a system account + var response = await graphqlClient.MutateAsync(""" + mutation DeactivateAccount($id: ID!) { + deactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = systemAccount!.Id }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("SYSTEM_ACCOUNT_READONLY")) || + errorDetails.Contains("SYSTEM_ACCOUNT_READONLY") || + errorDetails.Contains("System accounts cannot be deactivated")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_DeactivateAccount_FailsWhenAlreadyInactive() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Double Deactivate Test"); + var accountId = await CreateAccountAsync(graphqlClient, companyId, "4444", "Double Deactivate"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(accountId); + }); + + // First deactivation - should succeed + await graphqlClient.MutateAsync(""" + mutation DeactivateAccount($id: ID!) { + deactivateAccount(id: $id) { id } + } + """, + new { id = accountId }); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var account = await repo.GetByIdAsync(accountId); + return account?.IsActive == false ? account : null; + }); + + // Act - Try to deactivate again + var response = await graphqlClient.MutateAsync(""" + mutation DeactivateAccount($id: ID!) { + deactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = accountId }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("ACCOUNT_ALREADY_INACTIVE")) || + errorDetails.Contains("ACCOUNT_ALREADY_INACTIVE") || + errorDetails.Contains("already inactive")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_ReactivateAccount_FailsWhenAlreadyActive() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Reactivate Active Test"); + var accountId = await CreateAccountAsync(graphqlClient, companyId, "3333", "Already Active"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(accountId); + }); + + // Act - Try to reactivate an already active account + var response = await graphqlClient.MutateAsync(""" + mutation ReactivateAccount($id: ID!) { + reactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = accountId }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("ACCOUNT_ALREADY_ACTIVE")) || + errorDetails.Contains("ACCOUNT_ALREADY_ACTIVE") || + errorDetails.Contains("already active")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_CreateAccount_AcceptsMinimumDigits() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Min Digits Test"); + + // Act - Create account with exactly 4 digits (minimum allowed) + // Use a number not in the standard chart of accounts + var response = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + accountNumber + } + } + """, + new + { + input = new + { + companyId, + accountNumber = "9990", // Exactly 4 digits, not in standard chart + name = "Minimum Digits Account", + accountType = "ASSET" + } + }); + + // Assert - Should succeed + response.EnsureNoErrors(); + response.Data!.CreateAccount!.AccountNumber.Should().Be("9990"); + } + + [Fact] + public async Task Mutation_CreateAccount_FailsWithTooFewDigits() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Too Few Digits Test"); + + // Act - Create account with 3 digits (below minimum) + var response = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + } + } + """, + new + { + input = new + { + companyId, + accountNumber = "999", // Only 3 digits + name = "Too Few Digits", + accountType = "EXPENSE" + } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("INVALID_ACCOUNT_NUMBER")) || + errorDetails.Contains("INVALID_ACCOUNT_NUMBER") || + errorDetails.Contains("4-10 digits")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_CreateAccount_IsDeterministic() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Deterministic ID Test"); + + // Act - Create same account twice (use number not in standard chart) + var response1 = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + accountNumber + } + } + """, + new + { + input = new + { + companyId, + accountNumber = "9991", + name = "Deterministic Test", + accountType = "EXPENSE" + } + }); + + response1.EnsureNoErrors(); + var firstId = response1.Data!.CreateAccount!.Id; + + // Wait for eventual consistency + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(firstId); + }); + + // Second attempt with same input + var response2 = await graphqlClient.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { + id + accountNumber + } + } + """, + new + { + input = new + { + companyId, + accountNumber = "9991", + name = "Deterministic Test", + accountType = "EXPENSE" + } + }); + + // Assert - Second call should fail with ACCOUNT_EXISTS error (idempotency) + response2.HasErrors.Should().BeTrue(); + var errorDetails = response2.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response2.Errors!.Any(e => e.Message.Contains("ACCOUNT_EXISTS")) || + errorDetails.Contains("ACCOUNT_EXISTS") || + errorDetails.Contains("already exists")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_UpdateAccount_FailsForNonExistentAccount() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var nonExistentAccountId = $"account-{Guid.NewGuid():N}"; + + // Act - Try to update a non-existent account + var response = await graphqlClient.MutateAsync(""" + mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) { + updateAccount(id: $id, input: $input) { + id + name + } + } + """, + new + { + id = nonExistentAccountId, + input = new { name = "New Name" } + }); + + // Assert - Domain exception is wrapped by GraphQL/EventFlow + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + (errorInfo.Contains("ACCOUNT_NOT_FOUND") || + errorInfo.Contains("Account does not exist") || + errorInfo.Contains("does not exist") || + errorInfo.Contains("updateAccount")).Should().BeTrue( + $"Expected account not found error, got: {errorInfo}"); + } + + [Fact] + public async Task Mutation_DeactivateAccount_FailsForNonExistentAccount() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var nonExistentAccountId = $"account-{Guid.NewGuid():N}"; + + // Act - Try to deactivate a non-existent account + var response = await graphqlClient.MutateAsync(""" + mutation DeactivateAccount($id: ID!) { + deactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = nonExistentAccountId }); + + // Assert - Domain exception is wrapped by GraphQL/EventFlow + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + (errorInfo.Contains("ACCOUNT_NOT_FOUND") || + errorInfo.Contains("Account does not exist") || + errorInfo.Contains("does not exist") || + errorInfo.Contains("deactivateAccount")).Should().BeTrue( + $"Expected account not found error, got: {errorInfo}"); + } + + [Fact] + public async Task Mutation_ReactivateAccount_FailsForNonExistentAccount() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var nonExistentAccountId = $"account-{Guid.NewGuid():N}"; + + // Act - Try to reactivate a non-existent account + var response = await graphqlClient.MutateAsync(""" + mutation ReactivateAccount($id: ID!) { + reactivateAccount(id: $id) { + id + isActive + } + } + """, + new { id = nonExistentAccountId }); + + // Assert - Domain exception is wrapped by GraphQL/EventFlow + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + (errorInfo.Contains("ACCOUNT_NOT_FOUND") || + errorInfo.Contains("Account does not exist") || + errorInfo.Contains("does not exist") || + errorInfo.Contains("reactivateAccount")).Should().BeTrue( + $"Expected account not found error, got: {errorInfo}"); + } + + private async Task CreateCompanyAsync(GraphQLTestClient client, string name) + { + var response = await client.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name } }); + + response.EnsureNoErrors(); + var companyId = response.Data!.CreateCompany!.Id; + + // Set the company ID header for subsequent requests + client.SetCompanyId(companyId); + + return companyId; + } + + private async Task CreateAccountAsync(GraphQLTestClient client, string companyId, string number, string name) + { + var response = await client.MutateAsync(""" + mutation CreateAccount($input: CreateAccountInput!) { + createAccount(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + accountNumber = number, + name, + accountType = "EXPENSE" + } + }); + + response.EnsureNoErrors(); + return response.Data!.CreateAccount!.Id; + } + + // Response DTOs + private class AccountsResponse { public List Accounts { get; set; } = []; } + private class ActiveAccountsResponse { public List ActiveAccounts { get; set; } = []; } + private class AccountResponse { public AccountDto? Account { get; set; } } + private class CreateAccountResponse { public AccountDto? CreateAccount { get; set; } } + private class UpdateAccountResponse { public AccountDto? UpdateAccount { get; set; } } + private class DeactivateAccountResponse { public AccountDto? DeactivateAccount { get; set; } } + private class ReactivateAccountResponse { public AccountDto? ReactivateAccount { get; set; } } + private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } + + private class AccountDto + { + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string AccountNumber { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string AccountType { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } + public bool IsSystemAccount { get; set; } + } + + private class CompanyDto { public string Id { get; set; } = string.Empty; } +} diff --git a/backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs b/backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs new file mode 100644 index 0000000..8eae285 --- /dev/null +++ b/backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs @@ -0,0 +1,475 @@ +using Books.Api.EventFlow.Repositories; +using Books.Api.EventFlow.Subscribers; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Books.Api.Tests.GraphQL; + +/// +/// Integration tests for ChartOfAccountsInitializer. +/// Verifies that creating a company automatically bootstraps the standard Danish chart of accounts. +/// +[Trait("Category", "Integration")] +public class ChartOfAccountsInitializerTests(TestWebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + public async Task CreateCompany_AutomaticallyCreatesStandardAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var expectedAccountCount = StandardDanishAccounts.GetAll().Count(); + + // Act - Create a company + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { + id + name + } + } + """, + new + { + input = new + { + name = "Auto-Account Test Virksomhed" + } + }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Wait for accounts to be created (eventually consistent) + var accounts = await Eventually.GetListAsync( + async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, + expectedAccountCount, + timeout: TimeSpan.FromSeconds(30), // Allow more time for many accounts + failMessage: $"Expected {expectedAccountCount} accounts to be created"); + + accounts.Should().HaveCount(expectedAccountCount); + } + + [Fact] + public async Task CreateCompany_CreatesRevenueAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Revenue Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check revenue accounts (1xxx) + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + var revenue = all.Where(a => a.AccountType == "revenue").ToList(); + return revenue.Count >= 5 ? revenue : null; + }, timeout: TimeSpan.FromSeconds(30)); + + accounts.Should().NotBeEmpty(); + accounts.Should().Contain(a => a.AccountNumber == "1000"); // Salg af varer, DK + accounts.Should().Contain(a => a.AccountNumber == "1200"); // Salg af ydelser, DK + accounts.Should().OnlyContain(a => a.AccountType == "revenue"); + } + + [Fact] + public async Task CreateCompany_CreatesExpenseAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Expense Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + var expenses = all.Where(a => a.AccountType == "expense").ToList(); + return expenses.Count >= 10 ? expenses : null; + }, timeout: TimeSpan.FromSeconds(30)); + + accounts.Should().NotBeEmpty(); + accounts.Should().Contain(a => a.AccountNumber == "4000"); // Annoncer og reklame + accounts.Should().Contain(a => a.AccountNumber == "5000"); // Husleje + accounts.Should().Contain(a => a.AccountNumber == "7320"); // Køb af software + } + + [Fact] + public async Task CreateCompany_CreatesSystemAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "System Accounts Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check system accounts exist (we have many: equity, revenue, debtors, creditors, tax) + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + var system = all.Where(a => a.IsSystemAccount).ToList(); + return system.Count >= 15 ? system : null; + }, timeout: TimeSpan.FromSeconds(30)); + + accounts.Should().NotBeEmpty(); + accounts.Should().Contain(a => a.AccountNumber == "3000"); // Aktiekapital + accounts.Should().Contain(a => a.AccountNumber == "3900"); // Overført resultat + accounts.Should().Contain(a => a.AccountNumber == "3910"); // Årets resultat + accounts.Should().Contain(a => a.AccountNumber == "1900"); // Debitorer + accounts.Should().Contain(a => a.AccountNumber == "6900"); // Kreditorer + accounts.Should().Contain(a => a.AccountNumber == "7950"); // Skyldig selskabsskat + accounts.Should().OnlyContain(a => a.IsSystemAccount); + } + + [Fact] + public async Task CreateCompany_CreatesLiabilityAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Liability Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + var liabilities = all.Where(a => a.AccountType == "liability").ToList(); + return liabilities.Count >= 5 ? liabilities : null; + }, timeout: TimeSpan.FromSeconds(30)); + + accounts.Should().NotBeEmpty(); + accounts.Should().Contain(a => a.AccountNumber == "6900"); // Kreditorer + accounts.Should().Contain(a => a.AccountNumber == "7900"); // Skyldig moms + accounts.Should().Contain(a => a.AccountNumber == "7910"); // Skyldig A-skat + } + + [Fact] + public async Task CreateCompany_AccountsHaveCorrectVatCodes() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "VAT Code Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check that accounts have correct VAT codes + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + return all.Count >= 50 ? all.ToList() : null; + }, timeout: TimeSpan.FromSeconds(30)); + + // Vareforbrug should have I25 (25% input VAT) + var vareforbrug = accounts!.FirstOrDefault(a => a.AccountNumber == "2000"); + vareforbrug.Should().NotBeNull(); + vareforbrug!.VatCodeId.Should().Be("I25"); + + // Salg af varer DK should have U25 (25% output VAT) + var salgVarer = accounts.FirstOrDefault(a => a.AccountNumber == "1000"); + salgVarer.Should().NotBeNull(); + salgVarer!.VatCodeId.Should().Be("U25"); + + // Bankrenter should have no VAT + var bankrenter = accounts.FirstOrDefault(a => a.AccountNumber == "9200"); + bankrenter.Should().NotBeNull(); + bankrenter!.VatCodeId.Should().BeNull(); + } + + [Fact] + public async Task Query_AccountsViaGraphQL_ReturnsBootstrappedAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + + // Create company + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "GraphQL Query Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Set company ID header for subsequent requests + graphqlClient.SetCompanyId(companyId); + + // Wait for accounts to be created + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + return all.Count >= 50 ? all : null; + }, timeout: TimeSpan.FromSeconds(30)); + + // Act - Query via GraphQL + var queryResponse = await graphqlClient.QueryAsync(""" + query Accounts($companyId: ID!) { + accounts(companyId: $companyId) { + id + accountNumber + name + accountType + isSystemAccount + } + } + """, + new { companyId }); + + // Assert + queryResponse.EnsureNoErrors(); + queryResponse.Data!.Accounts.Should().NotBeEmpty(); + queryResponse.Data.Accounts.Should().Contain(a => a.AccountNumber == "2000"); + queryResponse.Data.Accounts.Should().Contain(a => a.Name == "Vareforbrug"); + } + + [Fact] + public async Task ChartOfAccountsInitializer_IsIdempotent_CalledTwice() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var expectedAccountCount = StandardDanishAccounts.GetAll().Count(); + + // Create company (which triggers first initialization) + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Idempotency Test Company" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Wait for first initialization to complete + var initialAccounts = await Eventually.GetListAsync( + async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }, + expectedAccountCount, + timeout: TimeSpan.FromSeconds(30)); + + initialAccounts.Should().HaveCount(expectedAccountCount); + + // Act - Call initializer again (should be idempotent) + var initializer = GetService(); + await initializer.InitializeAsync(companyId); + + // Assert - Should still have the same number of accounts (not doubled) + var accountsAfterSecondInit = await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByCompanyIdAsync(companyId); + }); + + accountsAfterSecondInit.Should().HaveCount(expectedAccountCount, + "Calling initializer twice should not create duplicate accounts"); + } + + #region Danish Compliance: Chart of Accounts Structure Tests + + [Fact] + public async Task ChartOfAccounts_ContainsShareCapitalAccount() + { + // Arrange - Danish companies are required to have an equity account for share capital + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Share Capital Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check that Aktiekapital (3000) exists + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + return all.Any(a => a.AccountNumber == "3000") ? all.ToList() : null; + }, timeout: TimeSpan.FromSeconds(30)); + + var shareCapital = accounts!.FirstOrDefault(a => a.AccountNumber == "3000"); + shareCapital.Should().NotBeNull("Aktiekapital/Anpartskapital account (3000) is required per Selskabsloven"); + shareCapital!.AccountType.Should().Be("equity"); + shareCapital.IsSystemAccount.Should().BeTrue(); + } + + [Fact] + public async Task ChartOfAccounts_ContainsCorporateTaxAccount() + { + // Arrange - Danish companies need a liability account for corporate tax + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Corporate Tax Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check that Skyldig selskabsskat (7950) exists + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + return all.Any(a => a.AccountNumber == "7950") ? all.ToList() : null; + }, timeout: TimeSpan.FromSeconds(30)); + + var corporateTax = accounts!.FirstOrDefault(a => a.AccountNumber == "7950"); + corporateTax.Should().NotBeNull("Skyldig selskabsskat account (7950) is required per Selskabsloven"); + corporateTax!.AccountType.Should().Be("liability"); + corporateTax.IsSystemAccount.Should().BeTrue(); + } + + [Fact] + public async Task ChartOfAccounts_HasProperEquityRange() + { + // Arrange - Per Danish accounting standards, accounts 3000-3999 should only be Equity + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Equity Range Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check that all 3xxx accounts are Equity type + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + var equityRange = all.Where(a => + int.TryParse(a.AccountNumber, out var num) && num >= 3000 && num < 4000).ToList(); + return equityRange.Count >= 3 ? equityRange : null; + }, timeout: TimeSpan.FromSeconds(30)); + + accounts.Should().NotBeEmpty("3xxx range should have equity accounts"); + accounts.Should().OnlyContain( + a => a.AccountType == "equity", + "Per Danish accounting standards, 3xxx range should ONLY contain equity accounts"); + } + + [Fact] + public async Task ChartOfAccounts_ContainsFixedAssetAccounts() + { + // Arrange - Danish chart of accounts should include fixed asset accounts for depreciation + var graphqlClient = new GraphQLTestClient(Client); + + // Act + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name = "Fixed Assets Test" } }); + + createResponse.EnsureNoErrors(); + var companyId = createResponse.Data!.CreateCompany!.Id; + + // Assert - Check that fixed asset accounts exist (1710-1730) + var accounts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var all = await repo.GetByCompanyIdAsync(companyId); + var fixedAssets = all.Where(a => new[] { "1710", "1720", "1730" }.Contains(a.AccountNumber)).ToList(); + return fixedAssets.Count >= 3 ? all.ToList() : null; + }, timeout: TimeSpan.FromSeconds(30)); + + accounts.Should().Contain(a => a.AccountNumber == "1710", "Bygninger account required"); + accounts.Should().Contain(a => a.AccountNumber == "1720", "Maskiner og inventar account required"); + accounts.Should().Contain(a => a.AccountNumber == "1730", "Køretøjer account required"); + + // Also verify corresponding depreciation accounts exist + accounts.Should().Contain(a => a.AccountNumber == "8010", "Afskrivning, bygninger account required"); + accounts.Should().Contain(a => a.AccountNumber == "8020", "Afskrivning, maskiner account required"); + accounts.Should().Contain(a => a.AccountNumber == "8030", "Afskrivning, køretøjer account required"); + } + + #endregion + + // Response DTOs + private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } + private class AccountsResponse { public List Accounts { get; set; } = []; } + private class CompanyDto { public string Id { get; set; } = string.Empty; } + + private class AccountDto + { + public string Id { get; set; } = string.Empty; + public string AccountNumber { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string AccountType { get; set; } = string.Empty; + public string? VatCodeId { get; set; } + public bool IsSystemAccount { get; set; } + } +} diff --git a/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs b/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs new file mode 100644 index 0000000..2f350a5 --- /dev/null +++ b/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs @@ -0,0 +1,1102 @@ +using Books.Api.EventFlow.Repositories; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Books.Api.Tests.GraphQL; + +/// +/// Integration tests for FiscalYear GraphQL operations. +/// +[Trait("Category", "Integration")] +public class FiscalYearGraphQLTests(TestWebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + public async Task Query_FiscalYears_ReturnsEmptyList_WhenNoFiscalYearsExist() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Empty Test"); + + // Act + var response = await graphqlClient.QueryAsync(""" + query FiscalYears($companyId: ID!) { + fiscalYears(companyId: $companyId) { + id + name + } + } + """, + new { companyId }); + + // Assert + response.EnsureNoErrors(); + response.Data.Should().NotBeNull(); + response.Data!.FiscalYears.Should().BeEmpty(); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_CreatesFiscalYearSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Create Test"); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { + id + companyId + name + startDate + endDate + status + openingBalancePosted + } + } + """, + new + { + input = new + { + companyId, + name = "2024", + startDate = "2024-01-01T00:00:00", + endDate = "2025-01-01T00:00:00" // Exactly 12 months + } + }); + + // Assert + response.EnsureNoErrors(); + response.Data.Should().NotBeNull(); + response.Data!.CreateFiscalYear.Should().NotBeNull(); + response.Data.CreateFiscalYear!.Name.Should().Be("2024"); + response.Data.CreateFiscalYear.Status.Should().Be("open"); + response.Data.CreateFiscalYear.OpeningBalancePosted.Should().BeFalse(); + } + + [Fact] + public async Task Mutation_CloseFiscalYear_ClosesFiscalYearSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Close Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id status } + } + """, + new + { + input = new + { + companyId, + name = "2023", + startDate = "2023-01-01T00:00:00", + endDate = "2024-01-01T00:00:00" // Exactly 12 months + } + }); + + createResponse.EnsureNoErrors(); + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Act + var closeResponse = await graphqlClient.MutateAsync(""" + mutation CloseFiscalYear($id: ID!) { + closeFiscalYear(id: $id) { + id + status + closingDate + closedBy + } + } + """, + new { id = fiscalYearId }); + + // Assert + closeResponse.EnsureNoErrors(); + + var closedFiscalYear = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + closedFiscalYear.Should().NotBeNull(); + closedFiscalYear!.Status.Should().Be("closed"); + closedFiscalYear.ClosedBy.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Mutation_ReopenFiscalYear_ReopensFiscalYearSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Reopen Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2022", + startDate = "2022-01-01T00:00:00", + endDate = "2023-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Close first + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + // Act + var reopenResponse = await graphqlClient.MutateAsync(""" + mutation ReopenFiscalYear($id: ID!) { + reopenFiscalYear(id: $id) { + id + status + } + } + """, + new { id = fiscalYearId }); + + // Assert + reopenResponse.EnsureNoErrors(); + + var reopenedFiscalYear = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "open" ? fy : null; + }); + + reopenedFiscalYear.Should().NotBeNull(); + reopenedFiscalYear!.Status.Should().Be("open"); + } + + [Fact] + public async Task Mutation_LockFiscalYear_LocksFiscalYearSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Lock Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2021", + startDate = "2021-01-01T00:00:00", + endDate = "2022-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Must close before locking + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + // Act + var lockResponse = await graphqlClient.MutateAsync(""" + mutation LockFiscalYear($id: ID!) { + lockFiscalYear(id: $id) { + id + status + } + } + """, + new { id = fiscalYearId }); + + // Assert + lockResponse.EnsureNoErrors(); + + var lockedFiscalYear = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "locked" ? fy : null; + }); + + lockedFiscalYear.Should().NotBeNull(); + lockedFiscalYear!.Status.Should().Be("locked"); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_FailsWithInvalidDuration() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Invalid Duration Test"); + + // Act - Try to create fiscal year with only 3 months (must be 12 for standard, 6-18 for first/reorg) + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "Q1 2024", + startDate = "2024-01-01T00:00:00", + endDate = "2024-03-31T00:00:00" // Only 3 months - invalid + } + }); + + // Assert - Domain validation error is wrapped by GraphQL + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + var hasValidationError = response.Errors!.Any(e => + e.Message.Contains("INVALID_DURATION") || + e.Message.Contains("INVALID_STANDARD_DURATION") || + errorDetails.Contains("INVALID_DURATION") || + errorDetails.Contains("INVALID_STANDARD_DURATION") || + errorDetails.Contains("12 months")); + hasValidationError.Should().BeTrue("Expected an error related to invalid fiscal year duration"); + } + + [Fact] + public async Task Mutation_ReopenFiscalYear_FailsWhenLocked() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Cannot Reopen Locked Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2020", + startDate = "2020-01-01T00:00:00", + endDate = "2021-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Close and lock + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + await graphqlClient.MutateAsync( + $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "locked" ? fy : null; + }); + + // Act - Try to reopen locked fiscal year + var reopenResponse = await graphqlClient.MutateAsync(""" + mutation ReopenFiscalYear($id: ID!) { + reopenFiscalYear(id: $id) { id status } + } + """, + new { id = fiscalYearId }); + + // Assert - Domain validation error is wrapped by GraphQL + reopenResponse.HasErrors.Should().BeTrue(); + var errorDetails = reopenResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + var hasLockError = reopenResponse.Errors!.Any(e => + e.Message.Contains("FISCAL_YEAR_LOCKED") || + errorDetails.Contains("FISCAL_YEAR_LOCKED") || + errorDetails.Contains("Locked fiscal years cannot be reopened")); + hasLockError.Should().BeTrue("Expected an error related to locked fiscal year"); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_FailsWithOverlappingDates() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Overlap Test"); + + // Create first fiscal year (12 months as required for standard) + var firstResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2024", + startDate = "2024-01-01T00:00:00", + endDate = "2025-01-01T00:00:00" // Exactly 12 months + } + }); + + firstResponse.EnsureNoErrors(); + + // Wait for first fiscal year to be created + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(firstResponse.Data!.CreateFiscalYear!.Id); + }); + + // Act - Try to create overlapping fiscal year (also 12 months) + var overlapResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2024 Overlap", + startDate = "2024-06-01T00:00:00", + endDate = "2025-06-01T00:00:00" // Overlaps with first + } + }); + + // Assert + overlapResponse.HasErrors.Should().BeTrue(); + var errorDetails = overlapResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + var hasOverlapError = overlapResponse.Errors!.Any(e => + e.Message.Contains("OVERLAPPING_FISCAL_YEAR") || + errorDetails.Contains("OVERLAPPING_FISCAL_YEAR") || + errorDetails.Contains("overlaps")); + hasOverlapError.Should().BeTrue("Expected an error related to overlapping fiscal years"); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_IsDeterministic() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Deterministic Test"); + + // Act - Create same fiscal year twice + var firstResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2025", + startDate = "2025-01-01T00:00:00", + endDate = "2026-01-01T00:00:00" // Exactly 12 months + } + }); + + firstResponse.EnsureNoErrors(); + var firstId = firstResponse.Data!.CreateFiscalYear!.Id; + + // Second attempt should fail (due to existing fiscal year with same ID) + var secondResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2025 Second Attempt", + startDate = "2025-01-01T00:00:00", + endDate = "2025-12-31T00:00:00" + } + }); + + // Assert - Second attempt should fail with overlap error + // (because same dates = same deterministic ID AND overlap detection) + secondResponse.HasErrors.Should().BeTrue(); + } + + [Fact] + public async Task Mutation_LockFiscalYear_FailsWhenNotClosed() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Lock Open Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id status } + } + """, + new + { + input = new + { + companyId, + name = "2019", + startDate = "2019-01-01T00:00:00", + endDate = "2020-01-01T00:00:00" // Exactly 12 months + } + }); + + createResponse.EnsureNoErrors(); + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Act - Try to lock an open fiscal year (not closed) + var lockResponse = await graphqlClient.MutateAsync(""" + mutation LockFiscalYear($id: ID!) { + lockFiscalYear(id: $id) { id status } + } + """, + new { id = fiscalYearId }); + + // Assert + lockResponse.HasErrors.Should().BeTrue(); + var errorDetails = lockResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (lockResponse.Errors!.Any(e => e.Message.Contains("MUST_BE_CLOSED")) || + errorDetails.Contains("MUST_BE_CLOSED") || + errorDetails.Contains("must be closed")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_LockFiscalYear_FailsWhenAlreadyLocked() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Double Lock Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2018", + startDate = "2018-01-01T00:00:00", + endDate = "2019-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Close and lock first + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + await graphqlClient.MutateAsync( + $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "locked" ? fy : null; + }); + + // Act - Try to lock again + var lockResponse = await graphqlClient.MutateAsync(""" + mutation LockFiscalYear($id: ID!) { + lockFiscalYear(id: $id) { id status } + } + """, + new { id = fiscalYearId }); + + // Assert + lockResponse.HasErrors.Should().BeTrue(); + var errorDetails = lockResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (lockResponse.Errors!.Any(e => e.Message.Contains("ALREADY_LOCKED")) || + errorDetails.Contains("ALREADY_LOCKED") || + errorDetails.Contains("already locked")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_CloseFiscalYear_FailsWhenAlreadyClosed() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Double Close Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2017", + startDate = "2017-01-01T00:00:00", + endDate = "2018-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Close first + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + // Act - Try to close again + var closeResponse = await graphqlClient.MutateAsync(""" + mutation CloseFiscalYear($id: ID!) { + closeFiscalYear(id: $id) { id status } + } + """, + new { id = fiscalYearId }); + + // Assert + closeResponse.HasErrors.Should().BeTrue(); + var errorDetails = closeResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (closeResponse.Errors!.Any(e => e.Message.Contains("ALREADY_CLOSED")) || + errorDetails.Contains("ALREADY_CLOSED") || + errorDetails.Contains("already closed")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_FailsWhenEndDateBeforeStartDate() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Invalid Date Range Test"); + + // Act - Try to create fiscal year with end date before start date + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "Invalid Dates", + startDate = "2024-12-31T00:00:00", + endDate = "2024-01-01T00:00:00" // End before start + } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("INVALID_DATE_RANGE")) || + errorDetails.Contains("INVALID_DATE_RANGE") || + errorDetails.Contains("End date must be after start date")).Should().BeTrue(); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_AcceptsExactly6Months_WhenFirstFiscalYear() + { + // Arrange - Per Årsregnskabsloven §15, first fiscal year can be 6-18 months + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY 6 Months First Test"); + + // Act - Create fiscal year with exactly 6 months (minimum allowed for first year) + // Domain calculates: (endYear - startYear) * 12 + (endMonth - startMonth) + // Jan to Jul = (0 * 12) + (7 - 1) = 6 + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { + id + name + startDate + endDate + } + } + """, + new + { + input = new + { + companyId, + name = "First Year H1 2024", + startDate = "2024-01-01T00:00:00", + endDate = "2024-07-01T00:00:00", // 6 months by domain calculation + isFirstFiscalYear = true + } + }); + + // Assert - Should succeed because isFirstFiscalYear allows 6-18 months + response.EnsureNoErrors(); + response.Data!.CreateFiscalYear!.Name.Should().Be("First Year H1 2024"); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_AcceptsExactly18Months_WhenReorganization() + { + // Arrange - Per Årsregnskabsloven §15, reorganization allows up to 18 months + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY 18 Months Reorg Test"); + + // Act - Create fiscal year with exactly 18 months (maximum allowed for reorganization) + // Domain calculates: (endYear - startYear) * 12 + (endMonth - startMonth) + // Jan 2024 to Jul 2025 = (1 * 12) + (7 - 1) = 18 + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { + id + name + } + } + """, + new + { + input = new + { + companyId, + name = "Reorganization 2024-2025", + startDate = "2024-01-01T00:00:00", + endDate = "2025-07-01T00:00:00", // 18 months by domain calculation + isReorganization = true + } + }); + + // Assert - Should succeed because isReorganization allows 6-18 months + response.EnsureNoErrors(); + response.Data!.CreateFiscalYear!.Name.Should().Be("Reorganization 2024-2025"); + } + + #region Danish Compliance: Fiscal Year Duration Tests (Årsregnskabsloven §15) + + [Fact] + public async Task Mutation_CreateFiscalYear_RequiresExactly12MonthsForStandardYear() + { + // Arrange - Per Årsregnskabsloven §15, standard fiscal years must be 12 months + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Standard Duration Test"); + + // Act - Try to create 6-month fiscal year WITHOUT isFirstFiscalYear flag + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "Invalid Standard 6 Months", + startDate = "2024-01-01T00:00:00", + endDate = "2024-07-01T00:00:00" // 6 months - invalid for standard year + // NOT setting isFirstFiscalYear or isReorganization + } + }); + + // Assert - Should fail because standard years must be 12 months + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("INVALID_STANDARD_DURATION")) || + errorDetails.Contains("INVALID_STANDARD_DURATION") || + errorDetails.Contains("12 months")).Should().BeTrue( + "Standard fiscal year must be exactly 12 months per Årsregnskabsloven §15"); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_AllowsShorterForFirstYear() + { + // Arrange - Per Årsregnskabsloven §15, first fiscal year can be shorter than 12 months + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY First Year Shorter Test"); + + // Act - Create 9-month fiscal year WITH isFirstFiscalYear flag + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { + id + name + } + } + """, + new + { + input = new + { + companyId, + name = "First Year 9 Months", + startDate = "2024-04-01T00:00:00", + endDate = "2025-01-01T00:00:00", // 9 months + isFirstFiscalYear = true + } + }); + + // Assert - Should succeed because first year allows 6-18 months + response.EnsureNoErrors(); + response.Data!.CreateFiscalYear!.Name.Should().Be("First Year 9 Months"); + } + + [Fact] + public async Task Mutation_CreateFiscalYear_Allows18MonthsOnlyForReorganization() + { + // Arrange - Per Årsregnskabsloven §15, only reorganization allows up to 18 months + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY 18 Months Standard Test"); + + // Act - Try to create 18-month fiscal year WITHOUT flags + var response = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "Invalid Standard 18 Months", + startDate = "2024-01-01T00:00:00", + endDate = "2025-07-01T00:00:00" // 18 months - invalid for standard year + // NOT setting isFirstFiscalYear or isReorganization + } + }); + + // Assert - Should fail because standard years must be 12 months + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (response.Errors!.Any(e => e.Message.Contains("INVALID_STANDARD_DURATION")) || + errorDetails.Contains("INVALID_STANDARD_DURATION") || + errorDetails.Contains("12 months")).Should().BeTrue( + "Only reorganization allows 18 months per Årsregnskabsloven §15"); + } + + #endregion + + [Fact] + public async Task Mutation_ReopenFiscalYear_FailsWhenAlreadyOpen() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Reopen Already Open Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id status } + } + """, + new + { + input = new + { + companyId, + name = "2014", + startDate = "2014-01-01T00:00:00", + endDate = "2015-01-01T00:00:00" // Exactly 12 months + } + }); + + createResponse.EnsureNoErrors(); + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + createResponse.Data.CreateFiscalYear.Status.Should().Be("open"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Act - Try to reopen an already open fiscal year + var reopenResponse = await graphqlClient.MutateAsync(""" + mutation ReopenFiscalYear($id: ID!) { + reopenFiscalYear(id: $id) { id status } + } + """, + new { id = fiscalYearId }); + + // Assert + reopenResponse.HasErrors.Should().BeTrue(); + var errorDetails = reopenResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? ""; + (reopenResponse.Errors!.Any(e => e.Message.Contains("ALREADY_OPEN")) || + errorDetails.Contains("ALREADY_OPEN") || + errorDetails.Contains("already open")).Should().BeTrue(); + } + + [Fact] + public async Task Query_FiscalYear_ReturnsAuditFieldsAfterReopen() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Audit Reopen Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2016", + startDate = "2016-01-01T00:00:00", + endDate = "2017-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Close first + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + // Reopen + await graphqlClient.MutateAsync( + $"mutation {{ reopenFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + + // Act - Query to check audit fields + var queryResponse = await Eventually.GetAsync(async () => + { + var response = await graphqlClient.QueryAsync(""" + query FiscalYear($id: ID!) { + fiscalYear(id: $id) { + id + status + reopenedDate + reopenedBy + } + } + """, + new { id = fiscalYearId }); + + return response.Data?.FiscalYear?.ReopenedBy != null ? response : null; + }); + + // Assert + queryResponse.Should().NotBeNull(); + queryResponse!.Data!.FiscalYear!.Status.Should().Be("open"); + queryResponse.Data.FiscalYear.ReopenedBy.Should().NotBeNullOrEmpty(); + queryResponse.Data.FiscalYear.ReopenedDate.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Query_FiscalYear_ReturnsAuditFieldsAfterLock() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "FY Audit Lock Test"); + + var createResponse = await graphqlClient.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2015", + startDate = "2015-01-01T00:00:00", + endDate = "2016-01-01T00:00:00" // Exactly 12 months + } + }); + + var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + // Close and lock + await graphqlClient.MutateAsync( + $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var fy = await repo.GetByIdAsync(fiscalYearId); + return fy?.Status == "closed" ? fy : null; + }); + + await graphqlClient.MutateAsync( + $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}"); + + // Act - Query to check audit fields + var queryResponse = await Eventually.GetAsync(async () => + { + var response = await graphqlClient.QueryAsync(""" + query FiscalYear($id: ID!) { + fiscalYear(id: $id) { + id + status + lockedDate + lockedBy + } + } + """, + new { id = fiscalYearId }); + + return response.Data?.FiscalYear?.LockedBy != null ? response : null; + }); + + // Assert + queryResponse.Should().NotBeNull(); + queryResponse!.Data!.FiscalYear!.Status.Should().Be("locked"); + queryResponse.Data.FiscalYear.LockedBy.Should().NotBeNullOrEmpty(); + queryResponse.Data.FiscalYear.LockedDate.Should().NotBeNullOrEmpty(); + } + + private async Task CreateCompanyAsync(GraphQLTestClient client, string name) + { + var response = await client.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name } }); + + response.EnsureNoErrors(); + var companyId = response.Data!.CreateCompany!.Id; + + // Set the company ID header for subsequent requests + client.SetCompanyId(companyId); + + return companyId; + } + + // Response DTOs + private class FiscalYearsResponse { public List FiscalYears { get; set; } = []; } + private class FiscalYearResponse { public FiscalYearDto? FiscalYear { get; set; } } + private class FiscalYearQueryResponse { public FiscalYearDto? FiscalYear { get; set; } } + private class CreateFiscalYearResponse { public FiscalYearDto? CreateFiscalYear { get; set; } } + private class CloseFiscalYearResponse { public FiscalYearDto? CloseFiscalYear { get; set; } } + private class ReopenFiscalYearResponse { public FiscalYearDto? ReopenFiscalYear { get; set; } } + private class LockFiscalYearResponse { public FiscalYearDto? LockFiscalYear { get; set; } } + private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } + + private class FiscalYearDto + { + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string StartDate { get; set; } = string.Empty; + public string EndDate { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool OpeningBalancePosted { get; set; } + public string? ClosingDate { get; set; } + public string? ClosedBy { get; set; } + public string? ReopenedDate { get; set; } + public string? ReopenedBy { get; set; } + public string? LockedDate { get; set; } + public string? LockedBy { get; set; } + } + + private class CompanyDto { public string Id { get; set; } = string.Empty; } +} diff --git a/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs b/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs new file mode 100644 index 0000000..f7d232e --- /dev/null +++ b/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs @@ -0,0 +1,787 @@ +using Books.Api.EventFlow.Repositories; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Books.Api.Tests.GraphQL; + +/// +/// Integration tests for JournalEntryDraft (Kassekladde) GraphQL operations. +/// +[Trait("Category", "Integration")] +public class JournalEntryDraftGraphQLTests(TestWebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + #region Create Draft Tests + + [Fact] + public async Task Mutation_CreateJournalEntryDraft_CreatesSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Draft Test Company"); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation CreateDraft($input: CreateJournalEntryDraftInput!) { + createJournalEntryDraft(input: $input) { + id + companyId + name + status + createdBy + } + } + """, + new + { + input = new + { + companyId, + name = "Januar Udgifter" + } + }); + + // Assert + response.EnsureNoErrors(); + response.Data.Should().NotBeNull(); + response.Data!.CreateJournalEntryDraft.Should().NotBeNull(); + response.Data.CreateJournalEntryDraft!.Name.Should().Be("Januar Udgifter"); + response.Data.CreateJournalEntryDraft.Status.Should().Be("active"); + response.Data.CreateJournalEntryDraft.CompanyId.Should().Be(companyId); + response.Data.CreateJournalEntryDraft.Id.Should().StartWith("journalentrydraft-"); + } + + [Fact] + public async Task Mutation_CreateJournalEntryDraft_FailsWithoutCompanyHeader() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + // Note: We're NOT setting X-Company-Id header + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation CreateDraft($input: CreateJournalEntryDraftInput!) { + createJournalEntryDraft(input: $input) { + id + } + } + """, + new + { + input = new + { + companyId = "some-company-id", + name = "Test Draft" + } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + } + + [Fact] + public async Task Mutation_CreateJournalEntryDraft_FailsWithEmptyName() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Empty Name Test Company"); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation CreateDraft($input: CreateJournalEntryDraftInput!) { + createJournalEntryDraft(input: $input) { + id + } + } + """, + new + { + input = new + { + companyId, + name = " " // Whitespace only + } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("Draft name is required"); + } + + #endregion + + #region Update Draft Tests + + [Fact] + public async Task Mutation_UpdateJournalEntryDraft_UpdatesSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Update Draft Test Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Original Name"); + + // Wait for eventual consistency + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { + id + name + documentDate + description + lines { + lineNumber + accountId + debitAmount + creditAmount + description + } + } + } + """, + new + { + input = new + { + id = draftId, + name = "Updated Name", + documentDate = "2025-01-15", + description = "Test beskrivelse", + lines = new[] + { + new { lineNumber = 1, accountId = (string?)null, debitAmount = 1000m, creditAmount = 0m, description = "Debet linje" }, + new { lineNumber = 2, accountId = (string?)null, debitAmount = 0m, creditAmount = 1000m, description = "Kredit linje" } + } + } + }); + + // Assert + response.EnsureNoErrors(); + response.Data!.UpdateJournalEntryDraft!.Name.Should().Be("Updated Name"); + response.Data.UpdateJournalEntryDraft.Description.Should().Be("Test beskrivelse"); + response.Data.UpdateJournalEntryDraft.Lines.Should().HaveCount(2); + response.Data.UpdateJournalEntryDraft.Lines![0].DebitAmount.Should().Be(1000m); + response.Data.UpdateJournalEntryDraft.Lines[1].CreditAmount.Should().Be(1000m); + } + + [Fact] + public async Task Mutation_UpdateJournalEntryDraft_FailsForNonExistentDraft() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Non-existent Draft Company"); + var nonExistentId = $"journalentrydraft-{Guid.NewGuid():D}"; + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { + id + } + } + """, + new + { + input = new + { + id = nonExistentId, + name = "Test", + lines = Array.Empty() + } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("not found"); + } + + #endregion + + #region Query Tests + + [Fact] + public async Task Query_JournalEntryDrafts_ReturnsActiveDrafts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Query Drafts Test Company"); + + // Create multiple drafts + await CreateDraftAsync(graphqlClient, companyId, "Draft 1"); + await CreateDraftAsync(graphqlClient, companyId, "Draft 2"); + await CreateDraftAsync(graphqlClient, companyId, "Draft 3"); + + // Wait for eventual consistency + var drafts = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetActiveByCompanyIdAsync(companyId); + }, 3, timeout: TimeSpan.FromSeconds(10)); + + // Act + var response = await graphqlClient.QueryAsync(""" + query Drafts($companyId: ID!) { + journalEntryDrafts(companyId: $companyId) { + id + name + status + } + } + """, + new { companyId }); + + // Assert + response.EnsureNoErrors(); + response.Data!.JournalEntryDrafts.Should().HaveCount(3); + response.Data.JournalEntryDrafts.Should().AllSatisfy(d => d.Status.Should().Be("active")); + } + + [Fact] + public async Task Query_JournalEntryDraft_ReturnsSingleDraft() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Single Draft Query Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Query Test Draft"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Act + var response = await graphqlClient.QueryAsync(""" + query Draft($id: ID!) { + journalEntryDraft(id: $id) { + id + name + companyId + status + lines { + lineNumber + } + } + } + """, + new { id = draftId }); + + // Assert + response.EnsureNoErrors(); + response.Data!.JournalEntryDraft.Should().NotBeNull(); + response.Data.JournalEntryDraft!.Name.Should().Be("Query Test Draft"); + response.Data.JournalEntryDraft.CompanyId.Should().Be(companyId); + } + + [Fact] + public async Task Query_JournalEntryDrafts_FailsWithoutCompanyHeader() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + // Not setting company header + + // Act + var response = await graphqlClient.QueryAsync(""" + query Drafts($companyId: ID!) { + journalEntryDrafts(companyId: $companyId) { + id + } + } + """, + new { companyId = "some-id" }); + + // Assert + response.HasErrors.Should().BeTrue(); + } + + #endregion + + #region Discard Draft Tests + + [Fact] + public async Task Mutation_DiscardJournalEntryDraft_DiscardsSuccessfully() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Discard Draft Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "To Be Discarded"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation DiscardDraft($id: ID!) { + discardJournalEntryDraft(id: $id) { + id + status + } + } + """, + new { id = draftId }); + + // Assert + response.EnsureNoErrors(); + response.Data!.DiscardJournalEntryDraft!.Status.Should().Be("discarded"); + + // Verify it's no longer in active drafts + var activeDrafts = await Eventually.GetAsync(async () => + { + var repo = GetService(); + var drafts = await repo.GetActiveByCompanyIdAsync(companyId); + return drafts.Any(d => d.Id == draftId) ? null : drafts; + }); + + activeDrafts.Should().NotContain(d => d.Id == draftId); + } + + [Fact] + public async Task Mutation_DiscardJournalEntryDraft_FailsForAlreadyDiscarded() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Double Discard Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Discard Twice"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Discard once + await graphqlClient.MutateAsync(""" + mutation DiscardDraft($id: ID!) { + discardJournalEntryDraft(id: $id) { id status } + } + """, + new { id = draftId }); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var draft = await repo.GetByIdAsync(draftId); + return draft?.Status == "discarded" ? draft : null; + }); + + // Act - Try to discard again + var response = await graphqlClient.MutateAsync(""" + mutation DiscardDraft($id: ID!) { + discardJournalEntryDraft(id: $id) { id status } + } + """, + new { id = draftId }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("has been discarded"); + } + + #endregion + + #region Post Draft Tests + + [Fact] + public async Task Mutation_PostJournalEntryDraft_FailsWithUnbalancedLines() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Unbalanced Post Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Unbalanced Draft"); + + // Wait for standard accounts + var accounts = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetActiveByCompanyIdAsync(companyId); + }, 10, timeout: TimeSpan.FromSeconds(30)); + + var accountId1 = accounts.First().Id; + var accountId2 = accounts.Skip(1).First().Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Create fiscal year for posting + var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId); + + // Update with unbalanced lines + await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + id = draftId, + fiscalYearId, + lines = new[] + { + new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m }, + new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 500m } // Unbalanced! + } + } + }); + + await Task.Delay(500); // Wait for update + + // Act - Try to post unbalanced draft + var response = await graphqlClient.MutateAsync(""" + mutation PostDraft($id: ID!) { + postJournalEntryDraft(id: $id) { + id + status + transactionId + } + } + """, + new { id = draftId }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("must equal credits"); + } + + [Fact] + public async Task Mutation_PostJournalEntryDraft_FailsWithoutFiscalYear() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "No Fiscal Year Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "No Fiscal Year Draft"); + + // Wait for standard accounts + var accounts = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetActiveByCompanyIdAsync(companyId); + }, 10, timeout: TimeSpan.FromSeconds(30)); + + var accountId1 = accounts.First().Id; + var accountId2 = accounts.Skip(1).First().Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Update with balanced lines but NO fiscal year + await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + id = draftId, + // No fiscalYearId! + lines = new[] + { + new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m }, + new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 1000m } + } + } + }); + + await Task.Delay(500); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation PostDraft($id: ID!) { + postJournalEntryDraft(id: $id) { + id + status + } + } + """, + new { id = draftId }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("Fiscal year is required"); + } + + [Fact] + public async Task Mutation_PostJournalEntryDraft_FailsWithMissingAccounts() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Missing Account Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Missing Account Draft"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId); + + // Update with balanced lines but missing account IDs + await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + id = draftId, + fiscalYearId, + lines = new[] + { + new { lineNumber = 1, accountId = (string?)null, debitAmount = 1000m, creditAmount = 0m }, + new { lineNumber = 2, accountId = (string?)null, debitAmount = 0m, creditAmount = 1000m } + } + } + }); + + await Task.Delay(500); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation PostDraft($id: ID!) { + postJournalEntryDraft(id: $id) { + id + } + } + """, + new { id = draftId }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("must have an account"); + } + + [Fact(Skip = "Requires Ledger period setup which is complex; domain logic is tested in JournalEntryDraftAggregateTests")] + public async Task Mutation_UpdateJournalEntryDraft_FailsForPostedDraft() + { + // Arrange - Create and post a draft first + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Posted Update Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "To Be Posted Then Updated"); + + // Wait for accounts + var accounts = await Eventually.GetListAsync(async () => + { + var repo = GetService(); + return await repo.GetActiveByCompanyIdAsync(companyId); + }, 10, timeout: TimeSpan.FromSeconds(30)); + + var accountId1 = accounts.First().Id; + var accountId2 = accounts.Skip(1).First().Id; + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId); + + // Update draft with valid data for posting + await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + id = draftId, + fiscalYearId, + lines = new[] + { + new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m }, + new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 1000m } + } + } + }); + + await Task.Delay(500); + + // Post the draft + var postResponse = await graphqlClient.MutateAsync(""" + mutation PostDraft($id: ID!) { + postJournalEntryDraft(id: $id) { id status } + } + """, + new { id = draftId }); + + postResponse.EnsureNoErrors(); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var draft = await repo.GetByIdAsync(draftId); + return draft?.Status == "posted" ? draft : null; + }); + + // Act - Try to update the posted draft + var response = await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + id = draftId, + name = "Should Fail", + lines = Array.Empty() + } + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorInfo = string.Join(" ", response.Errors!.Select(e => + e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? ""))); + errorInfo.Should().Contain("already been posted"); + } + + #endregion + + #region Helper Methods + + private async Task CreateCompanyAsync(GraphQLTestClient client, string name) + { + var response = await client.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name } }); + + response.EnsureNoErrors(); + var companyId = response.Data!.CreateCompany!.Id; + client.SetCompanyId(companyId); + return companyId; + } + + private async Task CreateDraftAsync(GraphQLTestClient client, string companyId, string name) + { + var response = await client.MutateAsync(""" + mutation CreateDraft($input: CreateJournalEntryDraftInput!) { + createJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name + } + }); + + response.EnsureNoErrors(); + return response.Data!.CreateJournalEntryDraft!.Id; + } + + private async Task CreateFiscalYearAsync(GraphQLTestClient client, string companyId) + { + var response = await client.MutateAsync(""" + mutation CreateFiscalYear($input: CreateFiscalYearInput!) { + createFiscalYear(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name = "2025", + startDate = "2025-01-01T00:00:00", + endDate = "2025-12-31T00:00:00", + isFirstFiscalYear = true + } + }); + + response.EnsureNoErrors(); + var fiscalYearId = response.Data!.CreateFiscalYear!.Id; + + // Wait for fiscal year to be created + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(fiscalYearId); + }); + + return fiscalYearId; + } + + #endregion + + #region Response DTOs + + private class CreateDraftResponse { public DraftDto? CreateJournalEntryDraft { get; set; } } + private class UpdateDraftResponse { public DraftDto? UpdateJournalEntryDraft { get; set; } } + private class PostDraftResponse { public DraftDto? PostJournalEntryDraft { get; set; } } + private class DiscardDraftResponse { public DraftDto? DiscardJournalEntryDraft { get; set; } } + private class DraftsResponse { public List JournalEntryDrafts { get; set; } = []; } + private class SingleDraftResponse { public DraftDto? JournalEntryDraft { get; set; } } + private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } + private class CreateFiscalYearResponse { public FiscalYearDto? CreateFiscalYear { get; set; } } + + private class DraftDto + { + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? DocumentDate { get; set; } + public string? Description { get; set; } + public string? FiscalYearId { get; set; } + public string Status { get; set; } = string.Empty; + public string? TransactionId { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public List? Lines { get; set; } + } + + private class DraftLineDto + { + public int LineNumber { get; set; } + public string? AccountId { get; set; } + public decimal DebitAmount { get; set; } + public decimal CreditAmount { get; set; } + public string? Description { get; set; } + } + + private class CompanyDto { public string Id { get; set; } = string.Empty; } + private class FiscalYearDto { public string Id { get; set; } = string.Empty; } + + #endregion +} diff --git a/backend/Books.Api.Tests/Helpers/CvrGenerator.cs b/backend/Books.Api.Tests/Helpers/CvrGenerator.cs new file mode 100644 index 0000000..51253bc --- /dev/null +++ b/backend/Books.Api.Tests/Helpers/CvrGenerator.cs @@ -0,0 +1,51 @@ +namespace Books.Api.Tests.Helpers; + +/// +/// Generates valid Danish CVR numbers for testing. +/// +public static class CvrGenerator +{ + private static readonly int[] Weights = [2, 7, 6, 5, 4, 3, 2, 1]; + private static readonly Random Random = new(); + + /// + /// Generates a random valid CVR number with correct modulus 11 checksum. + /// + public static string Generate() + { + // Generate 7 random digits + var digits = new int[8]; + for (var i = 0; i < 7; i++) + { + digits[i] = Random.Next(0, 10); + } + + // Ensure first digit is not 0 (CVR numbers don't start with 0) + if (digits[0] == 0) digits[0] = Random.Next(1, 10); + + // Calculate checksum digit using modulus 11 + // Sum of (digit * weight) for first 7 digits + var sum = 0; + for (var i = 0; i < 7; i++) + { + sum += digits[i] * Weights[i]; + } + + // Find the last digit that makes sum % 11 == 0 + // We need: (sum + digit * 1) % 11 == 0 + // So: digit = (11 - (sum % 11)) % 11 + var remainder = sum % 11; + var checkDigit = (11 - remainder) % 11; + + // If checkDigit is 10, we can't use it (single digit only) + // So regenerate + if (checkDigit == 10) + { + return Generate(); + } + + digits[7] = checkDigit; + + return string.Join("", digits); + } +} diff --git a/backend/Books.Api.Tests/Infrastructure/IntegrationTestCollection.cs b/backend/Books.Api.Tests/Infrastructure/IntegrationTestCollection.cs new file mode 100644 index 0000000..6315f16 --- /dev/null +++ b/backend/Books.Api.Tests/Infrastructure/IntegrationTestCollection.cs @@ -0,0 +1,12 @@ +namespace Books.Api.Tests.Infrastructure; + +/// +/// Collection definition for integration tests. +/// All test classes decorated with [Collection(Name)] will run serially +/// to avoid Hangfire in-memory storage conflicts between parallel tests. +/// +[CollectionDefinition(Name)] +public class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "Integration"; +} diff --git a/backend/Books.Api.Tests/Infrastructure/TestAuthenticationHandler.cs b/backend/Books.Api.Tests/Infrastructure/TestAuthenticationHandler.cs new file mode 100644 index 0000000..555a76c --- /dev/null +++ b/backend/Books.Api.Tests/Infrastructure/TestAuthenticationHandler.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Books.Api.Tests.Infrastructure; + +/// +/// Test authentication handler that auto-authenticates all requests. +/// Used in integration tests to simulate an authenticated user. +/// +public class TestAuthenticationHandler : AuthenticationHandler +{ + public const string TestScheme = "TestScheme"; + public const string TestUserId = "test-user-001"; + public const string TestUserEmail = "test@example.com"; + public const string TestUserName = "Test User"; + + public TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, TestUserId), + new Claim(ClaimTypes.Email, TestUserEmail), + new Claim(ClaimTypes.Name, TestUserName), + new Claim(ClaimTypes.Role, "user"), + }; + + var identity = new ClaimsIdentity(claims, TestScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, TestScheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/backend/Books.Api.Tests/Integration/DocumentProcessingIntegrationTests.cs b/backend/Books.Api.Tests/Integration/DocumentProcessingIntegrationTests.cs new file mode 100644 index 0000000..137fa49 --- /dev/null +++ b/backend/Books.Api.Tests/Integration/DocumentProcessingIntegrationTests.cs @@ -0,0 +1,260 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using AwesomeAssertions; +using Books.Api.EventFlow.Repositories; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; + +namespace Books.Api.Tests.Integration; + +/// +/// Integration tests for document processing with AI Bookkeeper. +/// These tests require the AI Bookkeeper service to be running at localhost:8080. +/// Mark with [Trait("Category", "ExternalService")] to allow filtering. +/// +[Collection(IntegrationTestCollection.Name)] +[Trait("Category", "ExternalService")] +public class DocumentProcessingIntegrationTests : IntegrationTestBase +{ + private readonly GraphQLTestClient _graphqlClient; + + public DocumentProcessingIntegrationTests(TestWebApplicationFactory factory) : base(factory) + { + _graphqlClient = new GraphQLTestClient(Client); + } + + [Fact] + public async Task ProcessDocument_WithRealInvoice_ReturnsSuggestedJournalLines() + { + // Skip if test invoice doesn't exist + var invoicePath = GetTestInvoicePath(); + if (!File.Exists(invoicePath)) + { + Console.WriteLine($"SKIP: Test invoice not found at: {invoicePath}"); + return; + } + + // Arrange - Create company (which auto-creates chart of accounts) + var companyId = await CreateTestCompanyAsync(); + + // Wait for accounts to be created + await WaitForAccountsAsync(companyId); + + // Create HTTP client for REST API (not GraphQL) + using var httpClient = Factory.CreateClient(); + + // Create multipart form content with invoice + using var content = new MultipartFormDataContent(); + await using var fileStream = File.OpenRead(invoicePath); + var fileContent = new StreamContent(fileStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + content.Add(fileContent, "document", "test-invoice.pdf"); + + // Act - Call document processing endpoint + var response = await httpClient.PostAsync( + $"/api/documents/process?companyId={companyId}", + content); + + // Assert + var responseBody = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Response status: {response.StatusCode}"); + Console.WriteLine($"Response body: {responseBody}"); + + // If AI service is unavailable, show helpful message + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + var error = JsonSerializer.Deserialize(responseBody); + var code = error.GetProperty("code").GetString(); + if (code == "AI_UNAVAILABLE") + { + Console.WriteLine("\n*** AI Bookkeeper service is not running at localhost:8080 ***"); + Console.WriteLine("Start the AI service to run this test.\n"); + return; + } + } + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = JsonSerializer.Deserialize(responseBody); + + // Verify extraction data + result.TryGetProperty("extraction", out var extraction).Should().BeTrue("should have extraction data"); + extraction.TryGetProperty("vendor", out var vendorProp).Should().BeTrue("should extract vendor"); + extraction.TryGetProperty("amount", out var amount).Should().BeTrue("should extract amount"); + amount.GetDecimal().Should().BeGreaterThan(0, "amount should be positive"); + + // Verify suggested journal lines (the main thing we're testing!) + result.TryGetProperty("suggestedLines", out var suggestedLines).Should().BeTrue( + "should have suggestedLines - this is what we're testing!"); + + suggestedLines.GetArrayLength().Should().BeGreaterThanOrEqualTo(2, + "should have at least 2 lines (expense and creditor)"); + + // Verify lines are balanced + var totalDebits = 0m; + var totalCredits = 0m; + Console.WriteLine("\n=== Suggested Journal Lines ==="); + foreach (var line in suggestedLines.EnumerateArray()) + { + var accountNumber = line.TryGetProperty("accountNumber", out var an) ? an.GetString() : "-"; + var accountName = line.GetProperty("accountName").GetString(); + var debit = line.GetProperty("debitAmount").GetDecimal(); + var credit = line.GetProperty("creditAmount").GetDecimal(); + var vatCode = line.TryGetProperty("vatCode", out var vc) && vc.ValueKind != JsonValueKind.Null + ? vc.GetString() : null; + + totalDebits += debit; + totalCredits += credit; + + Console.WriteLine($" {accountNumber,-6} {accountName,-30} Debit: {debit,10:N2} Credit: {credit,10:N2} VAT: {vatCode ?? "-"}"); + } + + totalDebits.Should().Be(totalCredits, "journal entry must be balanced"); + + // Verify draft was created + result.TryGetProperty("draftId", out var draftId).Should().BeTrue(); + draftId.GetString().Should().NotBeNullOrEmpty(); + + Console.WriteLine($"\n=== Document Processing Result ==="); + Console.WriteLine($"Draft ID: {draftId.GetString()}"); + Console.WriteLine($"Vendor: {vendorProp.GetString()}"); + Console.WriteLine($"Total Amount: {amount.GetDecimal():N2}"); + Console.WriteLine($"Suggested Lines: {suggestedLines.GetArrayLength()}"); + Console.WriteLine($"Total Debits: {totalDebits:N2}"); + Console.WriteLine($"Total Credits: {totalCredits:N2}"); + Console.WriteLine($"BALANCED: {(totalDebits == totalCredits ? "YES" : "NO")}"); + } + + [Fact] + public async Task ProcessDocument_WithParkingReceipt_GeneratesSuggestionFromExtraction() + { + // This test uses a receipt to verify our fallback suggestion generation + // works when AI doesn't provide suggestedBooking + + var receiptPath = GetTestInvoicePath("parking_receipt_2582441883.pdf"); + if (!File.Exists(receiptPath)) + { + Console.WriteLine($"SKIP: Test receipt not found at: {receiptPath}"); + return; + } + + // Arrange + var companyId = await CreateTestCompanyAsync(); + await WaitForAccountsAsync(companyId); + + using var httpClient = Factory.CreateClient(); + using var content = new MultipartFormDataContent(); + await using var fileStream = File.OpenRead(receiptPath); + var fileContent = new StreamContent(fileStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + content.Add(fileContent, "document", "parking-receipt.pdf"); + + // Act + var response = await httpClient.PostAsync( + $"/api/documents/process?companyId={companyId}", + content); + + var responseBody = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Response: {responseBody}"); + + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + Console.WriteLine("\n*** AI Bookkeeper service is not running ***\n"); + return; + } + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = JsonSerializer.Deserialize(responseBody); + result.TryGetProperty("suggestedLines", out var lines).Should().BeTrue( + "should generate suggested lines from extraction data"); + + // Print the suggestions for manual verification + Console.WriteLine("\n=== Generated Suggestions (from extraction fallback) ==="); + foreach (var line in lines.EnumerateArray()) + { + var accountNumber = line.TryGetProperty("accountNumber", out var an) ? an.GetString() : "-"; + var accountName = line.GetProperty("accountName").GetString(); + var debit = line.GetProperty("debitAmount").GetDecimal(); + var credit = line.GetProperty("creditAmount").GetDecimal(); + var vatCode = line.TryGetProperty("vatCode", out var vc) && vc.ValueKind != JsonValueKind.Null + ? vc.GetString() : null; + + Console.WriteLine($" {accountNumber,-6} {accountName,-30} Debit: {debit,10:N2} Credit: {credit,10:N2} VAT: {vatCode ?? "-"}"); + } + } + + private async Task CreateTestCompanyAsync() + { + var createResponse = await _graphqlClient.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { + id + name + } + } + """, + new + { + input = new + { + name = $"AI Test Company {Guid.NewGuid():N}"[..30], + cvr = CvrGenerator.Generate() + } + }); + + createResponse.EnsureNoErrors(); + return createResponse.Data!.CreateCompany!.Id; + } + + private async Task WaitForAccountsAsync(string companyId) + { + // Wait for chart of accounts to be initialized (async event handler) + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var accounts = await repo.GetByCompanyIdAsync(companyId); + // Need at least the key accounts for booking: expense, VAT, creditor + return accounts.Count >= 50 ? accounts : null; + }, timeout: TimeSpan.FromSeconds(30)); + } + + private static string GetTestInvoicePath(string fileName = "invoice-F175490.pdf") + { + // Try to find test invoice relative to test assembly + var assemblyDir = Path.GetDirectoryName(typeof(DocumentProcessingIntegrationTests).Assembly.Location)!; + + // Navigate up to find the test-invoices folder + var searchPaths = new[] + { + Path.Combine(assemblyDir, "..", "..", "..", "..", "..", "..", "account-suggestions", "test-invoices", fileName), + Path.Combine(assemblyDir, "..", "..", "..", "..", "account-suggestions", "test-invoices", fileName), + $"/Users/nicolajhartmann/projects/books/account-suggestions/test-invoices/{fileName}" + }; + + foreach (var path in searchPaths) + { + var fullPath = Path.GetFullPath(path); + if (File.Exists(fullPath)) + { + return fullPath; + } + } + + return searchPaths[^1]; // Return last (absolute) path for error message + } + + // Response DTOs + private class CreateCompanyResponse + { + public CompanyDto? CreateCompany { get; set; } + } + + private class CompanyDto + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + } +} diff --git a/backend/Books.Api.Tests/Integration/EnableBankingClientTests.cs b/backend/Books.Api.Tests/Integration/EnableBankingClientTests.cs new file mode 100644 index 0000000..9ebac71 --- /dev/null +++ b/backend/Books.Api.Tests/Integration/EnableBankingClientTests.cs @@ -0,0 +1,100 @@ +using Books.Api.Banking; +using AwesomeAssertions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Books.Api.Tests.Integration; + +/// +/// Integration tests for Enable Banking API client. +/// These tests call the real Enable Banking API. +/// +[Trait("Category", "Integration")] +public class EnableBankingClientTests : IDisposable +{ + private readonly EnableBankingClient _client; + private readonly HttpClient _httpClient; + + public EnableBankingClientTests() + { + var options = new EnableBankingOptions + { + ApplicationId = "0bafa28d-41ea-4275-a4d5-221a78c72350", + KeyId = "0bafa28d-41ea-4275-a4d5-221a78c72350", + PrivateKey = File.ReadAllText(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "projects/ledger/private.key")) + }; + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.enablebanking.com") + }; + + _client = new EnableBankingClient(_httpClient, options, NullLogger.Instance); + } + + public void Dispose() + { + _client.Dispose(); + _httpClient.Dispose(); + } + + [Fact] + public async Task GetAspsps_ReturnsDanishBanks() + { + // Act + var banks = await _client.GetAspspsAsync("DK"); + + // Assert + banks.Should().NotBeEmpty(); + banks.Should().Contain(b => b.Name.Contains("Danske", StringComparison.OrdinalIgnoreCase)); + } + + [Fact(Skip = "Requires redirect URL to be registered in Enable Banking dashboard")] + public async Task StartAuthorization_WithRegisteredRedirectUrl_ReturnsAuthorizationUrl() + { + // Arrange - First get actual bank name from API + var banks = await _client.GetAspspsAsync("DK"); + var danskeBank = banks.FirstOrDefault(b => b.Name.Contains("Danske", StringComparison.OrdinalIgnoreCase)); + danskeBank.Should().NotBeNull("Danske Bank should exist in available banks"); + + // Note: This redirect URL must be registered in Enable Banking dashboard + // Configure at: https://enablebanking.com/dashboard + var redirectUrl = "https://your-registered-url.com/callback"; + var state = Guid.NewGuid().ToString(); + + // Act + var result = await _client.StartAuthorizationAsync( + aspspName: danskeBank!.Name, + redirectUrl: redirectUrl, + state: state, + psuType: "personal"); + + // Assert + result.Should().NotBeNull(); + result.AuthorizationId.Should().NotBeNullOrEmpty(); + result.Url.Should().StartWith("https://"); + } + + [Fact] + public async Task StartAuthorization_WithUnregisteredRedirectUrl_ThrowsExpectedError() + { + // Arrange + var banks = await _client.GetAspspsAsync("DK"); + var bank = banks.First(); + + var unregisteredRedirectUrl = "https://unregistered-domain.example.com/callback"; + var state = Guid.NewGuid().ToString(); + + // Act + var act = async () => await _client.StartAuthorizationAsync( + aspspName: bank.Name, + redirectUrl: unregisteredRedirectUrl, + state: state, + psuType: "personal"); + + // Assert - Should get REDIRECT_URI_NOT_ALLOWED error (proves API auth works) + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("REDIRECT_URI_NOT_ALLOWED"); + } +} diff --git a/backend/Books.Api.Tests/Integration/ReadModelRepopulationTests.cs b/backend/Books.Api.Tests/Integration/ReadModelRepopulationTests.cs new file mode 100644 index 0000000..913f154 --- /dev/null +++ b/backend/Books.Api.Tests/Integration/ReadModelRepopulationTests.cs @@ -0,0 +1,419 @@ +using Books.Api.EventFlow.Infrastructure; +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Books.Api.Tests.Helpers; +using Books.Api.Tests.Infrastructure; +using Dapper; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace Books.Api.Tests.Integration; + +/// +/// Integration tests for the read model auto-repair/repopulation system. +/// Tests verify that corrupt or out-of-sync read models can be repaired +/// by replaying events from the event store. +/// +[Trait("Category", "Integration")] +public class ReadModelRepopulationTests(TestWebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + [Fact] + public async Task RepopulateReadModel_FixesCorruptedStatus() + { + // Arrange: Create a draft through normal flow + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Repopulation Test Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Test Draft for Repopulation"); + + // Wait for read model to be created + var originalDraft = await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + originalDraft.Should().NotBeNull(); + originalDraft!.Status.Should().Be("active"); + + // Corrupt the read model: Set status to something wrong + await CorruptReadModelStatusAsync(draftId, "posted"); + + // Verify corruption + var corruptedDraft = await GetService().GetByIdAsync(draftId); + corruptedDraft!.Status.Should().Be("posted"); // Corrupted! + + // Act: Repopulate the read model + var response = await graphqlClient.MutateAsync(""" + mutation Repopulate($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } + """, + new + { + aggregateId = draftId, + readModelType = "JournalEntryDraftReadModel" + }); + + // Assert: Repopulation succeeded + response.EnsureNoErrors(); + response.Data!.RepopulateReadModel.Should().BeTrue(); + + // Verify read model is now fixed + var fixedDraft = await GetService().GetByIdAsync(draftId); + fixedDraft!.Status.Should().Be("active"); // Fixed! + } + + [Fact] + public async Task RepopulateReadModel_FixesMissingName() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Missing Name Test Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Original Draft Name"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Corrupt: Clear the name + await CorruptReadModelNameAsync(draftId, ""); + + var corruptedDraft = await GetService().GetByIdAsync(draftId); + corruptedDraft!.Name.Should().BeEmpty(); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation Repopulate($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } + """, + new + { + aggregateId = draftId, + readModelType = "JournalEntryDraftReadModel" + }); + + // Assert + response.EnsureNoErrors(); + var fixedDraft = await GetService().GetByIdAsync(draftId); + fixedDraft!.Name.Should().Be("Original Draft Name"); + } + + [Fact] + public async Task RepopulateReadModel_RestoresUpdatedData() + { + // Arrange: Create and update a draft + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Updated Data Test Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Initial Name"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Update the draft with new data + await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id name description } + } + """, + new + { + input = new + { + id = draftId, + name = "Updated Name", + description = "Test Description", + documentDate = "2025-01-15", + lines = Array.Empty() + } + }); + + // Wait for update + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var draft = await repo.GetByIdAsync(draftId); + return draft?.Name == "Updated Name" ? draft : null; + }); + + // Corrupt: Revert to old data + await CorruptReadModelNameAsync(draftId, "Wrong Name"); + await CorruptReadModelDescriptionAsync(draftId, "Wrong Description"); + + var corruptedDraft = await GetService().GetByIdAsync(draftId); + corruptedDraft!.Name.Should().Be("Wrong Name"); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation Repopulate($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } + """, + new + { + aggregateId = draftId, + readModelType = "JournalEntryDraftReadModel" + }); + + // Assert + response.EnsureNoErrors(); + var fixedDraft = await GetService().GetByIdAsync(draftId); + fixedDraft!.Name.Should().Be("Updated Name"); + fixedDraft.Description.Should().Be("Test Description"); + } + + [Fact] + public async Task RepopulateReadModel_FixesOutOfSyncSequenceNumber() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Sequence Number Test Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Sequence Test Draft"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Update draft multiple times to increase sequence number + for (int i = 0; i < 3; i++) + { + await graphqlClient.MutateAsync(""" + mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) { + updateJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + id = draftId, + name = $"Update {i + 1}", + lines = Array.Empty() + } + }); + await Task.Delay(100); + } + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + var draft = await repo.GetByIdAsync(draftId); + return draft?.Name == "Update 3" ? draft : null; + }); + + // Corrupt: Set sequence number to 1 (old) + await CorruptReadModelSequenceNumberAsync(draftId, 1); + await CorruptReadModelNameAsync(draftId, "Old Name"); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation Repopulate($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } + """, + new + { + aggregateId = draftId, + readModelType = "JournalEntryDraftReadModel" + }); + + // Assert + response.EnsureNoErrors(); + var fixedDraft = await GetService().GetByIdAsync(draftId); + fixedDraft!.Name.Should().Be("Update 3"); // Latest name from events + } + + [Fact] + public async Task RepopulateReadModel_FailsForNonExistentAggregate() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Non-existent Aggregate Company"); + var nonExistentId = $"journalentrydraft-{Guid.NewGuid():D}"; + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation Repopulate($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } + """, + new + { + aggregateId = nonExistentId, + readModelType = "JournalEntryDraftReadModel" + }); + + // Assert: Should fail or succeed without effect (no events to replay) + // The behavior depends on implementation - either error or silent success + // Since there are no events, it should complete but read model won't exist + if (!response.HasErrors) + { + var draft = await GetService().GetByIdAsync(nonExistentId); + draft.Should().BeNull(); + } + } + + [Fact] + public async Task RepopulateReadModel_FailsForInvalidReadModelType() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "Invalid Type Company"); + var draftId = await CreateDraftAsync(graphqlClient, companyId, "Invalid Type Test"); + + await Eventually.GetAsync(async () => + { + var repo = GetService(); + return await repo.GetByIdAsync(draftId); + }); + + // Act + var response = await graphqlClient.MutateAsync(""" + mutation Repopulate($aggregateId: String!, $readModelType: String!) { + repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType) + } + """, + new + { + aggregateId = draftId, + readModelType = "NonExistentReadModel" + }); + + // Assert + response.HasErrors.Should().BeTrue(); + var errorDetails = response.Errors! + .SelectMany(e => new[] { e.Message, e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "" }); + var errorInfo = string.Join(" ", errorDetails); + errorInfo.Should().Contain("Unknown read model type"); + } + + [Fact] + public async Task ListReadModelTypes_ReturnsAvailableTypes() + { + // Arrange + var graphqlClient = new GraphQLTestClient(Client); + var companyId = await CreateCompanyAsync(graphqlClient, "List Types Company"); + + // Act - listReadModelTypes is a mutation field (admin endpoint) + var response = await graphqlClient.MutateAsync(""" + mutation { + listReadModelTypes + } + """); + + // Assert + response.EnsureNoErrors(); + response.Data!.ListReadModelTypes.Should().Contain("JournalEntryDraftReadModel"); + response.Data.ListReadModelTypes.Should().Contain("AccountReadModel"); + response.Data.ListReadModelTypes.Should().Contain("FiscalYearReadModel"); + response.Data.ListReadModelTypes.Should().Contain("CompanyReadModel"); + } + + #region Database Corruption Helpers + + private async Task CorruptReadModelStatusAsync(string aggregateId, string newStatus) + { + var dataSource = GetService(); + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + "UPDATE journal_entry_draft_read_models SET status = @status WHERE aggregate_id = @id", + new { status = newStatus, id = aggregateId }); + } + + private async Task CorruptReadModelNameAsync(string aggregateId, string newName) + { + var dataSource = GetService(); + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + "UPDATE journal_entry_draft_read_models SET name = @name WHERE aggregate_id = @id", + new { name = newName, id = aggregateId }); + } + + private async Task CorruptReadModelDescriptionAsync(string aggregateId, string newDescription) + { + var dataSource = GetService(); + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + "UPDATE journal_entry_draft_read_models SET description = @description WHERE aggregate_id = @id", + new { description = newDescription, id = aggregateId }); + } + + private async Task CorruptReadModelSequenceNumberAsync(string aggregateId, int newSequenceNumber) + { + var dataSource = GetService(); + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + "UPDATE journal_entry_draft_read_models SET last_aggregate_sequence_number = @seq WHERE aggregate_id = @id", + new { seq = newSequenceNumber, id = aggregateId }); + } + + #endregion + + #region Helper Methods + + private async Task CreateCompanyAsync(GraphQLTestClient client, string name) + { + var response = await client.MutateAsync(""" + mutation CreateCompany($input: CreateCompanyInput!) { + createCompany(input: $input) { id } + } + """, + new { input = new { name } }); + + response.EnsureNoErrors(); + var companyId = response.Data!.CreateCompany!.Id; + client.SetCompanyId(companyId); + return companyId; + } + + private async Task CreateDraftAsync(GraphQLTestClient client, string companyId, string name) + { + var response = await client.MutateAsync(""" + mutation CreateDraft($input: CreateJournalEntryDraftInput!) { + createJournalEntryDraft(input: $input) { id } + } + """, + new + { + input = new + { + companyId, + name + } + }); + + response.EnsureNoErrors(); + return response.Data!.CreateJournalEntryDraft!.Id; + } + + #endregion + + #region Response DTOs + + private class RepopulateResponse { public bool RepopulateReadModel { get; set; } } + private class RepopulateNullableResponse { public bool? RepopulateReadModel { get; set; } } + private class ListTypesResponse { public List ListReadModelTypes { get; set; } = []; } + private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } } + private class CreateDraftResponse { public DraftDto? CreateJournalEntryDraft { get; set; } } + private class UpdateDraftResponse { public DraftDto? UpdateJournalEntryDraft { get; set; } } + + private class CompanyDto { public string Id { get; set; } = string.Empty; } + private class DraftDto + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string Status { get; set; } = string.Empty; + } + + #endregion +} diff --git a/backend/Books.Api.Tests/Invoicing/InvoicePdfGeneratorTests.cs b/backend/Books.Api.Tests/Invoicing/InvoicePdfGeneratorTests.cs new file mode 100644 index 0000000..95103e6 --- /dev/null +++ b/backend/Books.Api.Tests/Invoicing/InvoicePdfGeneratorTests.cs @@ -0,0 +1,484 @@ +using Books.Api.Invoicing.Pdf; +using AwesomeAssertions; +using QuestPDF.Infrastructure; + +namespace Books.Api.Tests.Invoicing; + +/// +/// Unit tests for the InvoicePdfGenerator. +/// +[Trait("Category", "Unit")] +public class InvoicePdfGeneratorTests +{ + private readonly InvoicePdfGenerator _generator; + + public InvoicePdfGeneratorTests() + { + // Ensure QuestPDF license is set for tests + QuestPDF.Settings.License = LicenseType.Community; + _generator = new InvoicePdfGenerator(); + } + + [Fact] + public void Generate_WithValidData_ProducesPdfBytes() + { + // Arrange + var data = CreateSampleInvoiceData(); + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + pdfBytes.Should().StartWith([0x25, 0x50, 0x44, 0x46]); // PDF magic bytes "%PDF" + } + + [Fact] + public void Generate_WithMultipleLines_ProducesPdf() + { + // Arrange + var data = CreateSampleInvoiceData(lineCount: 10); + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + pdfBytes.Length.Should().BeGreaterThan(1000); // PDF should be substantial + } + + [Fact] + public void Generate_WithSingleLine_ProducesPdf() + { + // Arrange + var data = CreateSampleInvoiceData(lineCount: 1); + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithDiscount_ProducesPdf() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + Lines = + [ + new InvoicePdfLineItem + { + LineNumber = 1, + Description = "Produkt med rabat", + Quantity = 10, + Unit = "stk", + UnitPrice = 100m, + DiscountPercent = 15m, + VatCode = "U25", + AmountExVat = 850m, + AmountVat = 212.50m, + AmountTotal = 1062.50m + } + ], + AmountExVat = 850m, + AmountVat = 212.50m, + AmountTotal = 1062.50m, + VatSummary = + [ + new VatSummaryLine + { + VatCode = "U25", + VatRate = 0.25m, + BasisAmount = 850m, + VatAmount = 212.50m + } + ] + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithMultipleVatCodes_ShowsVatSummary() + { + // Arrange + var data = CreateDataWithMixedVatCodes(); + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithDanishCharacters_HandlesEncoding() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + CompanyName = "Møller & Sønner ApS", + CustomerName = "Børge Åkeson", + Notes = "Tak for ordren! Vi ses igen. Æbler, øl og åben dør." + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithLongDescription_HandlesWrapping() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + Lines = + [ + new InvoicePdfLineItem + { + LineNumber = 1, + Description = "Dette er en meget lang beskrivelse af en ydelse der inkluderer mange detaljer om hvad der er leveret, hvornår det er leveret, og hvordan det er udført. Beskrivelsen fortsætter for at teste tekst-wrapping funktionaliteten i PDF generatoren.", + Quantity = 1, + Unit = "stk", + UnitPrice = 5000m, + DiscountPercent = 0, + VatCode = "U25", + AmountExVat = 5000m, + AmountVat = 1250m, + AmountTotal = 6250m + } + ], + AmountExVat = 5000m, + AmountVat = 1250m, + AmountTotal = 6250m, + VatSummary = + [ + new VatSummaryLine { VatCode = "U25", VatRate = 0.25m, BasisAmount = 5000m, VatAmount = 1250m } + ] + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithNotes_IncludesNotesSection() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + Notes = "Betalingsfrist overholdes venligst. Ved forsinket betaling beregnes morarenter." + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithReference_IncludesReference() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + Reference = "PO-2024-001234" + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithZeroVatLines_HandlesCorrectly() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + Lines = + [ + new InvoicePdfLineItem + { + LineNumber = 1, + Description = "EU-ydelse (momsfri)", + Quantity = 1, + Unit = "stk", + UnitPrice = 10000m, + DiscountPercent = 0, + VatCode = "UEU", + AmountExVat = 10000m, + AmountVat = 0m, + AmountTotal = 10000m + } + ], + AmountExVat = 10000m, + AmountVat = 0m, + AmountTotal = 10000m, + VatSummary = + [ + new VatSummaryLine { VatCode = "UEU", VatRate = 0m, BasisAmount = 10000m, VatAmount = 0m } + ] + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithDecimalQuantity_FormatsCorrectly() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + Lines = + [ + new InvoicePdfLineItem + { + LineNumber = 1, + Description = "Konsulentydelse", + Quantity = 7.5m, + Unit = "timer", + UnitPrice = 850m, + DiscountPercent = 0, + VatCode = "U25", + AmountExVat = 6375m, + AmountVat = 1593.75m, + AmountTotal = 7968.75m + } + ], + AmountExVat = 6375m, + AmountVat = 1593.75m, + AmountTotal = 7968.75m, + VatSummary = + [ + new VatSummaryLine { VatCode = "U25", VatRate = 0.25m, BasisAmount = 6375m, VatAmount = 1593.75m } + ] + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithBankDetails_IncludesPaymentInfo() + { + // Arrange + var data = CreateSampleInvoiceData() with + { + CompanyBankName = "Nordea", + CompanyBankRegNo = "2191", + CompanyBankAccountNo = "0012345678", + CompanyIban = "DK8920000012345678", + CompanyBic = "NDEADKKK" + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + pdfBytes.Should().StartWith([0x25, 0x50, 0x44, 0x46]); // PDF magic bytes + } + + [Fact] + public void Generate_WithPartialBankDetails_ProducesPdf() + { + // Arrange - Only Danish bank details, no IBAN/BIC + var data = CreateSampleInvoiceData() with + { + CompanyBankName = "Jyske Bank", + CompanyBankRegNo = "5064", + CompanyBankAccountNo = "1234567", + CompanyIban = null, + CompanyBic = null + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Generate_WithNoBankDetails_ProducesPdf() + { + // Arrange - No bank details at all + var data = CreateSampleInvoiceData() with + { + CompanyBankName = null, + CompanyBankRegNo = null, + CompanyBankAccountNo = null, + CompanyIban = null, + CompanyBic = null + }; + + // Act + var pdfBytes = _generator.Generate(data); + + // Assert + pdfBytes.Should().NotBeNullOrEmpty(); + } + + #region Test Data Helpers + + private static InvoicePdfData CreateSampleInvoiceData(int lineCount = 3) + { + var lines = Enumerable.Range(1, lineCount) + .Select(i => new InvoicePdfLineItem + { + LineNumber = i, + Description = $"Konsulentydelse - uge {i}", + Quantity = 40, + Unit = "timer", + UnitPrice = 850m, + DiscountPercent = 0, + VatCode = "U25", + AmountExVat = 34000m, + AmountVat = 8500m, + AmountTotal = 42500m + }) + .ToList(); + + var totalExVat = lines.Sum(l => l.AmountExVat); + var totalVat = lines.Sum(l => l.AmountVat); + var total = lines.Sum(l => l.AmountTotal); + + return new InvoicePdfData + { + CompanyName = "Test Firma ApS", + CompanyCvr = "12345678", + CompanyAddress = "Testvej 123", + CompanyPostalCode = "2100", + CompanyCity = "København Ø", + CompanyCountry = "DK", + + // Bank details + CompanyBankName = "Danske Bank", + CompanyBankRegNo = "3409", + CompanyBankAccountNo = "1234567890", + CompanyIban = "DK5000400440116243", + CompanyBic = "DABADKKK", + + CustomerName = "Kunde ApS", + CustomerCvr = "87654321", + CustomerAddress = "Kundevej 456", + CustomerPostalCode = "8000", + CustomerCity = "Aarhus C", + CustomerCountry = "DK", + CustomerNumber = "K-001", + + InvoiceNumber = "2024-0001", + InvoiceDate = new DateOnly(2024, 1, 15), + DueDate = new DateOnly(2024, 2, 14), + PaymentTermsDays = 30, + Currency = "DKK", + + Lines = lines, + AmountExVat = totalExVat, + AmountVat = totalVat, + AmountTotal = total, + VatSummary = + [ + new VatSummaryLine + { + VatCode = "U25", + VatRate = 0.25m, + BasisAmount = totalExVat, + VatAmount = totalVat + } + ] + }; + } + + private static InvoicePdfData CreateDataWithMixedVatCodes() + { + var lines = new List + { + new() + { + LineNumber = 1, + Description = "Dansk ydelse (25% moms)", + Quantity = 1, + Unit = "stk", + UnitPrice = 10000m, + DiscountPercent = 0, + VatCode = "U25", + AmountExVat = 10000m, + AmountVat = 2500m, + AmountTotal = 12500m + }, + new() + { + LineNumber = 2, + Description = "EU-ydelse (0% moms)", + Quantity = 1, + Unit = "stk", + UnitPrice = 5000m, + DiscountPercent = 0, + VatCode = "UEU", + AmountExVat = 5000m, + AmountVat = 0m, + AmountTotal = 5000m + } + }; + + return new InvoicePdfData + { + CompanyName = "Mixed VAT Company ApS", + CompanyCvr = "11111111", + CompanyAddress = "Momsvej 1", + CompanyPostalCode = "1000", + CompanyCity = "København K", + CompanyCountry = "DK", + + CustomerName = "EU Customer GmbH", + CustomerCvr = "DE123456789", + CustomerAddress = "Hauptstraße 1", + CustomerPostalCode = "10115", + CustomerCity = "Berlin", + CustomerCountry = "DE", + CustomerNumber = "K-EU-001", + + InvoiceNumber = "2024-0002", + InvoiceDate = new DateOnly(2024, 2, 1), + DueDate = new DateOnly(2024, 3, 1), + PaymentTermsDays = 30, + Currency = "DKK", + + Lines = lines, + AmountExVat = 15000m, + AmountVat = 2500m, + AmountTotal = 17500m, + VatSummary = + [ + new VatSummaryLine { VatCode = "U25", VatRate = 0.25m, BasisAmount = 10000m, VatAmount = 2500m }, + new VatSummaryLine { VatCode = "UEU", VatRate = 0m, BasisAmount = 5000m, VatAmount = 0m } + ] + }; + } + + #endregion +} diff --git a/backend/Books.Api.Tests/Reporting/VatReportServiceTests.cs b/backend/Books.Api.Tests/Reporting/VatReportServiceTests.cs new file mode 100644 index 0000000..9c4dd36 --- /dev/null +++ b/backend/Books.Api.Tests/Reporting/VatReportServiceTests.cs @@ -0,0 +1,368 @@ +using AutoFixture; +using AutoFixture.AutoMoq; +using AwesomeAssertions; +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Books.Api.Reporting; +using Ledger.Core.Models; +using Ledger.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Books.Api.Tests.Reporting; + +/// +/// Unit tests for VatReportService. +/// Tests VAT report generation from ledger data for SKAT compliance. +/// +[Trait("Category", "Unit")] +public class VatReportServiceTests +{ + private readonly IFixture _fixture; + private readonly Mock _accountRepository; + private readonly Mock _ledgerService; + private readonly VatReportService _sut; + + private const string CompanyId = "company-123"; + private const string InputVatAccountNumber = "5610"; + private const string OutputVatAccountNumber = "5611"; + + public VatReportServiceTests() + { + _fixture = new Fixture().Customize(new AutoMoqCustomization()); + _accountRepository = new Mock(); + _ledgerService = new Mock(); + _sut = new VatReportService( + _accountRepository.Object, + _ledgerService.Object, + NullLogger.Instance); + } + + #region Period Validation Tests + + [Fact] + public async Task GenerateReportAsync_PeriodEndBeforeStart_ThrowsArgumentException() + { + // Arrange + var periodStart = new DateOnly(2024, 3, 1); + var periodEnd = new DateOnly(2024, 2, 1); + + // Act + var act = () => _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*startdato*"); + } + + [Fact] + public async Task GenerateReportAsync_PeriodExceedsOneYear_ThrowsArgumentException() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2025, 2, 1); // 397 days + + // Act + var act = () => _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*366*"); + } + + [Fact] + public async Task GenerateReportAsync_ValidPeriod_DoesNotThrow() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 12, 31); // 365 days - valid + + SetupNoVatAccounts(); + SetupEmptyLedgerResponse(); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert - should not throw, returns empty report + result.Should().NotBeNull(); + } + + #endregion + + #region No VAT Accounts Tests + + [Fact] + public async Task GenerateReportAsync_NoVatAccounts_ReturnsEmptyReport() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 3, 31); + + SetupNoVatAccounts(); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + result.BoxA.Should().Be(0); + result.BoxB.Should().Be(0); + result.BoxC.Should().Be(0); + result.BoxD.Should().Be(0); + result.NetVat.Should().Be(0); + result.TransactionCount.Should().Be(0); + result.PeriodStart.Should().Be(periodStart); + result.PeriodEnd.Should().Be(periodEnd); + } + + #endregion + + #region Output VAT Tests + + [Fact] + public async Task GenerateReportAsync_OnlyOutputVat_ReturnsCorrectBoxA() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 3, 31); + var outputGuid = Guid.NewGuid(); + + SetupInputVatAccount(null); + SetupOutputVatAccount(outputGuid, "Salgsmoms"); + SetupLedgerResponse(new AccountPeriodBalance + { + AccountId = outputGuid, + TotalCredits = 25000m, + TotalDebits = 0m, + NetChange = -25000m, + EntryCount = 50, + Currency = "DKK" + }); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + result.BoxA.Should().Be(25000m); + result.BoxB.Should().Be(0); + result.TotalOutputVat.Should().Be(25000m); + result.TotalInputVat.Should().Be(0); + result.NetVat.Should().Be(25000m); + result.TransactionCount.Should().Be(50); + } + + #endregion + + #region Input VAT Tests + + [Fact] + public async Task GenerateReportAsync_OnlyInputVat_ReturnsCorrectBoxB() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 3, 31); + var inputGuid = Guid.NewGuid(); + + SetupInputVatAccount(inputGuid, "Købsmoms"); + SetupOutputVatAccount(null); + SetupLedgerResponse(new AccountPeriodBalance + { + AccountId = inputGuid, + TotalDebits = 15000m, + TotalCredits = 0m, + NetChange = 15000m, + EntryCount = 30, + Currency = "DKK" + }); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + result.BoxA.Should().Be(0); + result.BoxB.Should().Be(15000m); + result.TotalOutputVat.Should().Be(0); + result.TotalInputVat.Should().Be(15000m); + result.NetVat.Should().Be(-15000m); // Negative = refund + result.TransactionCount.Should().Be(30); + } + + #endregion + + #region Mixed VAT Tests + + [Fact] + public async Task GenerateReportAsync_BothInputAndOutputVat_CalculatesNetCorrectly() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 3, 31); + var inputGuid = Guid.NewGuid(); + var outputGuid = Guid.NewGuid(); + + SetupInputVatAccount(inputGuid, "Købsmoms"); + SetupOutputVatAccount(outputGuid, "Salgsmoms"); + SetupLedgerResponse( + new AccountPeriodBalance + { + AccountId = inputGuid, + TotalDebits = 10000m, + TotalCredits = 0m, + NetChange = 10000m, + EntryCount = 20, + Currency = "DKK" + }, + new AccountPeriodBalance + { + AccountId = outputGuid, + TotalCredits = 25000m, + TotalDebits = 0m, + NetChange = -25000m, + EntryCount = 40, + Currency = "DKK" + }); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + result.BoxA.Should().Be(25000m); + result.BoxB.Should().Be(10000m); + result.TotalOutputVat.Should().Be(25000m); + result.TotalInputVat.Should().Be(10000m); + result.NetVat.Should().Be(15000m); // 25000 - 10000 = to pay + result.TransactionCount.Should().Be(60); + } + + #endregion + + #region Basis Calculation Tests + + [Fact] + public async Task GenerateReportAsync_WithOutputVat_CalculatesBasis1From25PercentRate() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 3, 31); + var outputGuid = Guid.NewGuid(); + + SetupInputVatAccount(null); + SetupOutputVatAccount(outputGuid, "Salgsmoms"); + SetupLedgerResponse(new AccountPeriodBalance + { + AccountId = outputGuid, + TotalCredits = 2500m, // 25% VAT on 10000 + TotalDebits = 0m, + NetChange = -2500m, + EntryCount = 10, + Currency = "DKK" + }); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + result.BoxA.Should().Be(2500m); + result.Basis1.Should().Be(10000m); // 2500 / 0.25 = 10000 + } + + #endregion + + #region Empty Period Tests + + [Fact] + public async Task GenerateReportAsync_EmptyPeriod_ReturnsZeroAmounts() + { + // Arrange + var periodStart = new DateOnly(2024, 1, 1); + var periodEnd = new DateOnly(2024, 3, 31); + var inputGuid = Guid.NewGuid(); + var outputGuid = Guid.NewGuid(); + + SetupInputVatAccount(inputGuid, "Købsmoms"); + SetupOutputVatAccount(outputGuid, "Salgsmoms"); + SetupEmptyLedgerResponse(); + + // Act + var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd); + + // Assert + result.BoxA.Should().Be(0); + result.BoxB.Should().Be(0); + result.TransactionCount.Should().Be(0); + result.NetVat.Should().Be(0); + } + + #endregion + + #region Helper Methods + + private void SetupNoVatAccounts() + { + _accountRepository + .Setup(x => x.GetByCompanyAndNumberAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((AccountReadModelDto?)null); + } + + private void SetupEmptyLedgerResponse() + { + _ledgerService + .Setup(x => x.QueryEntriesAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EntriesQueryResult { Aggregates = [] }); + } + + private void SetupLedgerResponse(params AccountPeriodBalance[] balances) + { + _ledgerService + .Setup(x => x.QueryEntriesAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EntriesQueryResult { Aggregates = balances.ToList() }); + } + + private void SetupInputVatAccount(Guid? accountGuid, string name = "Købsmoms") + { + var account = accountGuid.HasValue + ? new AccountReadModelDto + { + Id = $"account-{accountGuid.Value}", + CompanyId = CompanyId, + AccountNumber = InputVatAccountNumber, + Name = name + } + : null; + + _accountRepository + .Setup(x => x.GetByCompanyAndNumberAsync( + CompanyId, + InputVatAccountNumber, + It.IsAny())) + .ReturnsAsync(account); + } + + private void SetupOutputVatAccount(Guid? accountGuid, string name = "Salgsmoms") + { + var account = accountGuid.HasValue + ? new AccountReadModelDto + { + Id = $"account-{accountGuid.Value}", + CompanyId = CompanyId, + AccountNumber = OutputVatAccountNumber, + Name = name + } + : null; + + _accountRepository + .Setup(x => x.GetByCompanyAndNumberAsync( + CompanyId, + OutputVatAccountNumber, + It.IsAny())) + .ReturnsAsync(account); + } + + #endregion +} diff --git a/backend/Books.Api/AiBookkeeper/AccountMappingService.cs b/backend/Books.Api/AiBookkeeper/AccountMappingService.cs new file mode 100644 index 0000000..cb14a0d --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/AccountMappingService.cs @@ -0,0 +1,126 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; + +namespace Books.Api.AiBookkeeper; + +/// +/// Maps standard account numbers to company-specific accounts. +/// Uses the standardAccountNumber field on accounts for matching. +/// +public class AccountMappingService( + IAccountRepository accountRepository, + ILogger logger) : IAccountMappingService +{ + public async Task FindByStandardAccountNumberAsync( + string companyId, + string standardAccountNumber, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(standardAccountNumber)) + { + return null; + } + + var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken); + + // First, try exact match on standardAccountNumber + var exactMatch = accounts.FirstOrDefault(a => + a.StandardAccountNumber != null && + a.StandardAccountNumber.Equals(standardAccountNumber, StringComparison.OrdinalIgnoreCase)); + + if (exactMatch != null) + { + logger.LogDebug( + "Found exact match for standard account {StandardAccount}: {AccountNumber} - {AccountName}", + standardAccountNumber, exactMatch.AccountNumber, exactMatch.Name); + return exactMatch; + } + + // Try prefix match (standard account numbers can be hierarchical) + var prefixMatch = accounts + .Where(a => + a.StandardAccountNumber != null && + standardAccountNumber.StartsWith(a.StandardAccountNumber, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(a => a.StandardAccountNumber?.Length ?? 0) + .FirstOrDefault(); + + if (prefixMatch != null) + { + logger.LogDebug( + "Found prefix match for standard account {StandardAccount}: {AccountNumber} - {AccountName}", + standardAccountNumber, prefixMatch.AccountNumber, prefixMatch.Name); + return prefixMatch; + } + + logger.LogDebug("No match found for standard account {StandardAccount}", standardAccountNumber); + return null; + } + + public async Task> MapSuggestedLinesAsync( + string companyId, + List suggestedLines, + CancellationToken cancellationToken = default) + { + var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken); + + // Index accounts by both StandardAccountNumber and direct AccountNumber + var accountsByStandardNumber = accounts + .Where(a => !string.IsNullOrEmpty(a.StandardAccountNumber)) + .GroupBy(a => a.StandardAccountNumber!) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var accountsByNumber = accounts + .ToDictionary(a => a.AccountNumber, a => a, StringComparer.OrdinalIgnoreCase); + + var result = new List(); + + foreach (var line in suggestedLines) + { + AccountReadModelDto? mappedAccount = null; + + if (!string.IsNullOrEmpty(line.StandardAccountNumber)) + { + // First, try direct account number match (AI service returns company account numbers) + if (accountsByNumber.TryGetValue(line.StandardAccountNumber, out var directMatch)) + { + mappedAccount = directMatch; + } + // Then try Erhvervsstyrelsen standard account number match + else if (accountsByStandardNumber.TryGetValue(line.StandardAccountNumber, out var exactMatch)) + { + mappedAccount = exactMatch; + } + else + { + // Try prefix match on standard numbers + mappedAccount = accountsByStandardNumber + .Where(kvp => line.StandardAccountNumber.StartsWith(kvp.Key, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(kvp => kvp.Key.Length) + .Select(kvp => kvp.Value) + .FirstOrDefault(); + } + } + + result.Add(new MappedSuggestedLine + { + Original = line, + MappedAccount = mappedAccount + }); + + if (mappedAccount != null) + { + logger.LogDebug( + "Mapped standard account {StandardAccount} to {AccountNumber} - {AccountName}", + line.StandardAccountNumber, mappedAccount.AccountNumber, mappedAccount.Name); + } + else + { + logger.LogDebug( + "Could not map standard account {StandardAccount}", + line.StandardAccountNumber); + } + } + + return result; + } +} diff --git a/backend/Books.Api/AiBookkeeper/AiBookkeeperClient.cs b/backend/Books.Api/AiBookkeeper/AiBookkeeperClient.cs new file mode 100644 index 0000000..594011d --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/AiBookkeeperClient.cs @@ -0,0 +1,340 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Books.Api.AiBookkeeper; + +/// +/// HTTP client for the AI Bookkeeper service. +/// +public class AiBookkeeperClient(HttpClient httpClient, ILogger logger) : IAiBookkeeperClient +{ + public async Task ProcessDocumentAsync( + Stream document, + string fileName, + string contentType, + ChartOfAccountsDto chartOfAccounts, + CancellationToken cancellationToken = default) + { + try + { + using var content = new MultipartFormDataContent(); + + // Add the document file + var documentContent = new StreamContent(document); + documentContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); + content.Add(documentContent, "Documents", fileName); + + // Convert chart of accounts to .toon format and add as file + var toonContent = ToonFormatConverter.ConvertToToon(chartOfAccounts); + if (!string.IsNullOrWhiteSpace(toonContent)) + { + var accountsBytes = System.Text.Encoding.UTF8.GetBytes(toonContent); + var accountsStream = new MemoryStream(accountsBytes); + var accountsContent = new StreamContent(accountsStream); + accountsContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + content.Add(accountsContent, "AccountsFile", "accounts.toon"); + } + + logger.LogInformation( + "Sending document {FileName} ({ContentType}) to AI Bookkeeper", + fileName, contentType); + + var response = await httpClient.PostAsync("/api/v1/bookkeeping/process", content, cancellationToken); + + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "AI Bookkeeper returned error {StatusCode}: {Body}", + response.StatusCode, responseBody); + + return new AiBookkeeperResponse + { + Success = false, + ErrorMessage = $"AI service returned {response.StatusCode}: {responseBody}" + }; + } + + logger.LogDebug("AI Bookkeeper response: {Response}", responseBody); + + return ParseResponse(responseBody); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "HTTP error calling AI Bookkeeper service"); + return new AiBookkeeperResponse + { + Success = false, + ErrorMessage = "AI-tjenesten er midlertidigt utilgængelig" + }; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, "Timeout calling AI Bookkeeper service"); + return new AiBookkeeperResponse + { + Success = false, + ErrorMessage = "AI-tjenesten svarede ikke i tide" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error calling AI Bookkeeper service"); + return new AiBookkeeperResponse + { + Success = false, + ErrorMessage = "Der opstod en uventet fejl ved dokumentanalyse" + }; + } + } + + private AiBookkeeperResponse ParseResponse(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var result = new AiBookkeeperResponse + { + Success = root.TryGetProperty("success", out var successProp) && successProp.GetBoolean(), + ErrorMessage = GetStringOrNull(root, "errorMessage") + }; + + // Parse extraction + if (root.TryGetProperty("extraction", out var extraction) && extraction.ValueKind == JsonValueKind.Object) + { + result.Extraction = new DocumentExtraction + { + DocumentType = GetStringOrNull(extraction, "documentType"), + Vendor = GetNestedStringOrDirect(extraction, "vendor", "name"), + VendorCvr = GetNestedStringOrDirect(extraction, "vendor", "cvr"), + InvoiceNumber = GetStringOrNull(extraction, "invoiceNumber"), + Date = ParseDateOnly(GetStringOrNull(extraction, "date") ?? GetStringOrNull(extraction, "invoiceDate")), + DueDate = ParseDateOnly(GetStringOrNull(extraction, "dueDate")), + TotalAmount = GetDecimalOrNull(extraction, "totalAmount") ?? GetDecimalOrNull(extraction, "total"), + AmountExVat = GetDecimalOrNull(extraction, "amountExVat") ?? GetDecimalOrNull(extraction, "subtotal"), + VatAmount = GetDecimalOrNull(extraction, "vatAmount") ?? GetDecimalOrNull(extraction, "vat"), + Currency = GetStringOrNull(extraction, "currency") ?? "DKK", + PaymentReference = GetStringOrNull(extraction, "paymentReference"), + RawText = GetStringOrNull(extraction, "rawText") + }; + + // Handle nested totals object (AI service may return totals as nested object) + if (extraction.TryGetProperty("totals", out var totals) && totals.ValueKind == JsonValueKind.Object) + { + result.Extraction.TotalAmount ??= GetDecimalOrNull(totals, "grandTotal"); + result.Extraction.AmountExVat ??= GetDecimalOrNull(totals, "subtotal"); + result.Extraction.VatAmount ??= GetDecimalOrNull(totals, "vatTotal"); + } + } + + // Parse suggested booking (full journal lines) or account suggestion (single account) + if (root.TryGetProperty("suggestedBooking", out var booking) && booking.ValueKind == JsonValueKind.Object) + { + result.Suggestion = ParseBookingSuggestion(booking); + } + + // Parse AI's single account suggestion (the smart recommendation) + AiAccountSuggestion? aiAccountSuggestion = null; + if (root.TryGetProperty("accountSuggestion", out var accountSuggestion) && accountSuggestion.ValueKind == JsonValueKind.Object) + { + aiAccountSuggestion = ParseAiAccountSuggestion(accountSuggestion); + } + + // Generate journal entry lines from extraction data, using AI's account suggestion if available + if ((result.Suggestion == null || result.Suggestion.Lines.Count == 0) && result.Extraction != null) + { + result.Suggestion = GenerateSuggestionFromExtraction(result.Extraction, aiAccountSuggestion); + } + + return result; + } + + /// + /// Parses the AI service's single account suggestion. + /// + private static AiAccountSuggestion? ParseAiAccountSuggestion(JsonElement element) + { + var accountNumber = GetStringOrNull(element, "accountNumber"); + if (string.IsNullOrEmpty(accountNumber)) + return null; + + return new AiAccountSuggestion + { + AccountNumber = accountNumber, + AccountName = GetStringOrNull(element, "accountName") ?? accountNumber, + VatCode = GetStringOrNull(element, "vatCode"), + Confidence = GetDecimalOrNull(element, "confidence") ?? 0.5m, + Reasoning = GetStringOrNull(element, "reasoning") + }; + } + + /// + /// Generates a booking suggestion from extraction data. + /// Uses AI's account suggestion for the expense line if available, otherwise falls back to generic Vareforbrug. + /// Creates standard Danish bookkeeping entries for expense documents. + /// + private static BookkeepingSuggestion? GenerateSuggestionFromExtraction( + DocumentExtraction extraction, + AiAccountSuggestion? aiSuggestion = null) + { + // Need at least a total amount to generate suggestion + if (!extraction.TotalAmount.HasValue || extraction.TotalAmount.Value <= 0) + return null; + + // Use AI's confidence if available, otherwise lower confidence for fallback + var overallConfidence = aiSuggestion?.Confidence ?? 0.7m; + + var suggestion = new BookkeepingSuggestion + { + Description = extraction.Vendor ?? "Udgift", + Confidence = overallConfidence, + Lines = [] + }; + + var totalAmount = extraction.TotalAmount.Value; + var amountExVat = extraction.AmountExVat ?? totalAmount; + var vatAmount = extraction.VatAmount ?? 0; + + // Determine VAT code: use AI suggestion's VAT code, or calculate from amounts + string? vatCode = aiSuggestion?.VatCode; + if (vatCode == null && vatAmount > 0 && amountExVat > 0) + { + var vatRate = vatAmount / amountExVat; + vatCode = vatRate >= 0.24m ? "I25" : null; // 25% indgaaende moms + } + + // Expense line (debit) - Use AI's account suggestion if available + // AI suggests specific account (e.g., 6080 Parkering, 7240 Telefoni) + // Otherwise fall back to Erhvervsstyrelsen standard 1610 = Varekøb (maps to account 2000) + var expenseAccountNumber = aiSuggestion?.AccountNumber ?? "1610"; + var expenseAccountName = aiSuggestion?.AccountName ?? "Vareforbrug"; + var expenseConfidence = aiSuggestion?.Confidence ?? 0.6m; + + suggestion.Lines.Add(new SuggestedLine + { + StandardAccountNumber = expenseAccountNumber, + AccountName = expenseAccountName, + DebitAmount = amountExVat, + CreditAmount = 0, + VatCode = vatCode, + Confidence = expenseConfidence + }); + + // VAT line (debit to reduce liability / credit if balance is positive) + // Erhvervsstyrelsen standard 7680 = Anden gæld til SKAT (maps to company account 7900 Skyldig moms) + // For input VAT (indgående moms), we debit the moms account + if (vatAmount > 0) + { + suggestion.Lines.Add(new SuggestedLine + { + StandardAccountNumber = "7680", + AccountName = "Skyldig moms", + DebitAmount = vatAmount, + CreditAmount = 0, + VatCode = null, + Confidence = 0.9m + }); + } + + // Creditor line (credit) + // Erhvervsstyrelsen standard 7350 = Leverandører (maps to company account 6900) + suggestion.Lines.Add(new SuggestedLine + { + StandardAccountNumber = "7350", + AccountName = "Kreditorer", + DebitAmount = 0, + CreditAmount = totalAmount, + VatCode = null, + Confidence = 0.8m + }); + + return suggestion; + } + + private static BookkeepingSuggestion ParseBookingSuggestion(JsonElement element) + { + var suggestion = new BookkeepingSuggestion + { + Description = GetStringOrNull(element, "description"), + Confidence = GetDecimalOrNull(element, "confidence") ?? 0 + }; + + // Parse lines from various possible properties + JsonElement? linesElement = null; + if (element.TryGetProperty("lines", out var lines)) + linesElement = lines; + else if (element.TryGetProperty("entries", out var entries)) + linesElement = entries; + + if (linesElement.HasValue && linesElement.Value.ValueKind == JsonValueKind.Array) + { + foreach (var line in linesElement.Value.EnumerateArray()) + { + suggestion.Lines.Add(new SuggestedLine + { + StandardAccountNumber = GetStringOrNull(line, "standardAccountNumber") ?? GetStringOrNull(line, "accountNumber"), + AccountName = GetStringOrNull(line, "accountName") ?? GetStringOrNull(line, "account"), + DebitAmount = GetDecimalOrNull(line, "debit") ?? GetDecimalOrNull(line, "debitAmount") ?? 0, + CreditAmount = GetDecimalOrNull(line, "credit") ?? GetDecimalOrNull(line, "creditAmount") ?? 0, + VatCode = GetStringOrNull(line, "vatCode"), + Confidence = GetDecimalOrNull(line, "confidence") ?? 0 + }); + } + } + + return suggestion; + } + + private static string? GetStringOrNull(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop)) + { + return prop.ValueKind switch + { + JsonValueKind.String => prop.GetString(), + JsonValueKind.Number => prop.GetRawText(), + JsonValueKind.Null => null, + _ => prop.GetRawText() + }; + } + return null; + } + + private static string? GetNestedStringOrDirect(JsonElement element, string propertyName, string nestedPropertyName) + { + if (element.TryGetProperty(propertyName, out var prop)) + { + if (prop.ValueKind == JsonValueKind.String) + return prop.GetString(); + if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty(nestedPropertyName, out var nested)) + return nested.ValueKind == JsonValueKind.String ? nested.GetString() : null; + } + return null; + } + + private static decimal? GetDecimalOrNull(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number) + { + return prop.GetDecimal(); + } + return null; + } + + private static DateOnly? ParseDateOnly(string? dateStr) + { + if (string.IsNullOrEmpty(dateStr)) + return null; + + // Try various formats + if (DateOnly.TryParse(dateStr, out var date)) + return date; + + // Try parsing just the date part if it contains time + if (DateTime.TryParse(dateStr, out var dateTime)) + return DateOnly.FromDateTime(dateTime); + + return null; + } +} diff --git a/backend/Books.Api/AiBookkeeper/AiBookkeeperOptions.cs b/backend/Books.Api/AiBookkeeper/AiBookkeeperOptions.cs new file mode 100644 index 0000000..f66907f --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/AiBookkeeperOptions.cs @@ -0,0 +1,24 @@ +namespace Books.Api.AiBookkeeper; + +/// +/// Configuration options for the AI Bookkeeper service. +/// +public class AiBookkeeperOptions +{ + public const string ConfigurationSection = "AiBookkeeper"; + + /// + /// Base URL for the AI Bookkeeper API. + /// + public string BaseUrl { get; set; } = "https://ai-bookkeeper.softwarehuset.com"; + + /// + /// API key for authentication. + /// + public string ApiKey { get; set; } = string.Empty; + + /// + /// Request timeout in seconds. + /// + public int TimeoutSeconds { get; set; } = 60; +} diff --git a/backend/Books.Api/AiBookkeeper/BankTransactionMatcher.cs b/backend/Books.Api/AiBookkeeper/BankTransactionMatcher.cs new file mode 100644 index 0000000..0f6129c --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/BankTransactionMatcher.cs @@ -0,0 +1,96 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; + +namespace Books.Api.AiBookkeeper; + +/// +/// Matches documents to pending bank transactions based on amount. +/// +public class BankTransactionMatcher( + IBankTransactionRepository transactionRepository, + ILogger logger) : IBankTransactionMatcher +{ + public async Task FindMatchingTransactionAsync( + string companyId, + decimal amount, + decimal tolerance = 0.01m, + CancellationToken cancellationToken = default) + { + // For invoice/expense: document shows positive amount, but bank transaction is negative (money leaving) + // We need to match on the absolute value + var targetAmount = amount; + + logger.LogDebug( + "Searching for pending transaction matching amount {Amount} (±{Tolerance}) for company {CompanyId}", + targetAmount, tolerance, companyId); + + var pendingTransactions = await transactionRepository.GetPendingByCompanyIdAsync(companyId, cancellationToken); + + if (pendingTransactions.Count == 0) + { + logger.LogDebug("No pending transactions found for company {CompanyId}", companyId); + return null; + } + + // Find transactions matching the amount within tolerance + // For expenses: document amount is positive, bank amount is negative + // So we compare: |bank amount| matches document amount + var matchingTransactions = pendingTransactions + .Where(t => IsAmountMatch(t.Amount, targetAmount, tolerance)) + .OrderBy(t => t.TransactionDate) // Oldest first + .ThenBy(t => t.CreatedAt) + .ToList(); + + if (matchingTransactions.Count == 0) + { + logger.LogDebug( + "No matching transactions found for amount {Amount} (checked {Count} pending)", + targetAmount, pendingTransactions.Count); + return null; + } + + var match = matchingTransactions.First(); + logger.LogInformation( + "Found matching transaction {TransactionId}: {Amount} on {Date} ({Description})", + match.Id, match.Amount, match.TransactionDate.ToString("yyyy-MM-dd"), match.Description); + + if (matchingTransactions.Count > 1) + { + logger.LogDebug( + "Multiple matches found ({Count}), returning oldest", + matchingTransactions.Count); + } + + return match; + } + + private static bool IsAmountMatch(decimal bankAmount, decimal documentAmount, decimal tolerance) + { + // For expenses: document amount is typically positive (e.g., invoice for 1000 DKK) + // Bank transaction is negative (e.g., -1000 DKK outgoing payment) + // So we need to check if the absolute values match + + // Option 1: Exact match on absolute values (most common for invoice matching) + var absBank = Math.Abs(bankAmount); + var absDoc = Math.Abs(documentAmount); + + if (Math.Abs(absBank - absDoc) <= tolerance) + { + return true; + } + + // Option 2: Direct match (same sign and value) + if (Math.Abs(bankAmount - documentAmount) <= tolerance) + { + return true; + } + + // Option 3: Opposite sign match (document positive, bank negative or vice versa) + if (Math.Abs(bankAmount + documentAmount) <= tolerance) + { + return true; + } + + return false; + } +} diff --git a/backend/Books.Api/AiBookkeeper/ChartOfAccountsDto.cs b/backend/Books.Api/AiBookkeeper/ChartOfAccountsDto.cs new file mode 100644 index 0000000..1d178fa --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/ChartOfAccountsDto.cs @@ -0,0 +1,23 @@ +namespace Books.Api.AiBookkeeper; + +/// +/// Chart of accounts data for AI Bookkeeper processing. +/// Contains the list of expense accounts that AI can suggest for document booking. +/// +public class ChartOfAccountsDto +{ + public required string CompanyId { get; init; } + public required IReadOnlyList Accounts { get; init; } +} + +/// +/// Account information for AI Bookkeeper. +/// +public class AiAccountDto +{ + public required string AccountNumber { get; init; } + public required string Name { get; init; } + public required string AccountType { get; init; } + public string? VatCodeId { get; init; } + public string? StandardAccountNumber { get; init; } +} diff --git a/backend/Books.Api/AiBookkeeper/ChartOfAccountsProvider.cs b/backend/Books.Api/AiBookkeeper/ChartOfAccountsProvider.cs new file mode 100644 index 0000000..111a266 --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/ChartOfAccountsProvider.cs @@ -0,0 +1,36 @@ +using Books.Api.EventFlow.Repositories; + +namespace Books.Api.AiBookkeeper; + +/// +/// Provides chart of accounts data for AI Bookkeeper processing. +/// Fetches accounts from repository and filters to expense-type accounts. +/// +public class ChartOfAccountsProvider(IAccountRepository accountRepository) : IChartOfAccountsProvider +{ + public async Task GetChartOfAccountsAsync( + string companyId, CancellationToken cancellationToken = default) + { + var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken); + + // Filter to expense-type accounts that AI can suggest + var expenseAccounts = accounts + .Where(a => a.AccountType is "expense" or "cogs" or "personnel" or "financial") + .OrderBy(a => a.AccountNumber) + .Select(a => new AiAccountDto + { + AccountNumber = a.AccountNumber, + Name = a.Name, + AccountType = a.AccountType, + VatCodeId = a.VatCodeId, + StandardAccountNumber = a.StandardAccountNumber + }) + .ToList(); + + return new ChartOfAccountsDto + { + CompanyId = companyId, + Accounts = expenseAccounts + }; + } +} diff --git a/backend/Books.Api/AiBookkeeper/IAccountMappingService.cs b/backend/Books.Api/AiBookkeeper/IAccountMappingService.cs new file mode 100644 index 0000000..1600eb2 --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/IAccountMappingService.cs @@ -0,0 +1,54 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.AiBookkeeper; + +/// +/// Maps standard account numbers (from Erhvervsstyrelsen) to company-specific accounts. +/// +public interface IAccountMappingService +{ + /// + /// Find the company's account that matches a standard account number. + /// + /// Company ID + /// Standard account number from Erhvervsstyrelsen + /// Cancellation token + /// The matching account, or null if not found + Task FindByStandardAccountNumberAsync( + string companyId, + string standardAccountNumber, + CancellationToken cancellationToken = default); + + /// + /// Map AI-suggested lines to company accounts. + /// + /// Company ID + /// Lines from AI suggestion + /// Cancellation token + /// Mapped lines with company account IDs + Task> MapSuggestedLinesAsync( + string companyId, + List suggestedLines, + CancellationToken cancellationToken = default); +} + +/// +/// A suggested line with the mapped company account. +/// +public class MappedSuggestedLine +{ + /// + /// Original suggested line from AI. + /// + public required SuggestedLine Original { get; init; } + + /// + /// Mapped company account (if found). + /// + public AccountReadModelDto? MappedAccount { get; init; } + + /// + /// Whether the line was successfully mapped to a company account. + /// + public bool IsMapped => MappedAccount != null; +} diff --git a/backend/Books.Api/AiBookkeeper/IAiBookkeeperClient.cs b/backend/Books.Api/AiBookkeeper/IAiBookkeeperClient.cs new file mode 100644 index 0000000..b24d3fc --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/IAiBookkeeperClient.cs @@ -0,0 +1,246 @@ +using System.Text.Json.Serialization; + +namespace Books.Api.AiBookkeeper; + +/// +/// Client for the AI Bookkeeper service that analyzes documents +/// and suggests bookkeeping entries. +/// +public interface IAiBookkeeperClient +{ + /// + /// Process a document (invoice, receipt, etc.) and get AI-suggested bookkeeping. + /// + /// The document file stream + /// Original filename + /// MIME type of the document + /// Chart of accounts data + /// Cancellation token + /// AI extraction and suggested bookkeeping + Task ProcessDocumentAsync( + Stream document, + string fileName, + string contentType, + ChartOfAccountsDto chartOfAccounts, + CancellationToken cancellationToken = default); +} + +/// +/// Response from AI Bookkeeper document processing. +/// +public class AiBookkeeperResponse +{ + /// + /// Whether the document was successfully analyzed. + /// + public bool Success { get; set; } + + /// + /// Error message if processing failed. + /// + public string? ErrorMessage { get; set; } + + /// + /// Extracted document information. + /// + public DocumentExtraction? Extraction { get; set; } + + /// + /// Suggested bookkeeping entry. + /// + public BookkeepingSuggestion? Suggestion { get; set; } +} + +/// +/// Extracted information from the document. +/// +public class DocumentExtraction +{ + /// + /// Detected document type (invoice, receipt, credit_note, etc.) + /// + [JsonPropertyName("documentType")] + public string? DocumentType { get; set; } + + /// + /// Vendor/supplier name. + /// + [JsonPropertyName("vendor")] + public string? Vendor { get; set; } + + /// + /// Vendor CVR number (Danish company registration). + /// + [JsonPropertyName("vendorCvr")] + public string? VendorCvr { get; set; } + + /// + /// Invoice/receipt number. + /// + [JsonPropertyName("invoiceNumber")] + public string? InvoiceNumber { get; set; } + + /// + /// Document date. + /// + [JsonPropertyName("date")] + public DateOnly? Date { get; set; } + + /// + /// Due date (for invoices). + /// + [JsonPropertyName("dueDate")] + public DateOnly? DueDate { get; set; } + + /// + /// Total amount including VAT. + /// + [JsonPropertyName("totalAmount")] + public decimal? TotalAmount { get; set; } + + /// + /// Amount excluding VAT. + /// + [JsonPropertyName("amountExVat")] + public decimal? AmountExVat { get; set; } + + /// + /// VAT amount. + /// + [JsonPropertyName("vatAmount")] + public decimal? VatAmount { get; set; } + + /// + /// Currency code (default DKK). + /// + [JsonPropertyName("currency")] + public string Currency { get; set; } = "DKK"; + + /// + /// Extracted line items. + /// + [JsonPropertyName("lineItems")] + public List LineItems { get; set; } = []; + + /// + /// Payment reference (FIK, girocard number, etc.) + /// + [JsonPropertyName("paymentReference")] + public string? PaymentReference { get; set; } + + /// + /// Raw text extracted from the document. + /// + [JsonPropertyName("rawText")] + public string? RawText { get; set; } +} + +/// +/// Extracted line item from invoice/receipt. +/// +public class ExtractedLineItem +{ + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("quantity")] + public decimal? Quantity { get; set; } + + [JsonPropertyName("unitPrice")] + public decimal? UnitPrice { get; set; } + + [JsonPropertyName("amount")] + public decimal? Amount { get; set; } + + [JsonPropertyName("vatRate")] + public decimal? VatRate { get; set; } +} + +/// +/// AI-suggested bookkeeping entry. +/// +public class BookkeepingSuggestion +{ + /// + /// Suggested description for the journal entry. + /// + public string? Description { get; set; } + + /// + /// Suggested account lines. + /// + public List Lines { get; set; } = []; + + /// + /// Overall confidence in the suggestion (0.0 - 1.0). + /// + public decimal Confidence { get; set; } +} + +/// +/// A suggested bookkeeping line with standard account number. +/// +public class SuggestedLine +{ + /// + /// Standard account number from Erhvervsstyrelsen. + /// + public string? StandardAccountNumber { get; set; } + + /// + /// Suggested account name/description. + /// + public string? AccountName { get; set; } + + /// + /// Debit amount. + /// + public decimal DebitAmount { get; set; } + + /// + /// Credit amount. + /// + public decimal CreditAmount { get; set; } + + /// + /// VAT code (I25, U25, etc.) + /// + public string? VatCode { get; set; } + + /// + /// Confidence in this specific line (0.0 - 1.0). + /// + public decimal Confidence { get; set; } +} + +/// +/// AI service's single account suggestion. +/// The AI analyzes the document and suggests the most appropriate expense account. +/// +public class AiAccountSuggestion +{ + /// + /// Company account number suggested by AI (e.g., "6080" for parking). + /// + public required string AccountNumber { get; init; } + + /// + /// Account name (e.g., "Parkering (gulplade)"). + /// + public required string AccountName { get; init; } + + /// + /// VAT code for this account (e.g., "I25" for 25% input VAT). + /// + public string? VatCode { get; init; } + + /// + /// Confidence in the suggestion (0.0 - 1.0). + /// + public decimal Confidence { get; init; } + + /// + /// AI's reasoning for why this account was chosen. + /// + public string? Reasoning { get; init; } +} diff --git a/backend/Books.Api/AiBookkeeper/IBankTransactionMatcher.cs b/backend/Books.Api/AiBookkeeper/IBankTransactionMatcher.cs new file mode 100644 index 0000000..de0ff0e --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/IBankTransactionMatcher.cs @@ -0,0 +1,25 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.AiBookkeeper; + +/// +/// Matches documents to pending bank transactions based on amount. +/// +public interface IBankTransactionMatcher +{ + /// + /// Find the oldest pending bank transaction matching the given amount. + /// For expenses (negative amounts), matches transactions where the bank amount is negative. + /// For income (positive amounts), matches transactions where the bank amount is positive. + /// + /// Company ID + /// Document amount (positive for income, negative for expense) + /// Amount tolerance (default ±0.01) + /// Cancellation token + /// Matching bank transaction, or null if not found + Task FindMatchingTransactionAsync( + string companyId, + decimal amount, + decimal tolerance = 0.01m, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/AiBookkeeper/IChartOfAccountsProvider.cs b/backend/Books.Api/AiBookkeeper/IChartOfAccountsProvider.cs new file mode 100644 index 0000000..be4925c --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/IChartOfAccountsProvider.cs @@ -0,0 +1,17 @@ +namespace Books.Api.AiBookkeeper; + +/// +/// Provides chart of accounts data for AI Bookkeeper processing. +/// +public interface IChartOfAccountsProvider +{ + /// + /// Get the chart of accounts for a company. + /// Returns only expense-type accounts (expense, cogs, personnel, financial) that AI can suggest. + /// + /// Company ID + /// Cancellation token + /// Chart of accounts with filtered expense accounts + Task GetChartOfAccountsAsync( + string companyId, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/AiBookkeeper/StubAiBookkeeperClient.cs b/backend/Books.Api/AiBookkeeper/StubAiBookkeeperClient.cs new file mode 100644 index 0000000..35cbea7 --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/StubAiBookkeeperClient.cs @@ -0,0 +1,22 @@ +namespace Books.Api.AiBookkeeper; + +/// +/// Stub implementation of IAiBookkeeperClient used when the API key is not configured. +/// Returns an error message indicating the service is unavailable. +/// +public class StubAiBookkeeperClient : IAiBookkeeperClient +{ + public Task ProcessDocumentAsync( + Stream document, + string fileName, + string contentType, + ChartOfAccountsDto chartOfAccounts, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new AiBookkeeperResponse + { + Success = false, + ErrorMessage = "AI Bookkeeper er ikke konfigureret. Kontakt administrator." + }); + } +} diff --git a/backend/Books.Api/AiBookkeeper/ToonFormatConverter.cs b/backend/Books.Api/AiBookkeeper/ToonFormatConverter.cs new file mode 100644 index 0000000..694c201 --- /dev/null +++ b/backend/Books.Api/AiBookkeeper/ToonFormatConverter.cs @@ -0,0 +1,115 @@ +using System.Text; + +namespace Books.Api.AiBookkeeper; + +/// +/// Converts ChartOfAccountsDto to .toon format for AI Bookkeeper. +/// The .toon format is a structured text format with meta and accounts sections. +/// +public static class ToonFormatConverter +{ + /// + /// Convert a chart of accounts to .toon format string. + /// + public static string ConvertToToon(ChartOfAccountsDto chartOfAccounts) + { + var sb = new StringBuilder(); + + // Header comment + sb.AppendLine("# Chart of Accounts for AI Bookkeeper"); + sb.AppendLine(); + + // Meta section + sb.AppendLine("meta:"); + sb.AppendLine(" source: Books API"); + sb.AppendLine($" organizationId: {chartOfAccounts.CompanyId}"); + sb.AppendLine(" accountType: expense"); + sb.AppendLine($" totalAccounts: {chartOfAccounts.Accounts.Count}"); + sb.AppendLine(); + + // Accounts section + // Format: number,name,category,vatCode,region,vatRubric,suggestions + sb.AppendLine($"accounts[{chartOfAccounts.Accounts.Count}]{{number,name,category,vatCode,region,vatRubric,suggestions}}:"); + + foreach (var account in chartOfAccounts.Accounts) + { + var vatCode = MapVatCode(account.VatCodeId); + var region = DetermineRegion(account.VatCodeId); + var category = MapCategory(account.AccountType); + var suggestions = GenerateSuggestions(account.Name, account.AccountNumber); + + // Format: number,name,category,vatCode,region,vatRubric,suggestions + sb.AppendLine($" {account.AccountNumber},{EscapeCommas(account.Name)},{category},{vatCode},{region},,{suggestions}"); + } + + return sb.ToString(); + } + + public static string MapVatCode(string? vatCodeId) + { + if (string.IsNullOrEmpty(vatCodeId)) + return ""; + + return vatCodeId.ToUpperInvariant() switch + { + "I25" => "I25", + "U25" => "", // Output VAT not relevant for expense accounts + "INGEN" => "", + _ => vatCodeId + }; + } + + public static string DetermineRegion(string? vatCodeId) + { + if (string.IsNullOrEmpty(vatCodeId)) + return ""; + + return vatCodeId.ToUpperInvariant() switch + { + "IEUV" => "EU", // EU goods + "IEUY" => "EU", // EU services + "IVV" => "WORLD", // World goods + "IVY" => "WORLD", // World services + _ => "" // Empty = available for all regions + }; + } + + public static string MapCategory(string accountType) + { + return accountType switch + { + "expense" => "Administrationsomkostninger", + "cogs" => "Variable omkostninger", + "personnel" => "Lønomkostninger", + "financial" => "Renteudgifter", + _ => "Øvrige omkostninger" + }; + } + + public static string GenerateSuggestions(string name, string accountNumber) + { + // Generate search keywords from account name + var suggestions = new List(); + + // Add words from name (lowercase, no special chars) + var words = name.ToLowerInvariant() + .Replace(",", " ") + .Replace(".", " ") + .Replace("-", " ") + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(w => w.Length > 2); + + suggestions.AddRange(words); + + // Add account number as suggestion + suggestions.Add(accountNumber); + + return string.Join("|", suggestions.Distinct()); + } + + private static string EscapeCommas(string value) + { + // The .toon format uses commas as delimiters, so we need to handle commas in values + return value.Replace(",", " "); + } +} diff --git a/backend/Books.Api/Authorization/CompanyAccessService.cs b/backend/Books.Api/Authorization/CompanyAccessService.cs new file mode 100644 index 0000000..25e9522 --- /dev/null +++ b/backend/Books.Api/Authorization/CompanyAccessService.cs @@ -0,0 +1,130 @@ +using Books.Api.Domain.UserAccess; +using Books.Api.EventFlow.Repositories; + +namespace Books.Api.Authorization; + +/// +/// Service for checking company access permissions. +/// +public interface ICompanyAccessService +{ + /// + /// Check if the current user has access to the specified company with the required role. + /// Throws UnauthorizedAccessException if access is denied. + /// + Task RequireAccessAsync( + string companyId, + CompanyRole minimumRole, + CancellationToken cancellationToken = default); + + /// + /// Check if the current user has access to the specified company. + /// Returns the access DTO if granted, null if denied. + /// + Task GetAccessAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Get all companies the current user has access to. + /// + Task> GetUserCompaniesAsync( + CancellationToken cancellationToken = default); + + /// + /// Check if the current user has write access (Owner or Accountant) to the company. + /// + Task CanWriteAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Check if the current user can manage users for the company (Owner only). + /// + Task CanManageUsersAsync( + string companyId, + CancellationToken cancellationToken = default); +} + +public class CompanyAccessService( + IHttpContextAccessor httpContextAccessor, + IUserCompanyAccessRepository accessRepository) : ICompanyAccessService +{ + public async Task RequireAccessAsync( + string companyId, + CompanyRole minimumRole, + CancellationToken cancellationToken = default) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + throw new UnauthorizedAccessException("User is not authenticated"); + } + + var hasAccess = await accessRepository.HasAccessAsync(userId, companyId, minimumRole, cancellationToken); + if (!hasAccess) + { + var roleDescription = minimumRole switch + { + CompanyRole.Owner => "ejer", + CompanyRole.Accountant => "bogholder", + CompanyRole.Viewer => "læser", + _ => "ukendt" + }; + + throw new Domain.DomainException( + "ACCESS_DENIED", + $"You do not have {minimumRole} access to this company", + $"Du har ikke {roleDescription}-adgang til denne virksomhed"); + } + } + + public async Task GetAccessAsync( + string companyId, + CancellationToken cancellationToken = default) + { + var userId = GetCurrentUserId(); + if (userId == null) return null; + + return await accessRepository.GetAccessAsync(userId, companyId, cancellationToken); + } + + public async Task> GetUserCompaniesAsync( + CancellationToken cancellationToken = default) + { + var userId = GetCurrentUserId(); + if (userId == null) return []; + + return await accessRepository.GetByUserIdAsync(userId, cancellationToken); + } + + public async Task CanWriteAsync( + string companyId, + CancellationToken cancellationToken = default) + { + var userId = GetCurrentUserId(); + if (userId == null) return false; + + return await accessRepository.HasAccessAsync(userId, companyId, CompanyRole.Accountant, cancellationToken); + } + + public async Task CanManageUsersAsync( + string companyId, + CancellationToken cancellationToken = default) + { + var userId = GetCurrentUserId(); + if (userId == null) return false; + + return await accessRepository.HasAccessAsync(userId, companyId, CompanyRole.Owner, cancellationToken); + } + + private string? GetCurrentUserId() + { + var user = httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) return null; + + // For API keys, use the API key ID as the user ID + // For OIDC users, use the NameIdentifier claim (Keycloak user ID) + return user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + } +} diff --git a/backend/Books.Api/Authorization/CompanyContext.cs b/backend/Books.Api/Authorization/CompanyContext.cs new file mode 100644 index 0000000..7ea624c --- /dev/null +++ b/backend/Books.Api/Authorization/CompanyContext.cs @@ -0,0 +1,82 @@ +using Books.Api.Domain.UserAccess; + +namespace Books.Api.Authorization; + +/// +/// Context for the currently selected company in a request. +/// Extracted from X-Company-Id header and validated against user access. +/// +public class CompanyContext +{ + /// + /// The currently selected company ID. + /// + public string? CompanyId { get; init; } + + /// + /// The user's role for the selected company. + /// + public CompanyRole? Role { get; init; } + + /// + /// Whether a valid company is selected and the user has access. + /// + public bool HasCompanySelected => CompanyId != null && Role != null; + + /// + /// Check if the user has at least the required role for the selected company. + /// + public bool HasRole(CompanyRole minimumRole) + { + if (Role == null) return false; + + return minimumRole switch + { + CompanyRole.Viewer => true, + CompanyRole.Accountant => Role is CompanyRole.Owner or CompanyRole.Accountant, + CompanyRole.Owner => Role is CompanyRole.Owner, + _ => false + }; + } + + /// + /// Require the user to have at least the specified role. + /// Throws DomainException if not. + /// + public void RequireRole(CompanyRole minimumRole) + { + if (!HasCompanySelected) + { + throw new Domain.DomainException( + "NO_COMPANY_SELECTED", + "No company selected. Set X-Company-Id header.", + "Ingen virksomhed valgt. Sæt X-Company-Id header."); + } + + if (!HasRole(minimumRole)) + { + var roleDescription = minimumRole switch + { + CompanyRole.Owner => "ejer", + CompanyRole.Accountant => "bogholder", + CompanyRole.Viewer => "læser", + _ => "ukendt" + }; + + throw new Domain.DomainException( + "INSUFFICIENT_PERMISSIONS", + $"This operation requires {minimumRole} role", + $"Denne handling kræver {roleDescription}-rolle"); + } + } + + /// + /// Require that the user can write (Owner or Accountant). + /// + public void RequireWrite() => RequireRole(CompanyRole.Accountant); + + /// + /// Require that the user is Owner. + /// + public void RequireOwner() => RequireRole(CompanyRole.Owner); +} diff --git a/backend/Books.Api/Authorization/CompanyContextMiddleware.cs b/backend/Books.Api/Authorization/CompanyContextMiddleware.cs new file mode 100644 index 0000000..6844733 --- /dev/null +++ b/backend/Books.Api/Authorization/CompanyContextMiddleware.cs @@ -0,0 +1,72 @@ +using Books.Api.EventFlow.Repositories; + +namespace Books.Api.Authorization; + +/// +/// Middleware that extracts the X-Company-Id header and validates user access. +/// Stores the result in HttpContext.Items for use by GraphQL resolvers. +/// +public class CompanyContextMiddleware(RequestDelegate next) +{ + public const string HeaderName = "X-Company-Id"; + public const string ContextKey = "CompanyContext"; + + public async Task InvokeAsync(HttpContext context, IUserCompanyAccessRepository accessRepository) + { + var companyContext = new CompanyContext(); + + // Only process if user is authenticated + if (context.User.Identity?.IsAuthenticated == true) + { + var userId = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + + if (userId != null && context.Request.Headers.TryGetValue(HeaderName, out var companyIdHeader)) + { + var companyId = companyIdHeader.ToString(); + + if (!string.IsNullOrWhiteSpace(companyId)) + { + // Validate user has access to this company + var access = await accessRepository.GetAccessAsync(userId, companyId); + + if (access != null) + { + companyContext = new CompanyContext + { + CompanyId = companyId, + Role = access.Role + }; + } + } + } + } + + // Store in HttpContext.Items for resolvers to access + context.Items[ContextKey] = companyContext; + + await next(context); + } +} + +/// +/// Extension methods for accessing CompanyContext. +/// +public static class CompanyContextExtensions +{ + /// + /// Get the CompanyContext from HttpContext. + /// + public static CompanyContext GetCompanyContext(this HttpContext context) + { + return context.Items[CompanyContextMiddleware.ContextKey] as CompanyContext + ?? new CompanyContext(); + } + + /// + /// Add the CompanyContext middleware to the pipeline. + /// + public static IApplicationBuilder UseCompanyContext(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} diff --git a/backend/Books.Api/Banking/BankTransactionSyncJob.cs b/backend/Books.Api/Banking/BankTransactionSyncJob.cs new file mode 100644 index 0000000..d814243 --- /dev/null +++ b/backend/Books.Api/Banking/BankTransactionSyncJob.cs @@ -0,0 +1,221 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Hangfire; + +namespace Books.Api.Banking; + +/// +/// Hangfire job for syncing bank transactions from Enable Banking. +/// Runs every 30 minutes to fetch new transactions for all active bank connections. +/// +public class BankTransactionSyncJob( + IBankConnectionRepository connectionRepository, + IBankTransactionRepository transactionRepository, + IEnableBankingClient bankingClient, + ILogger logger) +{ + /// + /// Sync all active bank connections for all companies. + /// Called by Hangfire recurring job. + /// + [DisableConcurrentExecution(timeoutInSeconds: 300)] + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 120, 300])] + public async Task SyncAllActiveConnectionsAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Starting bank transaction sync for all active connections"); + + var result = new BankTransactionSyncResult(); + + try + { + // Get all companies with active connections + // Note: This is a simplified approach - in production you might want to + // iterate through companies more efficiently + var connections = await GetAllActiveConnectionsAsync(cancellationToken); + result.TotalConnections = connections.Count; + + foreach (var connection in connections) + { + try + { + var connectionResult = await SyncConnectionAsync(connection, cancellationToken); + result.TotalAccounts += connectionResult.TotalAccounts; + result.NewTransactions += connectionResult.NewTransactions; + result.SkippedDuplicates += connectionResult.SkippedDuplicates; + } + catch (Exception ex) + { + logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id); + result.Errors++; + result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}"); + } + } + + logger.LogInformation( + "Bank transaction sync completed: {Connections} connections, {Accounts} accounts, " + + "{New} new transactions, {Skipped} duplicates, {Errors} errors", + result.TotalConnections, result.TotalAccounts, result.NewTransactions, + result.SkippedDuplicates, result.Errors); + } + catch (Exception ex) + { + logger.LogError(ex, "Fatal error during bank transaction sync"); + throw; + } + } + + /// + /// Sync transactions for a specific company (manual trigger from UI). + /// + public async Task SyncForCompanyAsync( + string companyId, + CancellationToken cancellationToken = default) + { + logger.LogInformation("Starting manual bank transaction sync for company {CompanyId}", companyId); + + var result = new BankTransactionSyncResult(); + + var connections = await connectionRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken); + result.TotalConnections = connections.Count; + + foreach (var connection in connections) + { + try + { + var connectionResult = await SyncConnectionAsync(connection, cancellationToken); + result.TotalAccounts += connectionResult.TotalAccounts; + result.NewTransactions += connectionResult.NewTransactions; + result.SkippedDuplicates += connectionResult.SkippedDuplicates; + } + catch (Exception ex) + { + logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id); + result.Errors++; + result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}"); + } + } + + logger.LogInformation( + "Manual sync for company {CompanyId} completed: {New} new transactions", + companyId, result.NewTransactions); + + return result; + } + + private async Task SyncConnectionAsync( + BankConnectionReadModelDto connection, + CancellationToken cancellationToken) + { + var result = new BankTransactionSyncResult(); + + if (string.IsNullOrEmpty(connection.SessionId)) + { + logger.LogWarning("Connection {ConnectionId} has no session ID", connection.Id); + return result; + } + + if (connection.Accounts == null || connection.Accounts.Count == 0) + { + logger.LogWarning("Connection {ConnectionId} has no accounts", connection.Id); + return result; + } + + result.TotalAccounts = connection.Accounts.Count; + + var dateTo = DateOnly.FromDateTime(DateTime.UtcNow); + + foreach (var account in connection.Accounts) + { + try + { + // Check if we have any transactions for this account + var hasTransactions = await transactionRepository.HasAnyAsync(account.AccountId, cancellationToken); + + // If new account, fetch 1 year back. If existing, just refresh last 7 days. + var dateFrom = hasTransactions ? dateTo.AddDays(-7) : dateTo.AddDays(-365); + + logger.LogInformation( + "Fetching transactions for account {AccountId} from {DateFrom} to {DateTo} (hasExisting: {HasExisting})", + account.AccountId, dateFrom, dateTo, hasTransactions); + + var response = await bankingClient.GetTransactionsAsync( + connection.SessionId, + account.AccountId, + dateFrom, + dateTo, + cancellationToken); + + logger.LogInformation( + "Enable Banking returned {Count} transactions for account {AccountId}", + response.Transactions.Count, account.AccountId); + + if (response.Transactions.Count == 0) + { + continue; + } + + var transactionsToUpsert = new List(); + + foreach (var tx in response.Transactions) + { + var dto = MapToDto(tx, connection, account.AccountId); + transactionsToUpsert.Add(dto); + } + + if (transactionsToUpsert.Count > 0) + { + await transactionRepository.InsertBatchAsync(transactionsToUpsert, cancellationToken); + result.NewTransactions += transactionsToUpsert.Count; // This counts all upserts (inserts + updates) + + logger.LogInformation( + "Synced {Count} transactions for account {AccountId} (from {From} to {To})", + transactionsToUpsert.Count, account.AccountId, dateFrom, dateTo); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error fetching transactions for account {AccountId}", account.AccountId); + result.Errors++; + result.ErrorMessages.Add($"Account {account.AccountId}: {ex.Message}"); + } + } + + return result; + } + + private async Task> GetAllActiveConnectionsAsync( + CancellationToken cancellationToken) + { + return await connectionRepository.GetAllActiveAsync(cancellationToken); + } + + private static BankTransactionDto MapToDto( + Transaction tx, + BankConnectionReadModelDto connection, + string bankAccountId) + { + var now = DateTime.UtcNow; + + return new BankTransactionDto + { + Id = $"banktx-{Guid.NewGuid()}", + CompanyId = connection.CompanyId, + BankConnectionId = connection.Id, + BankAccountId = bankAccountId, + ExternalId = tx.TransactionId, + Amount = tx.Amount, + Currency = tx.Currency, + TransactionDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue), + BookingDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue), + ValueDate = tx.ValueDate?.ToDateTime(TimeOnly.MinValue), + Description = tx.RemittanceInformation, + CounterpartyName = tx.CreditorName ?? tx.DebtorName, + CreditorName = tx.CreditorName, + DebtorName = tx.DebtorName, + Reference = tx.EndToEndId, + Status = "pending", + CreatedAt = now, + UpdatedAt = now + }; + } +} diff --git a/backend/Books.Api/Banking/EnableBankingClient.cs b/backend/Books.Api/Banking/EnableBankingClient.cs new file mode 100644 index 0000000..29008d7 --- /dev/null +++ b/backend/Books.Api/Banking/EnableBankingClient.cs @@ -0,0 +1,399 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.IdentityModel.Tokens; + +namespace Books.Api.Banking; + +public class EnableBankingClient : IEnableBankingClient, IDisposable +{ + private readonly HttpClient _httpClient; + private readonly EnableBankingOptions _options; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + private readonly RSA _rsaKey; + private readonly SigningCredentials _signingCredentials; + + public EnableBankingClient( + HttpClient httpClient, + EnableBankingOptions options, + ILogger logger) + { + _httpClient = httpClient; + _options = options; + _logger = logger; + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // Parse RSA key once at startup and cache it + _rsaKey = RSA.Create(); + if (!string.IsNullOrEmpty(options.PrivateKey)) + { + _rsaKey.ImportFromPem(options.PrivateKey); + _signingCredentials = new SigningCredentials( + new RsaSecurityKey(_rsaKey) { KeyId = options.KeyId }, + SecurityAlgorithms.RsaSha256) + { + CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false } + }; + } + else + { + _signingCredentials = null!; + } + } + + public void Dispose() + { + _rsaKey?.Dispose(); + } + + public async Task> GetAspspsAsync(string country = "DK", CancellationToken ct = default) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"/aspsps?country={country}"); + AddAuthHeader(request); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); + return result?.Aspsps?.Select(MapAspsp).ToList() ?? []; + } + + public async Task StartAuthorizationAsync( + string aspspName, + string redirectUrl, + string state, + string psuType = "personal", + string? psuIpAddress = null, + string? psuUserAgent = null, + CancellationToken ct = default) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/auth"); + AddAuthHeader(request); + AddPsuHeaders(request, psuIpAddress, psuUserAgent); + + var body = new + { + access = new + { + valid_until = DateTimeOffset.UtcNow.AddDays(90).ToString("o") + }, + aspsp = new { name = aspspName, country = "DK" }, + state, + redirect_url = redirectUrl, + psu_type = psuType + }; + + request.Content = JsonContent.Create(body, options: _jsonOptions); + + _logger.LogDebug( + "Starting authorization for {Bank} with PSU IP: {PsuIp}, PSU UA: {PsuUa}", + aspspName, psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)"); + + var response = await _httpClient.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"Enable Banking auth error {response.StatusCode}: {errorContent}"); + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); + return new AuthorizationResponse(result!.AuthorizationId, result.Url); + } + + public async Task CreateSessionAsync( + string authorizationCode, + string? psuIpAddress = null, + string? psuUserAgent = null, + CancellationToken ct = default) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/sessions"); + AddAuthHeader(request); + AddPsuHeaders(request, psuIpAddress, psuUserAgent); + + _logger.LogDebug( + "Creating session with PSU IP: {PsuIp}, PSU UA: {PsuUa}", + psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)"); + + request.Content = JsonContent.Create(new { code = authorizationCode }, options: _jsonOptions); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + // Log raw response for debugging + var rawJson = await response.Content.ReadAsStringAsync(ct); + _logger.LogInformation("Raw session response: {RawJson}", rawJson); + + var result = System.Text.Json.JsonSerializer.Deserialize(rawJson, _jsonOptions); + var sessionId = result!.SessionId; + var aspspName = result.Aspsp?.Name ?? ""; + + _logger.LogDebug( + "Session created: {SessionId}, Bank: {Bank}, Accounts in response: {AccountCount}", + sessionId, aspspName, result.Accounts?.Count ?? 0); + + // Map accounts from session response + // Use Uid as AccountId - this is required for fetching transactions/balances + // Filter out accounts without uid as they cannot be used for API calls + var accounts = result.Accounts? + .Where(a => !string.IsNullOrEmpty(a.Uid)) + .Select(a => new SessionAccount( + a.Uid!, + a.AccountId?.Iban ?? "", + a.Currency ?? "DKK", + a.Name)).ToList() ?? []; + + _logger.LogDebug( + "Mapped {MappedCount} accounts with valid uid from {TotalCount} accounts in response", + accounts.Count, result.Accounts?.Count ?? 0); + + // Note: There is NO separate /accounts endpoint in Enable Banking API + // The session response is the ONLY source for account information + // If accounts array is empty, it may be because: + // 1. Wrong psuType (personal vs business) + // 2. Bank doesn't provide account list for this connection type + if (accounts.Count == 0) + { + _logger.LogWarning( + "No accounts with valid uid in session response for {Bank}. " + + "This may indicate wrong psuType or bank limitation. Session: {SessionId}", + aspspName, sessionId); + } + + // valid_until is nested inside the access object in the API response + var validUntil = result.Access?.ValidUntil ?? DateTimeOffset.UtcNow.AddDays(90); + + return new SessionResponse(sessionId, aspspName, accounts, validUntil); + } + + public async Task GetAccountDetailsAsync(string sessionId, string accountId, CancellationToken ct = default) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/details"); + AddAuthHeader(request, sessionId); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); + return new AccountDetails( + accountId, + result?.Iban ?? "", + result?.Currency ?? "DKK", + result?.Name, + result?.OwnerName, + result?.Product); + } + + public async Task> GetBalancesAsync(string sessionId, string accountId, CancellationToken ct = default) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/balances"); + AddAuthHeader(request, sessionId); + + var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); + return result?.Balances?.Select(b => new Balance( + b.BalanceType ?? "", + b.BalanceAmount?.Amount ?? 0, + b.BalanceAmount?.Currency ?? "DKK", + b.ReferenceDate)).ToList() ?? []; + } + + public async Task GetTransactionsAsync( + string sessionId, + string accountId, + DateOnly? dateFrom = null, + DateOnly? dateTo = null, + CancellationToken ct = default) + { + var from = dateFrom ?? DateOnly.FromDateTime(DateTime.Today.AddMonths(-3)); + var to = dateTo ?? DateOnly.FromDateTime(DateTime.Today); + + var url = $"/accounts/{accountId}/transactions?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + AddAuthHeader(request, sessionId); + + var response = await _httpClient.SendAsync(request, ct); + + var rawJson = await response.Content.ReadAsStringAsync(ct); + _logger.LogInformation("Transactions API response ({Status}): {RawJson}", response.StatusCode, rawJson); + + response.EnsureSuccessStatusCode(); + + var result = JsonSerializer.Deserialize(rawJson, _jsonOptions); + return new TransactionsResponse( + result?.Transactions?.Select(t => new Transaction( + t.TransactionId ?? t.EntryReference ?? GenerateDeterministicId(t), + t.IsDebit ? -(t.TransactionAmount?.Amount ?? 0) : (t.TransactionAmount?.Amount ?? 0), + t.TransactionAmount?.Currency ?? "DKK", + t.BookingDate ?? DateOnly.FromDateTime(DateTime.Today), + t.ValueDate, + t.CreditorName, + t.DebtorName, + t.RemittanceInformationUnstructured, + t.EndToEndId, + t.IsDebit)).ToList() ?? [], + result?.ContinuationKey); + } + + private static string GenerateDeterministicId(TransactionApiModel t) + { + // Create a deterministic ID based on transaction content + // Used when bank doesn't provide a unique ID + var content = string.Join("|", + t.TransactionAmount?.Amount.ToString(System.Globalization.CultureInfo.InvariantCulture), + t.TransactionAmount?.Currency, + t.BookingDate?.ToString("yyyy-MM-dd"), + t.RemittanceInformationUnstructured?.Trim(), + t.CreditorName?.Trim(), + t.DebtorName?.Trim()); + + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var bytes = System.Text.Encoding.UTF8.GetBytes(content); + var hash = sha256.ComputeHash(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private void AddAuthHeader(HttpRequestMessage request, string? sessionId = null) + { + var jwt = GenerateJwt(sessionId); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt); + } + + private void AddPsuHeaders(HttpRequestMessage request, string? psuIpAddress, string? psuUserAgent) + { + if (!string.IsNullOrEmpty(psuIpAddress)) + request.Headers.Add("psu-ip-address", psuIpAddress); + if (!string.IsNullOrEmpty(psuUserAgent)) + request.Headers.Add("psu-user-agent", psuUserAgent); + } + + private string GenerateJwt(string? sessionId = null) + { + var now = DateTime.UtcNow; + + // Build claims list - only add access claim if session is provided + List? claims = null; + if (!string.IsNullOrEmpty(sessionId)) + { + claims = [new Claim("access", JsonSerializer.Serialize(new { session_id = sessionId }))]; + } + + // Create header with kid explicitly set + var header = new JwtHeader(_signingCredentials) + { + ["kid"] = _options.ApplicationId + }; + + // Create payload matching Enable Banking's expected format + var payload = new JwtPayload( + issuer: "enablebanking.com", + audience: "api.enablebanking.com", + claims: claims, + notBefore: null, + expires: now.AddHours(1), + issuedAt: now); + + var token = new JwtSecurityToken(header, payload); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static Aspsp MapAspsp(AspspApiModel a) => new( + a.Name ?? "", + a.Country ?? "", + a.Logo ?? "", + a.PsuTypes ?? [], + a.PsuTypes?.Contains("business") ?? false, + a.PsuTypes?.Contains("personal") ?? false); + + // API Response models + private record AspspsApiResponse(List? Aspsps); + private record AspspApiModel(string? Name, string? Country, string? Logo, List? PsuTypes); + private record AuthApiResponse(string AuthorizationId, string Url); + private record AccessApiModel(DateTimeOffset? ValidUntil); + private record SessionApiResponse( + string SessionId, + AspspApiModel? Aspsp, + List? Accounts, + AccessApiModel? Access); + private record AccountApiModel( + AccountIdApiModel? AccountId, + string? Uid, + string? Currency, + string? Name); + private record AccountIdApiModel(string? Iban); + private record AccountDetailsApiResponse( + string? Iban, + string? Currency, + string? Name, + string? OwnerName, + string? Product); + private record BalancesApiResponse(List? Balances); + private record BalanceApiModel( + string? BalanceType, + AmountApiModel? BalanceAmount, + DateTimeOffset? ReferenceDate); + private record AmountApiModel( + [property: JsonConverter(typeof(StringToDecimalConverter))] + decimal Amount, + string? Currency); + + private class StringToDecimalConverter : JsonConverter + { + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + return decimal.Parse(str!, System.Globalization.CultureInfo.InvariantCulture); + } + return reader.GetDecimal(); + } + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value); + } + } + private record TransactionsApiResponse( + List? Transactions, + string? ContinuationKey); + private record TransactionApiModel( + string? TransactionId, + string? EntryReference, + AmountApiModel? TransactionAmount, + DateOnly? BookingDate, + DateOnly? ValueDate, + PartyApiModel? Creditor, + PartyApiModel? Debtor, + List? RemittanceInformation, + string? EndToEndId, + string? CreditDebitIndicator) + { + public string? CreditorName => Creditor?.Name; + public string? DebtorName => Debtor?.Name; + public string? RemittanceInformationUnstructured => + RemittanceInformation != null && RemittanceInformation.Count > 0 + ? string.Join(" ", RemittanceInformation) + : null; + public bool IsDebit => CreditDebitIndicator == "DBIT"; + } + + private record PartyApiModel(string? Name); +} + +public class EnableBankingOptions +{ + public string ApplicationId { get; set; } = ""; + public string KeyId { get; set; } = ""; + public string PrivateKey { get; set; } = ""; +} diff --git a/backend/Books.Api/Banking/IEnableBankingClient.cs b/backend/Books.Api/Banking/IEnableBankingClient.cs new file mode 100644 index 0000000..a16adbe --- /dev/null +++ b/backend/Books.Api/Banking/IEnableBankingClient.cs @@ -0,0 +1,110 @@ +namespace Books.Api.Banking; + +/// +/// Client for Enable Banking Open Banking API +/// https://enablebanking.com/docs/api/reference/ +/// +public interface IEnableBankingClient +{ + /// + /// Get list of available ASPSPs (banks) for a country + /// + Task> GetAspspsAsync(string country = "DK", CancellationToken ct = default); + + /// + /// Start authorization flow for connecting a bank account + /// + Task StartAuthorizationAsync( + string aspspName, + string redirectUrl, + string state, + string psuType = "personal", + string? psuIpAddress = null, + string? psuUserAgent = null, + CancellationToken ct = default); + + /// + /// Complete authorization and create a session + /// + Task CreateSessionAsync( + string authorizationCode, + string? psuIpAddress = null, + string? psuUserAgent = null, + CancellationToken ct = default); + + /// + /// Get account details + /// + Task GetAccountDetailsAsync(string sessionId, string accountId, CancellationToken ct = default); + + /// + /// Get account balances + /// + Task> GetBalancesAsync(string sessionId, string accountId, CancellationToken ct = default); + + /// + /// Get account transactions + /// + Task GetTransactionsAsync( + string sessionId, + string accountId, + DateOnly? dateFrom = null, + DateOnly? dateTo = null, + CancellationToken ct = default); +} + +// DTOs for Enable Banking API responses + +public record Aspsp( + string Name, + string Country, + string Logo, + IReadOnlyList PsuTypes, + bool BusinessAccounts, + bool PersonalAccounts); + +public record AuthorizationResponse( + string AuthorizationId, + string Url); + +public record SessionResponse( + string SessionId, + string AspspName, + IReadOnlyList Accounts, + DateTimeOffset ValidUntil); + +public record SessionAccount( + string AccountId, + string Iban, + string Currency, + string? AccountName); + +public record AccountDetails( + string AccountId, + string Iban, + string Currency, + string? Name, + string? OwnerName, + string? Product); + +public record Balance( + string BalanceType, + decimal Amount, + string Currency, + DateTimeOffset? ReferenceDate); + +public record TransactionsResponse( + IReadOnlyList Transactions, + string? ContinuationKey); + +public record Transaction( + string TransactionId, + decimal Amount, + string Currency, + DateOnly BookingDate, + DateOnly? ValueDate, + string? CreditorName, + string? DebtorName, + string? RemittanceInformation, + string? EndToEndId, + bool IsDebit); diff --git a/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs b/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs new file mode 100644 index 0000000..98a7212 --- /dev/null +++ b/backend/Books.Api/Commands/Accounts/AccountCommandHandlers.cs @@ -0,0 +1,67 @@ +using Books.Api.Domain.Accounts; +using EventFlow.Commands; + +namespace Books.Api.Commands.Accounts; + +public class CreateAccountCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + AccountAggregate aggregate, + CreateAccountCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.AccountNumber, + command.Name, + command.AccountType, + command.ParentId, + command.Description, + command.VatCodeId, + command.IsSystemAccount, + command.StandardAccountNumber); + + return Task.CompletedTask; + } +} + +public class UpdateAccountCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + AccountAggregate aggregate, + UpdateAccountCommand command, + CancellationToken cancellationToken) + { + aggregate.Update( + command.Name, + command.ParentId, + command.Description, + command.VatCodeId); + + return Task.CompletedTask; + } +} + +public class DeactivateAccountCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + AccountAggregate aggregate, + DeactivateAccountCommand command, + CancellationToken cancellationToken) + { + aggregate.Deactivate(); + return Task.CompletedTask; + } +} + +public class ReactivateAccountCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + AccountAggregate aggregate, + ReactivateAccountCommand command, + CancellationToken cancellationToken) + { + aggregate.Reactivate(); + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/Accounts/AccountCommands.cs b/backend/Books.Api/Commands/Accounts/AccountCommands.cs new file mode 100644 index 0000000..2eb2f8d --- /dev/null +++ b/backend/Books.Api/Commands/Accounts/AccountCommands.cs @@ -0,0 +1,51 @@ +using Books.Api.Domain.Accounts; +using EventFlow.Commands; + +namespace Books.Api.Commands.Accounts; + +public class CreateAccountCommand( + AccountId aggregateId, + string companyId, + string accountNumber, + string name, + AccountType accountType, + string? parentId, + string? description, + string? vatCodeId, + bool isSystemAccount = false, + string? standardAccountNumber = null) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string AccountNumber { get; } = accountNumber; + public string Name { get; } = name; + public AccountType AccountType { get; } = accountType; + public string? ParentId { get; } = parentId; + public string? Description { get; } = description; + public string? VatCodeId { get; } = vatCodeId; + public bool IsSystemAccount { get; } = isSystemAccount; + /// + /// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. + /// + public string? StandardAccountNumber { get; } = standardAccountNumber; +} + +public class UpdateAccountCommand( + AccountId aggregateId, + string name, + string? parentId, + string? description, + string? vatCodeId) + : Command(aggregateId) +{ + public string Name { get; } = name; + public string? ParentId { get; } = parentId; + public string? Description { get; } = description; + public string? VatCodeId { get; } = vatCodeId; +} + +public class DeactivateAccountCommand(AccountId aggregateId) + : Command(aggregateId); + +public class ReactivateAccountCommand(AccountId aggregateId) + : Command(aggregateId); diff --git a/backend/Books.Api/Commands/Attachments/AttachmentCommandHandlers.cs b/backend/Books.Api/Commands/Attachments/AttachmentCommandHandlers.cs new file mode 100644 index 0000000..741686a --- /dev/null +++ b/backend/Books.Api/Commands/Attachments/AttachmentCommandHandlers.cs @@ -0,0 +1,55 @@ +using Books.Api.Domain.Attachments; +using EventFlow.Commands; + +namespace Books.Api.Commands.Attachments; + +public class UploadAttachmentCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + AttachmentAggregate aggregate, + UploadAttachmentCommand command, + CancellationToken cancellationToken) + { + aggregate.Upload( + command.CompanyId, + command.FileName, + command.OriginalFileName, + command.ContentType, + command.FileSize, + command.StoragePath, + command.UploadedBy, + command.DraftId, + command.TransactionId); + + return Task.CompletedTask; + } +} + +public class LinkAttachmentToTransactionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + AttachmentAggregate aggregate, + LinkAttachmentToTransactionCommand command, + CancellationToken cancellationToken) + { + aggregate.LinkToTransaction(command.TransactionId); + + return Task.CompletedTask; + } +} + +public class DeleteAttachmentCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + AttachmentAggregate aggregate, + DeleteAttachmentCommand command, + CancellationToken cancellationToken) + { + aggregate.Delete(command.DeletedBy, command.Reason); + + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/Attachments/AttachmentCommands.cs b/backend/Books.Api/Commands/Attachments/AttachmentCommands.cs new file mode 100644 index 0000000..13ab75c --- /dev/null +++ b/backend/Books.Api/Commands/Attachments/AttachmentCommands.cs @@ -0,0 +1,58 @@ +using Books.Api.Domain.Attachments; +using EventFlow.Commands; + +namespace Books.Api.Commands.Attachments; + +/// +/// Command to upload an attachment (bilag). +/// Required by Bogføringsloven § 6 for document retention. +/// +public class UploadAttachmentCommand( + AttachmentId aggregateId, + string companyId, + string fileName, + string originalFileName, + string contentType, + long fileSize, + string storagePath, + string uploadedBy, + string? draftId = null, + string? transactionId = null) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string FileName { get; } = fileName; + public string OriginalFileName { get; } = originalFileName; + public string ContentType { get; } = contentType; + public long FileSize { get; } = fileSize; + public string StoragePath { get; } = storagePath; + public string UploadedBy { get; } = uploadedBy; + public string? DraftId { get; } = draftId; + public string? TransactionId { get; } = transactionId; +} + +/// +/// Command to link an attachment to a posted transaction. +/// +public class LinkAttachmentToTransactionCommand( + AttachmentId aggregateId, + string transactionId) + : Command(aggregateId) +{ + public string TransactionId { get; } = transactionId; +} + +/// +/// Command to delete an attachment. +/// Note: Per Bogføringsloven § 6, this should only be used after +/// the 5-year retention period. +/// +public class DeleteAttachmentCommand( + AttachmentId aggregateId, + string deletedBy, + string reason) + : Command(aggregateId) +{ + public string DeletedBy { get; } = deletedBy; + public string Reason { get; } = reason; +} diff --git a/backend/Books.Api/Commands/BankConnections/BankConnectionCommandHandlers.cs b/backend/Books.Api/Commands/BankConnections/BankConnectionCommandHandlers.cs new file mode 100644 index 0000000..219e4c7 --- /dev/null +++ b/backend/Books.Api/Commands/BankConnections/BankConnectionCommandHandlers.cs @@ -0,0 +1,124 @@ +using Books.Api.Domain.BankConnections; +using EventFlow.Commands; + +namespace Books.Api.Commands.BankConnections; + +public class InitiateBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + InitiateBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.Initiate( + command.CompanyId, + command.AspspName, + command.AuthorizationId, + command.RedirectUrl, + command.State); + + return Task.CompletedTask; + } +} + +public class EstablishBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + EstablishBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.Establish( + command.SessionId, + command.ValidUntil, + command.Accounts); + + return Task.CompletedTask; + } +} + +public class FailBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + FailBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.Fail(command.Reason); + + return Task.CompletedTask; + } +} + +public class DisconnectBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + DisconnectBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.Disconnect(command.Reason); + + return Task.CompletedTask; + } +} + +public class RefreshBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + RefreshBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.Refresh(command.NewSessionId, command.ValidUntil); + + return Task.CompletedTask; + } +} + +public class LinkBankAccountCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + LinkBankAccountCommand command, + CancellationToken cancellationToken) + { + aggregate.LinkBankAccount(command.BankAccountId, command.LinkedAccountId, command.ImportFromDate); + + return Task.CompletedTask; + } +} + +public class ReInitiateBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + ReInitiateBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.ReInitiate(command.AuthorizationId, command.RedirectUrl, command.State); + + return Task.CompletedTask; + } +} + +public class ArchiveBankConnectionCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + BankConnectionAggregate aggregate, + ArchiveBankConnectionCommand command, + CancellationToken cancellationToken) + { + aggregate.Archive(command.Reason); + + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/BankConnections/BankConnectionCommands.cs b/backend/Books.Api/Commands/BankConnections/BankConnectionCommands.cs new file mode 100644 index 0000000..380901c --- /dev/null +++ b/backend/Books.Api/Commands/BankConnections/BankConnectionCommands.cs @@ -0,0 +1,91 @@ +using Books.Api.Domain.BankConnections; +using Books.Api.Domain.BankConnections.Events; +using EventFlow.Commands; + +namespace Books.Api.Commands.BankConnections; + +public class InitiateBankConnectionCommand( + BankConnectionId aggregateId, + string companyId, + string aspspName, + string authorizationId, + string redirectUrl, + string state) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string AspspName { get; } = aspspName; + public string AuthorizationId { get; } = authorizationId; + public string RedirectUrl { get; } = redirectUrl; + public string State { get; } = state; +} + +public class EstablishBankConnectionCommand( + BankConnectionId aggregateId, + string sessionId, + DateTimeOffset validUntil, + IReadOnlyList accounts) + : Command(aggregateId) +{ + public string SessionId { get; } = sessionId; + public DateTimeOffset ValidUntil { get; } = validUntil; + public IReadOnlyList Accounts { get; } = accounts; +} + +public class FailBankConnectionCommand( + BankConnectionId aggregateId, + string reason) + : Command(aggregateId) +{ + public string Reason { get; } = reason; +} + +public class DisconnectBankConnectionCommand( + BankConnectionId aggregateId, + string reason = "User requested disconnection") + : Command(aggregateId) +{ + public string Reason { get; } = reason; +} + +public class RefreshBankConnectionCommand( + BankConnectionId aggregateId, + string newSessionId, + DateTimeOffset validUntil) + : Command(aggregateId) +{ + public string NewSessionId { get; } = newSessionId; + public DateTimeOffset ValidUntil { get; } = validUntil; +} + +public class LinkBankAccountCommand( + BankConnectionId aggregateId, + string bankAccountId, + string linkedAccountId, + DateOnly? importFromDate = null) + : Command(aggregateId) +{ + public string BankAccountId { get; } = bankAccountId; + public string LinkedAccountId { get; } = linkedAccountId; + public DateOnly? ImportFromDate { get; } = importFromDate; +} + +public class ReInitiateBankConnectionCommand( + BankConnectionId aggregateId, + string authorizationId, + string redirectUrl, + string state) + : Command(aggregateId) +{ + public string AuthorizationId { get; } = authorizationId; + public string RedirectUrl { get; } = redirectUrl; + public string State { get; } = state; +} + +public class ArchiveBankConnectionCommand( + BankConnectionId aggregateId, + string reason = "User requested archival") + : Command(aggregateId) +{ + public string Reason { get; } = reason; +} diff --git a/backend/Books.Api/Commands/Customers/CustomerCommandHandlers.cs b/backend/Books.Api/Commands/Customers/CustomerCommandHandlers.cs new file mode 100644 index 0000000..816b34a --- /dev/null +++ b/backend/Books.Api/Commands/Customers/CustomerCommandHandlers.cs @@ -0,0 +1,78 @@ +using Books.Api.Domain.Customers; +using EventFlow.Commands; + +namespace Books.Api.Commands.Customers; + +public class CreateCustomerCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + CustomerAggregate aggregate, + CreateCustomerCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.CustomerNumber, + command.CustomerType, + command.Name, + command.Cvr, + command.Address, + command.PostalCode, + command.City, + command.Country, + command.Email, + command.Phone, + command.PaymentTermsDays, + command.DefaultRevenueAccountId, + command.SubLedgerAccountId); + + return Task.CompletedTask; + } +} + +public class UpdateCustomerCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + CustomerAggregate aggregate, + UpdateCustomerCommand command, + CancellationToken cancellationToken) + { + aggregate.Update( + command.Name, + command.Cvr, + command.Address, + command.PostalCode, + command.City, + command.Country, + command.Email, + command.Phone, + command.PaymentTermsDays, + command.DefaultRevenueAccountId); + + return Task.CompletedTask; + } +} + +public class DeactivateCustomerCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + CustomerAggregate aggregate, + DeactivateCustomerCommand command, + CancellationToken cancellationToken) + { + aggregate.Deactivate(); + return Task.CompletedTask; + } +} + +public class ReactivateCustomerCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + CustomerAggregate aggregate, + ReactivateCustomerCommand command, + CancellationToken cancellationToken) + { + aggregate.Reactivate(); + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/Customers/CustomerCommands.cs b/backend/Books.Api/Commands/Customers/CustomerCommands.cs new file mode 100644 index 0000000..26914ce --- /dev/null +++ b/backend/Books.Api/Commands/Customers/CustomerCommands.cs @@ -0,0 +1,70 @@ +using Books.Api.Domain.Customers; +using EventFlow.Commands; + +namespace Books.Api.Commands.Customers; + +public class CreateCustomerCommand( + CustomerId aggregateId, + 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) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string CustomerNumber { get; } = customerNumber; + public CustomerType CustomerType { get; } = customerType; + public string Name { get; } = name; + public string? Cvr { get; } = cvr; + public string? Address { get; } = address; + public string? PostalCode { get; } = postalCode; + public string? City { get; } = city; + public string Country { get; } = country; + public string? Email { get; } = email; + public string? Phone { get; } = phone; + public int PaymentTermsDays { get; } = paymentTermsDays; + public string? DefaultRevenueAccountId { get; } = defaultRevenueAccountId; + public string SubLedgerAccountId { get; } = subLedgerAccountId; +} + +public class UpdateCustomerCommand( + CustomerId aggregateId, + string name, + string? cvr, + string? address, + string? postalCode, + string? city, + string country, + string? email, + string? phone, + int paymentTermsDays, + string? defaultRevenueAccountId) + : Command(aggregateId) +{ + public string Name { get; } = name; + public string? Cvr { get; } = cvr; + public string? Address { get; } = address; + public string? PostalCode { get; } = postalCode; + public string? City { get; } = city; + public string Country { get; } = country; + public string? Email { get; } = email; + public string? Phone { get; } = phone; + public int PaymentTermsDays { get; } = paymentTermsDays; + public string? DefaultRevenueAccountId { get; } = defaultRevenueAccountId; +} + +public class DeactivateCustomerCommand(CustomerId aggregateId) + : Command(aggregateId); + +public class ReactivateCustomerCommand(CustomerId aggregateId) + : Command(aggregateId); diff --git a/backend/Books.Api/Commands/FiscalYears/FiscalYearCommandHandlers.cs b/backend/Books.Api/Commands/FiscalYears/FiscalYearCommandHandlers.cs new file mode 100644 index 0000000..3393d8f --- /dev/null +++ b/backend/Books.Api/Commands/FiscalYears/FiscalYearCommandHandlers.cs @@ -0,0 +1,71 @@ +using Books.Api.Domain.FiscalYears; +using EventFlow.Commands; + +namespace Books.Api.Commands.FiscalYears; + +public class CreateFiscalYearCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + FiscalYearAggregate aggregate, + CreateFiscalYearCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.Name, + command.StartDate, + command.EndDate, + command.IsFirstFiscalYear, + command.IsReorganization); + + return Task.CompletedTask; + } +} + +public class CloseFiscalYearCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + FiscalYearAggregate aggregate, + CloseFiscalYearCommand command, + CancellationToken cancellationToken) + { + aggregate.Close(command.ClosedBy); + return Task.CompletedTask; + } +} + +public class ReopenFiscalYearCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + FiscalYearAggregate aggregate, + ReopenFiscalYearCommand command, + CancellationToken cancellationToken) + { + aggregate.Reopen(command.ReopenedBy); + return Task.CompletedTask; + } +} + +public class LockFiscalYearCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + FiscalYearAggregate aggregate, + LockFiscalYearCommand command, + CancellationToken cancellationToken) + { + aggregate.Lock(command.LockedBy); + return Task.CompletedTask; + } +} + +public class MarkOpeningBalancePostedCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + FiscalYearAggregate aggregate, + MarkOpeningBalancePostedCommand command, + CancellationToken cancellationToken) + { + aggregate.MarkOpeningBalancePosted(); + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/FiscalYears/FiscalYearCommands.cs b/backend/Books.Api/Commands/FiscalYears/FiscalYearCommands.cs new file mode 100644 index 0000000..9f90721 --- /dev/null +++ b/backend/Books.Api/Commands/FiscalYears/FiscalYearCommands.cs @@ -0,0 +1,57 @@ +using Books.Api.Domain.FiscalYears; +using EventFlow.Commands; + +namespace Books.Api.Commands.FiscalYears; + +public class CreateFiscalYearCommand( + FiscalYearId aggregateId, + string companyId, + string name, + DateOnly startDate, + DateOnly endDate, + bool isFirstFiscalYear = false, + bool isReorganization = false) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string Name { get; } = name; + public DateOnly StartDate { get; } = startDate; + public DateOnly EndDate { get; } = endDate; + + /// + /// Per Årsregnskabsloven §15: First fiscal year can be shorter than 12 months (6-18 months allowed) + /// + public bool IsFirstFiscalYear { get; } = isFirstFiscalYear; + + /// + /// Per Årsregnskabsloven §15: Reorganization allows fiscal year up to 18 months + /// + public bool IsReorganization { get; } = isReorganization; +} + +public class CloseFiscalYearCommand( + FiscalYearId aggregateId, + string closedBy) + : Command(aggregateId) +{ + public string ClosedBy { get; } = closedBy; +} + +public class ReopenFiscalYearCommand( + FiscalYearId aggregateId, + string reopenedBy) + : Command(aggregateId) +{ + public string ReopenedBy { get; } = reopenedBy; +} + +public class LockFiscalYearCommand( + FiscalYearId aggregateId, + string lockedBy) + : Command(aggregateId) +{ + public string LockedBy { get; } = lockedBy; +} + +public class MarkOpeningBalancePostedCommand(FiscalYearId aggregateId) + : Command(aggregateId); diff --git a/backend/Books.Api/Commands/Invoices/CreditNoteCommands.cs b/backend/Books.Api/Commands/Invoices/CreditNoteCommands.cs new file mode 100644 index 0000000..87edb1f --- /dev/null +++ b/backend/Books.Api/Commands/Invoices/CreditNoteCommands.cs @@ -0,0 +1,77 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Commands; + +namespace Books.Api.Commands.Invoices; + +/// +/// Creates a new credit note draft for a customer. +/// Credit note number is assigned at creation (Momsloven §52). +/// +public class CreateCreditNoteCommand( + InvoiceId invoiceId, + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string creditNoteNumber, + DateOnly creditNoteDate, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy, + string? originalInvoiceId = null, + string? originalInvoiceNumber = null, + string? creditReason = null) : Command(invoiceId) +{ + public string CompanyId { get; } = companyId; + public string FiscalYearId { get; } = fiscalYearId; + public string CustomerId { get; } = customerId; + public string CustomerName { get; } = customerName; + public string CustomerNumber { get; } = customerNumber; + public string CreditNoteNumber { get; } = creditNoteNumber; + public DateOnly CreditNoteDate { get; } = creditNoteDate; + public string Currency { get; } = currency; + public string? VatCode { get; } = vatCode; + public string? Notes { get; } = notes; + public string? Reference { get; } = reference; + public string CreatedBy { get; } = createdBy; + public string? OriginalInvoiceId { get; } = originalInvoiceId; + public string? OriginalInvoiceNumber { get; } = originalInvoiceNumber; + public string? CreditReason { get; } = creditReason; +} + +/// +/// Issues a credit note (posts to ledger). +/// This is the credit note equivalent of MarkInvoiceSentCommand. +/// Should be called after successfully posting to the ledger. +/// +public class IssueCreditNoteCommand( + InvoiceId invoiceId, + string ledgerTransactionId, + string issuedBy) : Command(invoiceId) +{ + public string LedgerTransactionId { get; } = ledgerTransactionId; + public string IssuedBy { get; } = issuedBy; +} + +/// +/// Applies a credit note to an invoice, reducing the invoice's outstanding balance. +/// +public class ApplyCreditNoteCommand( + InvoiceId creditNoteId, + string targetInvoiceId, + string targetInvoiceNumber, + decimal amount, + DateOnly appliedDate, + string appliedBy, + string? ledgerTransactionId = null) : Command(creditNoteId) +{ + public string TargetInvoiceId { get; } = targetInvoiceId; + public string TargetInvoiceNumber { get; } = targetInvoiceNumber; + public decimal Amount { get; } = amount; + public DateOnly AppliedDate { get; } = appliedDate; + public string AppliedBy { get; } = appliedBy; + public string? LedgerTransactionId { get; } = ledgerTransactionId; +} diff --git a/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs b/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs new file mode 100644 index 0000000..7fe6223 --- /dev/null +++ b/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs @@ -0,0 +1,211 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Commands; + +namespace Books.Api.Commands.Invoices; + +public class CreateInvoiceCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + CreateInvoiceCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.FiscalYearId, + command.CustomerId, + command.CustomerName, + command.CustomerNumber, + command.InvoiceNumber, + command.InvoiceDate, + command.DueDate, + command.PaymentTermsDays, + command.Currency, + command.VatCode, + command.Notes, + command.Reference, + command.CreatedBy); + + return Task.CompletedTask; + } +} + +public class AddInvoiceLineCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + AddInvoiceLineCommand command, + CancellationToken cancellationToken) + { + aggregate.AddLine( + command.Description, + command.Quantity, + command.UnitPrice, + command.VatCode, + command.AccountId, + command.Unit, + command.DiscountPercent); + + return Task.CompletedTask; + } +} + +public class UpdateInvoiceLineCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + UpdateInvoiceLineCommand command, + CancellationToken cancellationToken) + { + aggregate.UpdateLine( + command.LineNumber, + command.Description, + command.Quantity, + command.UnitPrice, + command.VatCode, + command.AccountId, + command.Unit, + command.DiscountPercent); + + return Task.CompletedTask; + } +} + +public class RemoveInvoiceLineCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + RemoveInvoiceLineCommand command, + CancellationToken cancellationToken) + { + aggregate.RemoveLine(command.LineNumber); + + return Task.CompletedTask; + } +} + +public class MarkInvoiceSentCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + MarkInvoiceSentCommand command, + CancellationToken cancellationToken) + { + aggregate.Send( + command.LedgerTransactionId, + command.SentBy); + + return Task.CompletedTask; + } +} + +public class ReceiveInvoicePaymentCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + ReceiveInvoicePaymentCommand command, + CancellationToken cancellationToken) + { + aggregate.ReceivePayment( + command.Amount, + command.BankTransactionId, + command.LedgerTransactionId, + command.PaymentReference, + command.PaymentDate, + command.RecordedBy); + + return Task.CompletedTask; + } +} + +public class VoidInvoiceCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + VoidInvoiceCommand command, + CancellationToken cancellationToken) + { + aggregate.Void( + command.Reason, + command.ReversalLedgerTransactionId, + command.VoidedBy); + + return Task.CompletedTask; + } +} + +// ===================================================== +// CREDIT NOTE COMMAND HANDLERS +// ===================================================== + +public class CreateCreditNoteCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + CreateCreditNoteCommand command, + CancellationToken cancellationToken) + { + aggregate.CreateCreditNote( + command.CompanyId, + command.FiscalYearId, + command.CustomerId, + command.CustomerName, + command.CustomerNumber, + command.CreditNoteNumber, + command.CreditNoteDate, + command.Currency, + command.VatCode, + command.Notes, + command.Reference, + command.CreatedBy, + command.OriginalInvoiceId, + command.OriginalInvoiceNumber, + command.CreditReason); + + return Task.CompletedTask; + } +} + +public class IssueCreditNoteCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + IssueCreditNoteCommand command, + CancellationToken cancellationToken) + { + aggregate.Issue( + command.LedgerTransactionId, + command.IssuedBy); + + return Task.CompletedTask; + } +} + +public class ApplyCreditNoteCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + InvoiceAggregate aggregate, + ApplyCreditNoteCommand command, + CancellationToken cancellationToken) + { + aggregate.ApplyCredit( + command.TargetInvoiceId, + command.TargetInvoiceNumber, + command.Amount, + command.AppliedDate, + command.AppliedBy, + command.LedgerTransactionId); + + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/Invoices/InvoiceCommands.cs b/backend/Books.Api/Commands/Invoices/InvoiceCommands.cs new file mode 100644 index 0000000..e6e8661 --- /dev/null +++ b/backend/Books.Api/Commands/Invoices/InvoiceCommands.cs @@ -0,0 +1,144 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Commands; + +namespace Books.Api.Commands.Invoices; + +/// +/// Creates a new invoice draft for a customer. +/// Invoice number is assigned at creation (Momsloven §52). +/// +public class CreateInvoiceCommand( + InvoiceId invoiceId, + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string invoiceNumber, + DateOnly invoiceDate, + DateOnly dueDate, + int paymentTermsDays, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy) : Command(invoiceId) +{ + public string CompanyId { get; } = companyId; + public string FiscalYearId { get; } = fiscalYearId; + public string CustomerId { get; } = customerId; + public string CustomerName { get; } = customerName; + public string CustomerNumber { get; } = customerNumber; + public string InvoiceNumber { get; } = invoiceNumber; + public DateOnly InvoiceDate { get; } = invoiceDate; + public DateOnly DueDate { get; } = dueDate; + public int PaymentTermsDays { get; } = paymentTermsDays; + public string Currency { get; } = currency; + public string? VatCode { get; } = vatCode; + public string? Notes { get; } = notes; + public string? Reference { get; } = reference; + public string CreatedBy { get; } = createdBy; +} + +/// +/// Adds a line to an invoice draft. +/// +public class AddInvoiceLineCommand( + InvoiceId invoiceId, + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0) : Command(invoiceId) +{ + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; + public string? Unit { get; } = unit; + public decimal DiscountPercent { get; } = discountPercent; +} + +/// +/// Updates a line on an invoice draft. +/// +public class UpdateInvoiceLineCommand( + InvoiceId invoiceId, + int lineNumber, + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0) : Command(invoiceId) +{ + public int LineNumber { get; } = lineNumber; + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; + public string? Unit { get; } = unit; + public decimal DiscountPercent { get; } = discountPercent; +} + +/// +/// Removes a line from an invoice draft. +/// +public class RemoveInvoiceLineCommand( + InvoiceId invoiceId, + int lineNumber) : Command(invoiceId) +{ + public int LineNumber { get; } = lineNumber; +} + +/// +/// Marks an invoice as sent and records the ledger transaction. +/// Should be called after successfully posting to the ledger. +/// +public class MarkInvoiceSentCommand( + InvoiceId invoiceId, + string ledgerTransactionId, + string sentBy) : Command(invoiceId) +{ + public string LedgerTransactionId { get; } = ledgerTransactionId; + public string SentBy { get; } = sentBy; +} + +/// +/// Records a payment received for an invoice. +/// +public class ReceiveInvoicePaymentCommand( + InvoiceId invoiceId, + decimal amount, + string? bankTransactionId, + string? ledgerTransactionId, + string? paymentReference, + DateOnly paymentDate, + string recordedBy) : Command(invoiceId) +{ + public decimal Amount { get; } = amount; + public string? BankTransactionId { get; } = bankTransactionId; + public string? LedgerTransactionId { get; } = ledgerTransactionId; + public string? PaymentReference { get; } = paymentReference; + public DateOnly PaymentDate { get; } = paymentDate; + public string RecordedBy { get; } = recordedBy; +} + +/// +/// Voids an invoice. If already sent, includes the reversal transaction ID. +/// +public class VoidInvoiceCommand( + InvoiceId invoiceId, + string reason, + string? reversalLedgerTransactionId, + string voidedBy) : Command(invoiceId) +{ + public string Reason { get; } = reason; + public string? ReversalLedgerTransactionId { get; } = reversalLedgerTransactionId; + public string VoidedBy { get; } = voidedBy; +} diff --git a/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs new file mode 100644 index 0000000..cb67fb3 --- /dev/null +++ b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommandHandlers.cs @@ -0,0 +1,73 @@ +using Books.Api.Domain.JournalEntryDrafts; +using EventFlow.Commands; + +namespace Books.Api.Commands.JournalEntryDrafts; + +public class CreateJournalEntryDraftCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + JournalEntryDraftAggregate aggregate, + CreateJournalEntryDraftCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.Name, + command.CreatedBy, + command.VoucherNumber, + command.ExtractionData); + + return Task.CompletedTask; + } +} + +public class UpdateJournalEntryDraftCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + JournalEntryDraftAggregate aggregate, + UpdateJournalEntryDraftCommand command, + CancellationToken cancellationToken) + { + aggregate.Update( + command.Name, + command.DocumentDate, + command.Description, + command.FiscalYearId, + command.Lines, + command.AttachmentIds); + + return Task.CompletedTask; + } +} + +public class MarkJournalEntryDraftPostedCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + JournalEntryDraftAggregate aggregate, + MarkJournalEntryDraftPostedCommand command, + CancellationToken cancellationToken) + { + aggregate.MarkPosted( + command.TransactionId, + command.PostedBy); + + return Task.CompletedTask; + } +} + +public class DiscardJournalEntryDraftCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + JournalEntryDraftAggregate aggregate, + DiscardJournalEntryDraftCommand command, + CancellationToken cancellationToken) + { + aggregate.Discard(command.DiscardedBy); + + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommands.cs b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommands.cs new file mode 100644 index 0000000..cac1e8f --- /dev/null +++ b/backend/Books.Api/Commands/JournalEntryDrafts/JournalEntryDraftCommands.cs @@ -0,0 +1,77 @@ +using Books.Api.Domain.JournalEntryDrafts; +using EventFlow.Commands; + +namespace Books.Api.Commands.JournalEntryDrafts; + +/// +/// Command to create a new journal entry draft. +/// VoucherNumber is required by Bogføringsloven § 7, Stk. 4. +/// +public class CreateJournalEntryDraftCommand( + JournalEntryDraftId aggregateId, + string companyId, + string name, + string createdBy, + string voucherNumber, + string? extractionData = null) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string Name { get; } = name; + public string CreatedBy { get; } = createdBy; + /// + /// Bilagsnummer - unique document number per company. + /// + public string VoucherNumber { get; } = voucherNumber; + /// + /// Full AI extraction data as JSON string. + /// Contains vendor CVR, amounts, VAT, due date, payment reference, line items, etc. + /// + public string? ExtractionData { get; } = extractionData; +} + +/// +/// Command to update a journal entry draft (auto-save). +/// Includes VAT codes per line and attachment references. +/// +public class UpdateJournalEntryDraftCommand( + JournalEntryDraftId aggregateId, + string? name, + DateOnly? documentDate, + string? description, + string? fiscalYearId, + List lines, + List? attachmentIds = null) + : Command(aggregateId) +{ + public string? Name { get; } = name; + /// + /// Bilagsdato - the date of the transaction/document (e.g., invoice date) + /// + public DateOnly? DocumentDate { get; } = documentDate; + public string? Description { get; } = description; + public string? FiscalYearId { get; } = fiscalYearId; + public List Lines { get; } = lines; + /// + /// References to attached documents (bilag) - required by Bogføringsloven § 6. + /// + public List AttachmentIds { get; } = attachmentIds ?? []; +} + +public class MarkJournalEntryDraftPostedCommand( + JournalEntryDraftId aggregateId, + string transactionId, + string postedBy) + : Command(aggregateId) +{ + public string TransactionId { get; } = transactionId; + public string PostedBy { get; } = postedBy; +} + +public class DiscardJournalEntryDraftCommand( + JournalEntryDraftId aggregateId, + string discardedBy) + : Command(aggregateId) +{ + public string DiscardedBy { get; } = discardedBy; +} diff --git a/backend/Books.Api/Commands/Orders/OrderCommandHandlers.cs b/backend/Books.Api/Commands/Orders/OrderCommandHandlers.cs new file mode 100644 index 0000000..f68579f --- /dev/null +++ b/backend/Books.Api/Commands/Orders/OrderCommandHandlers.cs @@ -0,0 +1,136 @@ +using Books.Api.Domain.Orders; +using EventFlow.Commands; + +namespace Books.Api.Commands.Orders; + +public class CreateOrderCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + CreateOrderCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.FiscalYearId, + command.CustomerId, + command.CustomerName, + command.CustomerNumber, + command.OrderNumber, + command.OrderDate, + command.ExpectedDeliveryDate, + command.Currency, + command.VatCode, + command.Notes, + command.Reference, + command.CreatedBy); + + return Task.CompletedTask; + } +} + +public class AddOrderLineCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + AddOrderLineCommand command, + CancellationToken cancellationToken) + { + aggregate.AddLine( + command.Description, + command.Quantity, + command.UnitPrice, + command.VatCode, + command.AccountId, + command.Unit, + command.DiscountPercent, + command.ProductId); + + return Task.CompletedTask; + } +} + +public class UpdateOrderLineCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + UpdateOrderLineCommand command, + CancellationToken cancellationToken) + { + aggregate.UpdateLine( + command.LineNumber, + command.Description, + command.Quantity, + command.UnitPrice, + command.VatCode, + command.AccountId, + command.Unit, + command.DiscountPercent, + command.ProductId); + + return Task.CompletedTask; + } +} + +public class RemoveOrderLineCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + RemoveOrderLineCommand command, + CancellationToken cancellationToken) + { + aggregate.RemoveLine(command.LineNumber); + + return Task.CompletedTask; + } +} + +public class ConfirmOrderCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + ConfirmOrderCommand command, + CancellationToken cancellationToken) + { + aggregate.Confirm(command.ConfirmedBy); + + return Task.CompletedTask; + } +} + +public class MarkOrderLinesInvoicedCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + MarkOrderLinesInvoicedCommand command, + CancellationToken cancellationToken) + { + aggregate.MarkLinesAsInvoiced( + command.InvoiceId, + command.InvoiceNumber, + command.LineNumbers, + command.InvoicedBy); + + return Task.CompletedTask; + } +} + +public class CancelOrderCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + OrderAggregate aggregate, + CancelOrderCommand command, + CancellationToken cancellationToken) + { + aggregate.Cancel(command.Reason, command.CancelledBy); + + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/Orders/OrderCommands.cs b/backend/Books.Api/Commands/Orders/OrderCommands.cs new file mode 100644 index 0000000..7257a5d --- /dev/null +++ b/backend/Books.Api/Commands/Orders/OrderCommands.cs @@ -0,0 +1,136 @@ +using Books.Api.Domain.Orders; +using EventFlow.Commands; + +namespace Books.Api.Commands.Orders; + +/// +/// Creates a new order draft for a customer. +/// +public class CreateOrderCommand( + OrderId orderId, + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string orderNumber, + DateOnly orderDate, + DateOnly? expectedDeliveryDate, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy) : Command(orderId) +{ + public string CompanyId { get; } = companyId; + public string FiscalYearId { get; } = fiscalYearId; + public string CustomerId { get; } = customerId; + public string CustomerName { get; } = customerName; + public string CustomerNumber { get; } = customerNumber; + public string OrderNumber { get; } = orderNumber; + public DateOnly OrderDate { get; } = orderDate; + public DateOnly? ExpectedDeliveryDate { get; } = expectedDeliveryDate; + public string Currency { get; } = currency; + public string? VatCode { get; } = vatCode; + public string? Notes { get; } = notes; + public string? Reference { get; } = reference; + public string CreatedBy { get; } = createdBy; +} + +/// +/// Adds a line to an order draft. +/// +public class AddOrderLineCommand( + OrderId orderId, + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0, + string? productId = null) : Command(orderId) +{ + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; + public string? Unit { get; } = unit; + public decimal DiscountPercent { get; } = discountPercent; + public string? ProductId { get; } = productId; +} + +/// +/// Updates a line on an order draft. +/// +public class UpdateOrderLineCommand( + OrderId orderId, + int lineNumber, + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0, + string? productId = null) : Command(orderId) +{ + public int LineNumber { get; } = lineNumber; + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; + public string? Unit { get; } = unit; + public decimal DiscountPercent { get; } = discountPercent; + public string? ProductId { get; } = productId; +} + +/// +/// Removes a line from an order draft. +/// +public class RemoveOrderLineCommand( + OrderId orderId, + int lineNumber) : Command(orderId) +{ + public int LineNumber { get; } = lineNumber; +} + +/// +/// Confirms an order. +/// +public class ConfirmOrderCommand( + OrderId orderId, + string confirmedBy) : Command(orderId) +{ + public string ConfirmedBy { get; } = confirmedBy; +} + +/// +/// Marks specified lines on an order as invoiced. +/// +public class MarkOrderLinesInvoicedCommand( + OrderId orderId, + string invoiceId, + string invoiceNumber, + IReadOnlyList lineNumbers, + string invoicedBy) : Command(orderId) +{ + public string InvoiceId { get; } = invoiceId; + public string InvoiceNumber { get; } = invoiceNumber; + public IReadOnlyList LineNumbers { get; } = lineNumbers; + public string InvoicedBy { get; } = invoicedBy; +} + +/// +/// Cancels an order. +/// +public class CancelOrderCommand( + OrderId orderId, + string reason, + string cancelledBy) : Command(orderId) +{ + public string Reason { get; } = reason; + public string CancelledBy { get; } = cancelledBy; +} diff --git a/backend/Books.Api/Commands/Products/ProductCommandHandlers.cs b/backend/Books.Api/Commands/Products/ProductCommandHandlers.cs new file mode 100644 index 0000000..de5eb2c --- /dev/null +++ b/backend/Books.Api/Commands/Products/ProductCommandHandlers.cs @@ -0,0 +1,73 @@ +using Books.Api.Domain.Products; +using EventFlow.Commands; + +namespace Books.Api.Commands.Products; + +public class CreateProductCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + ProductAggregate aggregate, + CreateProductCommand command, + CancellationToken cancellationToken) + { + aggregate.Create( + command.CompanyId, + command.ProductNumber, + command.Name, + command.Description, + command.UnitPrice, + command.VatCode, + command.Unit, + command.DefaultAccountId, + command.Ean, + command.Manufacturer); + + return Task.CompletedTask; + } +} + +public class UpdateProductCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + ProductAggregate aggregate, + UpdateProductCommand command, + CancellationToken cancellationToken) + { + aggregate.Update( + command.ProductNumber, + command.Name, + command.Description, + command.UnitPrice, + command.VatCode, + command.Unit, + command.DefaultAccountId, + command.Ean, + command.Manufacturer); + + return Task.CompletedTask; + } +} + +public class DeactivateProductCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + ProductAggregate aggregate, + DeactivateProductCommand command, + CancellationToken cancellationToken) + { + aggregate.Deactivate(); + return Task.CompletedTask; + } +} + +public class ReactivateProductCommandHandler : CommandHandler +{ + public override Task ExecuteAsync( + ProductAggregate aggregate, + ReactivateProductCommand command, + CancellationToken cancellationToken) + { + aggregate.Reactivate(); + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/Products/ProductCommands.cs b/backend/Books.Api/Commands/Products/ProductCommands.cs new file mode 100644 index 0000000..f627ac4 --- /dev/null +++ b/backend/Books.Api/Commands/Products/ProductCommands.cs @@ -0,0 +1,60 @@ +using Books.Api.Domain.Products; +using EventFlow.Commands; + +namespace Books.Api.Commands.Products; + +public class CreateProductCommand( + ProductId aggregateId, + string companyId, + string? productNumber, + string name, + string? description, + decimal unitPrice, + string vatCode, + string? unit, + string? defaultAccountId, + string? ean, + string? manufacturer) + : Command(aggregateId) +{ + public string CompanyId { get; } = companyId; + public string? ProductNumber { get; } = productNumber; + public string Name { get; } = name; + public string? Description { get; } = description; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? Unit { get; } = unit; + public string? DefaultAccountId { get; } = defaultAccountId; + public string? Ean { get; } = ean; + public string? Manufacturer { get; } = manufacturer; +} + +public class UpdateProductCommand( + ProductId aggregateId, + string? productNumber, + string name, + string? description, + decimal unitPrice, + string vatCode, + string? unit, + string? defaultAccountId, + string? ean, + string? manufacturer) + : Command(aggregateId) +{ + public string? ProductNumber { get; } = productNumber; + public string Name { get; } = name; + public string? Description { get; } = description; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? Unit { get; } = unit; + public string? DefaultAccountId { get; } = defaultAccountId; + public string? Ean { get; } = ean; + public string? Manufacturer { get; } = manufacturer; +} + +public class DeactivateProductCommand(ProductId aggregateId) + : Command(aggregateId); + +public class ReactivateProductCommand(ProductId aggregateId) + : Command(aggregateId); diff --git a/backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommandHandlers.cs b/backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommandHandlers.cs new file mode 100644 index 0000000..b373160 --- /dev/null +++ b/backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommandHandlers.cs @@ -0,0 +1,43 @@ +using Books.Api.Domain.UserAccess; +using EventFlow.Commands; + +namespace Books.Api.Commands.UserAccess; + +public class GrantUserCompanyAccessCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + UserCompanyAccessAggregate aggregate, + GrantUserCompanyAccessCommand command, + CancellationToken cancellationToken) + { + aggregate.GrantAccess(command.UserId, command.CompanyId, command.Role, command.GrantedBy); + return Task.CompletedTask; + } +} + +public class ChangeUserCompanyAccessRoleCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + UserCompanyAccessAggregate aggregate, + ChangeUserCompanyAccessRoleCommand command, + CancellationToken cancellationToken) + { + aggregate.ChangeRole(command.NewRole, command.ChangedBy); + return Task.CompletedTask; + } +} + +public class RevokeUserCompanyAccessCommandHandler + : CommandHandler +{ + public override Task ExecuteAsync( + UserCompanyAccessAggregate aggregate, + RevokeUserCompanyAccessCommand command, + CancellationToken cancellationToken) + { + aggregate.RevokeAccess(command.RevokedBy); + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommands.cs b/backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommands.cs new file mode 100644 index 0000000..d64ab3d --- /dev/null +++ b/backend/Books.Api/Commands/UserAccess/UserCompanyAccessCommands.cs @@ -0,0 +1,33 @@ +using Books.Api.Domain.UserAccess; +using EventFlow.Commands; + +namespace Books.Api.Commands.UserAccess; + +public class GrantUserCompanyAccessCommand( + UserCompanyAccessId aggregateId, + string userId, + string companyId, + CompanyRole role, + string grantedBy) : Command(aggregateId) +{ + public string UserId { get; } = userId; + public string CompanyId { get; } = companyId; + public CompanyRole Role { get; } = role; + public string GrantedBy { get; } = grantedBy; +} + +public class ChangeUserCompanyAccessRoleCommand( + UserCompanyAccessId aggregateId, + CompanyRole newRole, + string changedBy) : Command(aggregateId) +{ + public CompanyRole NewRole { get; } = newRole; + public string ChangedBy { get; } = changedBy; +} + +public class RevokeUserCompanyAccessCommand( + UserCompanyAccessId aggregateId, + string revokedBy) : Command(aggregateId) +{ + public string RevokedBy { get; } = revokedBy; +} diff --git a/backend/Books.Api/Controllers/AttachmentController.cs b/backend/Books.Api/Controllers/AttachmentController.cs new file mode 100644 index 0000000..ec9b8d6 --- /dev/null +++ b/backend/Books.Api/Controllers/AttachmentController.cs @@ -0,0 +1,269 @@ +using System.Security.Claims; +using Books.Api.Authorization; +using Books.Api.Commands.Attachments; +using Books.Api.Domain; +using Books.Api.Domain.Attachments; +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Books.Api.Infrastructure.FileStorage; +using EventFlow; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Books.Api.Controllers; + +/// +/// REST API for attachment (bilag) file operations. +/// GraphQL doesn't handle file uploads well, so we use REST for this. +/// +[ApiController] +[Route("api/attachments")] +[Authorize] +public class AttachmentController( + ICommandBus commandBus, + IAttachmentRepository attachmentRepository, + IFileStorageService fileStorage, + ICompanyAccessService companyAccess, + ILogger logger) : ControllerBase +{ + /// + /// Upload one or more attachments for a company. + /// + /// Company ID + /// Optional draft ID to link attachments to + /// Files to upload + [HttpPost("upload")] + [RequestSizeLimit(50 * 1024 * 1024)] // 50MB max total + public async Task Upload( + [FromQuery] string companyId, + [FromQuery] string? draftId, + [FromForm] List files, + CancellationToken cancellationToken) + { + // Validate company access + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new { error = "NOT_AUTHENTICATED", message = "You must be authenticated to upload attachments" }); + } + + // Check if user can write (Accountant or Owner role) + var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken); + if (!canWrite) + { + return Forbid(); + } + + if (files.Count == 0) + { + return BadRequest(new { error = "NO_FILES", message = "No files provided" }); + } + + var results = new List(); + + foreach (var file in files) + { + try + { + // Validate file size (10MB per file) + if (file.Length > 10 * 1024 * 1024) + { + results.Add(new + { + success = false, + fileName = file.FileName, + error = "FILE_TOO_LARGE", + message = $"File '{file.FileName}' exceeds 10MB limit" + }); + continue; + } + + // Store file + await using var stream = file.OpenReadStream(); + var storageResult = await fileStorage.StoreAsync( + companyId, + file.FileName, + file.ContentType, + stream, + cancellationToken); + + // Create attachment aggregate + var attachmentId = AttachmentId.New; + var command = new UploadAttachmentCommand( + attachmentId, + companyId, + storageResult.StoredFileName, + file.FileName, + file.ContentType, + storageResult.FileSize, + storageResult.StoragePath, + userId, + draftId); + + await commandBus.PublishAsync(command, cancellationToken); + + // Wait briefly for read model to be updated (eventual consistency) + await Task.Delay(100, cancellationToken); + + var attachment = await attachmentRepository.GetByIdAsync(attachmentId.Value, cancellationToken); + + results.Add(new + { + success = true, + id = attachmentId.Value, + fileName = file.FileName, + fileType = file.ContentType, + fileSize = storageResult.FileSize, + uploadedAt = attachment?.UploadedAt ?? DateTimeOffset.UtcNow, + url = fileStorage.GetDownloadUrl(storageResult.StoragePath) + }); + } + catch (DomainException ex) + { + logger.LogWarning(ex, "Failed to upload file {FileName}", file.FileName); + results.Add(new + { + success = false, + fileName = file.FileName, + error = ex.Code, + message = ex.MessageDanish + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error uploading file {FileName}", file.FileName); + results.Add(new + { + success = false, + fileName = file.FileName, + error = "UPLOAD_FAILED", + message = "An unexpected error occurred" + }); + } + } + + return Ok(new { attachments = results }); + } + + /// + /// Get attachments for a draft. + /// + [HttpGet("draft/{draftId}")] + public async Task GetByDraft(string draftId, CancellationToken cancellationToken) + { + var attachments = await attachmentRepository.GetByDraftIdAsync(draftId, cancellationToken); + + if (attachments.Count == 0) + { + return Ok(new { attachments = Array.Empty() }); + } + + // Validate company access (use first attachment's company) + var access = await companyAccess.GetAccessAsync(attachments[0].CompanyId, cancellationToken); + if (access == null) + { + return Forbid(); + } + + var result = attachments.Select(a => new + { + id = a.Id, + fileName = a.OriginalFileName, + fileType = a.ContentType, + fileSize = a.FileSize, + uploadedAt = a.UploadedAt.ToString("O"), + uploadedBy = a.UploadedBy, + url = fileStorage.GetDownloadUrl(a.StoragePath) + }); + + return Ok(new { attachments = result }); + } + + /// + /// Download an attachment by storage path. + /// + [HttpGet("{*storagePath}")] + public async Task Download(string storagePath, CancellationToken cancellationToken) + { + // Validate path to prevent directory traversal attacks + if (string.IsNullOrWhiteSpace(storagePath) || + storagePath.Contains("..") || + storagePath.Contains("~") || + Path.IsPathRooted(storagePath) || + storagePath.StartsWith("/") || + storagePath.StartsWith("\\")) + { + logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath); + return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); + } + + var file = await fileStorage.GetAsync(storagePath, cancellationToken); + + if (file == null) + { + return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" }); + } + + return File(file.Content, file.ContentType, file.FileName); + } + + /// + /// Delete an attachment. + /// Note: Per Bogføringsloven § 6, attachments linked to transactions + /// cannot be deleted within the 5-year retention period. + /// + [HttpDelete("{id}")] + public async Task Delete( + string id, + [FromQuery] string reason, + CancellationToken cancellationToken) + { + var attachment = await attachmentRepository.GetByIdAsync(id, cancellationToken); + + if (attachment == null) + { + return NotFound(new { error = "ATTACHMENT_NOT_FOUND", message = "Attachment not found" }); + } + + // Validate company access - need write permission to delete + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(); + } + + var canWrite = await companyAccess.CanWriteAsync(attachment.CompanyId, cancellationToken); + if (!canWrite) + { + return Forbid(); + } + + if (string.IsNullOrWhiteSpace(reason)) + { + return BadRequest(new + { + error = "REASON_REQUIRED", + message = "A reason for deletion is required (Bogføringsloven § 6)" + }); + } + + try + { + var command = new DeleteAttachmentCommand( + new AttachmentId(id), + userId, + reason); + + await commandBus.PublishAsync(command, cancellationToken); + + // Also delete the physical file + await fileStorage.DeleteAsync(attachment.StoragePath, cancellationToken); + + return Ok(new { success = true, message = "Attachment deleted" }); + } + catch (DomainException ex) + { + return BadRequest(new { error = ex.Code, message = ex.MessageDanish }); + } + } +} diff --git a/backend/Books.Api/Controllers/BankingController.cs b/backend/Books.Api/Controllers/BankingController.cs new file mode 100644 index 0000000..b9f180b --- /dev/null +++ b/backend/Books.Api/Controllers/BankingController.cs @@ -0,0 +1,105 @@ +using Books.Api.Banking; +using Books.Api.Commands.BankConnections; +using Books.Api.Domain.BankConnections; +using EventFlow; +using EventFlow.Aggregates.ExecutionResults; +using Microsoft.AspNetCore.Mvc; + +namespace Books.Api.Controllers; + +[ApiController] +[Route("api/banking")] +public class BankingController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IEnableBankingClient _bankingClient; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public BankingController( + ICommandBus commandBus, + IEnableBankingClient bankingClient, + ILogger logger, + IConfiguration configuration) + { + _commandBus = commandBus; + _bankingClient = bankingClient; + _logger = logger; + _configuration = configuration; + } + + /// + /// OAuth callback from Enable Banking after user authorizes bank connection. + /// + [HttpGet("callback")] + public async Task Callback( + [FromQuery] string? code, + [FromQuery] string? state, + [FromQuery] string? error, + [FromQuery] string? error_description, + CancellationToken ct) + { + var frontendBaseUrl = _configuration["Frontend:BaseUrl"] ?? "http://localhost:3000"; + var redirectUrl = $"{frontendBaseUrl}/indstillinger?tab=bankAccounts"; + + // Handle error from bank + if (!string.IsNullOrEmpty(error)) + { + _logger.LogWarning("Bank authorization failed: {Error} - {Description}", error, error_description); + return Redirect($"{redirectUrl}&error={Uri.EscapeDataString(error_description ?? error)}"); + } + + // Validate required parameters + if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) + { + _logger.LogWarning("Missing code or state in callback"); + return Redirect($"{redirectUrl}&error=missing_parameters"); + } + + try + { + // State contains the connection ID (set during StartBankConnection) + var connectionId = state; + + // Get PSU headers from HttpContext (required by Enable Banking API) + var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); + var psuUserAgent = Request.Headers.UserAgent.ToString(); + + // Exchange authorization code for session + var session = await _bankingClient.CreateSessionAsync(code, psuIpAddress, psuUserAgent, ct); + + _logger.LogInformation( + "Bank session created: {SessionId}, Bank: {Bank}, Accounts: {AccountCount}", + session.SessionId, + session.AspspName, + session.Accounts.Count); + + // Complete the bank connection + var command = new EstablishBankConnectionCommand( + BankConnectionId.With(connectionId), + session.SessionId, + session.ValidUntil, + session.Accounts.Select(a => new BankAccountInfo( + a.AccountId, + a.Iban, + a.Currency, + a.AccountName)).ToList()); + + var result = await _commandBus.PublishAsync(command, ct); + + if (result is FailedExecutionResult failed) + { + _logger.LogError("Failed to complete bank connection: {Errors}", string.Join(", ", failed.Errors)); + return Redirect($"{redirectUrl}&error=completion_failed"); + } + + _logger.LogInformation("Bank connection {ConnectionId} completed successfully", connectionId); + return Redirect($"{redirectUrl}&success=true"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error completing bank connection"); + return Redirect($"{redirectUrl}&error=internal_error"); + } + } +} diff --git a/backend/Books.Api/Controllers/DocumentProcessingController.cs b/backend/Books.Api/Controllers/DocumentProcessingController.cs new file mode 100644 index 0000000..d60e003 --- /dev/null +++ b/backend/Books.Api/Controllers/DocumentProcessingController.cs @@ -0,0 +1,455 @@ +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using Books.Api.AiBookkeeper; +using Books.Api.Authorization; +using Books.Api.Commands.Attachments; +using Books.Api.Commands.JournalEntryDrafts; +using Books.Api.Domain.Attachments; +using Books.Api.Domain.JournalEntryDrafts; +using Books.Api.EventFlow.Repositories; +using Books.Api.Infrastructure.FileStorage; +using EventFlow; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Books.Api.Controllers; + +/// +/// REST API for AI-powered document processing. +/// Handles document upload, AI analysis, draft creation, and bank transaction matching. +/// +[ApiController] +[Route("api/documents")] +[Authorize] +public class DocumentProcessingController( + ICommandBus commandBus, + IAiBookkeeperClient aiClient, + IChartOfAccountsProvider chartProvider, + IAccountMappingService accountMapping, + IBankTransactionMatcher transactionMatcher, + IDocumentHashRepository hashRepository, + IBankTransactionRepository bankTransactionRepository, + IVoucherNumberService voucherNumberService, + IFileStorageService fileStorage, + ICompanyAccessService companyAccess, + IWebHostEnvironment environment, + ILogger logger) : ControllerBase +{ + private const long MaxFileSize = 10 * 1024 * 1024; // 10MB + private static readonly HashSet AllowedContentTypes = + [ + "application/pdf", + "image/png", + "image/jpeg", + "image/jpg", + "image/gif" + ]; + + /// + /// Process a document using AI and optionally match to a bank transaction. + /// + /// Company ID + /// Document file (PDF, PNG, JPG) + /// Cancellation token + [HttpPost("process")] + [RequestSizeLimit(MaxFileSize)] + public async Task ProcessDocument( + [FromQuery] string companyId, + [FromForm] IFormFile document, + CancellationToken cancellationToken) + { + // Validate authentication + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new DocumentProcessingError("NOT_AUTHENTICATED", "Du skal være logget ind")); + } + + // Validate company access + var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken); + if (!canWrite) + { + return Forbid(); + } + + // Validate file + if (document.Length == 0) + { + return BadRequest(new DocumentProcessingError("NO_FILE", "Ingen fil uploadet")); + } + + if (document.Length > MaxFileSize) + { + return BadRequest(new DocumentProcessingError("FILE_TOO_LARGE", "Filen er for stor (maks 10MB)")); + } + + if (!AllowedContentTypes.Contains(document.ContentType.ToLowerInvariant())) + { + return BadRequest(new DocumentProcessingError( + "INVALID_FILE_TYPE", + "Kun PDF og billeder (PNG, JPG) er tilladt")); + } + + try + { + // 1. Calculate content hash + string contentHash; + await using (var hashStream = document.OpenReadStream()) + { + contentHash = await ComputeHashAsync(hashStream, cancellationToken); + } + + // 2. Check for duplicate + var existingHash = await hashRepository.GetByHashAsync(companyId, contentHash, cancellationToken); + if (existingHash != null) + { + logger.LogInformation( + "Duplicate document detected for company {CompanyId}: {Hash}", + companyId, contentHash); + + return Ok(new DocumentProcessingResult + { + IsDuplicate = true, + DraftId = existingHash.DraftId, + AttachmentId = existingHash.AttachmentId, + Message = "Dokumentet er allerede behandlet" + }); + } + + // 3. Get chart of accounts + var chartOfAccounts = await chartProvider.GetChartOfAccountsAsync(companyId, cancellationToken); + + // 4. Call AI Bookkeeper + AiBookkeeperResponse aiResponse; + await using (var aiStream = document.OpenReadStream()) + { + aiResponse = await aiClient.ProcessDocumentAsync( + aiStream, + document.FileName, + document.ContentType, + chartOfAccounts, + cancellationToken); + } + + if (!aiResponse.Success) + { + logger.LogWarning( + "AI Bookkeeper failed for document {FileName}: {Error}", + document.FileName, aiResponse.ErrorMessage); + + return StatusCode(503, new DocumentProcessingError( + "AI_UNAVAILABLE", + aiResponse.ErrorMessage ?? "AI-tjenesten er midlertidigt utilgængelig")); + } + + // 5. Store attachment + string attachmentId; + await using (var storageStream = document.OpenReadStream()) + { + var storageResult = await fileStorage.StoreAsync( + companyId, + document.FileName, + document.ContentType, + storageStream, + cancellationToken); + + var attId = AttachmentId.New; + attachmentId = attId.Value; + + var uploadCommand = new UploadAttachmentCommand( + attId, + companyId, + storageResult.StoredFileName, + document.FileName, + document.ContentType, + storageResult.FileSize, + storageResult.StoragePath, + userId, + null); // draftId will be set later + + await commandBus.PublishAsync(uploadCommand, cancellationToken); + } + + // 6. Map standard accounts to company accounts + List? mappedLines = null; + if (aiResponse.Suggestion?.Lines != null) + { + mappedLines = await accountMapping.MapSuggestedLinesAsync( + companyId, + aiResponse.Suggestion.Lines, + cancellationToken); + } + + // 7. Create JournalEntryDraft + var draftId = JournalEntryDraftId.New(); + var voucherNumber = await voucherNumberService.GetNextVoucherNumberAsync(companyId, null, cancellationToken); + + // Serialize full extraction data to preserve all AI-extracted fields + string? extractionDataJson = null; + if (aiResponse.Extraction != null) + { + extractionDataJson = System.Text.Json.JsonSerializer.Serialize(aiResponse.Extraction, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + } + + var draftName = aiResponse.Extraction?.Vendor ?? document.FileName; + var createDraftCommand = new CreateJournalEntryDraftCommand( + draftId, + companyId, + draftName, + userId, + voucherNumber, + extractionDataJson); + + await commandBus.PublishAsync(createDraftCommand, cancellationToken); + + // Update draft with AI-suggested lines + var draftLines = CreateDraftLines(mappedLines); + var updateDraftCommand = new UpdateJournalEntryDraftCommand( + draftId, + draftName, + aiResponse.Extraction?.Date, + aiResponse.Extraction?.InvoiceNumber ?? aiResponse.Suggestion?.Description, + null, // fiscalYearId - let the system determine + draftLines, + [attachmentId]); + + await commandBus.PublishAsync(updateDraftCommand, cancellationToken); + + // 8. Find matching bank transaction + var matchedTransaction = await FindMatchingTransaction( + companyId, + aiResponse.Extraction?.TotalAmount, + cancellationToken); + + // 9. Link draft to transaction if matched + if (matchedTransaction != null) + { + await bankTransactionRepository.UpdateStatusAsync( + matchedTransaction.Id, + "booked", + draftId.Value, + cancellationToken); + + logger.LogInformation( + "Linked draft {DraftId} to bank transaction {TransactionId}", + draftId.Value, matchedTransaction.Id); + } + + // 10. Save content hash + await hashRepository.InsertAsync( + companyId, + contentHash, + document.FileName, + attachmentId, + draftId.Value, + cancellationToken); + + // 11. Build response + var result = new DocumentProcessingResult + { + DraftId = draftId.Value, + AttachmentId = attachmentId, + IsDuplicate = false, + Extraction = aiResponse.Extraction != null ? new ExtractionResult + { + Vendor = aiResponse.Extraction.Vendor, + VendorCvr = aiResponse.Extraction.VendorCvr, + Amount = aiResponse.Extraction.TotalAmount, + AmountExVat = aiResponse.Extraction.AmountExVat, + VatAmount = aiResponse.Extraction.VatAmount, + Date = aiResponse.Extraction.Date?.ToString("yyyy-MM-dd"), + DueDate = aiResponse.Extraction.DueDate?.ToString("yyyy-MM-dd"), + InvoiceNumber = aiResponse.Extraction.InvoiceNumber, + DocumentType = aiResponse.Extraction.DocumentType, + Currency = aiResponse.Extraction.Currency, + PaymentReference = aiResponse.Extraction.PaymentReference, + LineItems = aiResponse.Extraction.LineItems?.Select(li => new ExtractedLineItemResult + { + Description = li.Description, + Quantity = li.Quantity, + UnitPrice = li.UnitPrice, + Amount = li.Amount, + VatRate = li.VatRate + }).ToList() + } : null, + AccountSuggestion = mappedLines != null && mappedLines.Any(l => l.IsMapped) + ? new AccountSuggestionResult + { + MappedAccountId = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.Id, + MappedAccountNumber = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.AccountNumber, + MappedAccountName = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.Name, + Confidence = aiResponse.Suggestion?.Confidence ?? 0 + } + : null, + BankTransactionMatch = matchedTransaction != null + ? new BankTransactionMatchResult + { + TransactionId = matchedTransaction.Id, + Amount = matchedTransaction.Amount, + Date = matchedTransaction.TransactionDate.ToString("yyyy-MM-dd"), + Description = matchedTransaction.Description, + Counterparty = matchedTransaction.DisplayCounterparty + } + : null, + SuggestedLines = mappedLines?.Select(ml => new SuggestedJournalLine + { + AccountId = ml.MappedAccount?.Id, + AccountNumber = ml.MappedAccount?.AccountNumber, + AccountName = ml.MappedAccount?.Name ?? ml.Original.AccountName, + DebitAmount = ml.Original.DebitAmount, + CreditAmount = ml.Original.CreditAmount, + VatCode = ml.Original.VatCode + }).ToList() + }; + + logger.LogInformation( + "Successfully processed document {FileName} for company {CompanyId}. Draft: {DraftId}, Match: {HasMatch}", + document.FileName, companyId, draftId.Value, matchedTransaction != null); + + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error processing document {FileName}", document.FileName); + + var errorMessage = environment.IsDevelopment() + ? $"{ex.Message}\n\nStack trace:\n{ex.StackTrace}" + : "Der opstod en uventet fejl ved behandling af dokumentet"; + + return StatusCode(500, new DocumentProcessingError( + "PROCESSING_FAILED", + errorMessage)); + } + } + + private static async Task ComputeHashAsync(Stream stream, CancellationToken cancellationToken) + { + using var sha256 = SHA256.Create(); + var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + private async Task FindMatchingTransaction( + string companyId, + decimal? amount, + CancellationToken cancellationToken) + { + if (!amount.HasValue || amount.Value == 0) + { + return null; + } + + // For expenses (invoices to pay), the document amount is positive + // The bank transaction will be negative (money leaving the account) + // So we search for -amount + return await transactionMatcher.FindMatchingTransactionAsync( + companyId, + -amount.Value, // Negate for expense matching + 0.01m, + cancellationToken); + } + + private static List CreateDraftLines(List? mappedLines) + { + if (mappedLines == null || mappedLines.Count == 0) + { + return []; + } + + var lineNumber = 1; + var result = new List(); + + foreach (var mapped in mappedLines) + { + result.Add(new DraftLine( + lineNumber++, + mapped.MappedAccount?.Id, + mapped.Original.DebitAmount, + mapped.Original.CreditAmount, + mapped.Original.AccountName, + mapped.Original.VatCode)); + } + + return result; + } +} + +// Response DTOs + +public class DocumentProcessingResult +{ + public string? DraftId { get; set; } + public string? AttachmentId { get; set; } + public bool IsDuplicate { get; set; } + public string? Message { get; set; } + public ExtractionResult? Extraction { get; set; } + public AccountSuggestionResult? AccountSuggestion { get; set; } + public BankTransactionMatchResult? BankTransactionMatch { get; set; } + public List? SuggestedLines { get; set; } +} + +public class SuggestedJournalLine +{ + public string? AccountId { get; set; } + public string? AccountNumber { get; set; } + public string? AccountName { get; set; } + public decimal DebitAmount { get; set; } + public decimal CreditAmount { get; set; } + public string? VatCode { get; set; } +} + +public class ExtractionResult +{ + public string? Vendor { get; set; } + public string? VendorCvr { get; set; } + public decimal? Amount { get; set; } + public decimal? AmountExVat { get; set; } + public decimal? VatAmount { get; set; } + public string? Date { get; set; } + public string? DueDate { get; set; } + public string? InvoiceNumber { get; set; } + public string? DocumentType { get; set; } + public string? Currency { get; set; } + public string? PaymentReference { get; set; } + public List? LineItems { get; set; } +} + +public class ExtractedLineItemResult +{ + public string? Description { get; set; } + public decimal? Quantity { get; set; } + public decimal? UnitPrice { get; set; } + public decimal? Amount { get; set; } + public decimal? VatRate { get; set; } +} + +public class AccountSuggestionResult +{ + public string? MappedAccountId { get; set; } + public string? MappedAccountNumber { get; set; } + public string? MappedAccountName { get; set; } + public decimal Confidence { get; set; } +} + +public class BankTransactionMatchResult +{ + public string? TransactionId { get; set; } + public decimal Amount { get; set; } + public string? Date { get; set; } + public string? Description { get; set; } + public string? Counterparty { get; set; } +} + +public class DocumentProcessingError +{ + public string Code { get; set; } + public string Message { get; set; } + + public DocumentProcessingError(string code, string message) + { + Code = code; + Message = message; + } +} diff --git a/backend/Books.Api/Database/Migrations/003_AccountConstraints.sql b/backend/Books.Api/Database/Migrations/003_AccountConstraints.sql new file mode 100644 index 0000000..56e74e1 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/003_AccountConstraints.sql @@ -0,0 +1,5 @@ +-- Index for fiscal year date range queries including company_id for overlap checks +-- The existing idx_fiscal_year_dates from 001_Initial.sql only has (start_date, end_date) +-- This adds company_id for efficient company-scoped overlap queries +CREATE INDEX IF NOT EXISTS idx_fiscal_year_company_dates +ON fiscal_year_read_models(company_id, start_date, end_date); diff --git a/backend/Books.Api/Database/Migrations/004_FiscalYearAuditFields.sql b/backend/Books.Api/Database/Migrations/004_FiscalYearAuditFields.sql new file mode 100644 index 0000000..fb6e4d7 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/004_FiscalYearAuditFields.sql @@ -0,0 +1,14 @@ +-- Add audit fields for fiscal year state transitions +-- Tracks who and when reopened/locked fiscal years + +ALTER TABLE fiscal_year_read_models +ADD COLUMN IF NOT EXISTS reopened_date TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS reopened_by VARCHAR(255), +ADD COLUMN IF NOT EXISTS locked_date TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS locked_by VARCHAR(255); + +-- Add comment for documentation +COMMENT ON COLUMN fiscal_year_read_models.reopened_date IS 'Timestamp when the fiscal year was last reopened'; +COMMENT ON COLUMN fiscal_year_read_models.reopened_by IS 'User who reopened the fiscal year'; +COMMENT ON COLUMN fiscal_year_read_models.locked_date IS 'Timestamp when the fiscal year was locked'; +COMMENT ON COLUMN fiscal_year_read_models.locked_by IS 'User who locked the fiscal year'; diff --git a/backend/Books.Api/Database/Migrations/005_UserCompanyAccess.sql b/backend/Books.Api/Database/Migrations/005_UserCompanyAccess.sql new file mode 100644 index 0000000..f1c9b21 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/005_UserCompanyAccess.sql @@ -0,0 +1,33 @@ +-- User Company Access table for multi-tenant authorization +-- Maps users to companies with specific roles + +CREATE TABLE IF NOT EXISTS user_company_access_read_models ( + aggregate_id VARCHAR(255) PRIMARY KEY, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number INT NOT NULL DEFAULT 1, + + user_id VARCHAR(255) NOT NULL, -- Keycloak user ID or email + company_id VARCHAR(255) NOT NULL, -- Reference to company aggregate + role VARCHAR(50) NOT NULL, -- owner, accountant, viewer + granted_by VARCHAR(255) NOT NULL, -- Who granted this access + granted_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + revoked_at TIMESTAMPTZ, + revoked_by VARCHAR(255), + + -- Ensure unique user-company combination (only one active access per user per company) + CONSTRAINT uq_user_company_active UNIQUE (user_id, company_id), + + -- Ensure valid role values + CONSTRAINT chk_role CHECK (role IN ('owner', 'accountant', 'viewer')) +); + +-- Index for looking up all companies a user has access to +CREATE INDEX IF NOT EXISTS idx_user_access_user ON user_company_access_read_models(user_id) WHERE is_active = true; + +-- Index for looking up all users with access to a company +CREATE INDEX IF NOT EXISTS idx_user_access_company ON user_company_access_read_models(company_id) WHERE is_active = true; + +-- Composite index for efficient access checks +CREATE INDEX IF NOT EXISTS idx_user_access_check ON user_company_access_read_models(user_id, company_id, role) WHERE is_active = true; diff --git a/backend/Books.Api/Database/Migrations/006_JournalEntryDrafts.sql b/backend/Books.Api/Database/Migrations/006_JournalEntryDrafts.sql new file mode 100644 index 0000000..2469522 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/006_JournalEntryDrafts.sql @@ -0,0 +1,30 @@ +-- Migration: 006_JournalEntryDrafts +-- Description: Create journal entry draft read models table for kassekladde feature + +CREATE TABLE IF NOT EXISTS journal_entry_draft_read_models ( + aggregate_id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + name TEXT NOT NULL, + date DATE, + description TEXT, + fiscal_year_id TEXT, + lines TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'active', + transaction_id TEXT, + created_by TEXT NOT NULL, + create_time TIMESTAMPTZ NOT NULL, + updated_time TIMESTAMPTZ NOT NULL, + last_aggregate_sequence_number INT NOT NULL +); + +-- Index for efficient queries by company and status +CREATE INDEX IF NOT EXISTS idx_journal_entry_draft_company_status + ON journal_entry_draft_read_models(company_id, status); + +-- Index for efficient queries by company ordered by updated time +CREATE INDEX IF NOT EXISTS idx_journal_entry_draft_company_updated + ON journal_entry_draft_read_models(company_id, updated_time DESC); + +COMMENT ON TABLE journal_entry_draft_read_models IS 'Journal entry drafts (kassekladder) - work in progress entries before posting to ledger'; +COMMENT ON COLUMN journal_entry_draft_read_models.status IS 'active = work in progress, posted = sent to ledger, discarded = deleted'; +COMMENT ON COLUMN journal_entry_draft_read_models.lines IS 'JSON array (stored as TEXT) of draft lines with accountId, debitAmount, creditAmount, description'; diff --git a/backend/Books.Api/Database/Migrations/007_BankConnections.sql b/backend/Books.Api/Database/Migrations/007_BankConnections.sql new file mode 100644 index 0000000..9b2e035 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/007_BankConnections.sql @@ -0,0 +1,34 @@ +-- Migration: 007_BankConnections +-- Description: Create bank connection read models table for Open Banking integration + +CREATE TABLE IF NOT EXISTS bank_connection_read_models ( + aggregate_id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + aspsp_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'initiated', + session_id TEXT, + valid_until TIMESTAMPTZ, + accounts_json TEXT, + failure_reason TEXT, + create_time TIMESTAMPTZ NOT NULL, + updated_time TIMESTAMPTZ NOT NULL, + last_aggregate_sequence_number INT NOT NULL +); + +-- Index for efficient queries by company +CREATE INDEX IF NOT EXISTS idx_bank_connection_company + ON bank_connection_read_models(company_id); + +-- Index for efficient queries by company and status +CREATE INDEX IF NOT EXISTS idx_bank_connection_company_status + ON bank_connection_read_models(company_id, status); + +-- Index for finding active connections (established and not expired) +CREATE INDEX IF NOT EXISTS idx_bank_connection_active + ON bank_connection_read_models(company_id, status, valid_until) + WHERE status = 'established'; + +COMMENT ON TABLE bank_connection_read_models IS 'Bank connections via Enable Banking Open Banking API'; +COMMENT ON COLUMN bank_connection_read_models.status IS 'initiated = OAuth started, established = active connection, failed = authorization failed, disconnected = user disconnected'; +COMMENT ON COLUMN bank_connection_read_models.accounts_json IS 'JSON array of available bank accounts from the connection'; +COMMENT ON COLUMN bank_connection_read_models.session_id IS 'Enable Banking session ID - used for API calls (not exposed to client)'; diff --git a/backend/Books.Api/Database/Migrations/007_JournalEntryDraftCompliance.sql b/backend/Books.Api/Database/Migrations/007_JournalEntryDraftCompliance.sql new file mode 100644 index 0000000..2064f94 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/007_JournalEntryDraftCompliance.sql @@ -0,0 +1,45 @@ +-- Migration: 007_JournalEntryDraftCompliance +-- Description: Add Danish accounting law compliance fields to journal entry drafts +-- - VoucherNumber (Bilagsnummer) - required by Bogføringsloven § 7, Stk. 4 +-- - AttachmentIds - for document references required by Bogføringsloven § 6 +-- - DocumentDate (Bilagsdato) - renamed from generic "date" for clarity +-- - Voucher sequence table for auto-generation of bilagsnumre + +-- Add voucher_number column to existing table +ALTER TABLE journal_entry_draft_read_models + ADD COLUMN IF NOT EXISTS voucher_number TEXT NOT NULL DEFAULT ''; + +-- Add attachment_ids column (JSON array of references) +ALTER TABLE journal_entry_draft_read_models + ADD COLUMN IF NOT EXISTS attachment_ids TEXT NOT NULL DEFAULT '[]'; + +-- Rename date column to document_date (Bilagsdato) for semantic clarity +-- document_date = the date on the source document (e.g., invoice date) +-- This is different from posting_date which is when it's booked in the ledger +ALTER TABLE journal_entry_draft_read_models + RENAME COLUMN date TO document_date; + +-- Create voucher number sequence table per company/fiscal year +-- This ensures unique, sequential bilagsnumre as required by law +CREATE TABLE IF NOT EXISTS voucher_number_sequences ( + company_id TEXT NOT NULL, + fiscal_year_id TEXT NOT NULL, + last_number INT NOT NULL DEFAULT 0, + prefix TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (company_id, fiscal_year_id) +); + +-- Index for efficient lookup +CREATE INDEX IF NOT EXISTS idx_voucher_sequence_company + ON voucher_number_sequences(company_id); + +-- Index for efficient queries by voucher number +CREATE INDEX IF NOT EXISTS idx_journal_entry_draft_voucher + ON journal_entry_draft_read_models(company_id, voucher_number); + +COMMENT ON TABLE voucher_number_sequences IS 'Sequence generator for bilagsnumre (voucher numbers) per company and fiscal year'; +COMMENT ON COLUMN voucher_number_sequences.prefix IS 'Optional prefix for voucher numbers, e.g. "2025-" for year-based numbering'; +COMMENT ON COLUMN journal_entry_draft_read_models.voucher_number IS 'Bilagsnummer - unique document number required by Bogføringsloven § 7, Stk. 4'; +COMMENT ON COLUMN journal_entry_draft_read_models.attachment_ids IS 'JSON array of attachment IDs for document references required by Bogføringsloven § 6'; diff --git a/backend/Books.Api/Database/Migrations/008_FixJournalEntryDraftColumns.sql b/backend/Books.Api/Database/Migrations/008_FixJournalEntryDraftColumns.sql new file mode 100644 index 0000000..0d27fcf --- /dev/null +++ b/backend/Books.Api/Database/Migrations/008_FixJournalEntryDraftColumns.sql @@ -0,0 +1,20 @@ +-- Migration: 008_FixJournalEntryDraftColumns +-- Description: Fix column types for journal_entry_draft_read_models +-- - Change lines from jsonb to text (matches C# string serialization) +-- - Rename date to document_date if not already done + +-- Fix lines column type (EventFlow may have created it as jsonb) +ALTER TABLE journal_entry_draft_read_models + ALTER COLUMN lines TYPE text USING lines::text; + +-- Ensure document_date column exists (rename from date if needed) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'journal_entry_draft_read_models' + AND column_name = 'date' + ) THEN + ALTER TABLE journal_entry_draft_read_models RENAME COLUMN date TO document_date; + END IF; +END $$; diff --git a/backend/Books.Api/Database/Migrations/009_BankTransactions.sql b/backend/Books.Api/Database/Migrations/009_BankTransactions.sql new file mode 100644 index 0000000..664bf4e --- /dev/null +++ b/backend/Books.Api/Database/Migrations/009_BankTransactions.sql @@ -0,0 +1,63 @@ +-- Migration: 009_BankTransactions +-- Description: Create bank_transactions table for storing synced transactions from Enable Banking +-- Used by BankTransactionSyncJob (Hangfire) and displayed in Hurtig Bogføring + +CREATE TABLE IF NOT EXISTS bank_transactions ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + bank_connection_id TEXT NOT NULL, + bank_account_id TEXT NOT NULL, + external_id TEXT NOT NULL, -- Transaction ID from Enable Banking (for idempotency) + + -- Amount and currency + amount DECIMAL(18,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'DKK', + + -- Dates + transaction_date DATE NOT NULL, + booking_date DATE, + value_date DATE, + + -- Transaction details + description TEXT, + counterparty_name TEXT, + counterparty_account TEXT, + reference TEXT, + creditor_name TEXT, + debtor_name TEXT, + + -- Status tracking + status TEXT NOT NULL DEFAULT 'pending', -- pending | booked | ignored + journal_entry_draft_id TEXT, -- Reference to kassekladde when booked + + -- Raw data for debugging/auditing + raw_data JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure no duplicate transactions per company + UNIQUE(company_id, external_id) +); + +-- Index for fetching pending transactions by company (main query) +CREATE INDEX IF NOT EXISTS idx_bank_tx_company_status + ON bank_transactions(company_id, status); + +-- Index for date-based queries +CREATE INDEX IF NOT EXISTS idx_bank_tx_company_date + ON bank_transactions(company_id, transaction_date DESC); + +-- Index for bank account filtering +CREATE INDEX IF NOT EXISTS idx_bank_tx_bank_account + ON bank_transactions(bank_account_id, status); + +-- Index for checking existing transactions during sync +CREATE INDEX IF NOT EXISTS idx_bank_tx_external_id + ON bank_transactions(company_id, external_id); + +COMMENT ON TABLE bank_transactions IS 'Bank transactions synced from Enable Banking API via Hangfire job'; +COMMENT ON COLUMN bank_transactions.external_id IS 'Unique transaction ID from Enable Banking, used for deduplication'; +COMMENT ON COLUMN bank_transactions.status IS 'pending = not yet booked, booked = linked to journal entry, ignored = manually skipped'; +COMMENT ON COLUMN bank_transactions.journal_entry_draft_id IS 'Reference to journal_entry_draft_read_models.aggregate_id when booked'; diff --git a/backend/Books.Api/Database/Migrations/010_SyncFiscalYearsToLedger.sql b/backend/Books.Api/Database/Migrations/010_SyncFiscalYearsToLedger.sql new file mode 100644 index 0000000..cfa0ab3 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/010_SyncFiscalYearsToLedger.sql @@ -0,0 +1,28 @@ +-- 010_SyncFiscalYearsToLedger.sql +-- Syncs existing fiscal years from Books.Api to Ledger's accounting_periods table. +-- Required because the LedgerPeriodSyncSubscriber was added after fiscal years were created. + +-- Only run if both tables exist (Ledger schema must be set up first) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'accounting_periods') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'fiscal_year_read_models') THEN + + -- Extract GUID from aggregate_id (format: "fiscalyear-{guid}") + INSERT INTO accounting_periods (id, name, start_date, end_date, is_locked, created_at) + SELECT + uuid(substring(aggregate_id from 12))::uuid as id, + name, + start_date, + end_date, + (status = 'locked') as is_locked, + create_time as created_at + FROM fiscal_year_read_models + WHERE aggregate_id LIKE 'fiscalyear-%' + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + is_locked = EXCLUDED.is_locked; + END IF; +END $$; diff --git a/backend/Books.Api/Database/Migrations/011_Customers.sql b/backend/Books.Api/Database/Migrations/011_Customers.sql new file mode 100644 index 0000000..dc0b07b --- /dev/null +++ b/backend/Books.Api/Database/Migrations/011_Customers.sql @@ -0,0 +1,84 @@ +-- 011_Customers.sql +-- Creates customer read model tables and number series for invoicing support +-- Supports B2B (business with CVR) and B2C (private) customers +-- Each customer gets a sub-ledger account under 1900 Debitorer + +-- Customer read models +CREATE TABLE IF NOT EXISTS customer_read_models ( + -- EventFlow standard columns + aggregate_id VARCHAR(255) PRIMARY KEY, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number INT NOT NULL DEFAULT 1, + + -- Business columns (snake_case) + company_id VARCHAR(255) NOT NULL, + customer_number VARCHAR(20) NOT NULL, + customer_type VARCHAR(20) NOT NULL, -- 'Business' or 'Private' + name VARCHAR(255) NOT NULL, + cvr VARCHAR(8), -- Required for Business, optional for Private + address VARCHAR(500), + postal_code VARCHAR(10), + city VARCHAR(100), + country VARCHAR(2) NOT NULL DEFAULT 'DK', + email VARCHAR(255), + phone VARCHAR(50), + payment_terms_days INT NOT NULL DEFAULT 30, + default_revenue_account_id VARCHAR(255), + sub_ledger_account_id VARCHAR(255) NOT NULL, -- Auto-created 1900-XXXX account + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT fk_customer_company + FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE, + CONSTRAINT chk_customer_type + CHECK (customer_type IN ('Business', 'Private')), + CONSTRAINT chk_payment_terms + CHECK (payment_terms_days >= 0 AND payment_terms_days <= 365) +); + +-- Unique customer number per company +CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_number + ON customer_read_models(company_id, customer_number); + +-- Lookup by company +CREATE INDEX IF NOT EXISTS idx_customer_company + ON customer_read_models(company_id); + +-- Lookup by CVR (for B2B customers) +CREATE INDEX IF NOT EXISTS idx_customer_cvr + ON customer_read_models(cvr) WHERE cvr IS NOT NULL; + +-- Search by name +CREATE INDEX IF NOT EXISTS idx_customer_name + ON customer_read_models(company_id, name); + +-- Filter active customers +CREATE INDEX IF NOT EXISTS idx_customer_active + ON customer_read_models(company_id, is_active) WHERE is_active = TRUE; + +-- Number series for sequential numbering (customers, invoices, credit notes) +-- Uses atomic UPSERT for thread-safe number generation +CREATE TABLE IF NOT EXISTS number_series ( + id SERIAL PRIMARY KEY, + company_id VARCHAR(255) NOT NULL, + sequence_key VARCHAR(100) NOT NULL, -- e.g., 'customer', 'invoice-2024', 'creditnote-2024' + last_number INT NOT NULL DEFAULT 0, + prefix VARCHAR(20), -- e.g., 'INV-2024-', 'CN-2024-', '' + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(company_id, sequence_key), + CONSTRAINT fk_number_series_company + FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_number_series_company + ON number_series(company_id); + +-- Comments for documentation +COMMENT ON TABLE customer_read_models IS 'Customer master data supporting B2B and B2C customers'; +COMMENT ON COLUMN customer_read_models.customer_type IS 'Business (requires CVR) or Private (no CVR required)'; +COMMENT ON COLUMN customer_read_models.sub_ledger_account_id IS 'Auto-created sub-ledger account (1900-XXXX) for customer receivables'; +COMMENT ON COLUMN customer_read_models.payment_terms_days IS 'Default payment terms in days, used to calculate invoice due dates'; + +COMMENT ON TABLE number_series IS 'Sequential number generation for customers, invoices, and credit notes (Momsloven §52)'; +COMMENT ON COLUMN number_series.sequence_key IS 'Identifies the sequence: customer, invoice-YYYY, creditnote-YYYY'; diff --git a/backend/Books.Api/Database/Migrations/012_Invoices.sql b/backend/Books.Api/Database/Migrations/012_Invoices.sql new file mode 100644 index 0000000..096396e --- /dev/null +++ b/backend/Books.Api/Database/Migrations/012_Invoices.sql @@ -0,0 +1,86 @@ +-- Migration: 012_Invoices.sql +-- Description: Invoice read model for invoicing system +-- Date: 2026-01-18 +-- Phase: 2 of Invoicing Implementation + +-- Invoice read model table +CREATE TABLE IF NOT EXISTS invoice_read_models ( + -- EventFlow standard fields + aggregate_id VARCHAR(255) PRIMARY KEY, + last_aggregate_sequence_number BIGINT NOT NULL DEFAULT 0, + create_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + update_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Company and fiscal year + company_id VARCHAR(255) NOT NULL, + fiscal_year_id VARCHAR(255), + + -- Customer reference + customer_id VARCHAR(255) NOT NULL, + customer_name VARCHAR(255) NOT NULL, + customer_number VARCHAR(20) NOT NULL, + + -- Invoice identification (Momsloven §52: Sequential per year) + invoice_number VARCHAR(50) NOT NULL, + invoice_date DATE, + due_date DATE, + + -- Status: draft, sent, partially_paid, paid, voided + status VARCHAR(20) NOT NULL DEFAULT 'draft', + + -- Amounts (calculated from lines) + amount_ex_vat DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_vat DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_total DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_paid DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_remaining DECIMAL(18, 2) NOT NULL DEFAULT 0, + + -- Currency (default DKK) + currency VARCHAR(3) NOT NULL DEFAULT 'DKK', + + -- VAT settings + vat_code VARCHAR(20), + + -- Payment terms + payment_terms_days INT NOT NULL DEFAULT 30, + + -- Invoice lines as JSONB for flexibility + -- Structure: [{ lineNumber, description, quantity, unitPrice, vatCode, amountExVat, amountVat, amountTotal, accountId }] + lines JSONB NOT NULL DEFAULT '[]', + + -- Ledger integration + ledger_transaction_id VARCHAR(255), + + -- Notes and reference + notes TEXT, + reference VARCHAR(255), + + -- Timestamps for status changes + sent_at TIMESTAMP WITH TIME ZONE, + paid_at TIMESTAMP WITH TIME ZONE, + voided_at TIMESTAMP WITH TIME ZONE, + voided_reason TEXT, + voided_by VARCHAR(255), + + -- Audit + created_by VARCHAR(255) NOT NULL, + updated_by VARCHAR(255) +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_invoice_company_id ON invoice_read_models(company_id); +CREATE INDEX IF NOT EXISTS idx_invoice_customer_id ON invoice_read_models(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoice_status ON invoice_read_models(status); +CREATE INDEX IF NOT EXISTS idx_invoice_fiscal_year_id ON invoice_read_models(fiscal_year_id); +CREATE INDEX IF NOT EXISTS idx_invoice_number ON invoice_read_models(company_id, invoice_number); +CREATE INDEX IF NOT EXISTS idx_invoice_date ON invoice_read_models(invoice_date); +CREATE INDEX IF NOT EXISTS idx_invoice_due_date ON invoice_read_models(due_date); + +-- Unique constraint on invoice number per company (Momsloven §52 compliance) +CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_number_unique ON invoice_read_models(company_id, invoice_number); + +-- Comment explaining the table +COMMENT ON TABLE invoice_read_models IS 'Invoice read model for the Books accounting system. Supports B2B and B2C invoicing with Danish tax compliance (Momsloven §52).'; +COMMENT ON COLUMN invoice_read_models.invoice_number IS 'Sequential invoice number per year, format: INV-YYYY-NNNN (Momsloven §52)'; +COMMENT ON COLUMN invoice_read_models.lines IS 'Invoice lines as JSONB array with lineNumber, description, quantity, unitPrice, vatCode, amounts, accountId'; +COMMENT ON COLUMN invoice_read_models.ledger_transaction_id IS 'Reference to the Ledger transaction when invoice is sent/posted'; diff --git a/backend/Books.Api/Database/Migrations/013_CreditNotes.sql b/backend/Books.Api/Database/Migrations/013_CreditNotes.sql new file mode 100644 index 0000000..706a065 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/013_CreditNotes.sql @@ -0,0 +1,84 @@ +-- Migration: 013_CreditNotes.sql +-- Description: Credit notes for reversing/correcting invoices +-- Date: 2026-01-18 + +-- Credit note read models +CREATE TABLE IF NOT EXISTS credit_note_read_models ( + aggregate_id VARCHAR(255) PRIMARY KEY, + company_id VARCHAR(255) NOT NULL, + fiscal_year_id VARCHAR(255), + customer_id VARCHAR(255) NOT NULL, + customer_name VARCHAR(255) NOT NULL, + credit_note_number VARCHAR(50) NOT NULL, + original_invoice_id VARCHAR(255), + original_invoice_number VARCHAR(50), + credit_note_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'Draft', + + -- Amounts + amount_ex_vat DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_vat DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_total DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_applied DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_remaining DECIMAL(18, 2) NOT NULL DEFAULT 0, + + -- Lines stored as JSONB for flexibility + lines JSONB NOT NULL DEFAULT '[]'::jsonb, + + -- Ledger integration + ledger_transaction_id VARCHAR(255), + + -- Audit fields + reason TEXT, + issued_at TIMESTAMP WITH TIME ZONE, + voided_at TIMESTAMP WITH TIME ZONE, + voided_reason TEXT, + + -- EventFlow fields + create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number BIGINT NOT NULL DEFAULT 0 +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_company_id + ON credit_note_read_models(company_id); + +CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_customer_id + ON credit_note_read_models(customer_id); + +CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_status + ON credit_note_read_models(status); + +CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_original_invoice + ON credit_note_read_models(original_invoice_id); + +CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_date + ON credit_note_read_models(credit_note_date); + +-- Unique constraint: credit note number must be unique per company +CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_note_read_models_number_unique + ON credit_note_read_models(company_id, credit_note_number); + +-- Credit note applications (when credit is applied to invoices) +CREATE TABLE IF NOT EXISTS credit_note_applications ( + id TEXT PRIMARY KEY, + company_id VARCHAR(255) NOT NULL, + credit_note_id VARCHAR(255) NOT NULL, + invoice_id VARCHAR(255) NOT NULL, + amount DECIMAL(18, 2) NOT NULL, + applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + applied_by VARCHAR(255) NOT NULL, + ledger_transaction_id VARCHAR(255), + + CONSTRAINT fk_credit_note_applications_credit_note + FOREIGN KEY (credit_note_id) REFERENCES credit_note_read_models(aggregate_id), + CONSTRAINT fk_credit_note_applications_invoice + FOREIGN KEY (invoice_id) REFERENCES invoice_read_models(aggregate_id) +); + +CREATE INDEX IF NOT EXISTS idx_credit_note_applications_credit_note + ON credit_note_applications(credit_note_id); + +CREATE INDEX IF NOT EXISTS idx_credit_note_applications_invoice + ON credit_note_applications(invoice_id); diff --git a/backend/Books.Api/Database/Migrations/014_PaymentAllocations.sql b/backend/Books.Api/Database/Migrations/014_PaymentAllocations.sql new file mode 100644 index 0000000..c68b599 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/014_PaymentAllocations.sql @@ -0,0 +1,104 @@ +-- Migration: 014_PaymentAllocations.sql +-- Description: Payment allocations and suggested matches for bank reconciliation +-- Date: 2026-01-18 +-- Phase: 3 of Invoicing Implementation + +-- Payment allocations table +-- Records confirmed matches between bank transactions and invoices/credit notes +CREATE TABLE IF NOT EXISTS payment_allocations ( + id TEXT PRIMARY KEY, + company_id VARCHAR(255) NOT NULL, + + -- Source: bank transaction + bank_transaction_id VARCHAR(255) NOT NULL, + + -- Target: invoice or credit note (one must be set) + invoice_id VARCHAR(255), + credit_note_id VARCHAR(255), + + -- Allocation details + amount DECIMAL(18, 2) NOT NULL, + allocation_type VARCHAR(20) NOT NULL, -- 'payment', 'credit_note', 'refund' + + -- Match metadata + match_method VARCHAR(50) NOT NULL, -- 'manual', 'auto_amount', 'auto_reference', 'auto_name' + match_confidence DECIMAL(3, 2), -- 0.00 to 1.00 + + -- Ledger integration + ledger_transaction_id VARCHAR(255), + + -- Audit + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(255) NOT NULL, + + -- Constraints + CONSTRAINT chk_allocation_target CHECK ( + (invoice_id IS NOT NULL AND credit_note_id IS NULL) OR + (invoice_id IS NULL AND credit_note_id IS NOT NULL) + ), + CONSTRAINT chk_allocation_type CHECK ( + allocation_type IN ('payment', 'credit_note', 'refund') + ) +); + +-- Indexes for payment allocations +CREATE INDEX IF NOT EXISTS idx_payment_allocation_company ON payment_allocations(company_id); +CREATE INDEX IF NOT EXISTS idx_payment_allocation_bank_tx ON payment_allocations(bank_transaction_id); +CREATE INDEX IF NOT EXISTS idx_payment_allocation_invoice ON payment_allocations(invoice_id) WHERE invoice_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_payment_allocation_credit_note ON payment_allocations(credit_note_id) WHERE credit_note_id IS NOT NULL; + +-- Unique constraint: one allocation per bank transaction per invoice +CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_allocation_unique +ON payment_allocations(bank_transaction_id, invoice_id) +WHERE invoice_id IS NOT NULL; + +-- Suggested payment matches table +-- Stores AI/algorithm suggested matches for user review +CREATE TABLE IF NOT EXISTS suggested_payment_matches ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_id VARCHAR(255) NOT NULL, + + -- Source: bank transaction + bank_transaction_id VARCHAR(255) NOT NULL, + + -- Target: invoice + invoice_id VARCHAR(255) NOT NULL, + + -- Match scoring + confidence DECIMAL(3, 2) NOT NULL, -- 0.00 to 1.00 + match_reasons JSONB DEFAULT '[]', -- Array of { reason, score } + suggested_amount DECIMAL(18, 2) NOT NULL, + + -- Status tracking + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'accepted', 'rejected', 'expired' + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + reviewed_at TIMESTAMP WITH TIME ZONE, + reviewed_by VARCHAR(255), + + -- Constraints + CONSTRAINT chk_match_status CHECK ( + status IN ('pending', 'accepted', 'rejected', 'expired') + ), + CONSTRAINT chk_confidence_range CHECK ( + confidence >= 0 AND confidence <= 1 + ) +); + +-- Indexes for suggested matches +CREATE INDEX IF NOT EXISTS idx_suggested_match_company ON suggested_payment_matches(company_id); +CREATE INDEX IF NOT EXISTS idx_suggested_match_bank_tx ON suggested_payment_matches(bank_transaction_id); +CREATE INDEX IF NOT EXISTS idx_suggested_match_invoice ON suggested_payment_matches(invoice_id); +CREATE INDEX IF NOT EXISTS idx_suggested_match_status ON suggested_payment_matches(status); +CREATE INDEX IF NOT EXISTS idx_suggested_match_confidence ON suggested_payment_matches(confidence DESC); + +-- Unique constraint: one pending suggestion per bank transaction per invoice +CREATE UNIQUE INDEX IF NOT EXISTS idx_suggested_match_unique +ON suggested_payment_matches(bank_transaction_id, invoice_id) +WHERE status = 'pending'; + +-- Comments +COMMENT ON TABLE payment_allocations IS 'Confirmed matches between bank transactions and invoices/credit notes'; +COMMENT ON TABLE suggested_payment_matches IS 'AI/algorithm suggested matches for bank reconciliation'; +COMMENT ON COLUMN suggested_payment_matches.match_reasons IS 'JSON array of matching reasons: [{"reason": "exact_amount", "score": 0.8}, {"reason": "reference_match", "score": 0.15}]'; diff --git a/backend/Books.Api/Database/Migrations/015_AddStandardAccountNumber.sql b/backend/Books.Api/Database/Migrations/015_AddStandardAccountNumber.sql new file mode 100644 index 0000000..a4faa3b --- /dev/null +++ b/backend/Books.Api/Database/Migrations/015_AddStandardAccountNumber.sql @@ -0,0 +1,17 @@ +-- 015_AddStandardAccountNumber.sql +-- Adds Erhvervsstyrelsens standardkontonummer field for SAF-T 2.0 compliance +-- Required by January 2027 per Danish bookkeeping law (bogføringsloven) + +-- Add the standard_account_number column +ALTER TABLE account_read_models +ADD COLUMN IF NOT EXISTS standard_account_number VARCHAR(10); + +-- Create index for efficient lookups by standard account number +-- Useful for SAF-T export and reporting +CREATE INDEX IF NOT EXISTS idx_account_standard_number +ON account_read_models(company_id, standard_account_number) +WHERE standard_account_number IS NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN account_read_models.standard_account_number IS + 'Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. Mapping til officiel dansk kontoplan.'; diff --git a/backend/Books.Api/Database/Migrations/016_CompanyBankDetails.sql b/backend/Books.Api/Database/Migrations/016_CompanyBankDetails.sql new file mode 100644 index 0000000..356c7fe --- /dev/null +++ b/backend/Books.Api/Database/Migrations/016_CompanyBankDetails.sql @@ -0,0 +1,15 @@ +-- 016_CompanyBankDetails.sql +-- Adds bank account details to company read models for invoice payment information. + +ALTER TABLE company_read_models +ADD COLUMN IF NOT EXISTS bank_name VARCHAR(100), +ADD COLUMN IF NOT EXISTS bank_reg_no VARCHAR(10), +ADD COLUMN IF NOT EXISTS bank_account_no VARCHAR(20), +ADD COLUMN IF NOT EXISTS bank_iban VARCHAR(34), +ADD COLUMN IF NOT EXISTS bank_bic VARCHAR(11); + +COMMENT ON COLUMN company_read_models.bank_name IS 'Bank name for payment information'; +COMMENT ON COLUMN company_read_models.bank_reg_no IS 'Danish bank registration number (4 digits)'; +COMMENT ON COLUMN company_read_models.bank_account_no IS 'Danish bank account number (7-10 digits)'; +COMMENT ON COLUMN company_read_models.bank_iban IS 'International Bank Account Number'; +COMMENT ON COLUMN company_read_models.bank_bic IS 'Bank Identifier Code / SWIFT code'; diff --git a/backend/Books.Api/Database/Migrations/017_BankConnectionArchive.sql b/backend/Books.Api/Database/Migrations/017_BankConnectionArchive.sql new file mode 100644 index 0000000..37fe59c --- /dev/null +++ b/backend/Books.Api/Database/Migrations/017_BankConnectionArchive.sql @@ -0,0 +1,10 @@ +-- Add is_archived column to bank_connection_read_models table +-- This allows users to hide old/unused bank connections from the UI + +ALTER TABLE bank_connection_read_models +ADD COLUMN IF NOT EXISTS is_archived BOOLEAN NOT NULL DEFAULT FALSE; + +-- Create index for efficient filtering of non-archived connections +CREATE INDEX IF NOT EXISTS idx_bank_connection_read_models_company_archived +ON bank_connection_read_models (company_id, is_archived) +WHERE is_archived = FALSE; diff --git a/backend/Books.Api/Database/Migrations/018_InvoiceTypeConsolidation.sql b/backend/Books.Api/Database/Migrations/018_InvoiceTypeConsolidation.sql new file mode 100644 index 0000000..10bf9df --- /dev/null +++ b/backend/Books.Api/Database/Migrations/018_InvoiceTypeConsolidation.sql @@ -0,0 +1,140 @@ +-- Migration: 018_InvoiceTypeConsolidation.sql +-- Description: Consolidate credit notes into invoice table with type discrimination +-- Date: 2026-01-19 +-- Breaking change: Credit notes become invoices with type='credit_note' + +-- Add invoice type discrimination column +ALTER TABLE invoice_read_models +ADD COLUMN IF NOT EXISTS invoice_type VARCHAR(20) NOT NULL DEFAULT 'invoice'; + +-- Add credit note specific columns +ALTER TABLE invoice_read_models +ADD COLUMN IF NOT EXISTS original_invoice_id VARCHAR(255), +ADD COLUMN IF NOT EXISTS original_invoice_number VARCHAR(50), +ADD COLUMN IF NOT EXISTS credit_reason TEXT, +ADD COLUMN IF NOT EXISTS amount_applied DECIMAL(18, 2) NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS issued_at TIMESTAMP WITH TIME ZONE; + +-- Create index for invoice type filtering +CREATE INDEX IF NOT EXISTS idx_invoice_type ON invoice_read_models(invoice_type); + +-- Create index for credit note -> original invoice lookup +CREATE INDEX IF NOT EXISTS idx_invoice_original_invoice ON invoice_read_models(original_invoice_id) + WHERE original_invoice_id IS NOT NULL; + +-- Migrate existing credit notes to invoice table +-- Note: This assumes credit_note_read_models table exists +DO $$ +BEGIN + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'credit_note_read_models' + ) THEN + -- Insert credit notes as invoices with type='credit_note' + INSERT INTO invoice_read_models ( + aggregate_id, + last_aggregate_sequence_number, + create_time, + update_time, + company_id, + fiscal_year_id, + customer_id, + customer_name, + customer_number, + invoice_number, -- Using credit_note_number + invoice_date, -- Using credit_note_date + due_date, -- Same as invoice_date for credit notes + status, + amount_ex_vat, + amount_vat, + amount_total, + amount_paid, + amount_remaining, + currency, + vat_code, + payment_terms_days, + lines, + ledger_transaction_id, + notes, + reference, + sent_at, + voided_at, + voided_reason, + created_by, + -- New credit note specific columns + invoice_type, + original_invoice_id, + original_invoice_number, + credit_reason, + amount_applied, + issued_at + ) + SELECT + aggregate_id, + last_aggregate_sequence_number, + create_time, + update_time, + company_id, + fiscal_year_id, + customer_id, + customer_name, + '', -- customer_number not in credit notes, will need to be populated + credit_note_number, + credit_note_date, + credit_note_date, -- due_date = issue date for credit notes + -- Map credit note status to invoice status + CASE status + WHEN 'Draft' THEN 'draft' + WHEN 'Issued' THEN 'issued' + WHEN 'PartiallyApplied' THEN 'partially_applied' + WHEN 'FullyApplied' THEN 'fully_applied' + WHEN 'Voided' THEN 'voided' + ELSE LOWER(status) + END, + -- Amounts are negative for credit notes + -ABS(amount_ex_vat), + -ABS(amount_vat), + -ABS(amount_total), + 0, -- amount_paid (credit notes use amount_applied instead) + -ABS(amount_remaining), + 'DKK', -- Default currency + NULL, -- vat_code + 0, -- payment_terms_days + lines, + ledger_transaction_id, + reason, -- notes = reason + NULL, -- reference + issued_at, -- sent_at = issued_at for credit notes + voided_at, + voided_reason, + 'system', -- created_by (not tracked in old model) + -- Credit note specific + 'credit_note', + original_invoice_id, + original_invoice_number, + reason, + amount_applied, + issued_at + FROM credit_note_read_models + ON CONFLICT (aggregate_id) DO NOTHING; + + RAISE NOTICE 'Migrated credit notes to invoice table'; + END IF; +END $$; + +-- Update credit_note_applications to reference invoice table +-- (keeping for historical reference, can be dropped after verification) +COMMENT ON TABLE credit_note_applications IS 'DEPRECATED: Credit note applications are now tracked via invoice events. Table kept for migration reference.'; + +-- Drop credit note read models table after successful migration +-- Uncomment these after verifying migration success: +-- DROP TABLE IF EXISTS credit_note_applications; +-- DROP TABLE IF EXISTS credit_note_read_models; + +-- Add comment explaining the consolidation +COMMENT ON COLUMN invoice_read_models.invoice_type IS 'Document type: invoice or credit_note'; +COMMENT ON COLUMN invoice_read_models.original_invoice_id IS 'For credit notes: reference to the original invoice being credited'; +COMMENT ON COLUMN invoice_read_models.original_invoice_number IS 'For credit notes: the invoice number of the original invoice'; +COMMENT ON COLUMN invoice_read_models.credit_reason IS 'For credit notes: reason for issuing the credit'; +COMMENT ON COLUMN invoice_read_models.amount_applied IS 'For credit notes: total credit amount applied to invoices'; +COMMENT ON COLUMN invoice_read_models.issued_at IS 'For credit notes: when the credit note was issued (equivalent to sent_at for invoices)'; diff --git a/backend/Books.Api/Database/Migrations/019_FixInvoiceLinesColumn.sql b/backend/Books.Api/Database/Migrations/019_FixInvoiceLinesColumn.sql new file mode 100644 index 0000000..0f5768e --- /dev/null +++ b/backend/Books.Api/Database/Migrations/019_FixInvoiceLinesColumn.sql @@ -0,0 +1,6 @@ +-- Migration: 019_FixInvoiceLinesColumn +-- Description: Change lines column from jsonb to text (matches C# string serialization) +-- This is required because Dapper doesn't automatically convert string to jsonb. + +ALTER TABLE invoice_read_models + ALTER COLUMN lines TYPE text USING lines::text; diff --git a/backend/Books.Api/Database/Migrations/020_Products.sql b/backend/Books.Api/Database/Migrations/020_Products.sql new file mode 100644 index 0000000..25d3d8f --- /dev/null +++ b/backend/Books.Api/Database/Migrations/020_Products.sql @@ -0,0 +1,29 @@ +-- Migration: 020_Products +-- Description: Create product_read_models table for product catalog + +CREATE TABLE IF NOT EXISTS product_read_models ( + aggregate_id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + product_number TEXT, + name TEXT NOT NULL, + description TEXT, + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0, + vat_code TEXT NOT NULL DEFAULT 'U25', + unit TEXT, + default_account_id TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number INTEGER NOT NULL DEFAULT 0 +); + +-- Index for company queries +CREATE INDEX IF NOT EXISTS idx_product_company ON product_read_models(company_id); + +-- Unique constraint on product_number within a company (when product_number is not null) +CREATE UNIQUE INDEX IF NOT EXISTS idx_product_number_unique ON product_read_models(company_id, product_number) + WHERE product_number IS NOT NULL; + +-- Index for active products +CREATE INDEX IF NOT EXISTS idx_product_active ON product_read_models(company_id, is_active) + WHERE is_active = TRUE; diff --git a/backend/Books.Api/Database/Migrations/021_ProductEanManufacturer.sql b/backend/Books.Api/Database/Migrations/021_ProductEanManufacturer.sql new file mode 100644 index 0000000..495200c --- /dev/null +++ b/backend/Books.Api/Database/Migrations/021_ProductEanManufacturer.sql @@ -0,0 +1,14 @@ +-- Add EAN (barcode) and Manufacturer fields to products +-- Manufacturer will be used for autocomplete across products in a company + +ALTER TABLE product_read_models +ADD COLUMN IF NOT EXISTS ean TEXT, +ADD COLUMN IF NOT EXISTS manufacturer TEXT; + +-- Index for EAN lookup (useful for scanning barcodes) +CREATE INDEX IF NOT EXISTS idx_product_ean +ON product_read_models (company_id, ean) WHERE ean IS NOT NULL; + +-- Index for manufacturer autocomplete +CREATE INDEX IF NOT EXISTS idx_product_manufacturer +ON product_read_models (company_id, manufacturer) WHERE manufacturer IS NOT NULL; diff --git a/backend/Books.Api/Database/Migrations/022_DropPeriodNameUniqueConstraint.sql b/backend/Books.Api/Database/Migrations/022_DropPeriodNameUniqueConstraint.sql new file mode 100644 index 0000000..2770d70 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/022_DropPeriodNameUniqueConstraint.sql @@ -0,0 +1,11 @@ +-- 022_DropPeriodNameUniqueConstraint.sql +-- Fixes multi-tenant issue where multiple companies can have periods with same name. +-- The Ledger's accounting_periods table had a global unique constraint on 'name', +-- but in a multi-tenant system each company needs their own "2025", "2026" etc. periods. + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'accounting_periods') THEN + ALTER TABLE accounting_periods DROP CONSTRAINT IF EXISTS uq_period_name; + END IF; +END $$; diff --git a/backend/Books.Api/Database/Migrations/023_ResyncFiscalYearsToLedger.sql b/backend/Books.Api/Database/Migrations/023_ResyncFiscalYearsToLedger.sql new file mode 100644 index 0000000..2a7f46c --- /dev/null +++ b/backend/Books.Api/Database/Migrations/023_ResyncFiscalYearsToLedger.sql @@ -0,0 +1,25 @@ +-- 023_ResyncFiscalYearsToLedger.sql +-- Re-syncs all fiscal years that are missing from accounting_periods. +-- This fixes periods that failed to sync due to the unique constraint on name +-- (which was removed in migration 022). + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'accounting_periods') + AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'fiscal_year_read_models') THEN + + -- Insert any missing fiscal years + INSERT INTO accounting_periods (id, name, start_date, end_date, is_locked, created_at) + SELECT + uuid(substring(aggregate_id from 12))::uuid as id, + name, + start_date, + end_date, + (status = 'locked') as is_locked, + create_time as created_at + FROM fiscal_year_read_models + WHERE aggregate_id LIKE 'fiscalyear-%' + AND uuid(substring(aggregate_id from 12))::uuid NOT IN (SELECT id FROM accounting_periods) + ON CONFLICT (id) DO NOTHING; + END IF; +END $$; diff --git a/backend/Books.Api/Database/Migrations/024_CleanOrphanIdempotencyKeys.sql b/backend/Books.Api/Database/Migrations/024_CleanOrphanIdempotencyKeys.sql new file mode 100644 index 0000000..4b6b0ae --- /dev/null +++ b/backend/Books.Api/Database/Migrations/024_CleanOrphanIdempotencyKeys.sql @@ -0,0 +1,17 @@ +-- 024_CleanOrphanIdempotencyKeys.sql +-- Removes orphaned idempotency keys for invoices that were never successfully posted. +-- These occur when the process_transaction function fails AFTER inserting the idempotency key +-- but BEFORE creating ledger entries (e.g., due to missing period). + +-- Only run if the processed_transactions table exists (it's in ledger schema) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'processed_transactions') THEN + DELETE FROM processed_transactions pt + WHERE pt.idempotency_key LIKE 'invoice-send-%' + AND NOT EXISTS ( + SELECT 1 FROM ledger_entries le + WHERE le.idempotency_key = pt.idempotency_key + ); + END IF; +END $$; diff --git a/backend/Books.Api/Database/Migrations/025_Orders.sql b/backend/Books.Api/Database/Migrations/025_Orders.sql new file mode 100644 index 0000000..2cec852 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/025_Orders.sql @@ -0,0 +1,92 @@ +-- Migration: 025_Orders +-- Description: Create order_read_models table for order management + +CREATE TABLE IF NOT EXISTS order_read_models ( + -- EventFlow standard columns + aggregate_id TEXT PRIMARY KEY, + create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number INTEGER NOT NULL DEFAULT 0, + + -- Company and fiscal year + company_id TEXT NOT NULL, + fiscal_year_id TEXT NOT NULL, + + -- Customer reference + customer_id TEXT NOT NULL, + customer_name TEXT NOT NULL, + customer_number TEXT NOT NULL, + + -- Order identification + order_number TEXT NOT NULL, + order_date DATE, + expected_delivery_date DATE, + + -- Status: draft, confirmed, partially_invoiced, fully_invoiced, cancelled + status TEXT NOT NULL DEFAULT 'draft', + + -- Amounts (set when confirmed) + amount_ex_vat DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_vat DECIMAL(18, 2) NOT NULL DEFAULT 0, + amount_total DECIMAL(18, 2) NOT NULL DEFAULT 0, + + -- Currency + currency TEXT NOT NULL DEFAULT 'DKK', + + -- Lines (stored as JSON, includes invoicing status per line) + lines TEXT NOT NULL DEFAULT '[]', + + -- Invoice reference (latest invoice created from this order) + invoice_id TEXT, + invoice_number TEXT, + + -- Notes and reference + notes TEXT, + reference TEXT, + + -- Status timestamps + confirmed_at TIMESTAMP WITH TIME ZONE, + confirmed_by TEXT, + invoiced_at TIMESTAMP WITH TIME ZONE, + invoiced_by TEXT, + cancelled_at TIMESTAMP WITH TIME ZONE, + cancelled_by TEXT, + cancelled_reason TEXT, + + -- Audit + created_by TEXT NOT NULL, + updated_by TEXT +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_order_read_models_company_id + ON order_read_models(company_id); + +CREATE INDEX IF NOT EXISTS idx_order_read_models_customer_id + ON order_read_models(customer_id); + +CREATE INDEX IF NOT EXISTS idx_order_read_models_fiscal_year_id + ON order_read_models(fiscal_year_id); + +CREATE INDEX IF NOT EXISTS idx_order_read_models_status + ON order_read_models(status); + +CREATE INDEX IF NOT EXISTS idx_order_read_models_order_date + ON order_read_models(order_date DESC); + +CREATE INDEX IF NOT EXISTS idx_order_read_models_invoice_id + ON order_read_models(invoice_id) + WHERE invoice_id IS NOT NULL; + +-- Unique constraint for order number per company +CREATE UNIQUE INDEX IF NOT EXISTS idx_order_read_models_company_order_number + ON order_read_models(company_id, order_number); + +-- Composite index for listing orders by company and status +CREATE INDEX IF NOT EXISTS idx_order_read_models_company_status_date + ON order_read_models(company_id, status, order_date DESC); + +COMMENT ON TABLE order_read_models IS 'Read model for orders (Ordrer)'; +COMMENT ON COLUMN order_read_models.order_number IS 'Order number format: ORD-YYYY-NNNN (Ordrenummer)'; +COMMENT ON COLUMN order_read_models.status IS 'Order status: draft, confirmed, partially_invoiced, fully_invoiced, cancelled'; +COMMENT ON COLUMN order_read_models.lines IS 'JSON array of order lines with invoicing status'; diff --git a/backend/Books.Api/Database/Migrations/026_DocumentContentHashes.sql b/backend/Books.Api/Database/Migrations/026_DocumentContentHashes.sql new file mode 100644 index 0000000..4f08ba3 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/026_DocumentContentHashes.sql @@ -0,0 +1,27 @@ +-- Migration: 026_DocumentContentHashes +-- Description: Create table for tracking document content hashes to prevent duplicate processing + +CREATE TABLE IF NOT EXISTS document_content_hashes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id TEXT NOT NULL, + content_hash TEXT NOT NULL, -- SHA-256 hash of file content + original_filename TEXT NOT NULL, + attachment_id TEXT, -- Reference to attachment_read_models + draft_id TEXT, -- Reference to journal_entry_draft_read_models + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Ensure unique hash per company (same document can exist in different companies) + CONSTRAINT uq_document_hash_company UNIQUE(company_id, content_hash) +); + +-- Index for fast lookups by company +CREATE INDEX IF NOT EXISTS idx_document_content_hashes_company_id + ON document_content_hashes(company_id); + +-- Index for hash lookups +CREATE INDEX IF NOT EXISTS idx_document_content_hashes_content_hash + ON document_content_hashes(content_hash); + +COMMENT ON TABLE document_content_hashes IS 'Tracks document content hashes to prevent duplicate processing of the same document'; +COMMENT ON COLUMN document_content_hashes.content_hash IS 'SHA-256 hash of the file content'; +COMMENT ON COLUMN document_content_hashes.draft_id IS 'Reference to the journal entry draft created from this document'; diff --git a/backend/Books.Api/Database/Migrations/027_Attachments.sql b/backend/Books.Api/Database/Migrations/027_Attachments.sql new file mode 100644 index 0000000..6fefdc8 --- /dev/null +++ b/backend/Books.Api/Database/Migrations/027_Attachments.sql @@ -0,0 +1,36 @@ +-- 027_Attachments.sql +-- Creates attachment read model table for document/file storage +-- Used by AI Bookkeeper for uploaded invoices/receipts + +CREATE TABLE IF NOT EXISTS attachment_read_models ( + -- EventFlow standard columns + aggregate_id VARCHAR(255) PRIMARY KEY, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_aggregate_sequence_number INT NOT NULL DEFAULT 1, + + -- Business columns + company_id VARCHAR(255) NOT NULL, + file_name VARCHAR(500) NOT NULL, + original_file_name VARCHAR(500) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + storage_path VARCHAR(1000) NOT NULL, + uploaded_by VARCHAR(255) NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL, + draft_id VARCHAR(255), + transaction_id VARCHAR(255), + retention_end_date TIMESTAMPTZ NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_by VARCHAR(255), + delete_reason TEXT, + deleted_at TIMESTAMPTZ, + + CONSTRAINT fk_attachment_company + FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_attachment_company ON attachment_read_models(company_id); +CREATE INDEX IF NOT EXISTS idx_attachment_draft ON attachment_read_models(draft_id) WHERE draft_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attachment_transaction ON attachment_read_models(transaction_id) WHERE transaction_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_attachment_not_deleted ON attachment_read_models(company_id, is_deleted) WHERE is_deleted = FALSE; diff --git a/backend/Books.Api/Database/Migrations/028_DraftExtractionData.sql b/backend/Books.Api/Database/Migrations/028_DraftExtractionData.sql new file mode 100644 index 0000000..d8b5ffb --- /dev/null +++ b/backend/Books.Api/Database/Migrations/028_DraftExtractionData.sql @@ -0,0 +1,8 @@ +-- 028_DraftExtractionData.sql +-- Adds extraction_data column to store full AI extraction + +ALTER TABLE journal_entry_draft_read_models +ADD COLUMN IF NOT EXISTS extraction_data JSONB; + +COMMENT ON COLUMN journal_entry_draft_read_models.extraction_data IS +'Full AI extraction data: vendor, cvr, amounts, vat, due date, payment ref, line items'; diff --git a/backend/Books.Api/Database/Migrations/029_FixExtractionDataColumnType.sql b/backend/Books.Api/Database/Migrations/029_FixExtractionDataColumnType.sql new file mode 100644 index 0000000..09a819f --- /dev/null +++ b/backend/Books.Api/Database/Migrations/029_FixExtractionDataColumnType.sql @@ -0,0 +1,5 @@ +-- 029_FixExtractionDataColumnType.sql +-- Fix extraction_data column type from JSONB to TEXT for Dapper compatibility + +ALTER TABLE journal_entry_draft_read_models +ALTER COLUMN extraction_data TYPE TEXT USING extraction_data::TEXT; diff --git a/backend/Books.Api/Domain/Accounts/AccountAggregate.cs b/backend/Books.Api/Domain/Accounts/AccountAggregate.cs new file mode 100644 index 0000000..9e5624d --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/AccountAggregate.cs @@ -0,0 +1,106 @@ +using System.Text.RegularExpressions; +using Books.Api.Domain.Accounts.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Accounts; + +public class AccountAggregate(AccountId id) : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private bool _isActive = true; + private bool _isSystemAccount; + + public void Apply(AccountCreatedEvent e) + { + _isCreated = true; + _isSystemAccount = e.IsSystemAccount; + } + + public void Apply(AccountUpdatedEvent e) { } + + public void Apply(AccountDeactivatedEvent e) => _isActive = false; + + public void Apply(AccountReactivatedEvent e) => _isActive = true; + + public void Create( + string companyId, + string accountNumber, + string name, + AccountType accountType, + string? parentId, + string? description, + string? vatCodeId, + bool isSystemAccount = false, + string? standardAccountNumber = null) + { + if (_isCreated) + throw new DomainException("ACCOUNT_EXISTS", "Account already exists", "Konto eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er paakraevet"); + + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new DomainException("ACCOUNT_NUMBER_REQUIRED", "Account number is required", "Kontonummer er paakraevet"); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("ACCOUNT_NAME_REQUIRED", "Account name is required", "Kontonavn er paakraevet"); + + // Validate account number format (4-10 digits, Danish standard) + if (!Regex.IsMatch(accountNumber.Trim(), @"^\d{4,10}$")) + throw new DomainException("INVALID_ACCOUNT_NUMBER", "Account number must be 4-10 digits", "Kontonummer skal vaere 4-10 cifre"); + + Emit(new AccountCreatedEvent( + companyId, + accountNumber.Trim(), + name.Trim(), + accountType, + parentId, + description?.Trim(), + vatCodeId, + isSystemAccount, + standardAccountNumber?.Trim())); + } + + public void Update(string name, string? parentId, string? description, string? vatCodeId) + { + if (!_isCreated) + throw new DomainException("ACCOUNT_NOT_FOUND", "Account does not exist", "Konto findes ikke"); + + if (_isSystemAccount) + throw new DomainException("SYSTEM_ACCOUNT_READONLY", "System accounts cannot be modified", "Systemkonti kan ikke aendres"); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("ACCOUNT_NAME_REQUIRED", "Account name is required", "Kontonavn er paakraevet"); + + Emit(new AccountUpdatedEvent(name.Trim(), parentId, description?.Trim(), vatCodeId)); + } + + public void Deactivate() + { + if (!_isCreated) + throw new DomainException("ACCOUNT_NOT_FOUND", "Account does not exist", "Konto findes ikke"); + + if (_isSystemAccount) + throw new DomainException("SYSTEM_ACCOUNT_READONLY", "System accounts cannot be deactivated", "Systemkonti kan ikke deaktiveres"); + + if (!_isActive) + throw new DomainException("ACCOUNT_ALREADY_INACTIVE", "Account is already inactive", "Konto er allerede inaktiv"); + + Emit(new AccountDeactivatedEvent()); + } + + public void Reactivate() + { + if (!_isCreated) + throw new DomainException("ACCOUNT_NOT_FOUND", "Account does not exist", "Konto findes ikke"); + + if (_isActive) + throw new DomainException("ACCOUNT_ALREADY_ACTIVE", "Account is already active", "Konto er allerede aktiv"); + + Emit(new AccountReactivatedEvent()); + } +} diff --git a/backend/Books.Api/Domain/Accounts/AccountId.cs b/backend/Books.Api/Domain/Accounts/AccountId.cs new file mode 100644 index 0000000..23e12c7 --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/AccountId.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.Accounts; + +public class AccountId(string value) : Identity(value) +{ + /// + /// Creates a deterministic AccountId based on company and account number. + /// This prevents duplicate accounts even with race conditions. + /// Uses SHA256 to generate a deterministic GUID from the input. + /// + public static AccountId FromCompanyAndNumber(string companyId, string accountNumber) + { + // Create a deterministic GUID from the company ID and account number + var input = $"{companyId}:{accountNumber}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + // Use the first 16 bytes of the hash to create a GUID + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits to make it a valid UUID + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); // Version 4 + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); // Variant 1 + + var deterministicGuid = new Guid(guidBytes); + return new AccountId($"account-{deterministicGuid:D}"); + } +} diff --git a/backend/Books.Api/Domain/Accounts/AccountType.cs b/backend/Books.Api/Domain/Accounts/AccountType.cs new file mode 100644 index 0000000..bc42b06 --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/AccountType.cs @@ -0,0 +1,35 @@ +namespace Books.Api.Domain.Accounts; + +/// +/// Danish Standard Chart of Accounts (Standardkontoplan) account types. +/// Account number ranges follow Danish conventions. +/// +public enum AccountType +{ + /// Aktiver (1000-1999) + Asset, + + /// Passiver (2000-2999) + Liability, + + /// Egenkapital (3000-3999) + Equity, + + /// Omsaetning (4000-4999) + Revenue, + + /// Vareforbrug (5000-5999) + Cogs, + + /// Driftsomkostninger (6000-6999) + Expense, + + /// Personaleomkostninger (7000-7999) + Personnel, + + /// Finansielle poster (8000-8999) + Financial, + + /// Ekstraordinaere poster (9000-9999) + Extraordinary +} diff --git a/backend/Books.Api/Domain/Accounts/Events/AccountCreatedEvent.cs b/backend/Books.Api/Domain/Accounts/Events/AccountCreatedEvent.cs new file mode 100644 index 0000000..c623c23 --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/Events/AccountCreatedEvent.cs @@ -0,0 +1,28 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Accounts.Events; + +public class AccountCreatedEvent( + string companyId, + string accountNumber, + string name, + AccountType accountType, + string? parentId, + string? description, + string? vatCodeId, + bool isSystemAccount, + string? standardAccountNumber = null) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string AccountNumber { get; } = accountNumber; + public string Name { get; } = name; + public AccountType AccountType { get; } = accountType; + public string? ParentId { get; } = parentId; + public string? Description { get; } = description; + public string? VatCodeId { get; } = vatCodeId; + public bool IsSystemAccount { get; } = isSystemAccount; + /// + /// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. + /// + public string? StandardAccountNumber { get; } = standardAccountNumber; +} diff --git a/backend/Books.Api/Domain/Accounts/Events/AccountDeactivatedEvent.cs b/backend/Books.Api/Domain/Accounts/Events/AccountDeactivatedEvent.cs new file mode 100644 index 0000000..77e0cf4 --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/Events/AccountDeactivatedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Accounts.Events; + +public class AccountDeactivatedEvent() : AggregateEvent; diff --git a/backend/Books.Api/Domain/Accounts/Events/AccountReactivatedEvent.cs b/backend/Books.Api/Domain/Accounts/Events/AccountReactivatedEvent.cs new file mode 100644 index 0000000..1f275e5 --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/Events/AccountReactivatedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Accounts.Events; + +public class AccountReactivatedEvent() : AggregateEvent; diff --git a/backend/Books.Api/Domain/Accounts/Events/AccountUpdatedEvent.cs b/backend/Books.Api/Domain/Accounts/Events/AccountUpdatedEvent.cs new file mode 100644 index 0000000..b584e5f --- /dev/null +++ b/backend/Books.Api/Domain/Accounts/Events/AccountUpdatedEvent.cs @@ -0,0 +1,15 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Accounts.Events; + +public class AccountUpdatedEvent( + string name, + string? parentId, + string? description, + string? vatCodeId) : AggregateEvent +{ + public string Name { get; } = name; + public string? ParentId { get; } = parentId; + public string? Description { get; } = description; + public string? VatCodeId { get; } = vatCodeId; +} diff --git a/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs b/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs new file mode 100644 index 0000000..572878c --- /dev/null +++ b/backend/Books.Api/Domain/Attachments/AttachmentAggregate.cs @@ -0,0 +1,211 @@ +using Books.Api.Domain.Attachments.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Attachments; + +/// +/// Aggregate for managing document attachments (bilag). +/// Required by Bogføringsloven § 6 - documents must be retained for 5 years. +/// +public class AttachmentAggregate(AttachmentId id) + : AggregateRoot(id), + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private bool _isDeleted; + private string _companyId = string.Empty; + private string? _transactionId; + private DateTimeOffset _uploadedAt; + + public string CompanyId => _companyId; + public bool IsDeleted => _isDeleted; + + #region Apply Methods + + public void Apply(AttachmentUploadedEvent e) + { + _isCreated = true; + _companyId = e.CompanyId; + _transactionId = e.TransactionId; + _uploadedAt = DateTimeOffset.UtcNow; + } + + public void Apply(AttachmentLinkedToTransactionEvent e) + { + _transactionId = e.TransactionId; + } + + public void Apply(AttachmentDeletedEvent e) + { + _isDeleted = true; + } + + #endregion + + #region Command Methods + + /// + /// Upload a new attachment. + /// + public void Upload( + string companyId, + string fileName, + string originalFileName, + string contentType, + long fileSize, + string storagePath, + string uploadedBy, + string? draftId = null, + string? transactionId = null) + { + if (_isCreated) + throw new DomainException( + "ATTACHMENT_ALREADY_EXISTS", + "Attachment already exists", + "Bilag eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException( + "COMPANY_ID_REQUIRED", + "Company ID is required", + "Virksomheds-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(fileName)) + throw new DomainException( + "FILE_NAME_REQUIRED", + "File name is required", + "Filnavn er påkrævet"); + + if (string.IsNullOrWhiteSpace(contentType)) + throw new DomainException( + "CONTENT_TYPE_REQUIRED", + "Content type is required", + "Filtype er påkrævet"); + + if (fileSize <= 0) + throw new DomainException( + "INVALID_FILE_SIZE", + "File size must be greater than 0", + "Filstørrelse skal være større end 0"); + + if (string.IsNullOrWhiteSpace(storagePath)) + throw new DomainException( + "STORAGE_PATH_REQUIRED", + "Storage path is required", + "Lagringssti er påkrævet"); + + if (string.IsNullOrWhiteSpace(uploadedBy)) + throw new DomainException( + "UPLOADED_BY_REQUIRED", + "Uploaded by is required", + "Uploadet af er påkrævet"); + + // Validate allowed file types + var allowedTypes = new[] + { + "application/pdf", + "image/png", "image/jpeg", "image/gif", "image/webp", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }; + + if (!allowedTypes.Contains(contentType.ToLowerInvariant())) + throw new DomainException( + "INVALID_FILE_TYPE", + $"File type '{contentType}' is not allowed. Allowed types: PDF, images, Word, Excel", + $"Filtypen '{contentType}' er ikke tilladt. Tilladte typer: PDF, billeder, Word, Excel"); + + Emit(new AttachmentUploadedEvent( + companyId.Trim(), + fileName.Trim(), + originalFileName.Trim(), + contentType.ToLowerInvariant(), + fileSize, + storagePath.Trim(), + uploadedBy, + draftId?.Trim(), + transactionId?.Trim())); + } + + /// + /// Link attachment to a posted transaction. + /// Called after draft is posted to ledger. + /// + public void LinkToTransaction(string transactionId) + { + EnsureExists(); + + if (string.IsNullOrWhiteSpace(transactionId)) + throw new DomainException( + "TRANSACTION_ID_REQUIRED", + "Transaction ID is required", + "Transaktions-ID er påkrævet"); + + if (!string.IsNullOrEmpty(_transactionId)) + throw new DomainException( + "ALREADY_LINKED", + "Attachment is already linked to a transaction", + "Bilaget er allerede knyttet til en transaktion"); + + Emit(new AttachmentLinkedToTransactionEvent(transactionId.Trim())); + } + + /// + /// Delete an attachment. + /// Note: Per Bogføringsloven § 6, this should only be used for + /// administrative cleanup after the 5-year retention period. + /// + public void Delete(string deletedBy, string reason) + { + EnsureExists(); + + if (string.IsNullOrWhiteSpace(deletedBy)) + throw new DomainException( + "DELETED_BY_REQUIRED", + "Deleted by is required", + "Slettet af er påkrævet"); + + if (string.IsNullOrWhiteSpace(reason)) + throw new DomainException( + "DELETE_REASON_REQUIRED", + "A reason for deletion is required (Bogføringsloven § 6)", + "En begrundelse for sletning er påkrævet (Bogføringsloven § 6)"); + + // Check retention period + var retentionYears = 5; + var retentionEndDate = _uploadedAt.AddYears(retentionYears); + + if (DateTimeOffset.UtcNow < retentionEndDate && !string.IsNullOrEmpty(_transactionId)) + throw new DomainException( + "RETENTION_PERIOD_ACTIVE", + $"Cannot delete attachment linked to transaction - retention period ends {retentionEndDate:yyyy-MM-dd} (Bogføringsloven § 6)", + $"Kan ikke slette bilag knyttet til transaktion - opbevaringsperiode udløber {retentionEndDate:dd-MM-yyyy} (Bogføringsloven § 6)"); + + Emit(new AttachmentDeletedEvent(deletedBy, reason.Trim())); + } + + #endregion + + #region Private Methods + + private void EnsureExists() + { + if (!_isCreated) + throw new DomainException( + "ATTACHMENT_NOT_FOUND", + "Attachment does not exist", + "Bilaget findes ikke"); + + if (_isDeleted) + throw new DomainException( + "ATTACHMENT_DELETED", + "Attachment has been deleted", + "Bilaget er blevet slettet"); + } + + #endregion +} diff --git a/backend/Books.Api/Domain/Attachments/AttachmentId.cs b/backend/Books.Api/Domain/Attachments/AttachmentId.cs new file mode 100644 index 0000000..2043be4 --- /dev/null +++ b/backend/Books.Api/Domain/Attachments/AttachmentId.cs @@ -0,0 +1,13 @@ +using EventFlow.Core; +using EventFlow.ValueObjects; + +namespace Books.Api.Domain.Attachments; + +/// +/// Identity for Attachment aggregate. +/// Bilag (attachments) are required by Bogføringsloven § 6. +/// +public class AttachmentId : Identity +{ + public AttachmentId(string value) : base(value) { } +} diff --git a/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs b/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs new file mode 100644 index 0000000..b7c558e --- /dev/null +++ b/backend/Books.Api/Domain/Attachments/Events/AttachmentEvents.cs @@ -0,0 +1,80 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Attachments.Events; + +/// +/// Event raised when an attachment (bilag) is uploaded. +/// Required by Bogføringsloven § 6 for document retention. +/// +public class AttachmentUploadedEvent( + string companyId, + string fileName, + string originalFileName, + string contentType, + long fileSize, + string storagePath, + string uploadedBy, + string? draftId = null, + string? transactionId = null) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + + /// + /// Stored filename (sanitized/unique). + /// + public string FileName { get; } = fileName; + + /// + /// Original filename as uploaded by user. + /// + public string OriginalFileName { get; } = originalFileName; + + /// + /// MIME type (e.g., application/pdf, image/png). + /// + public string ContentType { get; } = contentType; + + /// + /// File size in bytes. + /// + public long FileSize { get; } = fileSize; + + /// + /// Path to the stored file (local path or blob URL). + /// + public string StoragePath { get; } = storagePath; + + public string UploadedBy { get; } = uploadedBy; + + /// + /// Optional reference to journal entry draft. + /// + public string? DraftId { get; } = draftId; + + /// + /// Optional reference to posted transaction. + /// + public string? TransactionId { get; } = transactionId; +} + +/// +/// Event raised when an attachment is linked to a transaction after posting. +/// +public class AttachmentLinkedToTransactionEvent( + string transactionId) : AggregateEvent +{ + public string TransactionId { get; } = transactionId; +} + +/// +/// Event raised when an attachment is deleted. +/// Note: Per Bogføringsloven § 6, attachments should be retained for 5 years. +/// This event is for administrative cleanup only. +/// +public class AttachmentDeletedEvent( + string deletedBy, + string reason) : AggregateEvent +{ + public string DeletedBy { get; } = deletedBy; + public string Reason { get; } = reason; +} diff --git a/backend/Books.Api/Domain/BankConnections/BankAccountInfo.cs b/backend/Books.Api/Domain/BankConnections/BankAccountInfo.cs new file mode 100644 index 0000000..37f9133 --- /dev/null +++ b/backend/Books.Api/Domain/BankConnections/BankAccountInfo.cs @@ -0,0 +1,12 @@ +namespace Books.Api.Domain.BankConnections; + +/// +/// Information about a bank account from the Open Banking connection +/// +public record BankAccountInfo( + string AccountId, + string Iban, + string Currency, + string? Name, + string? LinkedAccountId = null, + DateOnly? ImportFromDate = null); // Date to start importing transactions from diff --git a/backend/Books.Api/Domain/BankConnections/BankConnectionAggregate.cs b/backend/Books.Api/Domain/BankConnections/BankConnectionAggregate.cs new file mode 100644 index 0000000..fced69d --- /dev/null +++ b/backend/Books.Api/Domain/BankConnections/BankConnectionAggregate.cs @@ -0,0 +1,274 @@ +using Books.Api.Domain.BankConnections.Events; +using EventFlow.Aggregates; +using EventFlow.Core; + +namespace Books.Api.Domain.BankConnections; + +public class BankConnectionAggregate(BankConnectionId id) + : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isInitiated; + private bool _isEstablished; + private bool _isDisconnected; + private bool _isArchived; + private string? _sessionId; + private DateTimeOffset? _validUntil; + private List _accounts = []; + + public string? SessionId => _sessionId; + public bool IsActive => _isEstablished && !_isDisconnected && _validUntil > DateTimeOffset.UtcNow; + public IReadOnlyList Accounts => _accounts; + + public void Apply(BankConnectionInitiatedEvent e) + { + _isInitiated = true; + } + + public void Apply(BankConnectionEstablishedEvent e) + { + _isEstablished = true; + _sessionId = e.SessionId; + _validUntil = e.ValidUntil; + _accounts = e.Accounts.ToList(); + } + + public void Apply(BankConnectionFailedEvent e) + { + _isDisconnected = true; + } + + public void Apply(BankConnectionDisconnectedEvent e) + { + _isDisconnected = true; + _sessionId = null; + } + + public void Apply(BankConnectionRefreshedEvent e) + { + _sessionId = e.NewSessionId; + _validUntil = e.ValidUntil; + } + + public void Apply(BankAccountLinkedEvent e) + { + // Update the account with the linked account ID and import from date + var accountIndex = _accounts.FindIndex(a => a.AccountId == e.BankAccountId); + if (accountIndex >= 0) + { + var account = _accounts[accountIndex]; + _accounts[accountIndex] = account with + { + LinkedAccountId = e.LinkedAccountId, + ImportFromDate = e.ImportFromDate + }; + } + } + + public void Apply(BankConnectionReInitiatedEvent e) + { + // Reset to initiated state, ready for new authorization + _isEstablished = false; + _isDisconnected = false; + _sessionId = null; + _validUntil = null; + } + + public void Apply(BankConnectionArchivedEvent e) + { + _isArchived = true; + } + + /// + /// Initiate a bank connection authorization flow + /// + public void Initiate( + string companyId, + string aspspName, + string authorizationId, + string redirectUrl, + string state) + { + if (_isInitiated) + throw new DomainException("BANK_CONNECTION_ALREADY_INITIATED", + "Bank connection has already been initiated", + "Bankforbindelse er allerede påbegyndt"); + + if (string.IsNullOrWhiteSpace(aspspName)) + throw new DomainException("ASPSP_NAME_REQUIRED", + "Bank name (ASPSP) is required", + "Banknavn er påkrævet"); + + Emit(new BankConnectionInitiatedEvent( + companyId, + aspspName, + authorizationId, + redirectUrl, + state)); + } + + /// + /// Establish the bank connection after successful authorization + /// + public void Establish( + string sessionId, + DateTimeOffset validUntil, + IReadOnlyList accounts) + { + if (!_isInitiated) + throw new DomainException("BANK_CONNECTION_NOT_INITIATED", + "Bank connection must be initiated before establishing", + "Bankforbindelse skal påbegyndes før den kan etableres"); + + if (_isEstablished && !_isDisconnected) + throw new DomainException("BANK_CONNECTION_ALREADY_ESTABLISHED", + "Bank connection is already established", + "Bankforbindelse er allerede etableret"); + + if (string.IsNullOrWhiteSpace(sessionId)) + throw new DomainException("SESSION_ID_REQUIRED", + "Session ID is required", + "Session ID er påkrævet"); + + // Reject connections without accounts - the Enable Banking session response + // is the ONLY source for account information. If empty, user should try + // different psuType (personal vs business) + if (!accounts.Any()) + { + throw new DomainException("NO_ACCOUNTS_FOUND", + "No bank accounts found. Try switching between personal/business account type.", + "Ingen bankkonti fundet. Prøv at skifte mellem personlig/erhverv kontotype."); + } + + Emit(new BankConnectionEstablishedEvent(sessionId, validUntil, accounts)); + } + + /// + /// Mark the connection as failed + /// + public void Fail(string reason) + { + if (!_isInitiated) + throw new DomainException("BANK_CONNECTION_NOT_INITIATED", + "Cannot fail a connection that was not initiated", + "Kan ikke fejle en forbindelse der ikke er påbegyndt"); + + Emit(new BankConnectionFailedEvent(reason)); + } + + /// + /// Disconnect the bank connection (idempotent - safe to call on already disconnected connections) + /// + public void Disconnect(string reason = "User requested disconnection") + { + // Idempotent: if already disconnected, do nothing + if (_isDisconnected) + return; + + // Allow disconnecting failed connections that were never fully established + // This enables cleanup of connections stuck in "initiated" or "failed" state + if (!_isEstablished && !_isInitiated) + throw new DomainException("BANK_CONNECTION_NOT_INITIATED", + "Cannot disconnect a connection that was never initiated", + "Kan ikke afbryde en forbindelse der aldrig blev påbegyndt"); + + Emit(new BankConnectionDisconnectedEvent(reason)); + } + + /// + /// Refresh the bank connection session + /// + public void Refresh(string newSessionId, DateTimeOffset validUntil) + { + if (!_isEstablished || _isDisconnected) + throw new DomainException("BANK_CONNECTION_NOT_ACTIVE", + "Cannot refresh an inactive connection", + "Kan ikke genopfriske en inaktiv forbindelse"); + + Emit(new BankConnectionRefreshedEvent(newSessionId, validUntil)); + } + + /// + /// Link a bank account to a chart of accounts account + /// + public void LinkBankAccount(string bankAccountId, string linkedAccountId, DateOnly? importFromDate = null) + { + if (!_isEstablished || _isDisconnected) + throw new DomainException("BANK_CONNECTION_NOT_ACTIVE", + "Cannot link accounts on an inactive connection", + "Kan ikke koble konti på en inaktiv forbindelse"); + + if (string.IsNullOrWhiteSpace(bankAccountId)) + throw new DomainException("BANK_ACCOUNT_ID_REQUIRED", + "Bank account ID is required", + "Bankkonto ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(linkedAccountId)) + throw new DomainException("LINKED_ACCOUNT_ID_REQUIRED", + "Linked account ID is required", + "Finanskonto ID er påkrævet"); + + var account = _accounts.FirstOrDefault(a => a.AccountId == bankAccountId); + if (account == null) + throw new DomainException("BANK_ACCOUNT_NOT_FOUND", + $"Bank account with ID '{bankAccountId}' not found in this connection", + $"Bankkonto med ID '{bankAccountId}' blev ikke fundet i denne forbindelse"); + + Emit(new BankAccountLinkedEvent(bankAccountId, linkedAccountId, importFromDate)); + } + + /// + /// Re-initiate the bank connection authorization flow (reconnect). + /// Used for connections that are disconnected, failed, or expired. + /// Active connections are automatically disconnected before re-initiation. + /// + public void ReInitiate(string authorizationId, string redirectUrl, string state) + { + if (!_isInitiated) + throw new DomainException("BANK_CONNECTION_NOT_INITIATED", + "Cannot re-initiate a connection that was never initiated", + "Kan ikke genoptage en forbindelse der aldrig blev påbegyndt"); + + if (string.IsNullOrWhiteSpace(authorizationId)) + throw new DomainException("AUTHORIZATION_ID_REQUIRED", + "Authorization ID is required", + "Autorisations ID er påkrævet"); + + // Auto-disconnect active connections before re-initiation + if (!_isDisconnected && _isEstablished) + { + Emit(new BankConnectionDisconnectedEvent("Auto-disconnected for re-connection")); + } + + Emit(new BankConnectionReInitiatedEvent(authorizationId, redirectUrl, state)); + } + + /// + /// Archive the bank connection (hide from UI) + /// + public void Archive(string reason = "User requested archival") + { + if (!_isInitiated) + throw new DomainException("BANK_CONNECTION_NOT_INITIATED", + "Cannot archive a connection that was never initiated", + "Kan ikke arkivere en forbindelse der aldrig blev påbegyndt"); + + if (_isArchived) + return; // Idempotent - already archived + + // Don't allow archiving active connections + if (IsActive) + throw new DomainException("BANK_CONNECTION_STILL_ACTIVE", + "Cannot archive an active connection. Disconnect first.", + "Kan ikke arkivere en aktiv forbindelse. Afbryd først."); + + Emit(new BankConnectionArchivedEvent(reason)); + } +} diff --git a/backend/Books.Api/Domain/BankConnections/BankConnectionId.cs b/backend/Books.Api/Domain/BankConnections/BankConnectionId.cs new file mode 100644 index 0000000..6da85ab --- /dev/null +++ b/backend/Books.Api/Domain/BankConnections/BankConnectionId.cs @@ -0,0 +1,8 @@ +using EventFlow.Core; + +namespace Books.Api.Domain.BankConnections; + +public class BankConnectionId : Identity +{ + public BankConnectionId(string value) : base(value) { } +} diff --git a/backend/Books.Api/Domain/BankConnections/Events/BankConnectionEvents.cs b/backend/Books.Api/Domain/BankConnections/Events/BankConnectionEvents.cs new file mode 100644 index 0000000..7028f45 --- /dev/null +++ b/backend/Books.Api/Domain/BankConnections/Events/BankConnectionEvents.cs @@ -0,0 +1,97 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.BankConnections.Events; + +/// +/// Raised when a bank connection authorization is started +/// +public class BankConnectionInitiatedEvent( + string companyId, + string aspspName, + string authorizationId, + string redirectUrl, + string state) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string AspspName { get; } = aspspName; + public string AuthorizationId { get; } = authorizationId; + public string RedirectUrl { get; } = redirectUrl; + public string State { get; } = state; +} + +/// +/// Raised when a bank connection is successfully established +/// +public class BankConnectionEstablishedEvent( + string sessionId, + DateTimeOffset validUntil, + IReadOnlyList accounts) : AggregateEvent +{ + public string SessionId { get; } = sessionId; + public DateTimeOffset ValidUntil { get; } = validUntil; + public IReadOnlyList Accounts { get; } = accounts; +} + +/// +/// Raised when a bank connection fails or is rejected +/// +public class BankConnectionFailedEvent( + string reason) : AggregateEvent +{ + public string Reason { get; } = reason; +} + +/// +/// Raised when a bank connection is disconnected +/// +public class BankConnectionDisconnectedEvent( + string reason) : AggregateEvent +{ + public string Reason { get; } = reason; +} + +/// +/// Raised when a bank connection session is refreshed +/// +public class BankConnectionRefreshedEvent( + string newSessionId, + DateTimeOffset validUntil) : AggregateEvent +{ + public string NewSessionId { get; } = newSessionId; + public DateTimeOffset ValidUntil { get; } = validUntil; +} + +/// +/// Raised when a bank account is linked to a chart of accounts account +/// +public class BankAccountLinkedEvent( + string bankAccountId, + string linkedAccountId, + DateOnly? importFromDate) : AggregateEvent +{ + public string BankAccountId { get; } = bankAccountId; + public string LinkedAccountId { get; } = linkedAccountId; + public DateOnly? ImportFromDate { get; } = importFromDate; +} + +/// +/// Raised when a bank connection authorization is re-initiated (reconnect) +/// +public class BankConnectionReInitiatedEvent( + string authorizationId, + string redirectUrl, + string state) : AggregateEvent +{ + public string AuthorizationId { get; } = authorizationId; + public string RedirectUrl { get; } = redirectUrl; + public string State { get; } = state; +} + +/// +/// Raised when a bank connection is archived (hidden from UI) +/// +public class BankConnectionArchivedEvent( + string reason) : AggregateEvent +{ + public string Reason { get; } = reason; +} diff --git a/backend/Books.Api/Domain/Companies/CvrValidator.cs b/backend/Books.Api/Domain/Companies/CvrValidator.cs new file mode 100644 index 0000000..0b5454f --- /dev/null +++ b/backend/Books.Api/Domain/Companies/CvrValidator.cs @@ -0,0 +1,41 @@ +namespace Books.Api.Domain.Companies; + +/// +/// Validates Danish CVR (Central Business Register) numbers. +/// CVR numbers are 8 digits with a modulus 11 checksum. +/// +public static class CvrValidator +{ + private static readonly int[] Weights = [2, 7, 6, 5, 4, 3, 2, 1]; + + /// + /// Validates a CVR number using the modulus 11 algorithm. + /// + /// The CVR number to validate + /// True if valid, false otherwise + public static bool IsValid(string? cvr) + { + if (string.IsNullOrWhiteSpace(cvr)) + return false; + + // Must be exactly 8 digits + if (cvr.Length != 8 || !cvr.All(char.IsDigit)) + return false; + + // Modulus 11 check + var sum = cvr.Select((c, i) => (c - '0') * Weights[i]).Sum(); + return sum % 11 == 0; + } + + /// + /// Validates CVR format (8 digits) without checksum verification. + /// Useful for displaying format errors before checksum errors. + /// + public static bool HasValidFormat(string? cvr) + { + if (string.IsNullOrWhiteSpace(cvr)) + return false; + + return cvr.Length == 8 && cvr.All(char.IsDigit); + } +} diff --git a/backend/Books.Api/Domain/Companies/Events/CompanyBankDetailsUpdatedEvent.cs b/backend/Books.Api/Domain/Companies/Events/CompanyBankDetailsUpdatedEvent.cs new file mode 100644 index 0000000..88c474f --- /dev/null +++ b/backend/Books.Api/Domain/Companies/Events/CompanyBankDetailsUpdatedEvent.cs @@ -0,0 +1,17 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Companies.Events; + +public class CompanyBankDetailsUpdatedEvent( + string? bankName, + string? bankRegNo, + string? bankAccountNo, + string? bankIban, + string? bankBic) : AggregateEvent +{ + public string? BankName { get; } = bankName; + public string? BankRegNo { get; } = bankRegNo; + public string? BankAccountNo { get; } = bankAccountNo; + public string? BankIban { get; } = bankIban; + public string? BankBic { get; } = bankBic; +} diff --git a/backend/Books.Api/Domain/Customers/CustomerAggregate.cs b/backend/Books.Api/Domain/Customers/CustomerAggregate.cs new file mode 100644 index 0000000..ad130cb --- /dev/null +++ b/backend/Books.Api/Domain/Customers/CustomerAggregate.cs @@ -0,0 +1,191 @@ +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(id), + IEmit, + IEmit, + IEmit, + IEmit +{ + 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"); + } +} diff --git a/backend/Books.Api/Domain/Customers/CustomerId.cs b/backend/Books.Api/Domain/Customers/CustomerId.cs new file mode 100644 index 0000000..6f1838d --- /dev/null +++ b/backend/Books.Api/Domain/Customers/CustomerId.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.Customers; + +public class CustomerId(string value) : Identity(value) +{ + /// + /// Creates a deterministic CustomerId based on company and customer number. + /// This prevents duplicate customers even with race conditions. + /// Uses SHA256 to generate a deterministic GUID from the input. + /// + public static CustomerId FromCompanyAndNumber(string companyId, string customerNumber) + { + var input = $"{companyId}:customer:{customerNumber}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits to make it a valid UUID + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); + + var deterministicGuid = new Guid(guidBytes); + return new CustomerId($"customer-{deterministicGuid:D}"); + } +} diff --git a/backend/Books.Api/Domain/Customers/CustomerType.cs b/backend/Books.Api/Domain/Customers/CustomerType.cs new file mode 100644 index 0000000..d0e8901 --- /dev/null +++ b/backend/Books.Api/Domain/Customers/CustomerType.cs @@ -0,0 +1,14 @@ +namespace Books.Api.Domain.Customers; + +/// +/// Type of customer for invoicing purposes. +/// B2B customers require CVR, B2C customers do not. +/// +public enum CustomerType +{ + /// Business customer (erhvervskunde) - CVR required + Business, + + /// Private customer (privatkunde) - CVR not required + Private +} diff --git a/backend/Books.Api/Domain/Customers/Events/CustomerCreatedEvent.cs b/backend/Books.Api/Domain/Customers/Events/CustomerCreatedEvent.cs new file mode 100644 index 0000000..da8ce4f --- /dev/null +++ b/backend/Books.Api/Domain/Customers/Events/CustomerCreatedEvent.cs @@ -0,0 +1,35 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Customers.Events; + +public class CustomerCreatedEvent( + 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) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string CustomerNumber { get; } = customerNumber; + public CustomerType CustomerType { get; } = customerType; + public string Name { get; } = name; + public string? Cvr { get; } = cvr; + public string? Address { get; } = address; + public string? PostalCode { get; } = postalCode; + public string? City { get; } = city; + public string Country { get; } = country; + public string? Email { get; } = email; + public string? Phone { get; } = phone; + public int PaymentTermsDays { get; } = paymentTermsDays; + public string? DefaultRevenueAccountId { get; } = defaultRevenueAccountId; + public string SubLedgerAccountId { get; } = subLedgerAccountId; +} diff --git a/backend/Books.Api/Domain/Customers/Events/CustomerDeactivatedEvent.cs b/backend/Books.Api/Domain/Customers/Events/CustomerDeactivatedEvent.cs new file mode 100644 index 0000000..32e9726 --- /dev/null +++ b/backend/Books.Api/Domain/Customers/Events/CustomerDeactivatedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Customers.Events; + +public class CustomerDeactivatedEvent() : AggregateEvent; diff --git a/backend/Books.Api/Domain/Customers/Events/CustomerReactivatedEvent.cs b/backend/Books.Api/Domain/Customers/Events/CustomerReactivatedEvent.cs new file mode 100644 index 0000000..be16f6e --- /dev/null +++ b/backend/Books.Api/Domain/Customers/Events/CustomerReactivatedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Customers.Events; + +public class CustomerReactivatedEvent() : AggregateEvent; diff --git a/backend/Books.Api/Domain/Customers/Events/CustomerUpdatedEvent.cs b/backend/Books.Api/Domain/Customers/Events/CustomerUpdatedEvent.cs new file mode 100644 index 0000000..a7a0512 --- /dev/null +++ b/backend/Books.Api/Domain/Customers/Events/CustomerUpdatedEvent.cs @@ -0,0 +1,27 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Customers.Events; + +public class CustomerUpdatedEvent( + string name, + string? cvr, + string? address, + string? postalCode, + string? city, + string country, + string? email, + string? phone, + int paymentTermsDays, + string? defaultRevenueAccountId) : AggregateEvent +{ + public string Name { get; } = name; + public string? Cvr { get; } = cvr; + public string? Address { get; } = address; + public string? PostalCode { get; } = postalCode; + public string? City { get; } = city; + public string Country { get; } = country; + public string? Email { get; } = email; + public string? Phone { get; } = phone; + public int PaymentTermsDays { get; } = paymentTermsDays; + public string? DefaultRevenueAccountId { get; } = defaultRevenueAccountId; +} diff --git a/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearClosedEvent.cs b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearClosedEvent.cs new file mode 100644 index 0000000..ef6fe19 --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearClosedEvent.cs @@ -0,0 +1,8 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.FiscalYears.Events; + +public class FiscalYearClosedEvent(string closedBy) : AggregateEvent +{ + public string ClosedBy { get; } = closedBy; +} diff --git a/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearCreatedEvent.cs b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearCreatedEvent.cs new file mode 100644 index 0000000..16becc0 --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearCreatedEvent.cs @@ -0,0 +1,15 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.FiscalYears.Events; + +public class FiscalYearCreatedEvent( + string companyId, + string name, + DateOnly startDate, + DateOnly endDate) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string Name { get; } = name; + public DateOnly StartDate { get; } = startDate; + public DateOnly EndDate { get; } = endDate; +} diff --git a/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearLockedEvent.cs b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearLockedEvent.cs new file mode 100644 index 0000000..3353ded --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearLockedEvent.cs @@ -0,0 +1,8 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.FiscalYears.Events; + +public class FiscalYearLockedEvent(string lockedBy) : AggregateEvent +{ + public string LockedBy { get; } = lockedBy; +} diff --git a/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearReopenedEvent.cs b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearReopenedEvent.cs new file mode 100644 index 0000000..4d3c7d8 --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/Events/FiscalYearReopenedEvent.cs @@ -0,0 +1,8 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.FiscalYears.Events; + +public class FiscalYearReopenedEvent(string reopenedBy) : AggregateEvent +{ + public string ReopenedBy { get; } = reopenedBy; +} diff --git a/backend/Books.Api/Domain/FiscalYears/Events/OpeningBalancePostedEvent.cs b/backend/Books.Api/Domain/FiscalYears/Events/OpeningBalancePostedEvent.cs new file mode 100644 index 0000000..93cd613 --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/Events/OpeningBalancePostedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.FiscalYears.Events; + +public class OpeningBalancePostedEvent() : AggregateEvent; diff --git a/backend/Books.Api/Domain/FiscalYears/FiscalYearAggregate.cs b/backend/Books.Api/Domain/FiscalYears/FiscalYearAggregate.cs new file mode 100644 index 0000000..17b0dab --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/FiscalYearAggregate.cs @@ -0,0 +1,144 @@ +using Books.Api.Domain.FiscalYears.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.FiscalYears; + +public class FiscalYearAggregate(FiscalYearId id) : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private FiscalYearStatus _status = FiscalYearStatus.Open; + private bool _openingBalancePosted; + + public void Apply(FiscalYearCreatedEvent e) + { + _isCreated = true; + _status = FiscalYearStatus.Open; + } + + public void Apply(FiscalYearClosedEvent e) => _status = FiscalYearStatus.Closed; + + public void Apply(FiscalYearReopenedEvent e) => _status = FiscalYearStatus.Open; + + public void Apply(FiscalYearLockedEvent e) => _status = FiscalYearStatus.Locked; + + public void Apply(OpeningBalancePostedEvent e) => _openingBalancePosted = true; + + public void Create( + string companyId, + string name, + DateOnly startDate, + DateOnly endDate, + bool isFirstFiscalYear = false, + bool isReorganization = false) + { + if (_isCreated) + throw new DomainException("FISCAL_YEAR_EXISTS", "Fiscal year already exists", "Regnskabsaar eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er paakraevet"); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("NAME_REQUIRED", "Fiscal year name is required", "Regnskabsaarsnavn er paakraevet"); + + if (endDate <= startDate) + throw new DomainException("INVALID_DATE_RANGE", "End date must be after start date", "Slutdato skal vaere efter startdato"); + + // Calculate duration in months + var months = ((endDate.Year - startDate.Year) * 12) + endDate.Month - startDate.Month; + + // If endDate is NOT the 1st of month, we're including that month + // (i.e., Dec 31 means include December, but Jan 1 means exclude January) + if (endDate.Day > 1) + months++; + + // Per Årsregnskabsloven §15 (Danish Accounting Act): + // - Standard fiscal year: Exactly 12 months + // - First fiscal year: Can be 6-18 months + // - Reorganization: Can be up to 18 months + if (isFirstFiscalYear || isReorganization) + { + // Allow flexible duration for first year or reorganization + if (months < 6 || months > 18) + throw new DomainException("INVALID_DURATION", + "Fiscal year must be 6-18 months for first year or reorganization (Årsregnskabsloven §15)", + "Regnskabsår skal være 6-18 måneder for første år eller omlægning (Årsregnskabsloven §15)"); + } + else + { + // Standard fiscal year must be exactly 12 months + if (months != 12) + throw new DomainException("INVALID_STANDARD_DURATION", + "Standard fiscal year must be exactly 12 months. Use isFirstFiscalYear or isReorganization for non-standard duration (Årsregnskabsloven §15)", + "Standard regnskabsår skal være præcis 12 måneder. Brug isFirstFiscalYear eller isReorganization for afvigende varighed (Årsregnskabsloven §15)"); + } + + Emit(new FiscalYearCreatedEvent(companyId, name.Trim(), startDate, endDate)); + } + + public void Close(string closedBy) + { + if (!_isCreated) + throw new DomainException("FISCAL_YEAR_NOT_FOUND", "Fiscal year does not exist", "Regnskabsaar findes ikke"); + + if (_status == FiscalYearStatus.Closed) + throw new DomainException("ALREADY_CLOSED", "Fiscal year is already closed", "Regnskabsaar er allerede lukket"); + + if (_status == FiscalYearStatus.Locked) + throw new DomainException("FISCAL_YEAR_LOCKED", "Fiscal year is locked and cannot be modified", "Regnskabsaar er laast og kan ikke aendres"); + + if (string.IsNullOrWhiteSpace(closedBy)) + throw new DomainException("CLOSED_BY_REQUIRED", "User who closed the year is required", "Bruger der lukker aaret er paakraevet"); + + Emit(new FiscalYearClosedEvent(closedBy)); + } + + public void Reopen(string reopenedBy) + { + if (!_isCreated) + throw new DomainException("FISCAL_YEAR_NOT_FOUND", "Fiscal year does not exist", "Regnskabsaar findes ikke"); + + if (_status == FiscalYearStatus.Open) + throw new DomainException("ALREADY_OPEN", "Fiscal year is already open", "Regnskabsaar er allerede aabent"); + + if (_status == FiscalYearStatus.Locked) + throw new DomainException("FISCAL_YEAR_LOCKED", "Locked fiscal years cannot be reopened", "Laaste regnskabsaar kan ikke genåbnes"); + + if (string.IsNullOrWhiteSpace(reopenedBy)) + throw new DomainException("REOPENED_BY_REQUIRED", "User who reopened the year is required", "Bruger der genåbner aaret er paakraevet"); + + Emit(new FiscalYearReopenedEvent(reopenedBy)); + } + + public void Lock(string lockedBy) + { + if (!_isCreated) + throw new DomainException("FISCAL_YEAR_NOT_FOUND", "Fiscal year does not exist", "Regnskabsaar findes ikke"); + + if (_status == FiscalYearStatus.Locked) + throw new DomainException("ALREADY_LOCKED", "Fiscal year is already locked", "Regnskabsaar er allerede laast"); + + if (_status != FiscalYearStatus.Closed) + throw new DomainException("MUST_BE_CLOSED", "Fiscal year must be closed before it can be locked", "Regnskabsaar skal vaere lukket foer det kan laases"); + + if (string.IsNullOrWhiteSpace(lockedBy)) + throw new DomainException("LOCKED_BY_REQUIRED", "User who locked the year is required", "Bruger der laaser aaret er paakraevet"); + + Emit(new FiscalYearLockedEvent(lockedBy)); + } + + public void MarkOpeningBalancePosted() + { + if (!_isCreated) + throw new DomainException("FISCAL_YEAR_NOT_FOUND", "Fiscal year does not exist", "Regnskabsaar findes ikke"); + + if (_openingBalancePosted) + throw new DomainException("OPENING_BALANCE_ALREADY_POSTED", "Opening balance has already been posted", "Primosaldo er allerede bogfoert"); + + Emit(new OpeningBalancePostedEvent()); + } +} diff --git a/backend/Books.Api/Domain/FiscalYears/FiscalYearId.cs b/backend/Books.Api/Domain/FiscalYears/FiscalYearId.cs new file mode 100644 index 0000000..fb4806a --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/FiscalYearId.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.FiscalYears; + +public class FiscalYearId : Identity +{ + public FiscalYearId(string value) : base(value) { } + + /// + /// Creates a deterministic FiscalYearId based on company, start date, and end date. + /// This prevents duplicate fiscal years even with race conditions. + /// Uses SHA256 to generate a deterministic GUID from the input. + /// + public static FiscalYearId FromCompanyAndDates(string companyId, DateOnly startDate, DateOnly endDate) + { + var input = $"{companyId}:{startDate:yyyy-MM-dd}:{endDate:yyyy-MM-dd}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits to make it a valid UUID + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); // Version 4 + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); // Variant 1 + + var deterministicGuid = new Guid(guidBytes); + return new FiscalYearId($"fiscalyear-{deterministicGuid:D}"); + } +} diff --git a/backend/Books.Api/Domain/FiscalYears/FiscalYearStatus.cs b/backend/Books.Api/Domain/FiscalYears/FiscalYearStatus.cs new file mode 100644 index 0000000..cf75be2 --- /dev/null +++ b/backend/Books.Api/Domain/FiscalYears/FiscalYearStatus.cs @@ -0,0 +1,14 @@ +namespace Books.Api.Domain.FiscalYears; + +/// +/// Status of a fiscal year. +/// Open: Normal operation, transactions can be posted +/// Closed: Year-end completed, can be reopened if needed +/// Locked: Permanently locked, cannot be modified (e.g., after audit) +/// +public enum FiscalYearStatus +{ + Open, + Closed, + Locked +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceCreatedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceCreatedEvent.cs new file mode 100644 index 0000000..1a9be5b --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceCreatedEvent.cs @@ -0,0 +1,64 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when a new invoice or credit note draft is created. +/// At this point, the invoice/credit note number is assigned (Momsloven §52). +/// +public class InvoiceCreatedEvent( + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string invoiceNumber, + DateOnly invoiceDate, + DateOnly dueDate, + int paymentTermsDays, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy, + InvoiceType type = InvoiceType.Invoice, + string? originalInvoiceId = null, + string? originalInvoiceNumber = null, + string? creditReason = null) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string FiscalYearId { get; } = fiscalYearId; + public string CustomerId { get; } = customerId; + public string CustomerName { get; } = customerName; + public string CustomerNumber { get; } = customerNumber; + public string InvoiceNumber { get; } = invoiceNumber; + public DateOnly InvoiceDate { get; } = invoiceDate; + public DateOnly DueDate { get; } = dueDate; + public int PaymentTermsDays { get; } = paymentTermsDays; + public string Currency { get; } = currency; + public string? VatCode { get; } = vatCode; + public string? Notes { get; } = notes; + public string? Reference { get; } = reference; + public string CreatedBy { get; } = createdBy; + + /// + /// Type of document: Invoice or CreditNote. + /// + public InvoiceType Type { get; } = type; + + /// + /// For credit notes: Reference to the original invoice being credited. + /// + public string? OriginalInvoiceId { get; } = originalInvoiceId; + + /// + /// For credit notes: The invoice number of the original invoice. + /// + public string? OriginalInvoiceNumber { get; } = originalInvoiceNumber; + + /// + /// For credit notes: Reason for issuing the credit note. + /// + public string? CreditReason { get; } = creditReason; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceCreditAppliedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceCreditAppliedEvent.cs new file mode 100644 index 0000000..c51287d --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceCreditAppliedEvent.cs @@ -0,0 +1,64 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when a credit note is applied to an invoice. +/// This reduces the credit note's remaining amount and potentially the target invoice's balance. +/// +public class InvoiceCreditAppliedEvent( + string appliedToInvoiceId, + string appliedToInvoiceNumber, + decimal amount, + DateOnly appliedDate, + string appliedBy, + string? ledgerTransactionId, + decimal newAmountApplied, + decimal newAmountRemaining, + InvoiceStatus newStatus) : AggregateEvent +{ + /// + /// The invoice this credit note is being applied to. + /// + public string AppliedToInvoiceId { get; } = appliedToInvoiceId; + + /// + /// The invoice number of the target invoice. + /// + public string AppliedToInvoiceNumber { get; } = appliedToInvoiceNumber; + + /// + /// The amount of credit being applied. + /// + public decimal Amount { get; } = amount; + + /// + /// The date the credit was applied. + /// + public DateOnly AppliedDate { get; } = appliedDate; + + /// + /// User who applied the credit. + /// + public string AppliedBy { get; } = appliedBy; + + /// + /// Reference to the ledger transaction created for this application. + /// + public string? LedgerTransactionId { get; } = ledgerTransactionId; + + /// + /// Total amount applied after this operation. + /// + public decimal NewAmountApplied { get; } = newAmountApplied; + + /// + /// Remaining credit amount after this operation. + /// + public decimal NewAmountRemaining { get; } = newAmountRemaining; + + /// + /// New status after applying the credit. + /// + public InvoiceStatus NewStatus { get; } = newStatus; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceLineAddedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceLineAddedEvent.cs new file mode 100644 index 0000000..28d95d4 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceLineAddedEvent.cs @@ -0,0 +1,27 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when a line is added to an invoice draft. +/// +public class InvoiceLineAddedEvent( + int lineNumber, + string description, + decimal quantity, + string? unit, + decimal unitPrice, + decimal discountPercent, + string vatCode, + string? accountId) : AggregateEvent +{ + public int LineNumber { get; } = lineNumber; + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public string? Unit { get; } = unit; + public decimal UnitPrice { get; } = unitPrice; + public decimal DiscountPercent { get; } = discountPercent; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceLineRemovedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceLineRemovedEvent.cs new file mode 100644 index 0000000..78287a9 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceLineRemovedEvent.cs @@ -0,0 +1,12 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when a line is removed from an invoice draft. +/// +public class InvoiceLineRemovedEvent(int lineNumber) : AggregateEvent +{ + public int LineNumber { get; } = lineNumber; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceLineUpdatedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceLineUpdatedEvent.cs new file mode 100644 index 0000000..21c0cbf --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceLineUpdatedEvent.cs @@ -0,0 +1,27 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when a line on an invoice draft is updated. +/// +public class InvoiceLineUpdatedEvent( + int lineNumber, + string description, + decimal quantity, + string? unit, + decimal unitPrice, + decimal discountPercent, + string vatCode, + string? accountId) : AggregateEvent +{ + public int LineNumber { get; } = lineNumber; + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public string? Unit { get; } = unit; + public decimal UnitPrice { get; } = unitPrice; + public decimal DiscountPercent { get; } = discountPercent; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoicePaymentReceivedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoicePaymentReceivedEvent.cs new file mode 100644 index 0000000..9bdbb83 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoicePaymentReceivedEvent.cs @@ -0,0 +1,32 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when a payment is received for an invoice. +/// Creates ledger entries: +/// - Debit: Bank account +/// - Credit: Customer sub-ledger account (1900-XXXX) +/// +public class InvoicePaymentReceivedEvent( + decimal amount, + string? bankTransactionId, + string? ledgerTransactionId, + string? paymentReference, + DateOnly paymentDate, + string recordedBy, + decimal newAmountPaid, + decimal newAmountRemaining, + InvoiceStatus newStatus) : AggregateEvent +{ + public decimal Amount { get; } = amount; + public string? BankTransactionId { get; } = bankTransactionId; + public string? LedgerTransactionId { get; } = ledgerTransactionId; + public string? PaymentReference { get; } = paymentReference; + public DateOnly PaymentDate { get; } = paymentDate; + public string RecordedBy { get; } = recordedBy; + public decimal NewAmountPaid { get; } = newAmountPaid; + public decimal NewAmountRemaining { get; } = newAmountRemaining; + public InvoiceStatus NewStatus { get; } = newStatus; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceSentEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceSentEvent.cs new file mode 100644 index 0000000..d4e946f --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceSentEvent.cs @@ -0,0 +1,26 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when an invoice is sent to the customer and posted to the ledger. +/// This triggers the creation of ledger entries: +/// - Debit: Customer sub-ledger account (1900-XXXX) +/// - Credit: Revenue account + VAT account +/// +public class InvoiceSentEvent( + string ledgerTransactionId, + decimal amountExVat, + decimal amountVat, + decimal amountTotal, + string sentBy, + DateTimeOffset sentAt) : AggregateEvent +{ + public string LedgerTransactionId { get; } = ledgerTransactionId; + public decimal AmountExVat { get; } = amountExVat; + public decimal AmountVat { get; } = amountVat; + public decimal AmountTotal { get; } = amountTotal; + public string SentBy { get; } = sentBy; + public DateTimeOffset SentAt { get; } = sentAt; +} diff --git a/backend/Books.Api/Domain/Invoices/Events/InvoiceVoidedEvent.cs b/backend/Books.Api/Domain/Invoices/Events/InvoiceVoidedEvent.cs new file mode 100644 index 0000000..1c164a2 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/Events/InvoiceVoidedEvent.cs @@ -0,0 +1,20 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices.Events; + +/// +/// Raised when an invoice is voided/cancelled. +/// If the invoice was already sent, a reversing ledger entry is created. +/// +public class InvoiceVoidedEvent( + string reason, + string? reversalLedgerTransactionId, + string voidedBy, + DateTimeOffset voidedAt) : AggregateEvent +{ + public string Reason { get; } = reason; + public string? ReversalLedgerTransactionId { get; } = reversalLedgerTransactionId; + public string VoidedBy { get; } = voidedBy; + public DateTimeOffset VoidedAt { get; } = voidedAt; +} diff --git a/backend/Books.Api/Domain/Invoices/InvoiceAggregate.cs b/backend/Books.Api/Domain/Invoices/InvoiceAggregate.cs new file mode 100644 index 0000000..4cc147b --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/InvoiceAggregate.cs @@ -0,0 +1,472 @@ +using Books.Api.Domain.Invoices.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Invoices; + +public class InvoiceAggregate(InvoiceId id) : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private InvoiceType _type = InvoiceType.Invoice; + private InvoiceStatus _status = InvoiceStatus.Draft; + private readonly List _lines = []; + private string _customerId = string.Empty; + private string? _originalInvoiceId; + private string? _originalInvoiceNumber; + private decimal _amountPaid; // For invoices: total paid + private decimal _amountApplied; // For credit notes: total applied + private decimal _amountTotal; + + // Expose read-only state for command handlers + public InvoiceType Type => _type; + public InvoiceStatus Status => _status; + public IReadOnlyList Lines => _lines.AsReadOnly(); + public string CustomerId => _customerId; + public string? OriginalInvoiceId => _originalInvoiceId; + public string? OriginalInvoiceNumber => _originalInvoiceNumber; + public decimal AmountPaid => _amountPaid; + public decimal AmountApplied => _amountApplied; + public decimal AmountTotal => _amountTotal; + + /// + /// Remaining amount: For invoices = total - paid, for credit notes = total - applied + /// + public decimal AmountRemaining => _type == InvoiceType.CreditNote + ? Math.Abs(_amountTotal) - _amountApplied + : _amountTotal - _amountPaid; + + public bool IsCreditNote => _type == InvoiceType.CreditNote; + + #region Apply Methods + + public void Apply(InvoiceCreatedEvent e) + { + _isCreated = true; + _type = e.Type; + _status = InvoiceStatus.Draft; + _customerId = e.CustomerId; + _originalInvoiceId = e.OriginalInvoiceId; + _originalInvoiceNumber = e.OriginalInvoiceNumber; + } + + public void Apply(InvoiceLineAddedEvent e) + { + var line = new InvoiceLine + { + LineNumber = e.LineNumber, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId + }; + _lines.Add(line); + } + + public void Apply(InvoiceLineUpdatedEvent e) + { + var existingIndex = _lines.FindIndex(l => l.LineNumber == e.LineNumber); + if (existingIndex >= 0) + { + _lines[existingIndex] = new InvoiceLine + { + LineNumber = e.LineNumber, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId + }; + } + } + + public void Apply(InvoiceLineRemovedEvent e) + { + _lines.RemoveAll(l => l.LineNumber == e.LineNumber); + } + + public void Apply(InvoiceSentEvent e) + { + // Credit notes are "Issued", invoices are "Sent" + _status = _type == InvoiceType.CreditNote ? InvoiceStatus.Issued : InvoiceStatus.Sent; + _amountTotal = e.AmountTotal; + } + + public void Apply(InvoicePaymentReceivedEvent e) + { + _amountPaid = e.NewAmountPaid; + _status = e.NewStatus; + } + + public void Apply(InvoiceVoidedEvent e) + { + _status = InvoiceStatus.Voided; + } + + public void Apply(InvoiceCreditAppliedEvent e) + { + _amountApplied = e.NewAmountApplied; + _status = e.NewStatus; + } + + #endregion + + #region Command Methods + + public void Create( + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string invoiceNumber, + DateOnly invoiceDate, + DateOnly dueDate, + int paymentTermsDays, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy) + { + if (_isCreated) + throw new DomainException("INVOICE_EXISTS", "Invoice already exists", "Faktura eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(customerId)) + throw new DomainException("CUSTOMER_REQUIRED", "Customer ID is required", "Kunde-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(invoiceNumber)) + throw new DomainException("INVOICE_NUMBER_REQUIRED", "Invoice number is required", "Fakturanummer er påkrævet"); + + Emit(new InvoiceCreatedEvent( + companyId, + fiscalYearId, + customerId, + customerName, + customerNumber, + invoiceNumber, + invoiceDate, + dueDate, + paymentTermsDays, + currency, + vatCode, + notes, + reference, + createdBy)); + } + + public void AddLine( + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0) + { + if (!_isCreated) + throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("INVOICE_NOT_MODIFIABLE", $"Cannot modify invoice in status {_status}", $"Kan ikke ændre faktura med status {_status}"); + + if (string.IsNullOrWhiteSpace(description)) + throw new DomainException("DESCRIPTION_REQUIRED", "Description is required", "Beskrivelse er påkrævet"); + + if (quantity <= 0) + throw new DomainException("INVALID_QUANTITY", "Quantity must be positive", "Antal skal være positivt"); + + if (unitPrice < 0) + throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ"); + + var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1; + + Emit(new InvoiceLineAddedEvent( + lineNumber, + description.Trim(), + quantity, + unit, + unitPrice, + discountPercent, + vatCode, + accountId)); + } + + public void UpdateLine( + int lineNumber, + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0) + { + if (!_isCreated) + throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("INVOICE_NOT_MODIFIABLE", $"Cannot modify invoice in status {_status}", $"Kan ikke ændre faktura med status {_status}"); + + if (!_lines.Any(l => l.LineNumber == lineNumber)) + throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); + + Emit(new InvoiceLineUpdatedEvent( + lineNumber, + description.Trim(), + quantity, + unit, + unitPrice, + discountPercent, + vatCode, + accountId)); + } + + public void RemoveLine(int lineNumber) + { + if (!_isCreated) + throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("INVOICE_NOT_MODIFIABLE", $"Cannot modify invoice in status {_status}", $"Kan ikke ændre faktura med status {_status}"); + + if (!_lines.Any(l => l.LineNumber == lineNumber)) + throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); + + Emit(new InvoiceLineRemovedEvent(lineNumber)); + } + + public void Send( + string ledgerTransactionId, + string sentBy) + { + if (!_isCreated) + throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke"); + + if (!_status.CanSend()) + throw new DomainException("INVOICE_NOT_SENDABLE", $"Cannot send invoice in status {_status}", $"Kan ikke sende faktura med status {_status}"); + + if (_lines.Count == 0) + throw new DomainException("NO_LINES", "Invoice must have at least one line", "Faktura skal have mindst én linje"); + + var amountExVat = _lines.Sum(l => l.AmountExVat); + var amountVat = _lines.Sum(l => l.AmountVat); + var amountTotal = _lines.Sum(l => l.AmountTotal); + + Emit(new InvoiceSentEvent( + ledgerTransactionId, + amountExVat, + amountVat, + amountTotal, + sentBy, + DateTimeOffset.UtcNow)); + } + + public void ReceivePayment( + decimal amount, + string? bankTransactionId, + string? ledgerTransactionId, + string? paymentReference, + DateOnly paymentDate, + string recordedBy) + { + if (!_isCreated) + throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke"); + + if (!_status.CanReceivePayment()) + throw new DomainException("CANNOT_RECEIVE_PAYMENT", $"Cannot receive payment for invoice in status {_status}", $"Kan ikke modtage betaling for faktura med status {_status}"); + + if (amount <= 0) + throw new DomainException("INVALID_AMOUNT", "Payment amount must be positive", "Betalingsbeløb skal være positivt"); + + if (amount > AmountRemaining) + throw new DomainException("OVERPAYMENT", $"Payment amount ({amount:N2}) exceeds remaining amount ({AmountRemaining:N2})", $"Betalingsbeløb ({amount:N2}) overstiger udestående beløb ({AmountRemaining:N2})"); + + var newAmountPaid = _amountPaid + amount; + var newAmountRemaining = _amountTotal - newAmountPaid; + var newStatus = newAmountRemaining <= 0 ? InvoiceStatus.Paid : InvoiceStatus.PartiallyPaid; + + Emit(new InvoicePaymentReceivedEvent( + amount, + bankTransactionId, + ledgerTransactionId, + paymentReference, + paymentDate, + recordedBy, + newAmountPaid, + newAmountRemaining, + newStatus)); + } + + public void Void( + string reason, + string? reversalLedgerTransactionId, + string voidedBy) + { + if (!_isCreated) + throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke"); + + if (!_status.CanVoid()) + throw new DomainException("CANNOT_VOID", $"Cannot void invoice in status {_status}", $"Kan ikke annullere faktura med status {_status}"); + + if (string.IsNullOrWhiteSpace(reason)) + throw new DomainException("REASON_REQUIRED", "Void reason is required", "Annulleringsårsag er påkrævet"); + + Emit(new InvoiceVoidedEvent( + reason.Trim(), + reversalLedgerTransactionId, + voidedBy, + DateTimeOffset.UtcNow)); + } + + #endregion + + #region Credit Note Command Methods + + /// + /// Create a new credit note draft. + /// + public void CreateCreditNote( + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string creditNoteNumber, + DateOnly creditNoteDate, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy, + string? originalInvoiceId = null, + string? originalInvoiceNumber = null, + string? creditReason = null) + { + if (_isCreated) + throw new DomainException("CREDIT_NOTE_EXISTS", "Credit note already exists", "Kreditnota eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(customerId)) + throw new DomainException("CUSTOMER_REQUIRED", "Customer ID is required", "Kunde-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(creditNoteNumber)) + throw new DomainException("CREDIT_NOTE_NUMBER_REQUIRED", "Credit note number is required", "Kreditnotanummer er påkrævet"); + + // Credit notes typically have no due date - set to same as issue date + Emit(new InvoiceCreatedEvent( + companyId, + fiscalYearId, + customerId, + customerName, + customerNumber, + creditNoteNumber, + creditNoteDate, + creditNoteDate, // Due date = issue date for credit notes + 0, // No payment terms for credit notes + currency, + vatCode, + notes, + reference, + createdBy, + InvoiceType.CreditNote, + originalInvoiceId, + originalInvoiceNumber, + creditReason)); + } + + /// + /// Issue the credit note (post to ledger). + /// This is the credit note equivalent of Send() for invoices. + /// + public void Issue( + string ledgerTransactionId, + string issuedBy) + { + if (!_isCreated) + throw new DomainException("CREDIT_NOTE_NOT_FOUND", "Credit note does not exist", "Kreditnota findes ikke"); + + if (_type != InvoiceType.CreditNote) + throw new DomainException("NOT_CREDIT_NOTE", "This operation is only valid for credit notes", "Denne handling er kun gyldig for kreditnotaer"); + + if (!_status.CanIssue()) + throw new DomainException("CREDIT_NOTE_NOT_ISSUABLE", $"Cannot issue credit note in status {_status}", $"Kan ikke udstede kreditnota med status {_status}"); + + if (_lines.Count == 0) + throw new DomainException("NO_LINES", "Credit note must have at least one line", "Kreditnota skal have mindst én linje"); + + // Credit note amounts are stored as negative (opposite of invoice) + var amountExVat = -_lines.Sum(l => l.AmountExVat); + var amountVat = -_lines.Sum(l => l.AmountVat); + var amountTotal = -_lines.Sum(l => l.AmountTotal); + + // We reuse InvoiceSentEvent but the status will be set to Issued + Emit(new InvoiceSentEvent( + ledgerTransactionId, + amountExVat, + amountVat, + amountTotal, + issuedBy, + DateTimeOffset.UtcNow)); + } + + /// + /// Apply this credit note to an invoice. + /// + public void ApplyCredit( + string targetInvoiceId, + string targetInvoiceNumber, + decimal amount, + DateOnly appliedDate, + string appliedBy, + string? ledgerTransactionId = null) + { + if (!_isCreated) + throw new DomainException("CREDIT_NOTE_NOT_FOUND", "Credit note does not exist", "Kreditnota findes ikke"); + + if (_type != InvoiceType.CreditNote) + throw new DomainException("NOT_CREDIT_NOTE", "This operation is only valid for credit notes", "Denne handling er kun gyldig for kreditnotaer"); + + if (!_status.CanApplyCredit()) + throw new DomainException("CANNOT_APPLY_CREDIT", $"Cannot apply credit note in status {_status}", $"Kan ikke anvende kreditnota med status {_status}"); + + if (amount <= 0) + throw new DomainException("INVALID_AMOUNT", "Credit amount must be positive", "Kreditbeløb skal være positivt"); + + if (amount > AmountRemaining) + throw new DomainException("OVERCREDIT", $"Credit amount ({amount:N2}) exceeds remaining credit ({AmountRemaining:N2})", $"Kreditbeløb ({amount:N2}) overstiger udestående kredit ({AmountRemaining:N2})"); + + var newAmountApplied = _amountApplied + amount; + var newAmountRemaining = Math.Abs(_amountTotal) - newAmountApplied; + var newStatus = newAmountRemaining <= 0 ? InvoiceStatus.FullyApplied : InvoiceStatus.PartiallyApplied; + + Emit(new InvoiceCreditAppliedEvent( + targetInvoiceId, + targetInvoiceNumber, + amount, + appliedDate, + appliedBy, + ledgerTransactionId, + newAmountApplied, + newAmountRemaining, + newStatus)); + } + + #endregion +} diff --git a/backend/Books.Api/Domain/Invoices/InvoiceId.cs b/backend/Books.Api/Domain/Invoices/InvoiceId.cs new file mode 100644 index 0000000..ad89e0f --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/InvoiceId.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.Invoices; + +public class InvoiceId(string value) : Identity(value) +{ + /// + /// Creates a deterministic InvoiceId based on company, year, and invoice number. + /// This ensures idempotency and prevents duplicate invoices. + /// Momsloven §52 requires sequential numbering per year. + /// + public static InvoiceId FromCompanyYearAndNumber(string companyId, int year, string invoiceNumber) + { + var input = $"{companyId}:invoice:{year}:{invoiceNumber}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits to make it a valid UUID + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); + + var deterministicGuid = new Guid(guidBytes); + return new InvoiceId($"invoice-{deterministicGuid:D}"); + } + +} diff --git a/backend/Books.Api/Domain/Invoices/InvoiceLine.cs b/backend/Books.Api/Domain/Invoices/InvoiceLine.cs new file mode 100644 index 0000000..8d1b909 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/InvoiceLine.cs @@ -0,0 +1,118 @@ +namespace Books.Api.Domain.Invoices; + +/// +/// Value object representing a single line on an invoice. +/// Contains product/service description, quantity, pricing, and VAT information. +/// +public sealed record InvoiceLine +{ + /// + /// Line number for ordering (1-based). + /// + public int LineNumber { get; init; } + + /// + /// Description of the product or service. + /// + public string Description { get; init; } = string.Empty; + + /// + /// Quantity (can be decimal for services billed by hour). + /// + public decimal Quantity { get; init; } + + /// + /// Unit of measurement (e.g., "stk", "timer", "kg"). + /// + public string? Unit { get; init; } + + /// + /// Price per unit excluding VAT. + /// + public decimal UnitPrice { get; init; } + + /// + /// Discount percentage (0-100). + /// + public decimal DiscountPercent { get; init; } + + /// + /// VAT code (e.g., "U25" for 25%, "UEU" for EU sales, "UEXP" for export). + /// Uses existing VatCode definitions. + /// + public string VatCode { get; init; } = "U25"; + + /// + /// Revenue account to credit when invoice is sent. + /// If null, uses customer's default revenue account. + /// + public string? AccountId { get; init; } + + /// + /// Calculated: UnitPrice * Quantity * (1 - DiscountPercent/100) + /// + public decimal AmountExVat => Math.Round(UnitPrice * Quantity * (1 - DiscountPercent / 100m), 2); + + /// + /// Calculated VAT amount based on VatCode. + /// + public decimal AmountVat => Math.Round(AmountExVat * GetVatRate(), 2); + + /// + /// Calculated: AmountExVat + AmountVat + /// + public decimal AmountTotal => AmountExVat + AmountVat; + + /// + /// Gets the VAT rate for this line based on VatCode. + /// + private decimal GetVatRate() => VatCode switch + { + "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 + }; + + /// + /// Creates an InvoiceLine with validation. + /// + 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 + }; + } +} diff --git a/backend/Books.Api/Domain/Invoices/InvoiceStatus.cs b/backend/Books.Api/Domain/Invoices/InvoiceStatus.cs new file mode 100644 index 0000000..2ad6c91 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/InvoiceStatus.cs @@ -0,0 +1,149 @@ +namespace Books.Api.Domain.Invoices; + +/// +/// Invoice status representing the lifecycle of an invoice or credit note. +/// Invoice workflow: Draft → Sent → PartiallyPaid → Paid → (optionally Voided at any point) +/// Credit note workflow: Draft → Issued → PartiallyApplied → FullyApplied → (optionally Voided at any point) +/// +public enum InvoiceStatus +{ + /// + /// Invoice/credit note is being prepared, not yet sent/issued to customer. + /// No ledger entries have been created. + /// + Draft, + + /// + /// Invoice has been sent to customer and posted to the ledger. + /// Debits the customer's sub-ledger account (1900-XXXX). + /// + Sent, + + /// + /// Credit note has been issued to customer and posted to the ledger. + /// Credits the customer's sub-ledger account (1900-XXXX). + /// Functionally equivalent to "Sent" for credit notes. + /// + Issued, + + /// + /// Partial payment has been received (for invoices). + /// Amount paid > 0 but < total amount. + /// + PartiallyPaid, + + /// + /// Partial credit has been applied (for credit notes). + /// Amount applied > 0 but < total credit amount. + /// Functionally equivalent to "PartiallyPaid" for credit notes. + /// + PartiallyApplied, + + /// + /// Invoice has been fully paid. + /// Amount paid = total amount. + /// + Paid, + + /// + /// Credit note has been fully applied to invoice(s). + /// Amount applied = total credit amount. + /// Functionally equivalent to "Paid" for credit notes. + /// + FullyApplied, + + /// + /// Invoice/credit note has been voided/cancelled. + /// A reversing ledger entry has been created. + /// This status can be reached from Draft, Sent/Issued, or PartiallyPaid/PartiallyApplied. + /// + Voided +} + +public static class InvoiceStatusExtensions +{ + public static string ToStringValue(this InvoiceStatus status) => status switch + { + InvoiceStatus.Draft => "draft", + InvoiceStatus.Sent => "sent", + InvoiceStatus.Issued => "issued", + InvoiceStatus.PartiallyPaid => "partially_paid", + InvoiceStatus.PartiallyApplied => "partially_applied", + InvoiceStatus.Paid => "paid", + InvoiceStatus.FullyApplied => "fully_applied", + InvoiceStatus.Voided => "voided", + _ => throw new ArgumentOutOfRangeException(nameof(status)) + }; + + public static InvoiceStatus FromString(string value) => value.ToLowerInvariant() switch + { + "draft" => InvoiceStatus.Draft, + "sent" => InvoiceStatus.Sent, + "issued" => InvoiceStatus.Issued, + "partially_paid" => InvoiceStatus.PartiallyPaid, + "partially_applied" => InvoiceStatus.PartiallyApplied, + "paid" => InvoiceStatus.Paid, + "fully_applied" => InvoiceStatus.FullyApplied, + "voided" => InvoiceStatus.Voided, + _ => throw new ArgumentException($"Unknown invoice status: {value}", nameof(value)) + }; + + /// + /// Returns true if the invoice/credit note can be modified (lines added/removed). + /// Only drafts can be modified. + /// + public static bool CanModify(this InvoiceStatus status) => status == InvoiceStatus.Draft; + + /// + /// Returns true if the invoice can be sent. + /// Only drafts with at least one line can be sent. + /// + public static bool CanSend(this InvoiceStatus status) => status == InvoiceStatus.Draft; + + /// + /// Returns true if the credit note can be issued. + /// Only drafts with at least one line can be issued. + /// + public static bool CanIssue(this InvoiceStatus status) => status == InvoiceStatus.Draft; + + /// + /// Returns true if the invoice can receive payments. + /// Sent and PartiallyPaid invoices can receive payments. + /// + public static bool CanReceivePayment(this InvoiceStatus status) => + status == InvoiceStatus.Sent || status == InvoiceStatus.PartiallyPaid; + + /// + /// Returns true if the credit note can be applied to invoices. + /// Issued and PartiallyApplied credit notes can be applied. + /// + public static bool CanApplyCredit(this InvoiceStatus status) => + status == InvoiceStatus.Issued || status == InvoiceStatus.PartiallyApplied; + + /// + /// Returns true if the invoice/credit note can be voided. + /// Cannot void a fully paid/applied or already voided document. + /// + public static bool CanVoid(this InvoiceStatus status) => + status != InvoiceStatus.Paid && + status != InvoiceStatus.FullyApplied && + status != InvoiceStatus.Voided; + + /// + /// Returns true if the status represents a "sent" or "issued" state. + /// + public static bool IsSentOrIssued(this InvoiceStatus status) => + status == InvoiceStatus.Sent || status == InvoiceStatus.Issued; + + /// + /// Returns true if the status represents a "paid" or "fully applied" state. + /// + public static bool IsPaidOrFullyApplied(this InvoiceStatus status) => + status == InvoiceStatus.Paid || status == InvoiceStatus.FullyApplied; + + /// + /// Returns true if the status represents a "partial" state (partial payment or partial application). + /// + public static bool IsPartial(this InvoiceStatus status) => + status == InvoiceStatus.PartiallyPaid || status == InvoiceStatus.PartiallyApplied; +} diff --git a/backend/Books.Api/Domain/Invoices/InvoiceType.cs b/backend/Books.Api/Domain/Invoices/InvoiceType.cs new file mode 100644 index 0000000..de10b44 --- /dev/null +++ b/backend/Books.Api/Domain/Invoices/InvoiceType.cs @@ -0,0 +1,57 @@ +namespace Books.Api.Domain.Invoices; + +/// +/// Type of invoice document. Credit notes are accounting-wise just invoices with negative amounts. +/// +public enum InvoiceType +{ + /// + /// Regular invoice with positive amounts. + /// Debits the customer's sub-ledger account when sent. + /// + Invoice, + + /// + /// Credit note (kreditnota) - an invoice with negative amounts. + /// Credits the customer's sub-ledger account when issued. + /// Can reference an original invoice. + /// + CreditNote +} + +public static class InvoiceTypeExtensions +{ + public static string ToStringValue(this InvoiceType type) => type switch + { + InvoiceType.Invoice => "invoice", + InvoiceType.CreditNote => "credit_note", + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + public static InvoiceType FromString(string value) => value.ToLowerInvariant() switch + { + "invoice" => InvoiceType.Invoice, + "credit_note" or "creditnote" => InvoiceType.CreditNote, + _ => throw new ArgumentException($"Unknown invoice type: {value}", nameof(value)) + }; + + /// + /// Returns the document title in Danish for PDF generation. + /// + public static string GetDocumentTitleDanish(this InvoiceType type) => type switch + { + InvoiceType.Invoice => "FAKTURA", + InvoiceType.CreditNote => "KREDITNOTA", + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + /// + /// Returns the document title in English. + /// + public static string GetDocumentTitleEnglish(this InvoiceType type) => type switch + { + InvoiceType.Invoice => "INVOICE", + InvoiceType.CreditNote => "CREDIT NOTE", + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/DraftLine.cs b/backend/Books.Api/Domain/JournalEntryDrafts/DraftLine.cs new file mode 100644 index 0000000..dd683c5 --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/DraftLine.cs @@ -0,0 +1,15 @@ +namespace Books.Api.Domain.JournalEntryDrafts; + +/// +/// A single line in a journal entry draft. +/// Value object - immutable. +/// Includes VAT code for SKAT compliance. +/// +public record DraftLine( + int LineNumber, + string? AccountId, + decimal DebitAmount, + decimal CreditAmount, + string? Description, + string? VatCode = null +); diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/DraftStatus.cs b/backend/Books.Api/Domain/JournalEntryDrafts/DraftStatus.cs new file mode 100644 index 0000000..e44e663 --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/DraftStatus.cs @@ -0,0 +1,22 @@ +namespace Books.Api.Domain.JournalEntryDrafts; + +/// +/// Status for a journal entry draft (kassekladde). +/// +public enum DraftStatus +{ + /// + /// Draft is being worked on. + /// + Active, + + /// + /// Draft has been posted to the ledger. + /// + Posted, + + /// + /// Draft has been discarded/deleted. + /// + Discarded +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftCreatedEvent.cs b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftCreatedEvent.cs new file mode 100644 index 0000000..4ae87f0 --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftCreatedEvent.cs @@ -0,0 +1,28 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.JournalEntryDrafts.Events; + +/// +/// Event emitted when a new journal entry draft is created. +/// VoucherNumber is auto-generated and required by Bogføringsloven § 7, Stk. 4. +/// +public class JournalEntryDraftCreatedEvent( + string companyId, + string name, + string createdBy, + string voucherNumber, + string? extractionData = null) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string Name { get; } = name; + public string CreatedBy { get; } = createdBy; + /// + /// Bilagsnummer - unique document number per company, required by Danish accounting law. + /// + public string VoucherNumber { get; } = voucherNumber; + /// + /// Full AI extraction data as JSON string. + /// Contains vendor CVR, amounts, VAT, due date, payment reference, line items, etc. + /// + public string? ExtractionData { get; } = extractionData; +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftDiscardedEvent.cs b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftDiscardedEvent.cs new file mode 100644 index 0000000..4e5b32e --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftDiscardedEvent.cs @@ -0,0 +1,9 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.JournalEntryDrafts.Events; + +public class JournalEntryDraftDiscardedEvent( + string discardedBy) : AggregateEvent +{ + public string DiscardedBy { get; } = discardedBy; +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs new file mode 100644 index 0000000..b80277e --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftPostedEvent.cs @@ -0,0 +1,11 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.JournalEntryDrafts.Events; + +public class JournalEntryDraftPostedEvent( + string transactionId, + string postedBy) : AggregateEvent +{ + public string TransactionId { get; } = transactionId; + public string PostedBy { get; } = postedBy; +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftUpdatedEvent.cs b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftUpdatedEvent.cs new file mode 100644 index 0000000..936bb7d --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/Events/JournalEntryDraftUpdatedEvent.cs @@ -0,0 +1,29 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.JournalEntryDrafts.Events; + +/// +/// Event emitted when a journal entry draft is updated (auto-save). +/// Includes VAT codes per line and attachment references. +/// +public class JournalEntryDraftUpdatedEvent( + string? name, + DateOnly? documentDate, + string? description, + string? fiscalYearId, + List lines, + List? attachmentIds = null) : AggregateEvent +{ + public string? Name { get; } = name; + /// + /// Bilagsdato - the date of the transaction/document (e.g., invoice date). + /// + public DateOnly? DocumentDate { get; } = documentDate; + public string? Description { get; } = description; + public string? FiscalYearId { get; } = fiscalYearId; + public List Lines { get; } = lines; + /// + /// References to attached documents (Bilag) - required by Bogføringsloven § 6. + /// + public List AttachmentIds { get; } = attachmentIds ?? []; +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs new file mode 100644 index 0000000..2cda0f7 --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftAggregate.cs @@ -0,0 +1,196 @@ +using Books.Api.Domain.JournalEntryDrafts.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.JournalEntryDrafts; + +public class JournalEntryDraftAggregate(JournalEntryDraftId id) + : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private DraftStatus _status = DraftStatus.Active; + private string _companyId = string.Empty; + private string _voucherNumber = string.Empty; + + public string CompanyId => _companyId; + public string VoucherNumber => _voucherNumber; + + #region Apply Methods + + public void Apply(JournalEntryDraftCreatedEvent e) + { + _isCreated = true; + _companyId = e.CompanyId; + _voucherNumber = e.VoucherNumber; + _status = DraftStatus.Active; + } + + public void Apply(JournalEntryDraftUpdatedEvent e) + { + // State is stored in read model, not in aggregate + } + + public void Apply(JournalEntryDraftPostedEvent e) + { + _status = DraftStatus.Posted; + } + + public void Apply(JournalEntryDraftDiscardedEvent e) + { + _status = DraftStatus.Discarded; + } + + #endregion + + #region Command Methods + + /// + /// Creates a new journal entry draft with auto-generated voucher number. + /// + /// Company ID + /// Draft name + /// User who created the draft + /// Bilagsnummer - required by Bogføringsloven § 7 + /// Full AI extraction data as JSON string (optional) + public void Create(string companyId, string name, string createdBy, string voucherNumber, string? extractionData = null) + { + if (_isCreated) + throw new DomainException( + "DRAFT_ALREADY_EXISTS", + "Journal entry draft already exists", + "Kassekladden eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException( + "COMPANY_ID_REQUIRED", + "Company ID is required", + "Virksomheds-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException( + "DRAFT_NAME_REQUIRED", + "Draft name is required", + "Kladdenavn er påkrævet"); + + if (string.IsNullOrWhiteSpace(createdBy)) + throw new DomainException( + "CREATED_BY_REQUIRED", + "Created by is required", + "Oprettet af er påkrævet"); + + if (string.IsNullOrWhiteSpace(voucherNumber)) + throw new DomainException( + "VOUCHER_NUMBER_REQUIRED", + "Voucher number (bilagsnummer) is required by Danish accounting law", + "Bilagsnummer er påkrævet iht. Bogføringsloven"); + + Emit(new JournalEntryDraftCreatedEvent( + companyId.Trim(), + name.Trim(), + createdBy, + voucherNumber.Trim(), + extractionData)); + } + + /// + /// Updates a journal entry draft (auto-save). + /// + /// Draft name + /// Bilagsdato - the date of the transaction/document (e.g., invoice date) + /// Entry description + /// Fiscal year ID + /// Posting lines with VAT codes + /// References to attached documents (bilag) + public void Update( + string? name, + DateOnly? documentDate, + string? description, + string? fiscalYearId, + List lines, + List? attachmentIds = null) + { + EnsureCanModify(); + + // Validate VAT codes if provided + foreach (var line in lines.Where(l => !string.IsNullOrEmpty(l.VatCode))) + { + if (!VatCodes.IsValid(line.VatCode)) + { + throw new DomainException( + "INVALID_VAT_CODE", + $"Invalid VAT code '{line.VatCode}' on line {line.LineNumber}", + $"Ugyldig momskode '{line.VatCode}' på linje {line.LineNumber}"); + } + } + + Emit(new JournalEntryDraftUpdatedEvent( + name?.Trim(), + documentDate, + description?.Trim(), + fiscalYearId, + lines, + attachmentIds)); + } + + public void MarkPosted(string transactionId, string postedBy) + { + EnsureCanModify(); + + if (string.IsNullOrWhiteSpace(transactionId)) + throw new DomainException( + "TRANSACTION_ID_REQUIRED", + "Transaction ID is required", + "Transaktions-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(postedBy)) + throw new DomainException( + "POSTED_BY_REQUIRED", + "Posted by is required", + "Bogført af er påkrævet"); + + Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy)); + } + + public void Discard(string discardedBy) + { + EnsureCanModify(); + + if (string.IsNullOrWhiteSpace(discardedBy)) + throw new DomainException( + "DISCARDED_BY_REQUIRED", + "Discarded by is required", + "Kasseret af er påkrævet"); + + Emit(new JournalEntryDraftDiscardedEvent(discardedBy)); + } + + #endregion + + #region Private Methods + + private void EnsureCanModify() + { + if (!_isCreated) + throw new DomainException( + "DRAFT_NOT_FOUND", + "Journal entry draft does not exist", + "Kassekladden findes ikke"); + + if (_status == DraftStatus.Posted) + throw new DomainException( + "DRAFT_ALREADY_POSTED", + "Journal entry draft has already been posted", + "Kassekladden er allerede bogført"); + + if (_status == DraftStatus.Discarded) + throw new DomainException( + "DRAFT_ALREADY_DISCARDED", + "Journal entry draft has been discarded", + "Kassekladden er blevet kasseret"); + } + + #endregion +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftId.cs b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftId.cs new file mode 100644 index 0000000..61a557a --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/JournalEntryDraftId.cs @@ -0,0 +1,8 @@ +using EventFlow.Core; + +namespace Books.Api.Domain.JournalEntryDrafts; + +public class JournalEntryDraftId(string value) : Identity(value) +{ + public static new JournalEntryDraftId New() => new($"journalentrydraft-{Guid.NewGuid():D}"); +} diff --git a/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs b/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs new file mode 100644 index 0000000..f4b1275 --- /dev/null +++ b/backend/Books.Api/Domain/JournalEntryDrafts/VatCalculationService.cs @@ -0,0 +1,221 @@ +using Books.Api.Domain.JournalEntryDrafts; + +namespace Books.Api.Domain; + +/// +/// Service for calculating VAT (moms) on journal entry lines. +/// Handles Danish VAT rules including automatic calculation of VAT amounts +/// and generation of VAT posting lines. +/// +public interface IVatCalculationService +{ + /// + /// Calculate VAT for a list of draft lines. + /// Returns the original lines plus generated VAT posting lines. + /// + VatCalculationResult CalculateVat( + IEnumerable lines, + VatCalculationMode mode, + string inputVatAccountId, + string outputVatAccountId); +} + +/// +/// Determines how amounts on lines should be interpreted. +/// +public enum VatCalculationMode +{ + /// + /// Amounts are exclusive of VAT (ekskl. moms). + /// VAT will be added to the total. + /// + Exclusive, + + /// + /// Amounts are inclusive of VAT (inkl. moms). + /// VAT will be extracted from the total. + /// + Inclusive +} + +/// +/// Result of VAT calculation containing original lines, VAT lines, and totals. +/// +public class VatCalculationResult +{ + /// + /// The original lines (unchanged). + /// + public required List OriginalLines { get; init; } + + /// + /// Generated VAT posting lines (to be added to the journal entry). + /// + public required List VatLines { get; init; } + + /// + /// Breakdown of VAT by code for reporting purposes. + /// + public required List VatSummary { get; init; } + + /// + /// Total VAT amount (sum of all VAT lines). + /// + public decimal TotalVatAmount { get; init; } +} + +/// +/// A generated VAT posting line. +/// +public record VatPostingLine +{ + public required string AccountId { get; init; } + public required decimal DebitAmount { get; init; } + public required decimal CreditAmount { get; init; } + public required string Description { get; init; } + /// + /// The VAT code this line relates to. + /// + public required string VatCode { get; init; } + /// + /// The original line number this VAT line was calculated from. + /// + public required int SourceLineNumber { get; init; } +} + +/// +/// Summary of VAT for a specific VAT code. +/// +public record VatSummary +{ + public required string VatCode { get; init; } + public required decimal Rate { get; init; } + /// + /// Total base amount (before VAT) for this VAT code. + /// + public required decimal BaseAmount { get; init; } + /// + /// Total VAT amount for this VAT code. + /// + public required decimal VatAmount { get; init; } + /// + /// Is this input VAT (købsmoms/fradrag) or output VAT (salgsmoms/skyldig)? + /// + public required bool IsInputVat { get; init; } +} + +public class VatCalculationService : IVatCalculationService +{ + public VatCalculationResult CalculateVat( + IEnumerable lines, + VatCalculationMode mode, + string inputVatAccountId, + string outputVatAccountId) + { + var linesList = lines.ToList(); + var vatLines = new List(); + var vatByCode = new Dictionary(); + + foreach (var line in linesList) + { + if (string.IsNullOrEmpty(line.VatCode) || line.VatCode == VatCodes.INGEN) + continue; + + var rate = VatCodes.GetRate(line.VatCode); + if (rate == 0) + continue; + + // Determine if this is a debit or credit line + var isDebit = line.DebitAmount > 0; + var amount = isDebit ? line.DebitAmount : line.CreditAmount; + + // Calculate base and VAT amounts based on mode + decimal baseAmount; + decimal vatAmount; + + if (mode == VatCalculationMode.Inclusive) + { + // Amount includes VAT, extract it + // VAT = Amount * rate / (1 + rate) + vatAmount = Math.Round(amount * rate / (1 + rate), 2, MidpointRounding.AwayFromZero); + baseAmount = amount - vatAmount; + } + else + { + // Amount excludes VAT, calculate it + baseAmount = amount; + vatAmount = Math.Round(amount * rate, 2, MidpointRounding.AwayFromZero); + } + + // Determine if this is input or output VAT + var isInputVat = VatCodes.IsInputVat(line.VatCode); + var vatAccountId = isInputVat ? inputVatAccountId : outputVatAccountId; + + // Create VAT posting line + // For sales (output VAT): we credit the VAT account + // For purchases (input VAT): we debit the VAT account + var vatLine = new VatPostingLine + { + AccountId = vatAccountId, + DebitAmount = isInputVat && isDebit ? vatAmount : (isInputVat ? 0 : 0), + CreditAmount = !isInputVat && !isDebit ? vatAmount : (!isInputVat && isDebit ? vatAmount : 0), + Description = $"Moms {line.VatCode} ({rate * 100:0}%)", + VatCode = line.VatCode, + SourceLineNumber = line.LineNumber + }; + + // Correct the debit/credit logic: + // - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT) + // - For PURCHASES (I25): expense is debit, VAT should ALSO be debit (asset/receivable from SKAT) + // The key insight: VAT follows the same direction as the base transaction + if (isInputVat) + { + // Purchases: VAT follows the expense direction (typically debit) + vatLine = vatLine with + { + DebitAmount = isDebit ? vatAmount : 0, + CreditAmount = !isDebit ? vatAmount : 0 + }; + } + else + { + // Sales: VAT follows the revenue direction (typically credit) + vatLine = vatLine with + { + DebitAmount = isDebit ? vatAmount : 0, + CreditAmount = !isDebit ? vatAmount : 0 + }; + } + + vatLines.Add(vatLine); + + // Accumulate VAT summary + if (!vatByCode.TryGetValue(line.VatCode, out var summary)) + { + summary = new VatSummary + { + VatCode = line.VatCode, + Rate = rate, + BaseAmount = 0, + VatAmount = 0, + IsInputVat = isInputVat + }; + vatByCode[line.VatCode] = summary; + } + + vatByCode[line.VatCode] = summary with + { + BaseAmount = summary.BaseAmount + baseAmount, + VatAmount = summary.VatAmount + vatAmount + }; + } + + return new VatCalculationResult + { + OriginalLines = linesList, + VatLines = vatLines, + VatSummary = vatByCode.Values.ToList(), + TotalVatAmount = vatLines.Sum(v => v.DebitAmount - v.CreditAmount) + }; + } +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderCancelledEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderCancelledEvent.cs new file mode 100644 index 0000000..e244eec --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderCancelledEvent.cs @@ -0,0 +1,17 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when an order is cancelled. +/// Can only cancel orders that have not been invoiced. +/// +public class OrderCancelledEvent( + string reason, + string cancelledBy, + DateTimeOffset cancelledAt) : AggregateEvent +{ + public string Reason { get; } = reason; + public string CancelledBy { get; } = cancelledBy; + public DateTimeOffset CancelledAt { get; } = cancelledAt; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderCompletedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderCompletedEvent.cs new file mode 100644 index 0000000..897dd57 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderCompletedEvent.cs @@ -0,0 +1,14 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when an order is marked as completed. +/// +public class OrderCompletedEvent( + string completedBy, + DateTimeOffset completedAt) : AggregateEvent +{ + public string CompletedBy { get; } = completedBy; + public DateTimeOffset CompletedAt { get; } = completedAt; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderConfirmedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderConfirmedEvent.cs new file mode 100644 index 0000000..f24d871 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderConfirmedEvent.cs @@ -0,0 +1,21 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when an order is confirmed and ready for fulfillment/invoicing. +/// After confirmation, lines cannot be modified. +/// +public class OrderConfirmedEvent( + string confirmedBy, + DateTimeOffset confirmedAt, + decimal amountExVat, + decimal amountVat, + decimal amountTotal) : AggregateEvent +{ + public string ConfirmedBy { get; } = confirmedBy; + public DateTimeOffset ConfirmedAt { get; } = confirmedAt; + public decimal AmountExVat { get; } = amountExVat; + public decimal AmountVat { get; } = amountVat; + public decimal AmountTotal { get; } = amountTotal; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderCreatedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderCreatedEvent.cs new file mode 100644 index 0000000..c321348 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderCreatedEvent.cs @@ -0,0 +1,36 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when a new order draft is created. +/// +public class OrderCreatedEvent( + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string orderNumber, + DateOnly orderDate, + DateOnly? expectedDeliveryDate, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string FiscalYearId { get; } = fiscalYearId; + public string CustomerId { get; } = customerId; + public string CustomerName { get; } = customerName; + public string CustomerNumber { get; } = customerNumber; + public string OrderNumber { get; } = orderNumber; + public DateOnly OrderDate { get; } = orderDate; + public DateOnly? ExpectedDeliveryDate { get; } = expectedDeliveryDate; + public string Currency { get; } = currency; + public string? VatCode { get; } = vatCode; + public string? Notes { get; } = notes; + public string? Reference { get; } = reference; + public string CreatedBy { get; } = createdBy; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderLineAddedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderLineAddedEvent.cs new file mode 100644 index 0000000..a357f39 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderLineAddedEvent.cs @@ -0,0 +1,28 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when a line is added to an order draft. +/// +public class OrderLineAddedEvent( + int lineNumber, + string description, + decimal quantity, + string? unit, + decimal unitPrice, + decimal discountPercent, + string vatCode, + string? accountId, + string? productId) : AggregateEvent +{ + public int LineNumber { get; } = lineNumber; + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public string? Unit { get; } = unit; + public decimal UnitPrice { get; } = unitPrice; + public decimal DiscountPercent { get; } = discountPercent; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; + public string? ProductId { get; } = productId; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderLineRemovedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderLineRemovedEvent.cs new file mode 100644 index 0000000..2ccf8b3 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderLineRemovedEvent.cs @@ -0,0 +1,11 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when a line is removed from an order draft. +/// +public class OrderLineRemovedEvent(int lineNumber) : AggregateEvent +{ + public int LineNumber { get; } = lineNumber; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderLineUpdatedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderLineUpdatedEvent.cs new file mode 100644 index 0000000..94fd0ec --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderLineUpdatedEvent.cs @@ -0,0 +1,28 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when an order line is updated. +/// +public class OrderLineUpdatedEvent( + int lineNumber, + string description, + decimal quantity, + string? unit, + decimal unitPrice, + decimal discountPercent, + string vatCode, + string? accountId, + string? productId) : AggregateEvent +{ + public int LineNumber { get; } = lineNumber; + public string Description { get; } = description; + public decimal Quantity { get; } = quantity; + public string? Unit { get; } = unit; + public decimal UnitPrice { get; } = unitPrice; + public decimal DiscountPercent { get; } = discountPercent; + public string VatCode { get; } = vatCode; + public string? AccountId { get; } = accountId; + public string? ProductId { get; } = productId; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderLinesInvoicedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderLinesInvoicedEvent.cs new file mode 100644 index 0000000..6c0ce77 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderLinesInvoicedEvent.cs @@ -0,0 +1,39 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when order lines are converted to an invoice. +/// Supports partial invoicing - may only include some lines from the order. +/// +public class OrderLinesInvoicedEvent( + string invoiceId, + string invoiceNumber, + IReadOnlyList lineNumbers, + DateTimeOffset invoicedAt, + string invoicedBy, + OrderStatus newStatus) : AggregateEvent +{ + public string InvoiceId { get; } = invoiceId; + public string InvoiceNumber { get; } = invoiceNumber; + + /// + /// The line numbers that were included in this invoice. + /// + public IReadOnlyList LineNumbers { get; } = lineNumbers; + + /// + /// When the lines were invoiced. + /// + public DateTimeOffset InvoicedAt { get; } = invoicedAt; + + /// + /// User who performed the invoicing. + /// + public string InvoicedBy { get; } = invoicedBy; + + /// + /// The new order status after invoicing (PartiallyInvoiced or FullyInvoiced). + /// + public OrderStatus NewStatus { get; } = newStatus; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderRevertedToDraftEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderRevertedToDraftEvent.cs new file mode 100644 index 0000000..ef82ee3 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderRevertedToDraftEvent.cs @@ -0,0 +1,16 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when a confirmed order is reverted back to draft status. +/// +public class OrderRevertedToDraftEvent( + string revertedBy, + DateTimeOffset revertedAt, + string? reason) : AggregateEvent +{ + public string RevertedBy { get; } = revertedBy; + public DateTimeOffset RevertedAt { get; } = revertedAt; + public string? Reason { get; } = reason; +} diff --git a/backend/Books.Api/Domain/Orders/Events/OrderUpdatedEvent.cs b/backend/Books.Api/Domain/Orders/Events/OrderUpdatedEvent.cs new file mode 100644 index 0000000..5389aec --- /dev/null +++ b/backend/Books.Api/Domain/Orders/Events/OrderUpdatedEvent.cs @@ -0,0 +1,18 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders.Events; + +/// +/// Raised when order header information is updated (not lines). +/// +public class OrderUpdatedEvent( + DateOnly? expectedDeliveryDate, + string? notes, + string? reference, + string updatedBy) : AggregateEvent +{ + public DateOnly? ExpectedDeliveryDate { get; } = expectedDeliveryDate; + public string? Notes { get; } = notes; + public string? Reference { get; } = reference; + public string UpdatedBy { get; } = updatedBy; +} diff --git a/backend/Books.Api/Domain/Orders/OrderAggregate.cs b/backend/Books.Api/Domain/Orders/OrderAggregate.cs new file mode 100644 index 0000000..b7d04f7 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/OrderAggregate.cs @@ -0,0 +1,411 @@ +using Books.Api.Domain.Orders.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Orders; + +public class OrderAggregate(OrderId id) : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private OrderStatus _status = OrderStatus.Draft; + private readonly List _lines = []; + private string _customerId = string.Empty; + private decimal _amountExVat; + private decimal _amountVat; + private decimal _amountTotal; + + // Expose read-only state for command handlers + public OrderStatus Status => _status; + public IReadOnlyList Lines => _lines.AsReadOnly(); + public string CustomerId => _customerId; + public decimal AmountExVat => _amountExVat; + public decimal AmountVat => _amountVat; + public decimal AmountTotal => _amountTotal; + + /// + /// Count of lines that have not been invoiced yet. + /// + public int UninvoicedLineCount => _lines.Count(l => !l.IsInvoiced); + + /// + /// Total amount of uninvoiced lines. + /// + public decimal UninvoicedAmount => _lines.Where(l => !l.IsInvoiced).Sum(l => l.AmountTotal); + + #region Apply Methods + + public void Apply(OrderCreatedEvent e) + { + _isCreated = true; + _status = OrderStatus.Draft; + _customerId = e.CustomerId; + } + + public void Apply(OrderUpdatedEvent e) + { + // Header updates don't change aggregate state that affects business rules + } + + public void Apply(OrderLineAddedEvent e) + { + var line = new OrderLine + { + LineNumber = e.LineNumber, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId, + ProductId = e.ProductId, + IsInvoiced = false + }; + _lines.Add(line); + } + + public void Apply(OrderLineUpdatedEvent e) + { + var existingIndex = _lines.FindIndex(l => l.LineNumber == e.LineNumber); + if (existingIndex >= 0) + { + var existing = _lines[existingIndex]; + _lines[existingIndex] = new OrderLine + { + LineNumber = e.LineNumber, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId, + ProductId = e.ProductId, + IsInvoiced = existing.IsInvoiced, + InvoiceId = existing.InvoiceId, + InvoicedAt = existing.InvoicedAt + }; + } + } + + public void Apply(OrderLineRemovedEvent e) + { + _lines.RemoveAll(l => l.LineNumber == e.LineNumber); + } + + public void Apply(OrderConfirmedEvent e) + { + _status = OrderStatus.Confirmed; + _amountExVat = e.AmountExVat; + _amountVat = e.AmountVat; + _amountTotal = e.AmountTotal; + } + + public void Apply(OrderRevertedToDraftEvent e) + { + _status = OrderStatus.Draft; + } + + public void Apply(OrderCompletedEvent e) + { + // Completed is a terminal state - no additional state changes needed + } + + public void Apply(OrderLinesInvoicedEvent e) + { + _status = e.NewStatus; + foreach (var lineNumber in e.LineNumbers) + { + var index = _lines.FindIndex(l => l.LineNumber == lineNumber); + if (index >= 0) + { + _lines[index] = _lines[index].MarkAsInvoiced(e.InvoiceId, e.InvoicedAt); + } + } + } + + public void Apply(OrderCancelledEvent e) + { + _status = OrderStatus.Cancelled; + } + + #endregion + + #region Command Methods + + public void Create( + string companyId, + string fiscalYearId, + string customerId, + string customerName, + string customerNumber, + string orderNumber, + DateOnly orderDate, + DateOnly? expectedDeliveryDate, + string currency, + string? vatCode, + string? notes, + string? reference, + string createdBy) + { + if (_isCreated) + throw new DomainException("ORDER_EXISTS", "Order already exists", "Ordre eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(customerId)) + throw new DomainException("CUSTOMER_REQUIRED", "Customer ID is required", "Kunde-ID er påkrævet"); + + if (string.IsNullOrWhiteSpace(orderNumber)) + throw new DomainException("ORDER_NUMBER_REQUIRED", "Order number is required", "Ordrenummer er påkrævet"); + + Emit(new OrderCreatedEvent( + companyId, + fiscalYearId, + customerId, + customerName, + customerNumber, + orderNumber, + orderDate, + expectedDeliveryDate, + currency, + vatCode, + notes, + reference, + createdBy)); + } + + public void Update( + DateOnly? expectedDeliveryDate, + string? notes, + string? reference, + string updatedBy) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}"); + + Emit(new OrderUpdatedEvent( + expectedDeliveryDate, + notes?.Trim(), + reference?.Trim(), + updatedBy)); + } + + public void AddLine( + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0, + string? productId = null) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}"); + + if (string.IsNullOrWhiteSpace(description)) + throw new DomainException("DESCRIPTION_REQUIRED", "Description is required", "Beskrivelse er påkrævet"); + + if (quantity <= 0) + throw new DomainException("INVALID_QUANTITY", "Quantity must be positive", "Antal skal være positivt"); + + if (unitPrice < 0) + throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ"); + + var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1; + + Emit(new OrderLineAddedEvent( + lineNumber, + description.Trim(), + quantity, + unit, + unitPrice, + discountPercent, + vatCode, + accountId, + productId)); + } + + public void UpdateLine( + int lineNumber, + string description, + decimal quantity, + decimal unitPrice, + string vatCode, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0, + string? productId = null) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}"); + + var line = _lines.FirstOrDefault(l => l.LineNumber == lineNumber); + if (line == null) + throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); + + if (line.IsInvoiced) + throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret"); + + Emit(new OrderLineUpdatedEvent( + lineNumber, + description.Trim(), + quantity, + unit, + unitPrice, + discountPercent, + vatCode, + accountId, + productId)); + } + + public void RemoveLine(int lineNumber) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanModify()) + throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}"); + + var line = _lines.FirstOrDefault(l => l.LineNumber == lineNumber); + if (line == null) + throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); + + if (line.IsInvoiced) + throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret"); + + Emit(new OrderLineRemovedEvent(lineNumber)); + } + + public void Confirm(string confirmedBy) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanConfirm()) + throw new DomainException("ORDER_NOT_CONFIRMABLE", $"Cannot confirm order in status {_status}", $"Kan ikke bekræfte ordre med status {_status}"); + + if (_lines.Count == 0) + throw new DomainException("NO_LINES", "Order must have at least one line", "Ordre skal have mindst én linje"); + + var amountExVat = _lines.Sum(l => l.AmountExVat); + var amountVat = _lines.Sum(l => l.AmountVat); + var amountTotal = _lines.Sum(l => l.AmountTotal); + + Emit(new OrderConfirmedEvent( + confirmedBy, + DateTimeOffset.UtcNow, + amountExVat, + amountVat, + amountTotal)); + } + + public void RevertToDraft(string revertedBy, string? reason = null) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (_status != OrderStatus.Confirmed) + throw new DomainException("ORDER_NOT_CONFIRMED", "Only confirmed orders can be reverted to draft", "Kun bekræftede ordrer kan tilbageføres til kladde"); + + Emit(new OrderRevertedToDraftEvent( + revertedBy, + DateTimeOffset.UtcNow, + reason?.Trim())); + } + + /// + /// Mark specified lines as invoiced. + /// Called after invoice is created from this order. + /// + public void MarkLinesAsInvoiced( + string invoiceId, + string invoiceNumber, + IReadOnlyList lineNumbers, + string invoicedBy) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanInvoice()) + throw new DomainException("ORDER_NOT_INVOICEABLE", $"Cannot invoice order in status {_status}", $"Kan ikke fakturere ordre med status {_status}"); + + if (lineNumbers.Count == 0) + throw new DomainException("NO_LINES_SELECTED", "At least one line must be selected for invoicing", "Mindst én linje skal vælges til fakturering"); + + // Validate all lines exist and are not already invoiced + foreach (var lineNumber in lineNumbers) + { + var line = _lines.FirstOrDefault(l => l.LineNumber == lineNumber); + if (line == null) + throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke"); + + if (line.IsInvoiced) + throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret"); + } + + // Determine new status + var uninvoicedAfter = _lines.Count(l => !l.IsInvoiced && !lineNumbers.Contains(l.LineNumber)); + var newStatus = uninvoicedAfter == 0 ? OrderStatus.FullyInvoiced : OrderStatus.PartiallyInvoiced; + + Emit(new OrderLinesInvoicedEvent( + invoiceId, + invoiceNumber, + lineNumbers, + DateTimeOffset.UtcNow, + invoicedBy, + newStatus)); + } + + public void Complete(string completedBy) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (_status != OrderStatus.FullyInvoiced) + throw new DomainException("ORDER_NOT_FULLY_INVOICED", "Only fully invoiced orders can be completed", "Kun fuldt fakturerede ordrer kan markeres som afsluttet"); + + Emit(new OrderCompletedEvent( + completedBy, + DateTimeOffset.UtcNow)); + } + + public void Cancel(string reason, string cancelledBy) + { + if (!_isCreated) + throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke"); + + if (!_status.CanCancel()) + throw new DomainException("CANNOT_CANCEL", $"Cannot cancel order in status {_status}", $"Kan ikke annullere ordre med status {_status}"); + + if (string.IsNullOrWhiteSpace(reason)) + throw new DomainException("REASON_REQUIRED", "Cancellation reason is required", "Annulleringsårsag er påkrævet"); + + Emit(new OrderCancelledEvent( + reason.Trim(), + cancelledBy, + DateTimeOffset.UtcNow)); + } + + #endregion +} diff --git a/backend/Books.Api/Domain/Orders/OrderId.cs b/backend/Books.Api/Domain/Orders/OrderId.cs new file mode 100644 index 0000000..0ee76d9 --- /dev/null +++ b/backend/Books.Api/Domain/Orders/OrderId.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.Orders; + +public class OrderId(string value) : Identity(value) +{ + /// + /// Creates a deterministic OrderId based on company, year, and order number. + /// This ensures idempotency and prevents duplicate orders. + /// + public static OrderId FromCompanyYearAndNumber(string companyId, int year, string orderNumber) + { + var input = $"{companyId}:order:{year}:{orderNumber}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits to make it a valid UUID + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); + + var deterministicGuid = new Guid(guidBytes); + return new OrderId($"order-{deterministicGuid:D}"); + } +} diff --git a/backend/Books.Api/Domain/Orders/OrderLine.cs b/backend/Books.Api/Domain/Orders/OrderLine.cs new file mode 100644 index 0000000..597a07e --- /dev/null +++ b/backend/Books.Api/Domain/Orders/OrderLine.cs @@ -0,0 +1,157 @@ +namespace Books.Api.Domain.Orders; + +/// +/// Value object representing a single line on an order. +/// Contains product/service description, quantity, pricing, and VAT information. +/// Tracks whether the line has been invoiced. +/// +public sealed record OrderLine +{ + /// + /// Line number for ordering (1-based). + /// + public int LineNumber { get; init; } + + /// + /// Description of the product or service. + /// + public string Description { get; init; } = string.Empty; + + /// + /// Quantity (can be decimal for services billed by hour). + /// + public decimal Quantity { get; init; } + + /// + /// Unit of measurement (e.g., "stk", "timer", "kg"). + /// + public string? Unit { get; init; } + + /// + /// Price per unit excluding VAT. + /// + public decimal UnitPrice { get; init; } + + /// + /// Discount percentage (0-100). + /// + public decimal DiscountPercent { get; init; } + + /// + /// VAT code (e.g., "U25" for 25%, "UEU" for EU sales, "UEXP" for export). + /// + public string VatCode { get; init; } = "U25"; + + /// + /// Revenue account to credit when line is invoiced. + /// If null, uses customer's default revenue account. + /// + public string? AccountId { get; init; } + + /// + /// Product ID if this line was created from a product. + /// + public string? ProductId { get; init; } + + /// + /// True if this line has been invoiced. + /// + public bool IsInvoiced { get; init; } + + /// + /// The invoice ID where this line was invoiced. + /// Null if not yet invoiced. + /// + public string? InvoiceId { get; init; } + + /// + /// When this line was invoiced. + /// + public DateTimeOffset? InvoicedAt { get; init; } + + /// + /// Calculated: UnitPrice * Quantity * (1 - DiscountPercent/100) + /// + public decimal AmountExVat => Math.Round(UnitPrice * Quantity * (1 - DiscountPercent / 100m), 2); + + /// + /// Calculated VAT amount based on VatCode. + /// + public decimal AmountVat => Math.Round(AmountExVat * GetVatRate(), 2); + + /// + /// Calculated: AmountExVat + AmountVat + /// + public decimal AmountTotal => AmountExVat + AmountVat; + + /// + /// Gets the VAT rate for this line based on VatCode. + /// + private decimal GetVatRate() => VatCode switch + { + "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 + }; + + /// + /// Creates an OrderLine with validation. + /// + public static OrderLine Create( + int lineNumber, + string description, + decimal quantity, + decimal unitPrice, + string? vatCode = null, + string? accountId = null, + string? unit = null, + decimal discountPercent = 0, + string? productId = null) + { + 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 OrderLine + { + LineNumber = lineNumber, + Description = description.Trim(), + Quantity = quantity, + UnitPrice = unitPrice, + VatCode = vatCode ?? "U25", + AccountId = accountId, + Unit = unit, + DiscountPercent = discountPercent, + ProductId = productId, + IsInvoiced = false, + InvoiceId = null, + InvoicedAt = null + }; + } + + /// + /// Returns a copy of this line marked as invoiced. + /// + public OrderLine MarkAsInvoiced(string invoiceId, DateTimeOffset invoicedAt) + { + return this with + { + IsInvoiced = true, + InvoiceId = invoiceId, + InvoicedAt = invoicedAt + }; + } +} diff --git a/backend/Books.Api/Domain/Orders/OrderStatus.cs b/backend/Books.Api/Domain/Orders/OrderStatus.cs new file mode 100644 index 0000000..9700e7c --- /dev/null +++ b/backend/Books.Api/Domain/Orders/OrderStatus.cs @@ -0,0 +1,86 @@ +namespace Books.Api.Domain.Orders; + +/// +/// Order status representing the lifecycle of an order. +/// Workflow: Draft -> Confirmed -> PartiallyInvoiced/FullyInvoiced (or Cancelled at any point before FullyInvoiced) +/// +public enum OrderStatus +{ + /// + /// Order is being prepared, not yet confirmed. + /// Lines can be added, updated, or removed. + /// + Draft, + + /// + /// Order has been confirmed and is ready for fulfillment/invoicing. + /// Lines can no longer be modified. + /// + Confirmed, + + /// + /// Some but not all lines have been invoiced. + /// + PartiallyInvoiced, + + /// + /// All lines have been invoiced. + /// Order is complete. + /// + FullyInvoiced, + + /// + /// Order has been cancelled. + /// Can only cancel Draft or Confirmed orders (not after invoicing has started). + /// + Cancelled +} + +public static class OrderStatusExtensions +{ + public static string ToStringValue(this OrderStatus status) => status switch + { + OrderStatus.Draft => "draft", + OrderStatus.Confirmed => "confirmed", + OrderStatus.PartiallyInvoiced => "partially_invoiced", + OrderStatus.FullyInvoiced => "fully_invoiced", + OrderStatus.Cancelled => "cancelled", + _ => throw new ArgumentOutOfRangeException(nameof(status)) + }; + + public static OrderStatus FromString(string value) => value.ToLowerInvariant() switch + { + "draft" => OrderStatus.Draft, + "confirmed" => OrderStatus.Confirmed, + "partially_invoiced" => OrderStatus.PartiallyInvoiced, + "fully_invoiced" => OrderStatus.FullyInvoiced, + "cancelled" => OrderStatus.Cancelled, + _ => throw new ArgumentException($"Unknown order status: {value}", nameof(value)) + }; + + /// + /// Returns true if the order can be modified (lines added/removed). + /// Only drafts can be modified. + /// + public static bool CanModify(this OrderStatus status) => status == OrderStatus.Draft; + + /// + /// Returns true if the order can be confirmed. + /// Only drafts with at least one line can be confirmed. + /// + public static bool CanConfirm(this OrderStatus status) => status == OrderStatus.Draft; + + /// + /// Returns true if lines from this order can be invoiced. + /// Confirmed and PartiallyInvoiced orders can have lines invoiced. + /// + public static bool CanInvoice(this OrderStatus status) => + status == OrderStatus.Confirmed || status == OrderStatus.PartiallyInvoiced; + + /// + /// Returns true if the order can be cancelled. + /// Cannot cancel after any lines have been invoiced. + /// + public static bool CanCancel(this OrderStatus status) => + status == OrderStatus.Draft || status == OrderStatus.Confirmed; +} diff --git a/backend/Books.Api/Domain/Products/Events/ProductCreatedEvent.cs b/backend/Books.Api/Domain/Products/Events/ProductCreatedEvent.cs new file mode 100644 index 0000000..ac529bf --- /dev/null +++ b/backend/Books.Api/Domain/Products/Events/ProductCreatedEvent.cs @@ -0,0 +1,27 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Products.Events; + +public class ProductCreatedEvent( + string companyId, + string? productNumber, + string name, + string? description, + decimal unitPrice, + string vatCode, + string? unit, + string? defaultAccountId, + string? ean, + string? manufacturer) : AggregateEvent +{ + public string CompanyId { get; } = companyId; + public string? ProductNumber { get; } = productNumber; + public string Name { get; } = name; + public string? Description { get; } = description; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? Unit { get; } = unit; + public string? DefaultAccountId { get; } = defaultAccountId; + public string? Ean { get; } = ean; + public string? Manufacturer { get; } = manufacturer; +} diff --git a/backend/Books.Api/Domain/Products/Events/ProductDeactivatedEvent.cs b/backend/Books.Api/Domain/Products/Events/ProductDeactivatedEvent.cs new file mode 100644 index 0000000..7a88cae --- /dev/null +++ b/backend/Books.Api/Domain/Products/Events/ProductDeactivatedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Products.Events; + +public class ProductDeactivatedEvent : AggregateEvent; diff --git a/backend/Books.Api/Domain/Products/Events/ProductReactivatedEvent.cs b/backend/Books.Api/Domain/Products/Events/ProductReactivatedEvent.cs new file mode 100644 index 0000000..73cb3c7 --- /dev/null +++ b/backend/Books.Api/Domain/Products/Events/ProductReactivatedEvent.cs @@ -0,0 +1,5 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Products.Events; + +public class ProductReactivatedEvent : AggregateEvent; diff --git a/backend/Books.Api/Domain/Products/Events/ProductUpdatedEvent.cs b/backend/Books.Api/Domain/Products/Events/ProductUpdatedEvent.cs new file mode 100644 index 0000000..091e5cf --- /dev/null +++ b/backend/Books.Api/Domain/Products/Events/ProductUpdatedEvent.cs @@ -0,0 +1,25 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Products.Events; + +public class ProductUpdatedEvent( + string? productNumber, + string name, + string? description, + decimal unitPrice, + string vatCode, + string? unit, + string? defaultAccountId, + string? ean, + string? manufacturer) : AggregateEvent +{ + public string? ProductNumber { get; } = productNumber; + public string Name { get; } = name; + public string? Description { get; } = description; + public decimal UnitPrice { get; } = unitPrice; + public string VatCode { get; } = vatCode; + public string? Unit { get; } = unit; + public string? DefaultAccountId { get; } = defaultAccountId; + public string? Ean { get; } = ean; + public string? Manufacturer { get; } = manufacturer; +} diff --git a/backend/Books.Api/Domain/Products/ProductAggregate.cs b/backend/Books.Api/Domain/Products/ProductAggregate.cs new file mode 100644 index 0000000..917cc09 --- /dev/null +++ b/backend/Books.Api/Domain/Products/ProductAggregate.cs @@ -0,0 +1,134 @@ +using Books.Api.Domain.Products.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.Products; + +public class ProductAggregate(ProductId id) : AggregateRoot(id), + IEmit, + IEmit, + IEmit, + IEmit +{ + private bool _isCreated; + private bool _isActive = true; + + public void Apply(ProductCreatedEvent e) + { + _isCreated = true; + } + + public void Apply(ProductUpdatedEvent e) { } + + public void Apply(ProductDeactivatedEvent e) => _isActive = false; + + public void Apply(ProductReactivatedEvent e) => _isActive = true; + + public void Create( + string companyId, + string? productNumber, + string name, + string? description, + decimal unitPrice, + string vatCode, + string? unit, + string? defaultAccountId, + string? ean, + string? manufacturer) + { + if (_isCreated) + throw new DomainException("PRODUCT_EXISTS", "Product already exists", "Produktet eksisterer allerede"); + + if (string.IsNullOrWhiteSpace(companyId)) + throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er paakraevet"); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("PRODUCT_NAME_REQUIRED", "Product name is required", "Produktnavn er paakraevet"); + + if (unitPrice < 0) + throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Enhedspris kan ikke vaere negativ"); + + if (string.IsNullOrWhiteSpace(vatCode)) + throw new DomainException("VAT_CODE_REQUIRED", "VAT code is required", "Momskode er paakraevet"); + + Emit(new ProductCreatedEvent( + companyId, + productNumber?.Trim(), + name.Trim(), + description?.Trim(), + unitPrice, + vatCode, + unit?.Trim(), + defaultAccountId, + ean?.Trim(), + manufacturer?.Trim())); + } + + public void Update( + string? productNumber, + string name, + string? description, + decimal unitPrice, + string vatCode, + string? unit, + string? defaultAccountId, + string? ean, + string? manufacturer) + { + EnsureCanModify(); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("PRODUCT_NAME_REQUIRED", "Product name is required", "Produktnavn er paakraevet"); + + if (unitPrice < 0) + throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Enhedspris kan ikke vaere negativ"); + + if (string.IsNullOrWhiteSpace(vatCode)) + throw new DomainException("VAT_CODE_REQUIRED", "VAT code is required", "Momskode er paakraevet"); + + Emit(new ProductUpdatedEvent( + productNumber?.Trim(), + name.Trim(), + description?.Trim(), + unitPrice, + vatCode, + unit?.Trim(), + defaultAccountId, + ean?.Trim(), + manufacturer?.Trim())); + } + + public void Deactivate() + { + EnsureCanModify(); + + if (!_isActive) + throw new DomainException("PRODUCT_ALREADY_INACTIVE", + "Product is already inactive", + "Produktet er allerede inaktivt"); + + Emit(new ProductDeactivatedEvent()); + } + + public void Reactivate() + { + if (!_isCreated) + throw new DomainException("PRODUCT_NOT_FOUND", + "Product does not exist", + "Produktet findes ikke"); + + if (_isActive) + throw new DomainException("PRODUCT_ALREADY_ACTIVE", + "Product is already active", + "Produktet er allerede aktivt"); + + Emit(new ProductReactivatedEvent()); + } + + private void EnsureCanModify() + { + if (!_isCreated) + throw new DomainException("PRODUCT_NOT_FOUND", + "Product does not exist", + "Produktet findes ikke"); + } +} diff --git a/backend/Books.Api/Domain/Products/ProductId.cs b/backend/Books.Api/Domain/Products/ProductId.cs new file mode 100644 index 0000000..4e14f5d --- /dev/null +++ b/backend/Books.Api/Domain/Products/ProductId.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.Products; + +public class ProductId(string value) : Identity(value) +{ + /// + /// Creates a deterministic ProductId based on company and product number. + /// This prevents duplicate products even with race conditions. + /// Uses SHA256 to generate a deterministic GUID from the input. + /// + public static ProductId FromCompanyAndNumber(string companyId, string productNumber) + { + var input = $"{companyId}:product:{productNumber}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits to make it a valid UUID + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); + + var deterministicGuid = new Guid(guidBytes); + return new ProductId($"product-{deterministicGuid:D}"); + } +} diff --git a/backend/Books.Api/Domain/UserAccess/CompanyRole.cs b/backend/Books.Api/Domain/UserAccess/CompanyRole.cs new file mode 100644 index 0000000..fc90ca4 --- /dev/null +++ b/backend/Books.Api/Domain/UserAccess/CompanyRole.cs @@ -0,0 +1,56 @@ +namespace Books.Api.Domain.UserAccess; + +/// +/// Roles a user can have for a company. +/// +public enum CompanyRole +{ + /// + /// Full access - can manage users, delete company, all operations + /// + Owner, + + /// + /// Can create journal entries, manage accounts, close fiscal years + /// + Accountant, + + /// + /// Read-only access to all data + /// + Viewer +} + +public static class CompanyRoleExtensions +{ + public static string ToDbString(this CompanyRole role) => role switch + { + CompanyRole.Owner => "owner", + CompanyRole.Accountant => "accountant", + CompanyRole.Viewer => "viewer", + _ => throw new ArgumentOutOfRangeException(nameof(role)) + }; + + public static CompanyRole FromDbString(string value) => value switch + { + "owner" => CompanyRole.Owner, + "accountant" => CompanyRole.Accountant, + "viewer" => CompanyRole.Viewer, + _ => throw new ArgumentOutOfRangeException(nameof(value), $"Unknown role: {value}") + }; + + /// + /// Check if role can perform write operations (create/update/delete) + /// + public static bool CanWrite(this CompanyRole role) => role is CompanyRole.Owner or CompanyRole.Accountant; + + /// + /// Check if role can manage users (grant/revoke access) + /// + public static bool CanManageUsers(this CompanyRole role) => role is CompanyRole.Owner; + + /// + /// Check if role can delete the company + /// + public static bool CanDeleteCompany(this CompanyRole role) => role is CompanyRole.Owner; +} diff --git a/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs b/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs new file mode 100644 index 0000000..144122c --- /dev/null +++ b/backend/Books.Api/Domain/UserAccess/Events/UserCompanyAccessEvents.cs @@ -0,0 +1,40 @@ +using EventFlow.Aggregates; + +namespace Books.Api.Domain.UserAccess.Events; + +/// +/// Emitted when a user is granted access to a company. +/// +public class UserCompanyAccessGrantedEvent( + string userId, + string companyId, + CompanyRole role, + string grantedBy) : AggregateEvent +{ + public string UserId { get; } = userId; + public string CompanyId { get; } = companyId; + public CompanyRole Role { get; } = role; + public string GrantedBy { get; } = grantedBy; +} + +/// +/// Emitted when a user's role is changed for a company. +/// +public class UserCompanyAccessRoleChangedEvent( + CompanyRole oldRole, + CompanyRole newRole, + string changedBy) : AggregateEvent +{ + public CompanyRole OldRole { get; } = oldRole; + public CompanyRole NewRole { get; } = newRole; + public string ChangedBy { get; } = changedBy; +} + +/// +/// Emitted when a user's access to a company is revoked. +/// +public class UserCompanyAccessRevokedEvent( + string revokedBy) : AggregateEvent +{ + public string RevokedBy { get; } = revokedBy; +} diff --git a/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs b/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs new file mode 100644 index 0000000..c879d2e --- /dev/null +++ b/backend/Books.Api/Domain/UserAccess/UserCompanyAccessAggregate.cs @@ -0,0 +1,102 @@ +using Books.Api.Domain.UserAccess.Events; +using EventFlow.Aggregates; + +namespace Books.Api.Domain.UserAccess; + +public class UserCompanyAccessAggregate : AggregateRoot, + IEmit, + IEmit, + IEmit +{ + public string UserId { get; private set; } = string.Empty; + public string CompanyId { get; private set; } = string.Empty; + public CompanyRole Role { get; private set; } + public string GrantedBy { get; private set; } = string.Empty; + public DateTimeOffset GrantedAt { get; private set; } + public bool IsActive { get; private set; } + public DateTimeOffset? RevokedAt { get; private set; } + public string? RevokedBy { get; private set; } + + public UserCompanyAccessAggregate(UserCompanyAccessId id) : base(id) { } + + /// + /// Grant access to a user for a company. + /// + public void GrantAccess(string userId, string companyId, CompanyRole role, string grantedBy) + { + if (!IsNew && IsActive) + { + throw new DomainException( + "ACCESS_ALREADY_GRANTED", + $"User '{userId}' already has access to company '{companyId}'", + $"Brugeren '{userId}' har allerede adgang til virksomheden"); + } + + // If previously revoked, we're re-granting + Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy)); + } + + /// + /// Change the user's role for this company. + /// + public void ChangeRole(CompanyRole newRole, string changedBy) + { + if (!IsActive) + { + throw new DomainException( + "ACCESS_NOT_ACTIVE", + "Cannot change role - access is not active", + "Kan ikke ændre rolle - adgang er ikke aktiv"); + } + + if (Role == newRole) + { + throw new DomainException( + "ROLE_UNCHANGED", + $"User already has role '{newRole}'", + $"Brugeren har allerede rollen '{newRole}'"); + } + + Emit(new UserCompanyAccessRoleChangedEvent(Role, newRole, changedBy)); + } + + /// + /// Revoke the user's access to this company. + /// + public void RevokeAccess(string revokedBy) + { + if (!IsActive) + { + throw new DomainException( + "ACCESS_ALREADY_REVOKED", + "Access is already revoked", + "Adgang er allerede tilbagekaldt"); + } + + Emit(new UserCompanyAccessRevokedEvent(revokedBy)); + } + + public void Apply(UserCompanyAccessGrantedEvent e) + { + UserId = e.UserId; + CompanyId = e.CompanyId; + Role = e.Role; + GrantedBy = e.GrantedBy; + GrantedAt = DateTimeOffset.UtcNow; + IsActive = true; + RevokedAt = null; + RevokedBy = null; + } + + public void Apply(UserCompanyAccessRoleChangedEvent e) + { + Role = e.NewRole; + } + + public void Apply(UserCompanyAccessRevokedEvent e) + { + IsActive = false; + RevokedAt = DateTimeOffset.UtcNow; + RevokedBy = e.RevokedBy; + } +} diff --git a/backend/Books.Api/Domain/UserAccess/UserCompanyAccessId.cs b/backend/Books.Api/Domain/UserAccess/UserCompanyAccessId.cs new file mode 100644 index 0000000..bca1499 --- /dev/null +++ b/backend/Books.Api/Domain/UserAccess/UserCompanyAccessId.cs @@ -0,0 +1,27 @@ +using System.Security.Cryptography; +using System.Text; +using EventFlow.Core; + +namespace Books.Api.Domain.UserAccess; + +public class UserCompanyAccessId(string value) : Identity(value) +{ + /// + /// Creates a deterministic ID based on user and company. + /// This ensures only one access record per user per company. + /// + public static UserCompanyAccessId FromUserAndCompany(string userId, string companyId) + { + var input = $"{userId}:{companyId}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, 16); + + // Set version (4) and variant bits + guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); + guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); + + var deterministicGuid = new Guid(guidBytes); + return new UserCompanyAccessId($"usercompanyaccess-{deterministicGuid:D}"); + } +} diff --git a/backend/Books.Api/Domain/VatCode.cs b/backend/Books.Api/Domain/VatCode.cs new file mode 100644 index 0000000..da047c5 --- /dev/null +++ b/backend/Books.Api/Domain/VatCode.cs @@ -0,0 +1,88 @@ +namespace Books.Api.Domain; + +/// +/// Danish VAT (moms) codes for SKAT compliance. +/// These codes determine how transactions are reported in the VAT return. +/// +public static class VatCodes +{ + // Output VAT (Udgående moms) - Sales + public const string U25 = "U25"; // Salg med 25% moms (standard) + public const string UEU = "UEU"; // EU-salg af varer/ydelser (momsfrit) + public const string UEXP = "UEXP"; // Eksport uden for EU (momsfrit) + + // Input VAT (Indgående moms) - Purchases + public const string I25 = "I25"; // Køb med 25% moms (fuldt fradrag) + public const string IEUV = "IEUV"; // EU-erhvervelse varer (reverse charge) + public const string IEUY = "IEUY"; // EU-erhvervelse ydelser (reverse charge) + public const string IVV = "IVV"; // Import varer fra verden + public const string IVY = "IVY"; // Import ydelser fra verden + + // Special codes + public const string REP = "REP"; // Repræsentation (25% fradrag) + public const string INGEN = "INGEN"; // Ingen moms / momsfrit + + /// + /// Gets the VAT rate for a given code. + /// + public static decimal GetRate(string? vatCode) => vatCode switch + { + U25 => 0.25m, + I25 => 0.25m, + IEUV => 0.25m, // Reverse charge - calculated but offset + IEUY => 0.25m, // Reverse charge - calculated but offset + REP => 0.25m, // 25% rate, but only 25% deductible + _ => 0m + }; + + /// + /// Gets the deductible percentage for a given code. + /// + public static decimal GetDeductiblePercent(string? vatCode) => vatCode switch + { + I25 => 1.0m, // 100% deductible + IEUV => 1.0m, // 100% deductible (reverse charge) + IEUY => 1.0m, // 100% deductible (reverse charge) + REP => 0.25m, // Only 25% deductible + _ => 0m + }; + + /// + /// Validates if a VAT code is valid. + /// + public static bool IsValid(string? vatCode) + { + if (string.IsNullOrWhiteSpace(vatCode)) return true; // null/empty is allowed + return vatCode is U25 or UEU or UEXP or I25 or IEUV or IEUY or IVV or IVY or REP or INGEN; + } + + /// + /// Determines if this is input VAT (købsmoms/fradrag) vs output VAT (salgsmoms/skyldig). + /// Input VAT is on purchases (assets), output VAT is on sales (liabilities). + /// + public static bool IsInputVat(string? vatCode) => vatCode switch + { + I25 or IEUV or IEUY or IVV or IVY or REP => true, // Purchase/input VAT codes + U25 or UEU or UEXP => false, // Sales/output VAT codes + _ => false + }; + + /// + /// All valid VAT codes for UI dropdowns. + /// + public static IReadOnlyList All { get; } = + [ + new(INGEN, "Ingen moms", "No VAT", 0m), + new(U25, "Salg 25%", "Sales 25%", 0.25m), + new(UEU, "EU-salg", "EU Sales", 0m), + new(UEXP, "Eksport", "Export", 0m), + new(I25, "Køb 25%", "Purchase 25%", 0.25m), + new(IEUV, "EU-køb varer", "EU Purchase Goods", 0.25m), + new(IEUY, "EU-køb ydelser", "EU Purchase Services", 0.25m), + new(IVV, "Import varer", "Import Goods", 0m), + new(IVY, "Import ydelser", "Import Services", 0m), + new(REP, "Repræsentation", "Entertainment", 0.25m), + ]; +} + +public record VatCodeInfo(string Code, string NameDanish, string NameEnglish, decimal Rate); diff --git a/backend/Books.Api/EventFlow/Infrastructure/DapperConfiguration.cs b/backend/Books.Api/EventFlow/Infrastructure/DapperConfiguration.cs new file mode 100644 index 0000000..df8559c --- /dev/null +++ b/backend/Books.Api/EventFlow/Infrastructure/DapperConfiguration.cs @@ -0,0 +1,140 @@ +using System.Data; +using Dapper; + +namespace Books.Api.EventFlow.Infrastructure; + +/// +/// Configures Dapper type handlers for PostgreSQL/Npgsql compatibility. +/// Npgsql 6.0+ returns DateOnly for DATE columns by default. +/// +public static class DapperConfiguration +{ + private static bool _initialized; + + public static void Configure() + { + if (_initialized) return; + + // Handle DateOnly serialization/deserialization for Dapper + SqlMapper.AddTypeHandler(new DateOnlyDapperHandler()); + SqlMapper.AddTypeHandler(new NullableDateOnlyDapperHandler()); + + // Handle DateOnly -> DateTime conversion for reading DATE columns + SqlMapper.AddTypeHandler(new DateOnlyTypeHandler()); + + // Handle DateTime -> DateTimeOffset? conversion safely (treats DateTime.MinValue as null) + SqlMapper.AddTypeHandler(new NullableDateTimeOffsetTypeHandler()); + + _initialized = true; + } +} + +/// +/// Dapper type handler for DateOnly - allows DateOnly to be used as a parameter value. +/// +public class DateOnlyDapperHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, DateOnly value) + { + parameter.Value = value.ToDateTime(TimeOnly.MinValue); + parameter.DbType = DbType.Date; + } + + public override DateOnly Parse(object value) + { + return value switch + { + DateOnly dateOnly => dateOnly, + DateTime dateTime => DateOnly.FromDateTime(dateTime), + _ => throw new InvalidCastException($"Cannot convert {value?.GetType().Name ?? "null"} to DateOnly") + }; + } +} + +/// +/// Dapper type handler for nullable DateOnly - allows DateOnly? to be used as a parameter value. +/// +public class NullableDateOnlyDapperHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, DateOnly? value) + { + if (value.HasValue) + { + parameter.Value = value.Value.ToDateTime(TimeOnly.MinValue); + parameter.DbType = DbType.Date; + } + else + { + parameter.Value = DBNull.Value; + parameter.DbType = DbType.Date; + } + } + + public override DateOnly? Parse(object value) + { + if (value == null || value == DBNull.Value) + return null; + + return value switch + { + DateOnly dateOnly => dateOnly, + DateTime dateTime => DateOnly.FromDateTime(dateTime), + _ => throw new InvalidCastException($"Cannot convert {value?.GetType().Name ?? "null"} to DateOnly?") + }; + } +} + +/// +/// Dapper type handler to convert PostgreSQL DATE (DateOnly) to DateTime. +/// +public class DateOnlyTypeHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, DateTime value) + { + parameter.Value = DateOnly.FromDateTime(value); + parameter.DbType = DbType.Date; + } + + public override DateTime Parse(object value) + { + return value switch + { + DateOnly dateOnly => dateOnly.ToDateTime(TimeOnly.MinValue), + DateTime dateTime => dateTime, + _ => throw new InvalidCastException($"Cannot convert {value?.GetType().Name ?? "null"} to DateTime") + }; + } +} + +/// +/// Dapper type handler to safely convert DateTime to nullable DateTimeOffset. +/// Treats DateTime.MinValue (0001-01-01) as null since DateTimeOffset cannot represent it. +/// +public class NullableDateTimeOffsetTypeHandler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, DateTimeOffset? value) + { + parameter.Value = value.HasValue ? value.Value.UtcDateTime : DBNull.Value; + parameter.DbType = DbType.DateTime; + } + + public override DateTimeOffset? Parse(object value) + { + if (value == null || value == DBNull.Value) + return null; + + if (value is DateTime dateTime) + { + // Treat DateTime.MinValue as null (cannot convert to DateTimeOffset) + if (dateTime.Year <= 1) + return null; + + return new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)); + } + + if (value is DateTimeOffset dto) + return dto; + + return null; + } +} diff --git a/backend/Books.Api/EventFlow/Infrastructure/ISingleAggregateReadModelPopulator.cs b/backend/Books.Api/EventFlow/Infrastructure/ISingleAggregateReadModelPopulator.cs new file mode 100644 index 0000000..6c49b1d --- /dev/null +++ b/backend/Books.Api/EventFlow/Infrastructure/ISingleAggregateReadModelPopulator.cs @@ -0,0 +1,30 @@ +using EventFlow.Aggregates; +using EventFlow.Core; + +namespace Books.Api.EventFlow.Infrastructure; + +/// +/// Service for repopulating read models for a single aggregate by replaying events from the event store. +/// Used by to repair read model inconsistencies. +/// +public interface ISingleAggregateReadModelPopulator +{ + /// + /// Repopulates a specific read model for the given aggregate by replaying all its events. + /// + Task PopulateAsync(TIdentity id, Type readModelType) + where TAggregate : IAggregateRoot + where TIdentity : IIdentity; + + /// + /// Repopulates ALL read models that handle events for the given aggregate. + /// + Task PopulateAllCompatibleReadModelsAsync(TIdentity id) + where TAggregate : IAggregateRoot + where TIdentity : IIdentity; + + /// + /// Non-generic version for use with Hangfire background jobs. + /// + Task PopulateAsyncNonGeneric(Type aggregateType, Type identityType, string id, Type readModelType); +} diff --git a/backend/Books.Api/EventFlow/Infrastructure/SingleAggregateReadModelPopulator.cs b/backend/Books.Api/EventFlow/Infrastructure/SingleAggregateReadModelPopulator.cs new file mode 100644 index 0000000..be3f856 --- /dev/null +++ b/backend/Books.Api/EventFlow/Infrastructure/SingleAggregateReadModelPopulator.cs @@ -0,0 +1,189 @@ +using System.Diagnostics; +using System.Reflection; +using EventFlow.Aggregates; +using EventFlow.Core; +using EventFlow.Core.Caching; +using EventFlow.EventStores; +using EventFlow.Extensions; +using EventFlow.ReadStores; +using Microsoft.Extensions.Caching.Memory; + +namespace Books.Api.EventFlow.Infrastructure; + +/// +/// Repopulates read models for a single aggregate by replaying events from the event store. +/// Adapted from nontrade's implementation. +/// +public class SingleAggregateReadModelPopulator( + IEventStore eventStore, + IMemoryCache memoryCache, + ILogger logger, + IEnumerable allReadStoreManagers) + : ISingleAggregateReadModelPopulator +{ + public async Task PopulateAsync(TIdentity id, Type readModelType) + where TAggregate : IAggregateRoot + where TIdentity : IIdentity + { + var events = await eventStore.LoadEventsAsync(id, CancellationToken.None); + await ProcessEvents(readModelType, events, CancellationToken.None); + } + + public async Task PopulateAsyncNonGeneric(Type aggregateType, Type identityType, string id, Type readModelType) + { + if (!typeof(IAggregateRoot<>).MakeGenericType(identityType).IsAssignableFrom(aggregateType)) + { + throw new ArgumentException( + "Aggregate type must implement IAggregateRoot with the specified identity type.", + nameof(aggregateType)); + } + + if (!typeof(IIdentity).IsAssignableFrom(identityType)) + { + throw new ArgumentException("Identity type must implement IIdentity.", nameof(identityType)); + } + + var methodInfo = typeof(SingleAggregateReadModelPopulator) + .GetMethod(nameof(PopulateAsync), BindingFlags.Public | BindingFlags.Instance); + + if (methodInfo == null) + { + throw new InvalidOperationException( + $"Method PopulateAsync not found for aggregate type {aggregateType} and identity type {identityType}"); + } + + var genericMethodInfo = methodInfo.MakeGenericMethod(aggregateType, identityType); + var identity = CreateIdentityInstance(identityType, id); + + await (Task)genericMethodInfo.Invoke(this, [identity, readModelType])!; + } + + public async Task PopulateAllCompatibleReadModelsAsync(TIdentity id) + where TAggregate : IAggregateRoot + where TIdentity : IIdentity + { + try + { + var compatibleReadStoreManagers = allReadStoreManagers + .Where(x => x.GetType().GetGenericArguments().FirstOrDefault() == typeof(TAggregate)) + .ToList(); + + var events = await eventStore.LoadEventsAsync(id, CancellationToken.None); + + foreach (var readStore in compatibleReadStoreManagers) + { + await ProcessEvents(readStore.ReadModelType, events, CancellationToken.None); + } + } + catch (Exception e) + { + logger.LogError(e, "Exception when populating all compatible read models for {Id}", id); + throw; + } + } + + private async Task ProcessEvents( + Type readModelType, + IReadOnlyCollection processEvents, + CancellationToken cancellationToken) + { + try + { + var stopwatch = Stopwatch.StartNew(); + var readStoreManagers = ResolveReadStoreManagers(readModelType); + long relevantEvents = 0; + + var readModelTypes = new[] + { + typeof(IAmReadModelFor<,,>) + }; + + var aggregateEventTypes = memoryCache.GetOrCreate( + CacheKey.With(GetType(), readModelType.ToString(), nameof(ProcessEvents)), + _ => new HashSet(readModelType.GetTypeInfo() + .GetInterfaces() + .Where(i => i.GetTypeInfo().IsGenericType && readModelTypes.Contains(i.GetGenericTypeDefinition())) + .Select(i => i.GetTypeInfo().GetGenericArguments()[2]))); + + var domainEvents = processEvents + .Where(e => aggregateEventTypes!.Contains(e.EventType)) + .ToList(); + relevantEvents += domainEvents.Count; + + if (domainEvents.Count == 0) + { + logger.LogWarning( + "[REPOPULATION] Will not populate {ReadModelType} because no relevant events were found", + readModelType.PrettyPrint()); + return; + } + + var applyTasks = readStoreManagers! + .Select(m => m.UpdateReadStoresAsync(domainEvents, cancellationToken)); + await Task.WhenAll(applyTasks).ConfigureAwait(false); + + logger.LogInformation( + "[REPOPULATION] Population of read model {ReadModelType} took {Seconds:F2} seconds, replayed {RelevantEventCount} events", + readModelType.FullName, + stopwatch.Elapsed.TotalSeconds, + relevantEvents); + } + catch (Exception e) + { + var aggregateId = processEvents.FirstOrDefault()?.GetIdentity()?.Value; + + logger.LogError(e, + "[REPOPULATION] Exception when populating: {ReadModelType} for {AggregateId}. " + + "This will cause data integrity issues if not resolved.", + readModelType.FullName, aggregateId); + throw; + } + } + + private IReadOnlyCollection? ResolveReadStoreManagers(Type readModelType) + { + return memoryCache.GetOrCreate( + CacheKey.With(GetType(), readModelType.ToString(), nameof(ResolveReadStoreManagers)), + _ => + { + var readStoreManagers = allReadStoreManagers + .Where(m => m.ReadModelType == readModelType) + .ToList(); + + if (readStoreManagers.Count == 0) + { + throw new ArgumentException( + $"Did not find any read store managers for read model type '{readModelType.PrettyPrint()}'"); + } + + return readStoreManagers; + }); + } + + private static object CreateIdentityInstance(Type identityType, string idString) + { + // EventFlow identity types have a static With(string) method on the Identity base class + // The method signature is: public static T With(string value) + var withMethod = identityType.GetMethod( + "With", + BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy, + null, + [typeof(string)], + null); + + if (withMethod != null) + { + return withMethod.Invoke(null, [idString])!; + } + + // Try the constructor directly (all EventFlow identities have a string constructor) + var constructor = identityType.GetConstructor([typeof(string)]); + if (constructor != null) + { + return constructor.Invoke([idString])!; + } + + throw new InvalidOperationException( + $"The type {identityType.Name} does not contain a public static With method or a string constructor."); + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/AccountReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/AccountReadModel.cs new file mode 100644 index 0000000..044e758 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/AccountReadModel.cs @@ -0,0 +1,101 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.Accounts; +using Books.Api.Domain.Accounts.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("account_read_models")] +public class AccountReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business columns + public string CompanyId { get; set; } = string.Empty; + public string AccountNumber { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string AccountType { get; set; } = string.Empty; + public string? ParentId { get; set; } + public string? Description { get; set; } + public string? VatCodeId { get; set; } + public bool IsActive { get; set; } = true; + public bool IsSystemAccount { get; set; } + /// + /// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. + /// + public string? StandardAccountNumber { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + AccountNumber = e.AccountNumber; + Name = e.Name; + AccountType = e.AccountType.ToString().ToLowerInvariant(); + ParentId = e.ParentId; + Description = e.Description; + VatCodeId = e.VatCodeId; + IsActive = true; + IsSystemAccount = e.IsSystemAccount; + StandardAccountNumber = e.StandardAccountNumber; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Name = e.Name; + ParentId = e.ParentId; + Description = e.Description; + VatCodeId = e.VatCodeId; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = false; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = true; + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/AccountReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/AccountReadModelDto.cs new file mode 100644 index 0000000..20152bb --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/AccountReadModelDto.cs @@ -0,0 +1,26 @@ +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for reading account data from the database. +/// Uses a class with properties because PostgreSQL returns column names +/// in lowercase, and Dapper matches properties case-insensitively. +/// +public class AccountReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string AccountNumber { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string AccountType { get; set; } = string.Empty; + public string? ParentId { get; set; } + public string? Description { get; set; } + public string? VatCodeId { get; set; } + public bool IsActive { get; set; } + public bool IsSystemAccount { get; set; } + /// + /// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. + /// + public string? StandardAccountNumber { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/AccountReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/AccountReadModelLocator.cs new file mode 100644 index 0000000..490e083 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/AccountReadModelLocator.cs @@ -0,0 +1,16 @@ +using Books.Api.Domain.Accounts; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +public class AccountReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent is IDomainEvent typedEvent) + { + yield return typedEvent.AggregateIdentity.Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModel.cs new file mode 100644 index 0000000..40bdced --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModel.cs @@ -0,0 +1,112 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.Attachments; +using Books.Api.Domain.Attachments.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("attachment_read_models")] +public class AttachmentReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business columns + public string CompanyId { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string OriginalFileName { get; set; } = string.Empty; + public string ContentType { get; set; } = string.Empty; + public long FileSize { get; set; } + public string StoragePath { get; set; } = string.Empty; + public string UploadedBy { get; set; } = string.Empty; + public DateTimeOffset UploadedAt { get; set; } + + /// + /// Reference to journal entry draft (before posting). + /// + public string? DraftId { get; set; } + + /// + /// Reference to posted transaction (after posting). + /// + public string? TransactionId { get; set; } + + /// + /// Retention end date - 5 years from upload per Bogføringsloven § 6. + /// + public DateTimeOffset RetentionEndDate { get; set; } + + public bool IsDeleted { get; set; } + public string? DeletedBy { get; set; } + public string? DeleteReason { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + CompanyId = e.CompanyId; + FileName = e.FileName; + OriginalFileName = e.OriginalFileName; + ContentType = e.ContentType; + FileSize = e.FileSize; + StoragePath = e.StoragePath; + UploadedBy = e.UploadedBy; + UploadedAt = domainEvent.Timestamp; + DraftId = e.DraftId; + TransactionId = e.TransactionId; + + // Bogføringsloven § 6: 5-year retention + RetentionEndDate = domainEvent.Timestamp.AddYears(5); + + IsDeleted = false; + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + TransactionId = e.TransactionId; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsDeleted = true; + DeletedBy = e.DeletedBy; + DeleteReason = e.Reason; + DeletedAt = domainEvent.Timestamp; + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelDto.cs new file mode 100644 index 0000000..a10ef2a --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelDto.cs @@ -0,0 +1,38 @@ +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for attachment (bilag) read model. +/// +public class AttachmentReadModelDto +{ + public required string Id { get; init; } + public required string CompanyId { get; init; } + public required string FileName { get; init; } + public required string OriginalFileName { get; init; } + public required string ContentType { get; init; } + public required long FileSize { get; init; } + public required string StoragePath { get; init; } + public required string UploadedBy { get; init; } + public required DateTimeOffset UploadedAt { get; init; } + public string? DraftId { get; init; } + public string? TransactionId { get; init; } + public required DateTimeOffset RetentionEndDate { get; init; } + public required bool IsDeleted { get; init; } + + public static AttachmentReadModelDto FromReadModel(AttachmentReadModel model) => new() + { + Id = model.AggregateId, + CompanyId = model.CompanyId, + FileName = model.FileName, + OriginalFileName = model.OriginalFileName, + ContentType = model.ContentType, + FileSize = model.FileSize, + StoragePath = model.StoragePath, + UploadedBy = model.UploadedBy, + UploadedAt = model.UploadedAt, + DraftId = model.DraftId, + TransactionId = model.TransactionId, + RetentionEndDate = model.RetentionEndDate, + IsDeleted = model.IsDeleted + }; +} diff --git a/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelLocator.cs new file mode 100644 index 0000000..63107b0 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/AttachmentReadModelLocator.cs @@ -0,0 +1,21 @@ +using Books.Api.Domain.Attachments; +using Books.Api.Domain.Attachments.Events; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// Locator for attachment read models. +/// Uses aggregate ID as the read model ID. +/// +public class AttachmentReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent.GetAggregateEvent() is IAggregateEvent) + { + yield return domainEvent.GetIdentity().Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModel.cs new file mode 100644 index 0000000..082e03b --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModel.cs @@ -0,0 +1,165 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using Books.Api.Domain.BankConnections; +using Books.Api.Domain.BankConnections.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("bank_connection_read_models")] +public class BankConnectionReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business columns + public string CompanyId { get; set; } = string.Empty; + public string AspspName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? SessionId { get; set; } + public DateTimeOffset? ValidUntil { get; set; } + public string? AccountsJson { get; set; } + public string? FailureReason { get; set; } + public bool IsArchived { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + AspspName = e.AspspName; + Status = "initiated"; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "established"; + SessionId = e.SessionId; + ValidUntil = e.ValidUntil; + AccountsJson = JsonSerializer.Serialize(e.Accounts); + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "failed"; + FailureReason = e.Reason; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "disconnected"; + SessionId = null; + FailureReason = e.Reason; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + SessionId = e.NewSessionId; + ValidUntil = e.ValidUntil; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + // Update the linked account ID and import from date in the accounts JSON + if (!string.IsNullOrEmpty(AccountsJson)) + { + var accounts = JsonSerializer.Deserialize>(AccountsJson) ?? []; + var accountIndex = accounts.FindIndex(a => a.AccountId == e.BankAccountId); + if (accountIndex >= 0) + { + var account = accounts[accountIndex]; + accounts[accountIndex] = account with + { + LinkedAccountId = e.LinkedAccountId, + ImportFromDate = e.ImportFromDate + }; + AccountsJson = JsonSerializer.Serialize(accounts); + } + } + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "initiated"; // Reset to initiated, waiting for authorization + SessionId = null; + FailureReason = null; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsArchived = true; + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelDto.cs new file mode 100644 index 0000000..b27bb25 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelDto.cs @@ -0,0 +1,20 @@ +using Books.Api.Domain.BankConnections; + +namespace Books.Api.EventFlow.ReadModels; + +public class BankConnectionReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string AspspName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? SessionId { get; set; } + public DateTimeOffset? ValidUntil { get; set; } + public List? Accounts { get; set; } + public string? FailureReason { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public bool IsArchived { get; set; } + + public bool IsActive => Status == "established" && ValidUntil > DateTimeOffset.UtcNow; +} diff --git a/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelLocator.cs new file mode 100644 index 0000000..329a7b5 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/BankConnectionReadModelLocator.cs @@ -0,0 +1,16 @@ +using Books.Api.Domain.BankConnections; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +public class BankConnectionReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent is IDomainEvent typedEvent) + { + yield return typedEvent.AggregateIdentity.Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/BankTransactionDto.cs b/backend/Books.Api/EventFlow/ReadModels/BankTransactionDto.cs new file mode 100644 index 0000000..66e983c --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/BankTransactionDto.cs @@ -0,0 +1,65 @@ +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for bank transactions synced from Enable Banking. +/// Used for display in Hurtig Bogføring and tracking booking status. +/// +public class BankTransactionDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string BankConnectionId { get; set; } = string.Empty; + public string BankAccountId { get; set; } = string.Empty; + public string ExternalId { get; set; } = string.Empty; + + public decimal Amount { get; set; } + public string Currency { get; set; } = "DKK"; + + public DateTime TransactionDate { get; set; } + public DateTime? BookingDate { get; set; } + public DateTime? ValueDate { get; set; } + + public string? Description { get; set; } + public string? CounterpartyName { get; set; } + public string? CounterpartyAccount { get; set; } + public string? Reference { get; set; } + public string? CreditorName { get; set; } + public string? DebtorName { get; set; } + + /// + /// Status: pending | booked | ignored + /// + public string Status { get; set; } = "pending"; + + /// + /// Reference to journal_entry_draft when booked. + /// + public string? JournalEntryDraftId { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + /// + /// Helper to get display name for counterparty. + /// + public string DisplayCounterparty => + CounterpartyName ?? CreditorName ?? DebtorName ?? "Ukendt"; + + /// + /// Helper to determine if this is an income (positive) or expense (negative). + /// + public bool IsIncome => Amount > 0; +} + +/// +/// Result of a bank transaction sync operation. +/// +public class BankTransactionSyncResult +{ + public int TotalConnections { get; set; } + public int TotalAccounts { get; set; } + public int NewTransactions { get; set; } + public int SkippedDuplicates { get; set; } + public int Errors { get; set; } + public List ErrorMessages { get; set; } = []; +} diff --git a/backend/Books.Api/EventFlow/ReadModels/CustomerReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/CustomerReadModel.cs new file mode 100644 index 0000000..2d8bd81 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/CustomerReadModel.cs @@ -0,0 +1,114 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.Customers; +using Books.Api.Domain.Customers.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("customer_read_models")] +public class CustomerReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business columns + public string CompanyId { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string CustomerType { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Cvr { get; set; } + public string? Address { get; set; } + public string? PostalCode { get; set; } + public string? City { get; set; } + public string Country { get; set; } = "DK"; + public string? Email { get; set; } + public string? Phone { get; set; } + public int PaymentTermsDays { get; set; } = 30; + public string? DefaultRevenueAccountId { get; set; } + public string SubLedgerAccountId { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + CustomerNumber = e.CustomerNumber; + CustomerType = e.CustomerType.ToString(); + Name = e.Name; + Cvr = e.Cvr; + Address = e.Address; + PostalCode = e.PostalCode; + City = e.City; + Country = e.Country; + Email = e.Email; + Phone = e.Phone; + PaymentTermsDays = e.PaymentTermsDays; + DefaultRevenueAccountId = e.DefaultRevenueAccountId; + SubLedgerAccountId = e.SubLedgerAccountId; + IsActive = true; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Name = e.Name; + Cvr = e.Cvr; + Address = e.Address; + PostalCode = e.PostalCode; + City = e.City; + Country = e.Country; + Email = e.Email; + Phone = e.Phone; + PaymentTermsDays = e.PaymentTermsDays; + DefaultRevenueAccountId = e.DefaultRevenueAccountId; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = false; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = true; + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/CustomerReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/CustomerReadModelDto.cs new file mode 100644 index 0000000..08b8526 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/CustomerReadModelDto.cs @@ -0,0 +1,28 @@ +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for reading customer data from the database. +/// Uses a class with properties because PostgreSQL returns column names +/// in lowercase, and Dapper matches properties case-insensitively. +/// +public class CustomerReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string CustomerType { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Cvr { get; set; } + public string? Address { get; set; } + public string? PostalCode { get; set; } + public string? City { get; set; } + public string Country { get; set; } = "DK"; + public string? Email { get; set; } + public string? Phone { get; set; } + public int PaymentTermsDays { get; set; } + public string? DefaultRevenueAccountId { get; set; } + public string SubLedgerAccountId { get; set; } = string.Empty; + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/CustomerReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/CustomerReadModelLocator.cs new file mode 100644 index 0000000..6c122dd --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/CustomerReadModelLocator.cs @@ -0,0 +1,16 @@ +using Books.Api.Domain.Customers; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +public class CustomerReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent is IDomainEvent typedEvent) + { + yield return typedEvent.AggregateIdentity.Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModel.cs new file mode 100644 index 0000000..9868ed4 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModel.cs @@ -0,0 +1,117 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.FiscalYears; +using Books.Api.Domain.FiscalYears.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("fiscal_year_read_models")] +public class FiscalYearReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business columns + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public string Status { get; set; } = "open"; + public bool OpeningBalancePosted { get; set; } + + // Audit fields for state transitions + public DateTimeOffset? ClosingDate { get; set; } + public string? ClosedBy { get; set; } + public DateTimeOffset? ReopenedDate { get; set; } + public string? ReopenedBy { get; set; } + public DateTimeOffset? LockedDate { get; set; } + public string? LockedBy { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + Name = e.Name; + StartDate = e.StartDate.ToDateTime(TimeOnly.MinValue); + EndDate = e.EndDate.ToDateTime(TimeOnly.MinValue); + Status = "open"; + OpeningBalancePosted = false; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "closed"; + ClosingDate = domainEvent.Timestamp; + ClosedBy = e.ClosedBy; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "open"; + ClosingDate = null; + ClosedBy = null; + ReopenedDate = domainEvent.Timestamp; + ReopenedBy = e.ReopenedBy; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "locked"; + LockedDate = domainEvent.Timestamp; + LockedBy = e.LockedBy; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + OpeningBalancePosted = true; + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelDto.cs new file mode 100644 index 0000000..289a67f --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelDto.cs @@ -0,0 +1,28 @@ +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for reading fiscal year data from the database. +/// Uses a class with properties because PostgreSQL returns column names +/// in lowercase, and Dapper matches properties case-insensitively. +/// +public class FiscalYearReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public string Status { get; set; } = string.Empty; + public bool OpeningBalancePosted { get; set; } + + // Audit fields for state transitions + public DateTime? ClosingDate { get; set; } + public string? ClosedBy { get; set; } + public DateTime? ReopenedDate { get; set; } + public string? ReopenedBy { get; set; } + public DateTime? LockedDate { get; set; } + public string? LockedBy { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelLocator.cs new file mode 100644 index 0000000..1ad2bad --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/FiscalYearReadModelLocator.cs @@ -0,0 +1,16 @@ +using Books.Api.Domain.FiscalYears; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +public class FiscalYearReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent is IDomainEvent typedEvent) + { + yield return typedEvent.AggregateIdentity.Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModel.cs new file mode 100644 index 0000000..3cbe291 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModel.cs @@ -0,0 +1,357 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using Books.Api.Domain.Invoices; +using Books.Api.Domain.Invoices.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("invoice_read_models")] +public class InvoiceReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdateTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Company and fiscal year + public string CompanyId { get; set; } = string.Empty; + public string? FiscalYearId { get; set; } + + // Customer reference + public string CustomerId { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + + // Invoice identification + public string InvoiceNumber { get; set; } = string.Empty; + public DateTime? InvoiceDate { get; set; } + public DateTime? DueDate { get; set; } + + // Document type (invoice or credit_note) + public string InvoiceType { get; set; } = "invoice"; + + // Credit note specific fields + public string? OriginalInvoiceId { get; set; } + public string? OriginalInvoiceNumber { get; set; } + public string? CreditReason { get; set; } + + // Status + public string Status { get; set; } = "draft"; + + // Amounts + public decimal AmountExVat { get; set; } + public decimal AmountVat { get; set; } + public decimal AmountTotal { get; set; } + public decimal AmountPaid { get; set; } + public decimal AmountApplied { get; set; } // For credit notes: total applied + public decimal AmountRemaining { get; set; } + + // Currency and VAT + public string Currency { get; set; } = "DKK"; + public string? VatCode { get; set; } + + // Payment terms + public int PaymentTermsDays { get; set; } = 30; + + // Lines (stored as JSON string - PostgreSQL column is text, not jsonb) + public string Lines { get; set; } = "[]"; + + // Ledger integration + public string? LedgerTransactionId { get; set; } + + // Notes and reference + public string? Notes { get; set; } + public string? Reference { get; set; } + + // Status timestamps + public DateTimeOffset? SentAt { get; set; } + public DateTimeOffset? IssuedAt { get; set; } // For credit notes + public DateTimeOffset? PaidAt { get; set; } + public DateTimeOffset? VoidedAt { get; set; } + public string? VoidedReason { get; set; } + public string? VoidedBy { get; set; } + + // Audit + public string CreatedBy { get; set; } = string.Empty; + public string? UpdatedBy { get; set; } + + // Internal list for line tracking + [NotMapped] + private List? _linesList; + + private List GetLinesList() + { + if (_linesList == null) + { + _linesList = string.IsNullOrEmpty(Lines) + ? [] + : JsonSerializer.Deserialize>(Lines) ?? []; + } + return _linesList; + } + + /// + /// Returns the invoice lines for external access. + /// + public IReadOnlyList GetLines() => GetLinesList(); + + private void SaveLines() + { + Lines = JsonSerializer.Serialize(_linesList ?? []); + RecalculateAmounts(); + } + + private void RecalculateAmounts() + { + var lines = GetLinesList(); + AmountExVat = lines.Sum(l => l.AmountExVat); + AmountVat = lines.Sum(l => l.AmountVat); + AmountTotal = lines.Sum(l => l.AmountTotal); + + // For credit notes, use AmountApplied; for invoices, use AmountPaid + if (InvoiceType == "credit_note") + { + AmountRemaining = Math.Abs(AmountTotal) - AmountApplied; + } + else + { + AmountRemaining = AmountTotal - AmountPaid; + } + } + + #region Apply Methods + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + FiscalYearId = e.FiscalYearId; + CustomerId = e.CustomerId; + CustomerName = e.CustomerName; + CustomerNumber = e.CustomerNumber; + InvoiceNumber = e.InvoiceNumber; + InvoiceDate = e.InvoiceDate.ToDateTime(TimeOnly.MinValue); + DueDate = e.DueDate.ToDateTime(TimeOnly.MinValue); + PaymentTermsDays = e.PaymentTermsDays; + Currency = e.Currency; + VatCode = e.VatCode; + Notes = e.Notes; + Reference = e.Reference; + CreatedBy = e.CreatedBy; + Status = "draft"; + + // Credit note specific fields + InvoiceType = e.Type.ToStringValue(); + OriginalInvoiceId = e.OriginalInvoiceId; + OriginalInvoiceNumber = e.OriginalInvoiceNumber; + CreditReason = e.CreditReason; + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + var lines = GetLinesList(); + lines.Add(new InvoiceLineDto + { + LineNumber = e.LineNumber, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId + }); + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + var lines = GetLinesList(); + var existingIndex = lines.FindIndex(l => l.LineNumber == e.LineNumber); + if (existingIndex >= 0) + { + lines[existingIndex] = new InvoiceLineDto + { + LineNumber = e.LineNumber, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId + }; + } + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + var lines = GetLinesList(); + lines.RemoveAll(l => l.LineNumber == e.LineNumber); + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + // Credit notes are "issued", invoices are "sent" + if (InvoiceType == "credit_note") + { + Status = "issued"; + IssuedAt = e.SentAt; + } + else + { + Status = "sent"; + SentAt = e.SentAt; + } + + AmountExVat = e.AmountExVat; + AmountVat = e.AmountVat; + AmountTotal = e.AmountTotal; + AmountRemaining = Math.Abs(e.AmountTotal); // Use absolute for remaining calculation + LedgerTransactionId = e.LedgerTransactionId; + UpdatedBy = e.SentBy; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + AmountPaid = e.NewAmountPaid; + AmountRemaining = e.NewAmountRemaining; + Status = e.NewStatus.ToStringValue(); + UpdatedBy = e.RecordedBy; + + if (e.NewStatus == InvoiceStatus.Paid) + { + PaidAt = domainEvent.Timestamp; + } + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "voided"; + VoidedAt = e.VoidedAt; + VoidedReason = e.Reason; + VoidedBy = e.VoidedBy; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + AmountApplied = e.NewAmountApplied; + AmountRemaining = e.NewAmountRemaining; + Status = e.NewStatus.ToStringValue(); + UpdatedBy = e.AppliedBy; + return Task.CompletedTask; + } + + #endregion +} + +/// +/// DTO for invoice lines stored as JSON in the read model. +/// +public class InvoiceLineDto +{ + public int LineNumber { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public string? Unit { get; set; } + public decimal UnitPrice { get; set; } + public decimal DiscountPercent { get; set; } + public string VatCode { get; set; } = "U25"; + public string? AccountId { get; set; } + + public decimal AmountExVat => Math.Round(UnitPrice * Quantity * (1 - DiscountPercent / 100m), 2); + public decimal AmountVat => Math.Round(AmountExVat * GetVatRate(), 2); + public decimal AmountTotal => AmountExVat + AmountVat; + + private decimal GetVatRate() => VatCode switch + { + "U25" or "I25" => 0.25m, + "UEU" or "IEU" => 0m, + "UEXP" or "IEXP" => 0m, + "INGEN" => 0m, + _ => 0.25m + }; +} diff --git a/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelDto.cs new file mode 100644 index 0000000..9bcdc05 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelDto.cs @@ -0,0 +1,75 @@ +using System.Text.Json; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for Invoice read model, used for Dapper queries. +/// +public class InvoiceReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string? FiscalYearId { get; set; } + public string CustomerId { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string InvoiceNumber { get; set; } = string.Empty; + public DateTime? InvoiceDate { get; set; } + public DateTime? DueDate { get; set; } + + // Document type (invoice or credit_note) + public string InvoiceType { get; set; } = "invoice"; + + // Credit note specific fields + public string? OriginalInvoiceId { get; set; } + public string? OriginalInvoiceNumber { get; set; } + public string? CreditReason { get; set; } + + public string Status { get; set; } = "draft"; + public decimal AmountExVat { get; set; } + public decimal AmountVat { get; set; } + public decimal AmountTotal { get; set; } + public decimal AmountPaid { get; set; } + public decimal AmountApplied { get; set; } // For credit notes + public decimal AmountRemaining { get; set; } + public string Currency { get; set; } = "DKK"; + public string? VatCode { get; set; } + public int PaymentTermsDays { get; set; } + public string LinesJson { get; set; } = "[]"; + public string? LedgerTransactionId { get; set; } + public string? Notes { get; set; } + public string? Reference { get; set; } + public DateTime? SentAt { get; set; } + public DateTime? IssuedAt { get; set; } // For credit notes + public DateTime? PaidAt { get; set; } + public DateTime? VoidedAt { get; set; } + public string? VoidedReason { get; set; } + public string? VoidedBy { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public string? UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + /// + /// Returns true if this is a credit note. + /// + public bool IsCreditNote => InvoiceType == "credit_note"; + + /// + /// Parses the Lines JSON into a list of InvoiceLineDto. + /// + public IReadOnlyList GetLines() + { + if (string.IsNullOrEmpty(LinesJson)) + return []; + + try + { + return JsonSerializer.Deserialize>(LinesJson) ?? []; + } + catch + { + return []; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelLocator.cs new file mode 100644 index 0000000..1d9c6f6 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/InvoiceReadModelLocator.cs @@ -0,0 +1,20 @@ +using Books.Api.Domain.Invoices; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// Locator for Invoice read models. +/// Maps domain events to the read model identifier. +/// +public class InvoiceReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent.GetAggregateEvent() is IAggregateEvent) + { + yield return domainEvent.GetIdentity().Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs new file mode 100644 index 0000000..0579841 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModel.cs @@ -0,0 +1,129 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using Books.Api.Domain.JournalEntryDrafts; +using Books.Api.Domain.JournalEntryDrafts.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("journal_entry_draft_read_models")] +public class JournalEntryDraftReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business fields + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + /// + /// Bilagsnummer - unique document number per company (required by Bogføringsloven § 7) + /// + public string VoucherNumber { get; set; } = string.Empty; + /// + /// Bilagsdato - the date of the transaction/document (e.g., invoice date) + /// + public DateTime? DocumentDate { get; set; } + public string? Description { get; set; } + public string? FiscalYearId { get; set; } + /// + /// JSON array of posting lines with VAT codes + /// + public string Lines { get; set; } = "[]"; + /// + /// JSON array of attachment IDs (bilag references, required by Bogføringsloven § 6) + /// + public string AttachmentIds { get; set; } = "[]"; + public string Status { get; set; } = "active"; + public string? TransactionId { get; set; } + public string CreatedBy { get; set; } = string.Empty; + /// + /// Full AI extraction data stored as JSON string. + /// Contains vendor CVR, amounts, VAT, due date, payment reference, line items, etc. + /// + public string? ExtractionData { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + CompanyId = e.CompanyId; + Name = e.Name; + VoucherNumber = e.VoucherNumber; + CreatedBy = e.CreatedBy; + Status = "active"; + Lines = "[]"; + AttachmentIds = "[]"; + ExtractionData = e.ExtractionData; + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + if (e.Name != null) + Name = e.Name; + + DocumentDate = e.DocumentDate?.ToDateTime(TimeOnly.MinValue); + Description = e.Description; + FiscalYearId = e.FiscalYearId; + Lines = JsonSerializer.Serialize(e.Lines); + AttachmentIds = JsonSerializer.Serialize(e.AttachmentIds); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + Status = "posted"; + TransactionId = domainEvent.AggregateEvent.TransactionId; + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + Status = "discarded"; + + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs new file mode 100644 index 0000000..227ac4c --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelDto.cs @@ -0,0 +1,60 @@ +using Books.Api.Domain.JournalEntryDrafts; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for reading journal entry draft data from the database. +/// +public class JournalEntryDraftReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + /// + /// Bilagsnummer - unique document number required by Bogføringsloven § 7, Stk. 4 + /// + public string VoucherNumber { get; set; } = string.Empty; + /// + /// Bilagsdato - the date of the transaction/document (e.g., invoice date) + /// + public DateTime? DocumentDate { get; set; } + public string? Description { get; set; } + public string? FiscalYearId { get; set; } + public string Lines { get; set; } = "[]"; + /// + /// JSON array of attachment IDs (bilag references) + /// + public string AttachmentIds { get; set; } = "[]"; + public string Status { get; set; } = "active"; + public string? TransactionId { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + /// + /// Full AI extraction data stored as JSON string. + /// Contains vendor CVR, amounts, VAT, due date, payment reference, line items, etc. + /// + public string? ExtractionData { get; set; } + + /// + /// Deserialize lines from JSON to list of DraftLine objects. + /// + public List GetLines() + { + if (string.IsNullOrEmpty(Lines)) + return []; + + return System.Text.Json.JsonSerializer.Deserialize>(Lines) ?? []; + } + + /// + /// Deserialize attachment IDs from JSON. + /// + public List GetAttachmentIds() + { + if (string.IsNullOrEmpty(AttachmentIds)) + return []; + + return System.Text.Json.JsonSerializer.Deserialize>(AttachmentIds) ?? []; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelLocator.cs new file mode 100644 index 0000000..d7adf4d --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/JournalEntryDraftReadModelLocator.cs @@ -0,0 +1,16 @@ +using Books.Api.Domain.JournalEntryDrafts; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +public class JournalEntryDraftReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent is IDomainEvent typedEvent) + { + yield return typedEvent.AggregateIdentity.Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/OrderReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/OrderReadModel.cs new file mode 100644 index 0000000..e150e2d --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/OrderReadModel.cs @@ -0,0 +1,294 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using Books.Api.Domain.Orders; +using Books.Api.Domain.Orders.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("order_read_models")] +public class OrderReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdateTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Company and fiscal year + public string CompanyId { get; set; } = string.Empty; + public string FiscalYearId { get; set; } = string.Empty; + + // Customer reference + public string CustomerId { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + + // Order identification + public string OrderNumber { get; set; } = string.Empty; + public DateTime? OrderDate { get; set; } + public DateTime? ExpectedDeliveryDate { get; set; } + + // Status + public string Status { get; set; } = "draft"; + + // Amounts (set when confirmed) + public decimal AmountExVat { get; set; } + public decimal AmountVat { get; set; } + public decimal AmountTotal { get; set; } + + // Currency + public string Currency { get; set; } = "DKK"; + + // Lines (stored as JSON string) + public string Lines { get; set; } = "[]"; + + // Invoice reference (when converted) - stores last invoice created + public string? InvoiceId { get; set; } + public string? InvoiceNumber { get; set; } + + // Notes and reference + public string? Notes { get; set; } + public string? Reference { get; set; } + + // Status timestamps + public DateTimeOffset? ConfirmedAt { get; set; } + public string? ConfirmedBy { get; set; } + public DateTimeOffset? InvoicedAt { get; set; } + public string? InvoicedBy { get; set; } + public DateTimeOffset? CancelledAt { get; set; } + public string? CancelledBy { get; set; } + public string? CancelledReason { get; set; } + + // Audit + public string CreatedBy { get; set; } = string.Empty; + public string? UpdatedBy { get; set; } + + // Internal list for line tracking + [NotMapped] + private List? _linesList; + + private List GetLinesList() + { + if (_linesList == null) + { + _linesList = string.IsNullOrEmpty(Lines) + ? [] + : JsonSerializer.Deserialize>(Lines) ?? []; + } + return _linesList; + } + + /// + /// Returns the order lines for external access. + /// + public IReadOnlyList GetLines() => GetLinesList(); + + private void SaveLines() + { + Lines = JsonSerializer.Serialize(_linesList ?? []); + RecalculateAmounts(); + } + + private void RecalculateAmounts() + { + var lines = GetLinesList(); + AmountExVat = lines.Sum(l => l.AmountExVat); + AmountVat = lines.Sum(l => l.AmountVat); + AmountTotal = lines.Sum(l => l.AmountTotal); + } + + #region Apply Methods + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + FiscalYearId = e.FiscalYearId; + CustomerId = e.CustomerId; + CustomerName = e.CustomerName; + CustomerNumber = e.CustomerNumber; + OrderNumber = e.OrderNumber; + OrderDate = e.OrderDate.ToDateTime(TimeOnly.MinValue); + ExpectedDeliveryDate = e.ExpectedDeliveryDate?.ToDateTime(TimeOnly.MinValue); + Currency = e.Currency; + Notes = e.Notes; + Reference = e.Reference; + CreatedBy = e.CreatedBy; + Status = "draft"; + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + var lines = GetLinesList(); + lines.Add(new OrderLineDto + { + LineNumber = e.LineNumber, + ProductId = e.ProductId, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId, + IsInvoiced = false + }); + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + var lines = GetLinesList(); + var existingIndex = lines.FindIndex(l => l.LineNumber == e.LineNumber); + if (existingIndex >= 0) + { + var existing = lines[existingIndex]; + lines[existingIndex] = new OrderLineDto + { + LineNumber = e.LineNumber, + ProductId = e.ProductId, + Description = e.Description, + Quantity = e.Quantity, + Unit = e.Unit, + UnitPrice = e.UnitPrice, + DiscountPercent = e.DiscountPercent, + VatCode = e.VatCode, + AccountId = e.AccountId, + IsInvoiced = existing.IsInvoiced, + InvoiceId = existing.InvoiceId, + InvoicedAt = existing.InvoicedAt + }; + } + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + + var lines = GetLinesList(); + lines.RemoveAll(l => l.LineNumber == e.LineNumber); + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "confirmed"; + AmountExVat = e.AmountExVat; + AmountVat = e.AmountVat; + AmountTotal = e.AmountTotal; + ConfirmedAt = e.ConfirmedAt; + ConfirmedBy = e.ConfirmedBy; + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = e.NewStatus.ToStringValue(); + + // Update invoice reference (to the latest invoice) + InvoiceId = e.InvoiceId; + InvoiceNumber = e.InvoiceNumber; + InvoicedAt = e.InvoicedAt; + InvoicedBy = e.InvoicedBy; + + // Mark the specific lines as invoiced + var lines = GetLinesList(); + foreach (var lineNumber in e.LineNumbers) + { + var lineIndex = lines.FindIndex(l => l.LineNumber == lineNumber); + if (lineIndex >= 0) + { + var line = lines[lineIndex]; + lines[lineIndex] = line with + { + IsInvoiced = true, + InvoiceId = e.InvoiceId, + InvoicedAt = e.InvoicedAt + }; + } + } + SaveLines(); + + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Status = "cancelled"; + CancelledAt = e.CancelledAt; + CancelledBy = e.CancelledBy; + CancelledReason = e.Reason; + + return Task.CompletedTask; + } + + #endregion +} diff --git a/backend/Books.Api/EventFlow/ReadModels/OrderReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/OrderReadModelDto.cs new file mode 100644 index 0000000..08053ab --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/OrderReadModelDto.cs @@ -0,0 +1,110 @@ +using System.Text.Json; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for Order read model, used for Dapper queries. +/// +public class OrderReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string FiscalYearId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string OrderNumber { get; set; } = string.Empty; + public DateTime? OrderDate { get; set; } + public DateTime? ExpectedDeliveryDate { get; set; } + public string Status { get; set; } = "draft"; + public decimal AmountExVat { get; set; } + public decimal AmountVat { get; set; } + public decimal AmountTotal { get; set; } + public string Currency { get; set; } = "DKK"; + public string LinesJson { get; set; } = "[]"; + public string? InvoiceId { get; set; } + public string? InvoiceNumber { get; set; } + public string? Notes { get; set; } + public string? Reference { get; set; } + public DateTime? ConfirmedAt { get; set; } + public string? ConfirmedBy { get; set; } + public DateTime? InvoicedAt { get; set; } + public string? InvoicedBy { get; set; } + public DateTime? CancelledAt { get; set; } + public string? CancelledBy { get; set; } + public string? CancelledReason { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public string? UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + /// + /// Parses the Lines JSON into a list of OrderLineDto. + /// + public IReadOnlyList GetLines() + { + if (string.IsNullOrEmpty(LinesJson)) + return []; + + try + { + return JsonSerializer.Deserialize>(LinesJson) ?? []; + } + catch + { + return []; + } + } + + /// + /// Returns lines that have not yet been invoiced. + /// + public IReadOnlyList GetUninvoicedLines() + { + return GetLines().Where(l => !l.IsInvoiced).ToList(); + } + + /// + /// Returns the count of uninvoiced lines. + /// + public int UninvoicedLineCount => GetLines().Count(l => !l.IsInvoiced); + + /// + /// Returns the total amount of uninvoiced lines. + /// + public decimal UninvoicedAmount => GetLines().Where(l => !l.IsInvoiced).Sum(l => l.AmountTotal); +} + +/// +/// DTO for order lines stored as JSON in the read model. +/// +public record OrderLineDto +{ + public int LineNumber { get; init; } + public string? ProductId { get; init; } + public string Description { get; init; } = string.Empty; + public decimal Quantity { get; init; } + public string? Unit { get; init; } + public decimal UnitPrice { get; init; } + public decimal DiscountPercent { get; init; } + public string VatCode { get; init; } = "U25"; + public string? AccountId { get; init; } + + // Invoicing tracking + public bool IsInvoiced { get; init; } + public string? InvoiceId { get; init; } + public DateTimeOffset? InvoicedAt { get; init; } + + public decimal AmountExVat => Math.Round(UnitPrice * Quantity * (1 - DiscountPercent / 100m), 2); + public decimal AmountVat => Math.Round(AmountExVat * GetVatRate(), 2); + public decimal AmountTotal => AmountExVat + AmountVat; + + private decimal GetVatRate() => VatCode switch + { + "U25" or "I25" => 0.25m, + "UEU" or "IEU" => 0m, + "UEXP" or "IEXP" => 0m, + "INGEN" => 0m, + _ => 0.25m + }; +} diff --git a/backend/Books.Api/EventFlow/ReadModels/OrderReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/OrderReadModelLocator.cs new file mode 100644 index 0000000..cb8cef6 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/OrderReadModelLocator.cs @@ -0,0 +1,20 @@ +using Books.Api.Domain.Orders; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// Locator for Order read models. +/// Maps domain events to the read model identifier. +/// +public class OrderReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent.GetAggregateEvent() is IAggregateEvent) + { + yield return domainEvent.GetIdentity().Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/ProductReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/ProductReadModel.cs new file mode 100644 index 0000000..9b10278 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/ProductReadModel.cs @@ -0,0 +1,105 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.Products; +using Books.Api.Domain.Products.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("product_read_models")] +public class ProductReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + // EventFlow standard columns + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdateTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + // Business columns + public string CompanyId { get; set; } = string.Empty; + public string? ProductNumber { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal UnitPrice { get; set; } + public string VatCode { get; set; } = "U25"; + public string? Unit { get; set; } + public string? DefaultAccountId { get; set; } + public string? Ean { get; set; } + public string? Manufacturer { get; set; } + public bool IsActive { get; set; } = true; + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + CompanyId = e.CompanyId; + ProductNumber = e.ProductNumber; + Name = e.Name; + Description = e.Description; + UnitPrice = e.UnitPrice; + VatCode = e.VatCode; + Unit = e.Unit; + DefaultAccountId = e.DefaultAccountId; + Ean = e.Ean; + Manufacturer = e.Manufacturer; + IsActive = true; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + ProductNumber = e.ProductNumber; + Name = e.Name; + Description = e.Description; + UnitPrice = e.UnitPrice; + VatCode = e.VatCode; + Unit = e.Unit; + DefaultAccountId = e.DefaultAccountId; + Ean = e.Ean; + Manufacturer = e.Manufacturer; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = false; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + UpdateTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = true; + return Task.CompletedTask; + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/ProductReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/ProductReadModelDto.cs new file mode 100644 index 0000000..00899dc --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/ProductReadModelDto.cs @@ -0,0 +1,24 @@ +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for reading product data from the database. +/// Uses a class with properties because PostgreSQL returns column names +/// in lowercase, and Dapper matches properties case-insensitively. +/// +public class ProductReadModelDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string? ProductNumber { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal UnitPrice { get; set; } + public string VatCode { get; set; } = "U25"; + public string? Unit { get; set; } + public string? DefaultAccountId { get; set; } + public string? Ean { get; set; } + public string? Manufacturer { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/ProductReadModelLocator.cs b/backend/Books.Api/EventFlow/ReadModels/ProductReadModelLocator.cs new file mode 100644 index 0000000..e866325 --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/ProductReadModelLocator.cs @@ -0,0 +1,16 @@ +using Books.Api.Domain.Products; +using EventFlow.Aggregates; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +public class ProductReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent is IDomainEvent typedEvent) + { + yield return typedEvent.AggregateIdentity.Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModel.cs b/backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModel.cs new file mode 100644 index 0000000..512369a --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModel.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Books.Api.Domain.UserAccess; +using Books.Api.Domain.UserAccess.Events; +using EventFlow.Aggregates; +using EventFlow.PostgreSql.ReadStores.Attributes; +using EventFlow.ReadStores; + +namespace Books.Api.EventFlow.ReadModels; + +[Table("user_company_access_read_models")] +public class UserCompanyAccessReadModel : IReadModel, + IAmReadModelFor, + IAmReadModelFor, + IAmReadModelFor +{ + [PostgreSqlReadModelIdentityColumn] + public string AggregateId { get; set; } = string.Empty; + + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + [PostgreSqlReadModelVersionColumn] + public int LastAggregateSequenceNumber { get; set; } + + public string UserId { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string GrantedBy { get; set; } = string.Empty; + public DateTimeOffset GrantedAt { get; set; } + public bool IsActive { get; set; } = true; + public DateTimeOffset? RevokedAt { get; set; } + public string? RevokedBy { get; set; } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + AggregateId = domainEvent.AggregateIdentity.Value; + CreateTime = domainEvent.Timestamp; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + UserId = e.UserId; + CompanyId = e.CompanyId; + Role = e.Role.ToDbString(); + GrantedBy = e.GrantedBy; + GrantedAt = domainEvent.Timestamp; + IsActive = true; + RevokedAt = null; + RevokedBy = null; + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + Role = e.NewRole.ToDbString(); + return Task.CompletedTask; + } + + public Task ApplyAsync( + IReadModelContext context, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + UpdatedTime = domainEvent.Timestamp; + LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber; + IsActive = false; + RevokedAt = domainEvent.Timestamp; + RevokedBy = e.RevokedBy; + return Task.CompletedTask; + } +} + +public class UserCompanyAccessReadModelLocator : IReadModelLocator +{ + public IEnumerable GetReadModelIds(IDomainEvent domainEvent) + { + if (domainEvent.GetAggregateEvent() is IAggregateEvent) + { + yield return domainEvent.GetIdentity().Value; + } + } +} diff --git a/backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModelDto.cs b/backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModelDto.cs new file mode 100644 index 0000000..d668dbe --- /dev/null +++ b/backend/Books.Api/EventFlow/ReadModels/UserCompanyAccessReadModelDto.cs @@ -0,0 +1,27 @@ +using Books.Api.Domain.UserAccess; + +namespace Books.Api.EventFlow.ReadModels; + +/// +/// DTO for UserCompanyAccess query results. +/// Maps from database rows via Dapper. +/// +public class UserCompanyAccessReadModelDto +{ + public string AggregateId { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public string GrantedBy { get; set; } = string.Empty; + public DateTimeOffset GrantedAt { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + public string? RevokedBy { get; set; } + public DateTimeOffset CreateTime { get; set; } + public DateTimeOffset UpdatedTime { get; set; } + + /// + /// Parse the role string to the enum. + /// + public CompanyRole GetRole() => CompanyRoleExtensions.FromDbString(Role); +} diff --git a/backend/Books.Api/EventFlow/Repositories/AccountRepository.cs b/backend/Books.Api/EventFlow/Repositories/AccountRepository.cs new file mode 100644 index 0000000..0918c8f --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/AccountRepository.cs @@ -0,0 +1,95 @@ +using Books.Api.Domain.Accounts; +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class AccountRepository(NpgsqlDataSource dataSource) : IAccountRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + account_number AS AccountNumber, + name AS Name, + account_type AS AccountType, + parent_id AS ParentId, + description AS Description, + vat_code_id AS VatCodeId, + is_active AS IsActive, + is_system_account AS IsSystemAccount, + standard_account_number AS StandardAccountNumber, + create_time AS CreatedAt, + updated_time AS UpdatedAt + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM account_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM account_read_models + WHERE aggregate_id = ANY(@Ids) + ORDER BY account_number + """; + + return await connection.QueryAsync(sql, new { Ids = ids.Select(i => i.Value).ToArray() }); + } + + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM account_read_models + WHERE company_id = @CompanyId + ORDER BY account_number + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM account_read_models + WHERE company_id = @CompanyId AND is_active = TRUE + ORDER BY account_number + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task GetByCompanyAndNumberAsync(string companyId, string accountNumber, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM account_read_models + WHERE company_id = @CompanyId AND account_number = @AccountNumber + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { CompanyId = companyId, AccountNumber = accountNumber }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/AttachmentRepository.cs b/backend/Books.Api/EventFlow/Repositories/AttachmentRepository.cs new file mode 100644 index 0000000..09a5649 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/AttachmentRepository.cs @@ -0,0 +1,97 @@ +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class AttachmentRepository(NpgsqlDataSource dataSource) : IAttachmentRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + file_name AS FileName, + original_file_name AS OriginalFileName, + content_type AS ContentType, + file_size AS FileSize, + storage_path AS StoragePath, + uploaded_by AS UploadedBy, + uploaded_at AS UploadedAt, + draft_id AS DraftId, + transaction_id AS TransactionId, + retention_end_date AS RetentionEndDate, + is_deleted AS IsDeleted + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM attachment_read_models + WHERE aggregate_id = @Id AND is_deleted = FALSE + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM attachment_read_models + WHERE company_id = @CompanyId AND is_deleted = FALSE + ORDER BY uploaded_at DESC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM attachment_read_models + WHERE draft_id = @DraftId AND is_deleted = FALSE + ORDER BY uploaded_at ASC + """; + + var result = await connection.QueryAsync(sql, new { DraftId = draftId }); + return result.ToList(); + } + + public async Task> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM attachment_read_models + WHERE transaction_id = @TransactionId AND is_deleted = FALSE + ORDER BY uploaded_at ASC + """; + + var result = await connection.QueryAsync(sql, new { TransactionId = transactionId }); + return result.ToList(); + } + + public async Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM attachment_read_models + WHERE aggregate_id = ANY(@Ids) AND is_deleted = FALSE + ORDER BY uploaded_at ASC + """; + + var result = await connection.QueryAsync(sql, new { Ids = ids.ToArray() }); + return result.ToList(); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/BankConnectionRepository.cs b/backend/Books.Api/EventFlow/Repositories/BankConnectionRepository.cs new file mode 100644 index 0000000..9fc8d37 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/BankConnectionRepository.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using Books.Api.Domain.BankConnections; +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class BankConnectionRepository(NpgsqlDataSource dataSource) : IBankConnectionRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + aspsp_name AS AspspName, + status AS Status, + session_id AS SessionId, + valid_until AS ValidUntil, + accounts_json AS AccountsJson, + failure_reason AS FailureReason, + create_time AS CreatedAt, + updated_time AS UpdatedAt, + is_archived AS IsArchived + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM bank_connection_read_models + WHERE aggregate_id = @Id + """; + + var raw = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return raw != null ? MapToDto(raw) : null; + } + + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM bank_connection_read_models + WHERE company_id = @CompanyId + AND (is_archived = FALSE OR is_archived IS NULL) + ORDER BY create_time DESC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.Select(MapToDto).ToList(); + } + + public async Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM bank_connection_read_models + WHERE company_id = @CompanyId + AND status = 'established' + AND valid_until > NOW() + AND (is_archived = FALSE OR is_archived IS NULL) + ORDER BY create_time DESC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.Select(MapToDto).ToList(); + } + + public async Task> GetAllActiveAsync(CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM bank_connection_read_models + WHERE status = 'established' + AND valid_until > NOW() + AND (is_archived = FALSE OR is_archived IS NULL) + ORDER BY company_id, create_time DESC + """; + + var result = await connection.QueryAsync(sql); + return result.Select(MapToDto).ToList(); + } + + private static BankConnectionReadModelDto MapToDto(BankConnectionRawDto raw) + { + // Handle DateTime.MinValue as null (database might store 0001-01-01 instead of NULL) + // Use DateTime.SpecifyKind to ensure UTC interpretation before creating DateTimeOffset + DateTimeOffset? validUntil = raw.ValidUntil.HasValue && raw.ValidUntil.Value.Year > 1 + ? new DateTimeOffset(DateTime.SpecifyKind(raw.ValidUntil.Value, DateTimeKind.Utc)) + : null; + + return new BankConnectionReadModelDto + { + Id = raw.Id, + CompanyId = raw.CompanyId, + AspspName = raw.AspspName, + Status = raw.Status, + SessionId = raw.SessionId, + ValidUntil = validUntil, + Accounts = string.IsNullOrEmpty(raw.AccountsJson) + ? null + : JsonSerializer.Deserialize>(raw.AccountsJson), + FailureReason = raw.FailureReason, + CreatedAt = raw.CreatedAt, + UpdatedAt = raw.UpdatedAt, + IsArchived = raw.IsArchived + }; + } + + private class BankConnectionRawDto + { + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string AspspName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? SessionId { get; set; } + public DateTime? ValidUntil { get; set; } // Use DateTime to avoid DateTimeOffset conversion issues + public string? AccountsJson { get; set; } + public string? FailureReason { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsArchived { get; set; } + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/BankTransactionRepository.cs b/backend/Books.Api/EventFlow/Repositories/BankTransactionRepository.cs new file mode 100644 index 0000000..5e84854 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/BankTransactionRepository.cs @@ -0,0 +1,245 @@ +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class BankTransactionRepository(NpgsqlDataSource dataSource) : IBankTransactionRepository +{ + private const string SelectColumns = """ + id AS Id, + company_id AS CompanyId, + bank_connection_id AS BankConnectionId, + bank_account_id AS BankAccountId, + external_id AS ExternalId, + amount AS Amount, + currency AS Currency, + transaction_date AS TransactionDate, + booking_date AS BookingDate, + value_date AS ValueDate, + description AS Description, + counterparty_name AS CounterpartyName, + counterparty_account AS CounterpartyAccount, + reference AS Reference, + creditor_name AS CreditorName, + debtor_name AS DebtorName, + status AS Status, + journal_entry_draft_id AS JournalEntryDraftId, + created_at AS CreatedAt, + updated_at AS UpdatedAt + """; + + public async Task> GetPendingByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM bank_transactions + WHERE company_id = @CompanyId AND status = 'pending' + ORDER BY transaction_date DESC, created_at DESC + """; + + var result = await connection.QueryAsync( + sql, + new { CompanyId = companyId }); + + return result.ToList(); + } + + public async Task> GetByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = status != null + ? $""" + SELECT {SelectColumns} + FROM bank_transactions + WHERE company_id = @CompanyId AND status = @Status + ORDER BY transaction_date DESC, created_at DESC + """ + : $""" + SELECT {SelectColumns} + FROM bank_transactions + WHERE company_id = @CompanyId + ORDER BY transaction_date DESC, created_at DESC + """; + + var result = await connection.QueryAsync( + sql, + new { CompanyId = companyId, Status = status }); + + return result.ToList(); + } + + public async Task GetByIdAsync( + string id, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM bank_transactions + WHERE id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync( + sql, + new { Id = id }); + } + + public async Task ExistsAsync( + string companyId, + string externalId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = """ + SELECT EXISTS( + SELECT 1 FROM bank_transactions + WHERE company_id = @CompanyId AND external_id = @ExternalId + ) + """; + + return await connection.ExecuteScalarAsync( + sql, + new { CompanyId = companyId, ExternalId = externalId }); + } + + public async Task InsertAsync( + BankTransactionDto transaction, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = """ + INSERT INTO bank_transactions ( + id, company_id, bank_connection_id, bank_account_id, external_id, + amount, currency, transaction_date, booking_date, value_date, + description, counterparty_name, counterparty_account, reference, + creditor_name, debtor_name, status, journal_entry_draft_id, + created_at, updated_at + ) VALUES ( + @Id, @CompanyId, @BankConnectionId, @BankAccountId, @ExternalId, + @Amount, @Currency, @TransactionDate, @BookingDate, @ValueDate, + @Description, @CounterpartyName, @CounterpartyAccount, @Reference, + @CreditorName, @DebtorName, @Status, @JournalEntryDraftId, + @CreatedAt, @UpdatedAt + ) + """; + + await connection.ExecuteAsync(sql, transaction); + } + + public async Task InsertBatchAsync( + IEnumerable transactions, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + try + { + var sql = """ + INSERT INTO bank_transactions ( + id, company_id, bank_connection_id, bank_account_id, external_id, + amount, currency, transaction_date, booking_date, value_date, + description, counterparty_name, counterparty_account, reference, + creditor_name, debtor_name, status, journal_entry_draft_id, + created_at, updated_at + ) VALUES ( + @Id, @CompanyId, @BankConnectionId, @BankAccountId, @ExternalId, + @Amount, @Currency, @TransactionDate, @BookingDate, @ValueDate, + @Description, @CounterpartyName, @CounterpartyAccount, @Reference, + @CreditorName, @DebtorName, @Status, @JournalEntryDraftId, + @CreatedAt, @UpdatedAt + ) + ON CONFLICT (company_id, external_id) DO UPDATE SET + booking_date = EXCLUDED.booking_date, + value_date = EXCLUDED.value_date, + description = EXCLUDED.description, + counterparty_name = EXCLUDED.counterparty_name, + reference = EXCLUDED.reference, + creditor_name = EXCLUDED.creditor_name, + debtor_name = EXCLUDED.debtor_name, + updated_at = NOW() + WHERE bank_transactions.status = 'pending' + """; + + await connection.ExecuteAsync(sql, transactions, transaction); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + public async Task UpdateStatusAsync( + string id, + string status, + string? journalEntryDraftId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = """ + UPDATE bank_transactions + SET status = @Status, + journal_entry_draft_id = @JournalEntryDraftId, + updated_at = NOW() + WHERE id = @Id + """; + + await connection.ExecuteAsync( + sql, + new { Id = id, Status = status, JournalEntryDraftId = journalEntryDraftId }); + } + + public async Task> GetByBankAccountIdAsync( + string bankAccountId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = status != null + ? $""" + SELECT {SelectColumns} + FROM bank_transactions + WHERE bank_account_id = @BankAccountId AND status = @Status + ORDER BY transaction_date DESC + """ + : $""" + SELECT {SelectColumns} + FROM bank_transactions + WHERE bank_account_id = @BankAccountId + ORDER BY transaction_date DESC + """; + + var result = await connection.QueryAsync( + sql, + new { BankAccountId = bankAccountId, Status = status }); + + return result.ToList(); + } + + public async Task HasAnyAsync(string bankAccountId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = "SELECT EXISTS(SELECT 1 FROM bank_transactions WHERE bank_account_id = @BankAccountId)"; + + return await connection.ExecuteScalarAsync( + sql, + new { BankAccountId = bankAccountId }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/CustomerRepository.cs b/backend/Books.Api/EventFlow/Repositories/CustomerRepository.cs new file mode 100644 index 0000000..76c6d86 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/CustomerRepository.cs @@ -0,0 +1,113 @@ +using Books.Api.Domain.Customers; +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class CustomerRepository(NpgsqlDataSource dataSource) : ICustomerRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + customer_number AS CustomerNumber, + customer_type AS CustomerType, + name AS Name, + cvr AS Cvr, + address AS Address, + postal_code AS PostalCode, + city AS City, + country AS Country, + email AS Email, + phone AS Phone, + payment_terms_days AS PaymentTermsDays, + default_revenue_account_id AS DefaultRevenueAccountId, + sub_ledger_account_id AS SubLedgerAccountId, + is_active AS IsActive, + create_time AS CreatedAt, + updated_time AS UpdatedAt + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM customer_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM customer_read_models + WHERE aggregate_id = ANY(@Ids) + ORDER BY customer_number + """; + + return await connection.QueryAsync(sql, new { Ids = ids.Select(i => i.Value).ToArray() }); + } + + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM customer_read_models + WHERE company_id = @CompanyId + ORDER BY customer_number + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM customer_read_models + WHERE company_id = @CompanyId AND is_active = TRUE + ORDER BY customer_number + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task GetByCompanyAndNumberAsync(string companyId, string customerNumber, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM customer_read_models + WHERE company_id = @CompanyId AND customer_number = @CustomerNumber + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { CompanyId = companyId, CustomerNumber = customerNumber }); + } + + public async Task GetByCvrAsync(string cvr, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM customer_read_models + WHERE cvr = @Cvr + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Cvr = cvr }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/DocumentHashRepository.cs b/backend/Books.Api/EventFlow/Repositories/DocumentHashRepository.cs new file mode 100644 index 0000000..24a17e2 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/DocumentHashRepository.cs @@ -0,0 +1,63 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +/// +/// PostgreSQL implementation of the document hash repository. +/// +public class DocumentHashRepository(NpgsqlDataSource dataSource) : IDocumentHashRepository +{ + public async Task GetByHashAsync( + string companyId, + string contentHash, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + id::text AS Id, + company_id AS CompanyId, + content_hash AS ContentHash, + original_filename AS OriginalFilename, + attachment_id::text AS AttachmentId, + draft_id::text AS DraftId, + created_at AS CreatedAt + FROM document_content_hashes + WHERE company_id = @CompanyId AND content_hash = @ContentHash + """; + + return await connection.QuerySingleOrDefaultAsync( + sql, + new { CompanyId = companyId, ContentHash = contentHash }); + } + + public async Task InsertAsync( + string companyId, + string contentHash, + string originalFilename, + string? attachmentId, + string? draftId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + INSERT INTO document_content_hashes (company_id, content_hash, original_filename, attachment_id, draft_id) + VALUES (@CompanyId, @ContentHash, @OriginalFilename, @AttachmentId, @DraftId) + ON CONFLICT (company_id, content_hash) DO NOTHING + """; + + await connection.ExecuteAsync( + sql, + new + { + CompanyId = companyId, + ContentHash = contentHash, + OriginalFilename = originalFilename, + AttachmentId = attachmentId, + DraftId = draftId + }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/FiscalYearRepository.cs b/backend/Books.Api/EventFlow/Repositories/FiscalYearRepository.cs new file mode 100644 index 0000000..479bc1b --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/FiscalYearRepository.cs @@ -0,0 +1,110 @@ +using Books.Api.Domain.FiscalYears; +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class FiscalYearRepository(NpgsqlDataSource dataSource) : IFiscalYearRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + name AS Name, + start_date AS StartDate, + end_date AS EndDate, + status AS Status, + opening_balance_posted AS OpeningBalancePosted, + closing_date AS ClosingDate, + closed_by AS ClosedBy, + reopened_date AS ReopenedDate, + reopened_by AS ReopenedBy, + locked_date AS LockedDate, + locked_by AS LockedBy, + create_time AS CreatedAt, + updated_time AS UpdatedAt + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM fiscal_year_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM fiscal_year_read_models + WHERE aggregate_id = ANY(@Ids) + ORDER BY start_date DESC + """; + + return await connection.QueryAsync(sql, new { Ids = ids.Select(i => i.Value).ToArray() }); + } + + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM fiscal_year_read_models + WHERE company_id = @CompanyId + ORDER BY start_date DESC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task GetByCompanyAndDateAsync(string companyId, DateOnly date, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM fiscal_year_read_models + WHERE company_id = @CompanyId + AND start_date <= @Date + AND end_date >= @Date + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { CompanyId = companyId, Date = date.ToDateTime(TimeOnly.MinValue) }); + } + + public async Task HasOverlappingYearAsync(string companyId, DateOnly startDate, DateOnly endDate, string? excludeId = null, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + // Overlap detection: two ranges overlap if start1 <= end2 AND end1 >= start2 + // This correctly handles boundary cases where fiscal years share a date + var sql = """ + SELECT EXISTS( + SELECT 1 + FROM fiscal_year_read_models + WHERE company_id = @CompanyId + AND (@ExcludeId IS NULL OR aggregate_id != @ExcludeId) + AND start_date <= @EndDate + AND end_date >= @StartDate + ) + """; + + return await connection.QuerySingleAsync(sql, new + { + CompanyId = companyId, + StartDate = startDate.ToDateTime(TimeOnly.MinValue), + EndDate = endDate.ToDateTime(TimeOnly.MinValue), + ExcludeId = excludeId + }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/IAccountRepository.cs b/backend/Books.Api/EventFlow/Repositories/IAccountRepository.cs new file mode 100644 index 0000000..99b1a11 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IAccountRepository.cs @@ -0,0 +1,13 @@ +using Books.Api.Domain.Accounts; +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IAccountRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task GetByCompanyAndNumberAsync(string companyId, string accountNumber, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs b/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs new file mode 100644 index 0000000..4e54ef2 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IAttachmentRepository.cs @@ -0,0 +1,12 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IAttachmentRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default); + Task> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default); + Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IBankConnectionRepository.cs b/backend/Books.Api/EventFlow/Repositories/IBankConnectionRepository.cs new file mode 100644 index 0000000..e96ffe4 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IBankConnectionRepository.cs @@ -0,0 +1,16 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IBankConnectionRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + + /// + /// Get all active connections across all companies. + /// Used by the sync job to process all connections. + /// + Task> GetAllActiveAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IBankTransactionRepository.cs b/backend/Books.Api/EventFlow/Repositories/IBankTransactionRepository.cs new file mode 100644 index 0000000..bebd037 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IBankTransactionRepository.cs @@ -0,0 +1,74 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IBankTransactionRepository +{ + /// + /// Get all pending (unbooked) transactions for a company. + /// Used by Hurtig Bogføring to display transactions awaiting booking. + /// + Task> GetPendingByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Get all transactions for a company, optionally filtered by status. + /// + Task> GetByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default); + + /// + /// Get a single transaction by ID. + /// + Task GetByIdAsync( + string id, + CancellationToken cancellationToken = default); + + /// + /// Check if a transaction with the given external ID already exists. + /// Used during sync to avoid duplicates. + /// + Task ExistsAsync( + string companyId, + string externalId, + CancellationToken cancellationToken = default); + + /// + /// Insert a new bank transaction. + /// + Task InsertAsync( + BankTransactionDto transaction, + CancellationToken cancellationToken = default); + + /// + /// Insert multiple bank transactions in a batch. + /// + Task InsertBatchAsync( + IEnumerable transactions, + CancellationToken cancellationToken = default); + + /// + /// Update transaction status (e.g., pending -> booked). + /// + Task UpdateStatusAsync( + string id, + string status, + string? journalEntryDraftId, + CancellationToken cancellationToken = default); + + /// + /// Get transactions for a specific bank account. + /// + Task> GetByBankAccountIdAsync( + string bankAccountId, + string? status = null, + CancellationToken cancellationToken = default); + + /// + /// Check if any transactions exist for a specific bank account. + /// + Task HasAnyAsync(string bankAccountId, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/ICustomerRepository.cs b/backend/Books.Api/EventFlow/Repositories/ICustomerRepository.cs new file mode 100644 index 0000000..b8e02a2 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/ICustomerRepository.cs @@ -0,0 +1,14 @@ +using Books.Api.Domain.Customers; +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface ICustomerRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task GetByCompanyAndNumberAsync(string companyId, string customerNumber, CancellationToken cancellationToken = default); + Task GetByCvrAsync(string cvr, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IDocumentHashRepository.cs b/backend/Books.Api/EventFlow/Repositories/IDocumentHashRepository.cs new file mode 100644 index 0000000..3c0248e --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IDocumentHashRepository.cs @@ -0,0 +1,40 @@ +namespace Books.Api.EventFlow.Repositories; + +/// +/// Repository for document content hashes to prevent duplicate processing. +/// +public interface IDocumentHashRepository +{ + /// + /// Check if a document with this hash has already been processed for a company. + /// + Task GetByHashAsync( + string companyId, + string contentHash, + CancellationToken cancellationToken = default); + + /// + /// Record a processed document hash. + /// + Task InsertAsync( + string companyId, + string contentHash, + string originalFilename, + string? attachmentId, + string? draftId, + CancellationToken cancellationToken = default); +} + +/// +/// Record of a processed document. +/// +public class DocumentHashRecord +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string ContentHash { get; set; } = string.Empty; + public string OriginalFilename { get; set; } = string.Empty; + public string? AttachmentId { get; set; } + public string? DraftId { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/backend/Books.Api/EventFlow/Repositories/IFiscalYearRepository.cs b/backend/Books.Api/EventFlow/Repositories/IFiscalYearRepository.cs new file mode 100644 index 0000000..bbb9300 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IFiscalYearRepository.cs @@ -0,0 +1,13 @@ +using Books.Api.Domain.FiscalYears; +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IFiscalYearRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task GetByCompanyAndDateAsync(string companyId, DateOnly date, CancellationToken cancellationToken = default); + Task HasOverlappingYearAsync(string companyId, DateOnly startDate, DateOnly endDate, string? excludeId = null, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IInvoiceRepository.cs b/backend/Books.Api/EventFlow/Repositories/IInvoiceRepository.cs new file mode 100644 index 0000000..2355419 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IInvoiceRepository.cs @@ -0,0 +1,69 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IInvoiceRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + + Task> GetByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default); + + Task> GetByCustomerIdAsync( + string customerId, + string? status = null, + CancellationToken cancellationToken = default); + + Task> GetByFiscalYearIdAsync( + string fiscalYearId, + string? status = null, + CancellationToken cancellationToken = default); + + Task> GetUnpaidByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default); + + Task> GetOverdueByCompanyIdAsync( + string companyId, + DateOnly asOfDate, + CancellationToken cancellationToken = default); + + Task GetByInvoiceNumberAsync( + string companyId, + string invoiceNumber, + CancellationToken cancellationToken = default); + + // Credit note specific queries + + /// + /// Gets all credit notes for a company (invoice_type = 'credit_note'). + /// + Task> GetCreditNotesByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default); + + /// + /// Gets all credit notes referencing a specific invoice. + /// + Task> GetCreditNotesForInvoiceAsync( + string invoiceId, + CancellationToken cancellationToken = default); + + /// + /// Gets credit notes that have remaining credit to apply (status = issued or partially_applied). + /// + Task> GetUnappliedCreditNotesAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Gets only invoices (not credit notes) for a company. + /// + Task> GetInvoicesOnlyByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IJournalEntryDraftRepository.cs b/backend/Books.Api/EventFlow/Repositories/IJournalEntryDraftRepository.cs new file mode 100644 index 0000000..87c0391 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IJournalEntryDraftRepository.cs @@ -0,0 +1,25 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IJournalEntryDraftRepository +{ + /// + /// Get a draft by its ID. + /// + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + + /// + /// Get all active drafts for a company. + /// + Task> GetActiveByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Get all drafts for a company (including posted and discarded). + /// + Task> GetByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/ILedgerTransactionRepository.cs b/backend/Books.Api/EventFlow/Repositories/ILedgerTransactionRepository.cs new file mode 100644 index 0000000..2adc13e --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/ILedgerTransactionRepository.cs @@ -0,0 +1,19 @@ +namespace Books.Api.EventFlow.Repositories; + +/// +/// Repository for querying ledger transaction data by idempotency key. +/// Used for idempotent retry handling when ledger returns "already processed". +/// +public interface ILedgerTransactionRepository +{ + /// + /// Checks if a transaction was successfully processed (has ledger entries). + /// Returns the transaction ID if found, null otherwise. + /// + /// The idempotency key used when posting the transaction + /// Cancellation token + /// The transaction ID if ledger entries exist, null otherwise + Task GetTransactionIdByIdempotencyKeyAsync( + string idempotencyKey, + CancellationToken ct = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IManufacturerRepository.cs b/backend/Books.Api/EventFlow/Repositories/IManufacturerRepository.cs new file mode 100644 index 0000000..34f1daf --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IManufacturerRepository.cs @@ -0,0 +1,14 @@ +namespace Books.Api.EventFlow.Repositories; + +public interface IManufacturerRepository +{ + /// + /// Gets distinct manufacturers for a company, optionally filtered by search term. + /// Used for autocomplete functionality. + /// + Task> GetManufacturersAsync( + string companyId, + string? searchTerm = null, + int limit = 20, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IOrderNumberService.cs b/backend/Books.Api/EventFlow/Repositories/IOrderNumberService.cs new file mode 100644 index 0000000..b3c5a0e --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IOrderNumberService.cs @@ -0,0 +1,16 @@ +namespace Books.Api.EventFlow.Repositories; + +/// +/// Service for generating sequential order numbers per company per year. +/// +public interface IOrderNumberService +{ + /// + /// Gets the next available order number for a company. + /// Format: ORD-YYYY-NNNN (e.g., ORD-2024-0001) + /// + Task GetNextOrderNumberAsync( + string companyId, + DateOnly orderDate, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IOrderRepository.cs b/backend/Books.Api/EventFlow/Repositories/IOrderRepository.cs new file mode 100644 index 0000000..42cf0de --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IOrderRepository.cs @@ -0,0 +1,50 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IOrderRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + + Task> GetByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default); + + Task> GetByCustomerIdAsync( + string customerId, + string? status = null, + CancellationToken cancellationToken = default); + + Task> GetByFiscalYearIdAsync( + string fiscalYearId, + string? status = null, + CancellationToken cancellationToken = default); + + Task GetByOrderNumberAsync( + string companyId, + string orderNumber, + CancellationToken cancellationToken = default); + + /// + /// Gets orders that can be converted to invoices (status = confirmed). + /// + Task> GetConfirmedOrdersAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Gets orders linked to a specific invoice. + /// + Task GetByInvoiceIdAsync( + string invoiceId, + CancellationToken cancellationToken = default); + + /// + /// Gets the next order number for a company within a year. + /// + Task GetNextOrderNumberAsync( + string companyId, + int year, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IProductRepository.cs b/backend/Books.Api/EventFlow/Repositories/IProductRepository.cs new file mode 100644 index 0000000..fccd2df --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IProductRepository.cs @@ -0,0 +1,11 @@ +using Books.Api.EventFlow.ReadModels; + +namespace Books.Api.EventFlow.Repositories; + +public interface IProductRepository +{ + Task GetByIdAsync(string id, CancellationToken cancellationToken = default); + Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default); + Task GetByCompanyAndNumberAsync(string companyId, string productNumber, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/IUserCompanyAccessRepository.cs b/backend/Books.Api/EventFlow/Repositories/IUserCompanyAccessRepository.cs new file mode 100644 index 0000000..b163ede --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IUserCompanyAccessRepository.cs @@ -0,0 +1,56 @@ +using Books.Api.Domain.UserAccess; + +namespace Books.Api.EventFlow.Repositories; + +public interface IUserCompanyAccessRepository +{ + /// + /// Get all companies a user has access to (active only). + /// + Task> GetByUserIdAsync( + string userId, + CancellationToken cancellationToken = default); + + /// + /// Get all users with access to a company (active only). + /// + Task> GetByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Check if a user has access to a company and return their role. + /// Returns null if no active access exists. + /// + Task GetAccessAsync( + string userId, + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Check if a user has at least the specified role for a company. + /// + Task HasAccessAsync( + string userId, + string companyId, + CompanyRole minimumRole, + CancellationToken cancellationToken = default); + + /// + /// Get access by aggregate ID. + /// + Task GetByIdAsync( + string accessId, + CancellationToken cancellationToken = default); +} + +public record UserCompanyAccessDto( + string Id, + string UserId, + string CompanyId, + CompanyRole Role, + string GrantedBy, + DateTimeOffset GrantedAt, + bool IsActive, + DateTimeOffset? RevokedAt, + string? RevokedBy); diff --git a/backend/Books.Api/EventFlow/Repositories/IVoucherNumberService.cs b/backend/Books.Api/EventFlow/Repositories/IVoucherNumberService.cs new file mode 100644 index 0000000..4394f83 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/IVoucherNumberService.cs @@ -0,0 +1,21 @@ +namespace Books.Api.EventFlow.Repositories; + +/// +/// Service for generating unique voucher numbers (bilagsnumre) per company. +/// Required by Bogføringsloven § 7, Stk. 4 for Danish accounting compliance. +/// +public interface IVoucherNumberService +{ + /// + /// Gets the next voucher number for a company. + /// Format: {prefix}{sequence} where prefix is optional (e.g., "2025-" or "K-"). + /// + /// The company ID + /// Optional fiscal year ID for year-specific sequences + /// Cancellation token + /// The next unique voucher number + Task GetNextVoucherNumberAsync( + string companyId, + string? fiscalYearId = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/EventFlow/Repositories/InvoiceRepository.cs b/backend/Books.Api/EventFlow/Repositories/InvoiceRepository.cs new file mode 100644 index 0000000..9821910 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/InvoiceRepository.cs @@ -0,0 +1,271 @@ +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class InvoiceRepository(NpgsqlDataSource dataSource) : IInvoiceRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + fiscal_year_id AS FiscalYearId, + customer_id AS CustomerId, + customer_name AS CustomerName, + customer_number AS CustomerNumber, + invoice_number AS InvoiceNumber, + invoice_date AS InvoiceDate, + due_date AS DueDate, + invoice_type AS InvoiceType, + original_invoice_id AS OriginalInvoiceId, + original_invoice_number AS OriginalInvoiceNumber, + credit_reason AS CreditReason, + status AS Status, + amount_ex_vat AS AmountExVat, + amount_vat AS AmountVat, + amount_total AS AmountTotal, + amount_paid AS AmountPaid, + amount_applied AS AmountApplied, + amount_remaining AS AmountRemaining, + currency AS Currency, + vat_code AS VatCode, + payment_terms_days AS PaymentTermsDays, + lines AS LinesJson, + ledger_transaction_id AS LedgerTransactionId, + notes AS Notes, + reference AS Reference, + sent_at AS SentAt, + issued_at AS IssuedAt, + paid_at AS PaidAt, + voided_at AS VoidedAt, + voided_reason AS VoidedReason, + voided_by AS VoidedBy, + created_by AS CreatedBy, + updated_by AS UpdatedBy, + create_time AS CreatedAt, + update_time AS UpdatedAt + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + {(status != null ? "AND status = @Status" : "")} + ORDER BY invoice_date DESC, invoice_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { CompanyId = companyId, Status = status }); + + return result.ToList(); + } + + public async Task> GetByCustomerIdAsync( + string customerId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE customer_id = @CustomerId + {(status != null ? "AND status = @Status" : "")} + ORDER BY invoice_date DESC, invoice_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { CustomerId = customerId, Status = status }); + + return result.ToList(); + } + + public async Task> GetByFiscalYearIdAsync( + string fiscalYearId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE fiscal_year_id = @FiscalYearId + {(status != null ? "AND status = @Status" : "")} + ORDER BY invoice_date DESC, invoice_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { FiscalYearId = fiscalYearId, Status = status }); + + return result.ToList(); + } + + public async Task> GetUnpaidByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + AND status IN ('sent', 'partially_paid') + AND amount_remaining > 0 + ORDER BY due_date ASC, invoice_number ASC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + + return result.ToList(); + } + + public async Task> GetOverdueByCompanyIdAsync( + string companyId, + DateOnly asOfDate, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + AND status IN ('sent', 'partially_paid') + AND amount_remaining > 0 + AND due_date < @AsOfDate + ORDER BY due_date ASC, invoice_number ASC + """; + + var result = await connection.QueryAsync( + sql, new { CompanyId = companyId, AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue) }); + + return result.ToList(); + } + + public async Task GetByInvoiceNumberAsync( + string companyId, + string invoiceNumber, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + AND invoice_number = @InvoiceNumber + """; + + return await connection.QuerySingleOrDefaultAsync( + sql, new { CompanyId = companyId, InvoiceNumber = invoiceNumber }); + } + + // Credit note specific queries + + public async Task> GetCreditNotesByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + AND invoice_type = 'credit_note' + {(status != null ? "AND status = @Status" : "")} + ORDER BY invoice_date DESC, invoice_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { CompanyId = companyId, Status = status }); + + return result.ToList(); + } + + public async Task> GetCreditNotesForInvoiceAsync( + string invoiceId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE original_invoice_id = @InvoiceId + AND invoice_type = 'credit_note' + ORDER BY invoice_date DESC + """; + + var result = await connection.QueryAsync(sql, new { InvoiceId = invoiceId }); + + return result.ToList(); + } + + public async Task> GetUnappliedCreditNotesAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + AND invoice_type = 'credit_note' + AND status IN ('issued', 'partially_applied') + AND amount_remaining > 0 + ORDER BY invoice_date ASC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + + return result.ToList(); + } + + public async Task> GetInvoicesOnlyByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM invoice_read_models + WHERE company_id = @CompanyId + AND (invoice_type = 'invoice' OR invoice_type IS NULL) + {(status != null ? "AND status = @Status" : "")} + ORDER BY invoice_date DESC, invoice_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { CompanyId = companyId, Status = status }); + + return result.ToList(); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs b/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs new file mode 100644 index 0000000..a7480d2 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/JournalEntryDraftRepository.cs @@ -0,0 +1,82 @@ +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournalEntryDraftRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + name AS Name, + voucher_number AS VoucherNumber, + document_date AS DocumentDate, + description AS Description, + fiscal_year_id AS FiscalYearId, + lines AS Lines, + attachment_ids AS AttachmentIds, + status AS Status, + transaction_id AS TransactionId, + created_by AS CreatedBy, + create_time AS CreatedAt, + updated_time AS UpdatedAt + """; + + public async Task GetByIdAsync( + string id, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM journal_entry_draft_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync( + sql, + new { Id = id }); + } + + public async Task> GetActiveByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM journal_entry_draft_read_models + WHERE company_id = @CompanyId AND status = 'active' + ORDER BY updated_time DESC + """; + + var result = await connection.QueryAsync( + sql, + new { CompanyId = companyId }); + + return result.ToList(); + } + + public async Task> GetByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM journal_entry_draft_read_models + WHERE company_id = @CompanyId + ORDER BY updated_time DESC + """; + + var result = await connection.QueryAsync( + sql, + new { CompanyId = companyId }); + + return result.ToList(); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/LedgerTransactionRepository.cs b/backend/Books.Api/EventFlow/Repositories/LedgerTransactionRepository.cs new file mode 100644 index 0000000..d1744cd --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/LedgerTransactionRepository.cs @@ -0,0 +1,29 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +/// +/// Repository for querying ledger transaction data by idempotency key. +/// Used for idempotent retry handling when ledger returns "already processed". +/// +public class LedgerTransactionRepository(NpgsqlDataSource dataSource) : ILedgerTransactionRepository +{ + public async Task GetTransactionIdByIdempotencyKeyAsync( + string idempotencyKey, + CancellationToken ct = default) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + // Query ledger_entries table for the idempotency key + // The id column contains the transaction ID (UUID) + var sql = """ + SELECT id::text + FROM ledger_entries + WHERE idempotency_key = @Key + LIMIT 1 + """; + + return await connection.QueryFirstOrDefaultAsync(sql, new { Key = idempotencyKey }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/ManufacturerRepository.cs b/backend/Books.Api/EventFlow/Repositories/ManufacturerRepository.cs new file mode 100644 index 0000000..ffb6812 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/ManufacturerRepository.cs @@ -0,0 +1,50 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class ManufacturerRepository(NpgsqlDataSource dataSource) : IManufacturerRepository +{ + public async Task> GetManufacturersAsync( + string companyId, + string? searchTerm = null, + int limit = 20, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + string sql; + object parameters; + + if (string.IsNullOrWhiteSpace(searchTerm)) + { + sql = """ + SELECT DISTINCT manufacturer + FROM product_read_models + WHERE company_id = @CompanyId + AND manufacturer IS NOT NULL + AND manufacturer != '' + ORDER BY manufacturer + LIMIT @Limit + """; + parameters = new { CompanyId = companyId, Limit = limit }; + } + else + { + sql = """ + SELECT DISTINCT manufacturer + FROM product_read_models + WHERE company_id = @CompanyId + AND manufacturer IS NOT NULL + AND manufacturer != '' + AND manufacturer ILIKE @SearchPattern + ORDER BY manufacturer + LIMIT @Limit + """; + parameters = new { CompanyId = companyId, SearchPattern = $"%{searchTerm}%", Limit = limit }; + } + + var result = await connection.QueryAsync(sql, parameters); + return result.ToList(); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/OrderNumberService.cs b/backend/Books.Api/EventFlow/Repositories/OrderNumberService.cs new file mode 100644 index 0000000..331147d --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/OrderNumberService.cs @@ -0,0 +1,54 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +/// +/// Service for generating sequential order numbers per company per year. +/// Uses a database sequence pattern for thread-safe number generation. +/// +public class OrderNumberService(NpgsqlDataSource dataSource) : IOrderNumberService +{ + public async Task GetNextOrderNumberAsync( + string companyId, + DateOnly orderDate, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var year = orderDate.Year; + var prefix = $"ORD-{year}-"; + + // Use advisory lock to prevent race conditions + var lockKey = HashCode.Combine(companyId, "order", year); + + await connection.ExecuteAsync( + "SELECT pg_advisory_lock(@LockKey)", + new { LockKey = lockKey }); + + try + { + // Get current max number for this company/year + var sql = """ + SELECT COALESCE(MAX( + CAST(SUBSTRING(order_number FROM '\d+$') AS INTEGER) + ), 0) + FROM order_read_models + WHERE company_id = @CompanyId + AND order_number LIKE @Prefix || '%' + """; + + var currentMax = await connection.QuerySingleAsync( + sql, new { CompanyId = companyId, Prefix = prefix }); + + var nextNumber = currentMax + 1; + return $"{prefix}{nextNumber:D4}"; + } + finally + { + await connection.ExecuteAsync( + "SELECT pg_advisory_unlock(@LockKey)", + new { LockKey = lockKey }); + } + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/OrderRepository.cs b/backend/Books.Api/EventFlow/Repositories/OrderRepository.cs new file mode 100644 index 0000000..4d23285 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/OrderRepository.cs @@ -0,0 +1,192 @@ +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class OrderRepository(NpgsqlDataSource dataSource) : IOrderRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + fiscal_year_id AS FiscalYearId, + customer_id AS CustomerId, + customer_name AS CustomerName, + customer_number AS CustomerNumber, + order_number AS OrderNumber, + order_date AS OrderDate, + expected_delivery_date AS ExpectedDeliveryDate, + status AS Status, + amount_ex_vat AS AmountExVat, + amount_vat AS AmountVat, + amount_total AS AmountTotal, + currency AS Currency, + lines AS LinesJson, + invoice_id AS InvoiceId, + invoice_number AS InvoiceNumber, + notes AS Notes, + reference AS Reference, + confirmed_at AS ConfirmedAt, + confirmed_by AS ConfirmedBy, + invoiced_at AS InvoicedAt, + invoiced_by AS InvoicedBy, + cancelled_at AS CancelledAt, + cancelled_by AS CancelledBy, + cancelled_reason AS CancelledReason, + created_by AS CreatedBy, + updated_by AS UpdatedBy, + create_time AS CreatedAt, + update_time AS UpdatedAt + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByCompanyIdAsync( + string companyId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE company_id = @CompanyId + {(status != null ? "AND status = @Status" : "")} + ORDER BY order_date DESC, order_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { CompanyId = companyId, Status = status }); + + return result.ToList(); + } + + public async Task> GetByCustomerIdAsync( + string customerId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE customer_id = @CustomerId + {(status != null ? "AND status = @Status" : "")} + ORDER BY order_date DESC, order_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { CustomerId = customerId, Status = status }); + + return result.ToList(); + } + + public async Task> GetByFiscalYearIdAsync( + string fiscalYearId, + string? status = null, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE fiscal_year_id = @FiscalYearId + {(status != null ? "AND status = @Status" : "")} + ORDER BY order_date DESC, order_number DESC + """; + + var result = await connection.QueryAsync( + sql, new { FiscalYearId = fiscalYearId, Status = status }); + + return result.ToList(); + } + + public async Task GetByOrderNumberAsync( + string companyId, + string orderNumber, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE company_id = @CompanyId + AND order_number = @OrderNumber + """; + + return await connection.QuerySingleOrDefaultAsync( + sql, new { CompanyId = companyId, OrderNumber = orderNumber }); + } + + public async Task> GetConfirmedOrdersAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + // Get orders that can have lines invoiced (confirmed or partially_invoiced) + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE company_id = @CompanyId + AND status IN ('confirmed', 'partially_invoiced') + ORDER BY order_date ASC, order_number ASC + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + + return result.ToList(); + } + + public async Task GetByInvoiceIdAsync( + string invoiceId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM order_read_models + WHERE invoice_id = @InvoiceId + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { InvoiceId = invoiceId }); + } + + public async Task GetNextOrderNumberAsync( + string companyId, + int year, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + // Order numbers are formatted as "ORD-YYYY-NNNN" + var prefix = $"ORD-{year}-"; + + var sql = """ + SELECT COALESCE(MAX( + CAST(SUBSTRING(order_number FROM '\d+$') AS INTEGER) + ), 0) + 1 + FROM order_read_models + WHERE company_id = @CompanyId + AND order_number LIKE @Prefix || '%' + """; + + return await connection.QuerySingleAsync(sql, new { CompanyId = companyId, Prefix = prefix }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/ProductRepository.cs b/backend/Books.Api/EventFlow/Repositories/ProductRepository.cs new file mode 100644 index 0000000..58acf90 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/ProductRepository.cs @@ -0,0 +1,81 @@ +using Books.Api.EventFlow.ReadModels; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class ProductRepository(NpgsqlDataSource dataSource) : IProductRepository +{ + private const string SelectColumns = """ + aggregate_id AS Id, + company_id AS CompanyId, + product_number AS ProductNumber, + name AS Name, + description AS Description, + unit_price AS UnitPrice, + vat_code AS VatCode, + unit AS Unit, + default_account_id AS DefaultAccountId, + ean AS Ean, + manufacturer AS Manufacturer, + is_active AS IsActive, + create_time AS CreatedAt, + update_time AS UpdatedAt + """; + + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM product_read_models + WHERE aggregate_id = @Id + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM product_read_models + WHERE company_id = @CompanyId + ORDER BY COALESCE(product_number, ''), name + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task> GetActiveByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM product_read_models + WHERE company_id = @CompanyId AND is_active = TRUE + ORDER BY COALESCE(product_number, ''), name + """; + + var result = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return result.ToList(); + } + + public async Task GetByCompanyAndNumberAsync(string companyId, string productNumber, CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var sql = $""" + SELECT {SelectColumns} + FROM product_read_models + WHERE company_id = @CompanyId AND product_number = @ProductNumber + """; + + return await connection.QuerySingleOrDefaultAsync(sql, new { CompanyId = companyId, ProductNumber = productNumber }); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/UserCompanyAccessRepository.cs b/backend/Books.Api/EventFlow/Repositories/UserCompanyAccessRepository.cs new file mode 100644 index 0000000..73b976d --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/UserCompanyAccessRepository.cs @@ -0,0 +1,173 @@ +using Books.Api.Domain.UserAccess; +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +public class UserCompanyAccessRepository(NpgsqlDataSource dataSource) : IUserCompanyAccessRepository +{ + public async Task> GetByUserIdAsync( + string userId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + aggregate_id AS Id, + user_id AS UserId, + company_id AS CompanyId, + role AS RoleString, + granted_by AS GrantedBy, + granted_at AS GrantedAt, + is_active AS IsActive, + revoked_at AS RevokedAt, + revoked_by AS RevokedBy + FROM user_company_access_read_models + WHERE user_id = @UserId + AND is_active = true + ORDER BY granted_at DESC + """; + + var rows = await connection.QueryAsync(sql, new { UserId = userId }); + return rows.Select(r => r.ToDto()).ToList(); + } + + public async Task> GetByCompanyIdAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + aggregate_id AS Id, + user_id AS UserId, + company_id AS CompanyId, + role AS RoleString, + granted_by AS GrantedBy, + granted_at AS GrantedAt, + is_active AS IsActive, + revoked_at AS RevokedAt, + revoked_by AS RevokedBy + FROM user_company_access_read_models + WHERE company_id = @CompanyId + AND is_active = true + ORDER BY + CASE role + WHEN 'owner' THEN 1 + WHEN 'accountant' THEN 2 + WHEN 'viewer' THEN 3 + END, + granted_at ASC + """; + + var rows = await connection.QueryAsync(sql, new { CompanyId = companyId }); + return rows.Select(r => r.ToDto()).ToList(); + } + + public async Task GetAccessAsync( + string userId, + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + aggregate_id AS Id, + user_id AS UserId, + company_id AS CompanyId, + role AS RoleString, + granted_by AS GrantedBy, + granted_at AS GrantedAt, + is_active AS IsActive, + revoked_at AS RevokedAt, + revoked_by AS RevokedBy + FROM user_company_access_read_models + WHERE user_id = @UserId + AND company_id = @CompanyId + AND is_active = true + """; + + var row = await connection.QuerySingleOrDefaultAsync( + sql, + new { UserId = userId, CompanyId = companyId }); + + return row?.ToDto(); + } + + public async Task HasAccessAsync( + string userId, + string companyId, + CompanyRole minimumRole, + CancellationToken cancellationToken = default) + { + var access = await GetAccessAsync(userId, companyId, cancellationToken); + if (access == null) return false; + + // Role hierarchy: Owner > Accountant > Viewer + return minimumRole switch + { + CompanyRole.Viewer => true, // Any role can do viewer actions + CompanyRole.Accountant => access.Role is CompanyRole.Owner or CompanyRole.Accountant, + CompanyRole.Owner => access.Role is CompanyRole.Owner, + _ => false + }; + } + + public async Task GetByIdAsync( + string accessId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + const string sql = """ + SELECT + aggregate_id AS Id, + user_id AS UserId, + company_id AS CompanyId, + role AS RoleString, + granted_by AS GrantedBy, + granted_at AS GrantedAt, + is_active AS IsActive, + revoked_at AS RevokedAt, + revoked_by AS RevokedBy + FROM user_company_access_read_models + WHERE aggregate_id = @AccessId + """; + + var row = await connection.QuerySingleOrDefaultAsync( + sql, + new { AccessId = accessId }); + + return row?.ToDto(); + } + + /// + /// Internal row type for Dapper mapping with string role. + /// + private sealed class UserCompanyAccessRow + { + public string Id { get; init; } = string.Empty; + public string UserId { get; init; } = string.Empty; + public string CompanyId { get; init; } = string.Empty; + public string RoleString { get; init; } = string.Empty; + public string GrantedBy { get; init; } = string.Empty; + public DateTimeOffset GrantedAt { get; init; } + public bool IsActive { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + public string? RevokedBy { get; init; } + + public UserCompanyAccessDto ToDto() => new( + Id, + UserId, + CompanyId, + CompanyRoleExtensions.FromDbString(RoleString), + GrantedBy, + GrantedAt, + IsActive, + RevokedAt, + RevokedBy); + } +} diff --git a/backend/Books.Api/EventFlow/Repositories/VoucherNumberService.cs b/backend/Books.Api/EventFlow/Repositories/VoucherNumberService.cs new file mode 100644 index 0000000..71f4a78 --- /dev/null +++ b/backend/Books.Api/EventFlow/Repositories/VoucherNumberService.cs @@ -0,0 +1,54 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.EventFlow.Repositories; + +/// +/// PostgreSQL implementation of voucher number generation. +/// Uses database-level locking to ensure unique sequential numbers. +/// +public class VoucherNumberService(NpgsqlDataSource dataSource) : IVoucherNumberService +{ + public async Task GetNextVoucherNumberAsync( + string companyId, + string? fiscalYearId = null, + CancellationToken cancellationToken = default) + { + var effectiveFiscalYearId = fiscalYearId ?? "default"; + + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + // Use UPSERT with RETURNING to atomically get and increment the sequence + const string sql = """ + INSERT INTO voucher_number_sequences (company_id, fiscal_year_id, last_number, prefix, updated_at) + VALUES (@CompanyId, @FiscalYearId, 1, @Prefix, NOW()) + ON CONFLICT (company_id, fiscal_year_id) + DO UPDATE SET + last_number = voucher_number_sequences.last_number + 1, + updated_at = NOW() + RETURNING prefix, last_number + """; + + // Generate prefix from fiscal year (e.g., "2025-") or use "K-" for kassekladde + var prefix = GeneratePrefix(fiscalYearId); + + var result = await connection.QuerySingleAsync<(string Prefix, int LastNumber)>( + sql, + new { CompanyId = companyId, FiscalYearId = effectiveFiscalYearId, Prefix = prefix }); + + // Format: K-0001 or 2025-0001 + return $"{result.Prefix}{result.LastNumber:D4}"; + } + + private static string GeneratePrefix(string? fiscalYearId) + { + if (string.IsNullOrEmpty(fiscalYearId) || fiscalYearId == "default") + { + return "K-"; // K for Kassekladde + } + + // Try to extract year from fiscal year ID (format: fiscalyear-{guid}) + // In a real scenario, you might want to look up the fiscal year's actual year + return "K-"; + } +} diff --git a/backend/Books.Api/EventFlow/Subscribers/ChartOfAccountsInitializer.cs b/backend/Books.Api/EventFlow/Subscribers/ChartOfAccountsInitializer.cs new file mode 100644 index 0000000..3bbbc1f --- /dev/null +++ b/backend/Books.Api/EventFlow/Subscribers/ChartOfAccountsInitializer.cs @@ -0,0 +1,90 @@ +using Books.Api.Commands.Accounts; +using Books.Api.Domain.Accounts; +using Books.Api.EventFlow.Repositories; +using EventFlow; +using Microsoft.Extensions.Logging; + +namespace Books.Api.EventFlow.Subscribers; + +/// +/// Service to initialize standard Danish chart of accounts for a company. +/// Called explicitly after company creation. +/// Idempotent: Safe to call multiple times for the same company. +/// +public interface IChartOfAccountsInitializer +{ + Task InitializeAsync(string companyId, CancellationToken cancellationToken = default); +} + +public class ChartOfAccountsInitializer( + ICommandBus commandBus, + IAccountRepository accountRepository, + ILogger logger) : IChartOfAccountsInitializer +{ + public async Task InitializeAsync(string companyId, CancellationToken cancellationToken = default) + { + // Check for idempotency - if accounts already exist, skip initialization + var existingAccounts = await accountRepository.GetByCompanyIdAsync(companyId, cancellationToken); + if (existingAccounts.Count > 0) + { + logger.LogInformation( + "Chart of accounts already initialized for company {CompanyId} ({Count} accounts exist)", + companyId, existingAccounts.Count); + return; + } + + var standardAccounts = StandardDanishAccounts.GetAll().ToList(); + logger.LogInformation( + "Initializing chart of accounts for company {CompanyId} with {Count} standard accounts", + companyId, standardAccounts.Count); + + var successCount = 0; + var errorCount = 0; + + foreach (var account in standardAccounts) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var accountId = AccountId.FromCompanyAndNumber(companyId, account.AccountNumber); + + var command = new CreateAccountCommand( + accountId, + companyId, + account.AccountNumber, + account.Name, + account.AccountType, + parentId: null, + account.Description, + account.DefaultVatCode, + isSystemAccount: account.IsSystemAccount, + standardAccountNumber: account.StandardAccountNumber); + + await commandBus.PublishAsync(command, cancellationToken); + successCount++; + } + catch (Exception ex) + { + errorCount++; + logger.LogWarning(ex, + "Failed to create account {AccountNumber} ({AccountName}) for company {CompanyId}", + account.AccountNumber, account.Name, companyId); + // Continue with remaining accounts - partial initialization is better than none + } + } + + if (errorCount > 0) + { + logger.LogWarning( + "Chart of accounts initialization completed for company {CompanyId} with {SuccessCount} accounts created and {ErrorCount} errors", + companyId, successCount, errorCount); + } + else + { + logger.LogInformation( + "Chart of accounts initialization completed for company {CompanyId} - {SuccessCount} accounts created", + companyId, successCount); + } + } +} diff --git a/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs b/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs new file mode 100644 index 0000000..67fb78a --- /dev/null +++ b/backend/Books.Api/EventFlow/Subscribers/StandardDanishAccounts.cs @@ -0,0 +1,221 @@ +using Books.Api.Domain.Accounts; + +namespace Books.Api.EventFlow.Subscribers; + +/// +/// Standard Danish chart of accounts following Danish accounting standards. +/// Account number ranges: +/// - 1xxx: Revenue (Omsætning) + Current Assets (Omsætningsaktiver) +/// - 17xx: Fixed Assets (Anlægsaktiver) +/// - 2xxx: Cost of Goods Sold (Vareforbrug) +/// - 3xxx: Equity (Egenkapital) - ONLY equity accounts per Danish standards +/// - 4xxx: Sales Expenses (Salgsomkostninger) +/// - 5xxx: Location Costs (Lokaleomkostninger) + Personnel (Personaleomkostninger) +/// - 6xxx: Vehicle/Travel Expenses (Kørselsomkostninger) +/// - 7xxx: Administrative Expenses (Administrationsomkostninger) + Liabilities +/// - 8xxx: Depreciation (Afskrivninger) +/// - 9xxx: Financial Items (Finansielle poster) +/// +/// StandardAccountNumber refers to Erhvervsstyrelsens standardkontoplan for SAF-T compliance. +/// Mapping based on official SAF-T 2.0 specification (mandatory from Jan 2027). +/// +public static class StandardDanishAccounts +{ + public record StandardAccount( + string AccountNumber, + string Name, + AccountType AccountType, + string? Description, + string? DefaultVatCode, + string? StandardAccountNumber, + bool IsSystemAccount = false); + + public static IEnumerable GetAll() + { + // ========================================= + // OMSÆTNING (Revenue) - 1xxx + // Standard: 1010 = Salg af varer og ydelser med moms + // ========================================= + yield return new("1000", "Salg af varer, DK", AccountType.Revenue, "Salg med 25% moms", "U25", "1010", true); + yield return new("1010", "Salg af varer, momsfrit", AccountType.Revenue, "Momsfrit salg", null, "1010", true); + yield return new("1050", "Salg af varer, EU", AccountType.Revenue, "EU-salg af varer", "UEU", "1050", true); + yield return new("1100", "Salg af varer, verden", AccountType.Revenue, "Eksport uden for EU", "UEXP", "1100", true); + yield return new("1200", "Salg af ydelser, DK", AccountType.Revenue, "Salg med 25% moms", "U25", "1010", true); + yield return new("1250", "Salg af ydelser, EU", AccountType.Revenue, "EU-salg af ydelser", "UEU", "1210", true); + yield return new("1300", "Salg af ydelser, verden", AccountType.Revenue, "Eksport uden for EU", "UEXP", "1310", true); + yield return new("1400", "Øvrig omsætning", AccountType.Revenue, null, "U25", "1510"); + yield return new("1500", "Valutakursdifferencer, salg", AccountType.Revenue, null, null, "1510"); + + // ========================================= + // ANLÆGSAKTIVER (Fixed Assets) - 17xx + // Standard: 5530 = Grunde og bygninger, 5570 = Produktionsanlæg + // ========================================= + yield return new("1710", "Bygninger", AccountType.Asset, "Ejendomme og bygninger", null, "5530"); + yield return new("1720", "Maskiner og inventar", AccountType.Asset, "Driftsmidler og maskiner", null, "5570"); + yield return new("1730", "Køretøjer", AccountType.Asset, "Biler og transportmidler", null, "5570"); + + // ========================================= + // OMSÆTNINGSAKTIVER (Current Assets) - 18xx-19xx + // Standard: 6140 = Varebeholdninger, 6190 = Tilgodehavender fra salg + // ========================================= + yield return new("1800", "Varelager", AccountType.Asset, null, null, "6140"); + yield return new("1850", "Igangværende arbejder", AccountType.Asset, null, null, "6150"); + yield return new("1900", "Debitorer", AccountType.Asset, "Tilgodehavender fra salg", null, "6190", true); + yield return new("1950", "Andre tilgodehavender", AccountType.Asset, null, null, "6300"); + + // ========================================= + // LIKVIDE BEHOLDNINGER (Cash & Bank) - 197x-198x + // Standard: 6470 = Kontanter, 6480 = Bankindeståender + // ========================================= + yield return new("1970", "Bankkonto", AccountType.Asset, "Bankkonto til Open Banking", null, "6480"); + + // ========================================= + // VAREFORBRUG (COGS) - 2xxx + // Standard: 1610 = Anvendt råvarer, 2070 = Køb af ydelser + // ========================================= + yield return new("2000", "Vareforbrug", AccountType.Cogs, "Køb af varer til videresalg", "I25", "1610"); + yield return new("2050", "EU-erhvervelser varer", AccountType.Cogs, "Varekøb fra EU-lande", "IEUV", "1610"); + yield return new("2100", "EU-erhvervelser ydelser", AccountType.Cogs, "Ydelseskøb fra EU-lande", "IEUY", "2070"); + yield return new("2150", "Varekøb verden", AccountType.Cogs, "Varekøb fra lande uden for EU", "IVV", "1610"); + yield return new("2200", "Ydelseskøb verden", AccountType.Cogs, "Ydelseskøb fra lande uden for EU", "IVY", "2070"); + yield return new("2250", "Fragt med moms", AccountType.Cogs, "Fragtomkostninger med moms", "I25", "1810"); + yield return new("2300", "Fragt uden moms", AccountType.Cogs, "Fragtomkostninger uden moms", null, "1810"); + yield return new("2400", "Valutakursdifferencer, import", AccountType.Cogs, null, null, "3690"); + yield return new("2450", "Varelagerregulering", AccountType.Cogs, null, null, "1640"); + yield return new("2800", "Fremmed arbejde", AccountType.Cogs, "Underleverandører og freelancere", "I25", "2010"); + + // ========================================= + // EGENKAPITAL (Equity) - 3xxx + // Standard: 6510 = Virksomhedskapital, 6935 = Overført resultat + // Per Danish accounting standards, 3xxx should ONLY contain equity accounts + // ========================================= + yield return new("3000", "Aktiekapital/Anpartskapital", AccountType.Equity, "Indskudt kapital", null, "6510", true); + yield return new("3100", "Overkurs ved emission", AccountType.Equity, "Share premium", null, "6530"); + yield return new("3900", "Overført resultat", AccountType.Equity, "Akkumuleret resultat", null, "6935", true); + yield return new("3910", "Årets resultat", AccountType.Equity, "Resultat for indeværende år", null, "6940", true); + + // ========================================= + // SALGSOMKOSTNINGER (Sales Expenses) - 4xxx + // Standard: 2170 = Reklame, 2180 = Rejser og møder + // ========================================= + yield return new("4000", "Annoncer og reklame", AccountType.Expense, "Marketing og annoncering", "I25", "2170"); + yield return new("4040", "Hotel, personale", AccountType.Expense, "Fuldt fradrag", "I25", "2180"); + yield return new("4060", "Hotel, forretningsforbindelser", AccountType.Expense, "Delvis fradrag", "I25", "2180"); + yield return new("4080", "Konferencer", AccountType.Expense, null, null, "2180"); + yield return new("4100", "Messer", AccountType.Expense, null, "I25", "2170"); + yield return new("4120", "Repræsentation, restaurant, personale", AccountType.Expense, "Fuldt fradrag", "REP", "2190"); + yield return new("4140", "Repræsentation, restaurant, forretningsforbindelser", AccountType.Expense, "Delvis fradrag", "REP", "2190"); + yield return new("4180", "Repræsentation, gaver og blomster", AccountType.Expense, "Delvis fradrag", null, "2190"); + yield return new("4280", "Rejseomkostninger", AccountType.Expense, null, null, "2180"); + + // ========================================= + // LOKALEOMKOSTNINGER (Location Costs) - 50xx + // Standard: 2150 = Lokaleomkostninger + // ========================================= + yield return new("5000", "Husleje", AccountType.Expense, "Husleje med moms", "I25", "2150"); + yield return new("5010", "Husleje uden moms", AccountType.Expense, "Husleje uden moms", null, "2150"); + yield return new("5025", "El", AccountType.Expense, null, "I25", "2150"); + yield return new("5030", "Vand", AccountType.Expense, null, "I25", "2150"); + yield return new("5035", "Varme", AccountType.Expense, null, "I25", "2150"); + yield return new("5040", "Elafgift", AccountType.Expense, null, null, "2150"); + yield return new("5060", "Rengøring og affaldshåndtering", AccountType.Expense, null, "I25", "2150"); + yield return new("5080", "Reparation og vedligeholdelse", AccountType.Expense, null, "I25", "2150"); + yield return new("5100", "Ejendomsskat", AccountType.Expense, null, null, "2150"); + + // ========================================= + // PERSONALEOMKOSTNINGER (Personnel) - 53xx + // Standard: 2310 = Lønninger, 2330 = Pensioner + // Moved from 3xxx to comply with Danish standards (3xxx = Equity only) + // ========================================= + yield return new("5300", "AM-indkomst", AccountType.Personnel, "Løn til ansatte", null, "2310"); + yield return new("5310", "Arbejdsgiver ATP", AccountType.Personnel, null, null, "2350"); + yield return new("5320", "Medarbejder ATP", AccountType.Personnel, null, null, "2350"); + yield return new("5330", "Sygepenge mv.", AccountType.Personnel, null, null, "2390"); + yield return new("5340", "Personalegoder", AccountType.Personnel, "Herunder fri telefon", null, "2390"); + yield return new("5350", "B-honorar", AccountType.Personnel, null, null, "2310"); + yield return new("5360", "Barsel", AccountType.Personnel, null, null, "2390"); + yield return new("5370", "Feriepenge og SH", AccountType.Personnel, null, null, "2310"); + yield return new("5380", "Pension", AccountType.Personnel, null, null, "2330"); + yield return new("5390", "Diæter/rejsegodtgørelse", AccountType.Personnel, null, null, "2310"); + yield return new("5400", "Kørsel i egen bil", AccountType.Personnel, "Kilometergodtgørelse", null, "2140"); + yield return new("5410", "AER/AES/ATP-finansieringsbidrag", AccountType.Personnel, null, null, "2350"); + yield return new("5420", "Arbejdstøj", AccountType.Personnel, null, "I25", "2390"); + yield return new("5430", "Mad under kursus/møder", AccountType.Personnel, "Fuldt fradrag", "I25", "2180"); + yield return new("5440", "Gaver til personalet", AccountType.Personnel, "Fuldt fradrag", null, "2390"); + yield return new("5450", "Uddannelsesudgifter", AccountType.Personnel, null, "I25", "2180"); + yield return new("5460", "Regulering feriepenge", AccountType.Personnel, null, null, "2310"); + + // ========================================= + // KØRSELSOMKOSTNINGER (Vehicle/Travel) - 6xxx + // Standard: 2140 = Bilomkostninger + // ========================================= + yield return new("6000", "Billeje (gulplade)", AccountType.Expense, null, "I25", "2140"); + yield return new("6020", "Brændstof (gulplade)", AccountType.Expense, null, "I25", "2140"); + yield return new("6040", "Vedligeholdelse af bil", AccountType.Expense, null, "I25", "2140"); + yield return new("6060", "Vægtafgift og forsikringer", AccountType.Expense, null, null, "2140"); + yield return new("6080", "Parkering", AccountType.Expense, null, "I25", "2140"); + yield return new("6100", "Broafgift", AccountType.Expense, null, "I25", "2140"); + yield return new("6120", "Taxa", AccountType.Expense, null, null, "2140"); + yield return new("6140", "Tog", AccountType.Expense, null, null, "2180"); + yield return new("6160", "Fly", AccountType.Expense, null, null, "2180"); + + // ========================================= + // PASSIVER - KREDITORER (Liabilities) - 69xx + // Standard: 7350 = Leverandører af varer og tjenesteydelser + // ========================================= + yield return new("6900", "Kreditorer", AccountType.Liability, "Leverandørgæld", null, "7350", true); + yield return new("6950", "Anden gæld", AccountType.Liability, null, null, "7790"); + + // ========================================= + // ADMINISTRATIONSOMKOSTNINGER (Admin Expenses) - 7xxx + // Standard: 2110 = Administrationsomkostninger + // ========================================= + yield return new("7005", "Revision og regnskabsmæssig assistance", AccountType.Expense, null, "I25", "2110"); + yield return new("7010", "Advokat", AccountType.Expense, null, "I25", "2110"); + yield return new("7020", "Bogføringsassistance", AccountType.Expense, null, "I25", "2110"); + yield return new("7040", "Konsulentbistand", AccountType.Expense, null, "I25", "2110"); + yield return new("7060", "Kontingenter inkl. moms", AccountType.Expense, null, "I25", "2110"); + yield return new("7080", "Kontingenter ekskl. moms", AccountType.Expense, null, null, "2110"); + yield return new("7100", "Aviser", AccountType.Expense, null, null, "2110"); + yield return new("7120", "Faglitteratur", AccountType.Expense, null, "I25", "2110"); + yield return new("7160", "Erhvervsforsikringer", AccountType.Expense, null, null, "2110"); + yield return new("7200", "Kontorartikler og tryksager", AccountType.Expense, null, "I25", "2110"); + yield return new("7220", "Porto og gebyrer", AccountType.Expense, null, null, "2110"); + yield return new("7240", "Telefoni", AccountType.Expense, null, "I25", "2110"); + yield return new("7300", "Internet og webhotel", AccountType.Expense, null, "I25", "2110"); + yield return new("7320", "Køb af software", AccountType.Expense, "SaaS og licenser", "I25", "2110"); + yield return new("7360", "Offentlige bøder og gebyrer", AccountType.Expense, null, null, "2110"); + yield return new("7400", "Betalingsløsning", AccountType.Expense, "Stripe, MobilePay mv.", "I25", "2110"); + yield return new("7420", "Indløsere", AccountType.Expense, "Nets, Clearhaus mv.", null, "2110"); + yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110"); + yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "2110"); + + // ========================================= + // PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx + // Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat + // ========================================= + yield return new("7900", "Skyldig moms", AccountType.Liability, "Afregning med SKAT", null, "7680", true); + yield return new("7910", "Skyldig A-skat", AccountType.Liability, null, null, "7920", true); + yield return new("7920", "Skyldig AM-bidrag", AccountType.Liability, null, null, "7930", true); + yield return new("7930", "Skyldig ATP", AccountType.Liability, null, null, "7940", true); + yield return new("7940", "Skyldig feriepenge", AccountType.Liability, null, null, "8190", true); + yield return new("7950", "Skyldig selskabsskat", AccountType.Liability, "Selskabsskat til betaling", null, "7670", true); + + // ========================================= + // AFSKRIVNINGER (Depreciation) - 8xxx + // Standard: 3210 = Af- og nedskrivninger af materielle og immaterielle anlægsaktiver + // ========================================= + yield return new("8010", "Afskrivning, bygninger", AccountType.Expense, "Afskrivning på bygninger", null, "3210"); + yield return new("8020", "Afskrivning, maskiner", AccountType.Expense, "Afskrivning på maskiner og inventar", null, "3210"); + yield return new("8030", "Afskrivning, køretøjer", AccountType.Expense, "Afskrivning på køretøjer", null, "3210"); + yield return new("8040", "Småanskaffelser", AccountType.Expense, "Straksafskrivning", "I25", "3210"); + + // ========================================= + // FINANSIELLE POSTER (Financial) - 9xxx + // Standard: 3670 = Øvrige finansielle omkostninger + // ========================================= + yield return new("9200", "Bankrenter", AccountType.Financial, null, null, "3670"); + yield return new("9210", "Leverandører mv.", AccountType.Financial, "Renter til leverandører", null, "3670"); + yield return new("9220", "Ikke-fradragsberettigede renter", AccountType.Financial, null, null, "3670"); + } +} diff --git a/backend/Books.Api/GraphQL/GraphQLContextExtensions.cs b/backend/Books.Api/GraphQL/GraphQLContextExtensions.cs new file mode 100644 index 0000000..b5c7db1 --- /dev/null +++ b/backend/Books.Api/GraphQL/GraphQLContextExtensions.cs @@ -0,0 +1,86 @@ +using Books.Api.Authorization; +using Books.Api.Domain.UserAccess; +using GraphQL; + +namespace Books.Api.GraphQL; + +/// +/// Extension methods for accessing CompanyContext from GraphQL resolvers. +/// +public static class GraphQLContextExtensions +{ + /// + /// Get the CompanyContext from the GraphQL resolver context. + /// + public static CompanyContext GetCompanyContext(this IResolveFieldContext ctx) + { + var httpContext = ctx.RequestServices?.GetService()?.HttpContext; + return httpContext?.GetCompanyContext() ?? new CompanyContext(); + } + + /// + /// Require the user to have at least the specified role for the selected company. + /// Throws DomainException if not. + /// + public static void RequireRole(this IResolveFieldContext ctx, CompanyRole minimumRole) + { + ctx.GetCompanyContext().RequireRole(minimumRole); + } + + /// + /// Require the user to have write access (Owner or Accountant). + /// + public static void RequireWrite(this IResolveFieldContext ctx) + { + ctx.GetCompanyContext().RequireWrite(); + } + + /// + /// Require the user to be Owner. + /// + public static void RequireOwner(this IResolveFieldContext ctx) + { + ctx.GetCompanyContext().RequireOwner(); + } + + /// + /// Get the currently selected company ID. + /// Throws if no company is selected. + /// + public static string GetRequiredCompanyId(this IResolveFieldContext ctx) + { + var companyContext = ctx.GetCompanyContext(); + if (!companyContext.HasCompanySelected) + { + throw new Domain.DomainException( + "NO_COMPANY_SELECTED", + "No company selected. Set X-Company-Id header.", + "Ingen virksomhed valgt. Sæt X-Company-Id header."); + } + return companyContext.CompanyId!; + } + + /// + /// Validate that the requested company ID matches the selected company. + /// For operations that take a company ID argument. + /// + public static void ValidateCompanyAccess(this IResolveFieldContext ctx, string requestedCompanyId) + { + var companyContext = ctx.GetCompanyContext(); + if (!companyContext.HasCompanySelected) + { + throw new Domain.DomainException( + "NO_COMPANY_SELECTED", + "No company selected. Set X-Company-Id header.", + "Ingen virksomhed valgt. Sæt X-Company-Id header."); + } + + if (companyContext.CompanyId != requestedCompanyId) + { + throw new Domain.DomainException( + "COMPANY_MISMATCH", + "The requested company ID does not match the selected company", + "Det angivne virksomheds-ID matcher ikke den valgte virksomhed"); + } + } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/BankConnectionInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/BankConnectionInputTypes.cs new file mode 100644 index 0000000..142a183 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/BankConnectionInputTypes.cs @@ -0,0 +1,85 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class StartBankConnectionInputType : InputObjectGraphType +{ + public StartBankConnectionInputType() + { + Name = "StartBankConnectionInput"; + Description = "Input for starting a bank connection"; + + Field(x => x.CompanyId).Description("The company ID to connect the bank to"); + Field(x => x.AspspName).Description("The bank name (from availableBanks query)"); + Field(x => x.RedirectUrl).Description("URL to redirect back to after authorization"); + Field(x => x.PsuType, nullable: true).Description("PSU type: 'personal' or 'business' (default: personal)"); + } +} + +public class StartBankConnectionInput +{ + public string CompanyId { get; set; } = string.Empty; + public string AspspName { get; set; } = string.Empty; + public string RedirectUrl { get; set; } = string.Empty; + public string? PsuType { get; set; } +} + +public class CompleteBankConnectionInputType : InputObjectGraphType +{ + public CompleteBankConnectionInputType() + { + Name = "CompleteBankConnectionInput"; + Description = "Input for completing a bank connection after authorization"; + + Field(x => x.ConnectionId).Description("The bank connection ID from startBankConnection"); + Field(x => x.AuthorizationCode).Description("The authorization code from the bank callback"); + } +} + +public class CompleteBankConnectionInput +{ + public string ConnectionId { get; set; } = string.Empty; + public string AuthorizationCode { get; set; } = string.Empty; +} + +public class LinkBankAccountInputType : InputObjectGraphType +{ + public LinkBankAccountInputType() + { + Name = "LinkBankAccountInput"; + Description = "Input for linking a bank account to a chart of accounts account"; + + Field(x => x.ConnectionId).Description("The bank connection ID"); + Field(x => x.BankAccountId).Description("The bank account ID (from the connection's accounts list)"); + Field(x => x.LinkedAccountId).Description("The chart of accounts account ID to link to"); + Field(x => x.ImportFromDate, nullable: true).Description("Date to start importing transactions from (optional)"); + } +} + +public class LinkBankAccountInput +{ + public string ConnectionId { get; set; } = string.Empty; + public string BankAccountId { get; set; } = string.Empty; + public string LinkedAccountId { get; set; } = string.Empty; + public DateTime? ImportFromDate { get; set; } +} + +public class ReconnectBankConnectionInputType : InputObjectGraphType +{ + public ReconnectBankConnectionInputType() + { + Name = "ReconnectBankConnectionInput"; + Description = "Input for reconnecting an expired, failed, or disconnected bank connection"; + + Field(x => x.ConnectionId).Description("The bank connection ID to reconnect"); + Field(x => x.RedirectUrl).Description("URL to redirect back to after authorization"); + Field(x => x.PsuType, nullable: true).Description("PSU type: 'personal' or 'business' (default: personal)"); + } +} + +public class ReconnectBankConnectionInput +{ + public string ConnectionId { get; set; } = string.Empty; + public string RedirectUrl { get; set; } = string.Empty; + public string? PsuType { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/CreateAccountInputType.cs b/backend/Books.Api/GraphQL/InputTypes/CreateAccountInputType.cs new file mode 100644 index 0000000..041c086 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/CreateAccountInputType.cs @@ -0,0 +1,41 @@ +using Books.Api.Domain.Accounts; +using Books.Api.GraphQL.Types; +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateAccountInputType : InputObjectGraphType +{ + public CreateAccountInputType() + { + Name = "CreateAccountInput"; + Description = "Input for creating a new account"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.AccountNumber).Description("Account number, 4-10 digits (required)"); + Field(x => x.Name).Description("Account name (required)"); + Field>("accountType") + .Description("Type of account (required)"); + Field(x => x.ParentId, nullable: true).Description("Parent account ID for hierarchy"); + Field(x => x.Description, nullable: true).Description("Account description"); + Field(x => x.VatCodeId, nullable: true).Description("Default VAT code ID"); + Field(x => x.IsSystemAccount, nullable: true).Description("Whether this is a system account (default: false)"); + Field(x => x.StandardAccountNumber, nullable: true).Description("Erhvervsstyrelsens standardkontonummer for SAF-T rapportering"); + } +} + +public class CreateAccountInput +{ + public string CompanyId { get; set; } = string.Empty; + public string AccountNumber { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public AccountType AccountType { get; set; } + public string? ParentId { get; set; } + public string? Description { get; set; } + public string? VatCodeId { get; set; } + public bool? IsSystemAccount { get; set; } + /// + /// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. + /// + public string? StandardAccountNumber { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/CreateFiscalYearInputType.cs b/backend/Books.Api/GraphQL/InputTypes/CreateFiscalYearInputType.cs new file mode 100644 index 0000000..c64f065 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/CreateFiscalYearInputType.cs @@ -0,0 +1,39 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateFiscalYearInputType : InputObjectGraphType +{ + public CreateFiscalYearInputType() + { + Name = "CreateFiscalYearInput"; + Description = "Input for creating a new fiscal year"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.Name).Description("Fiscal year name, e.g., '2024' (required)"); + Field(x => x.StartDate).Description("First day of the fiscal year (required)"); + Field(x => x.EndDate).Description("Last day of the fiscal year (required)"); + Field(x => x.IsFirstFiscalYear, nullable: true) + .Description("Set to true if this is the company's first fiscal year (allows 6-18 months duration)"); + Field(x => x.IsReorganization, nullable: true) + .Description("Set to true if this is a reorganization fiscal year (allows up to 18 months duration)"); + } +} + +public class CreateFiscalYearInput +{ + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + + /// + /// Per Årsregnskabsloven §15: First fiscal year can be shorter than 12 months (6-18 months allowed) + /// + public bool? IsFirstFiscalYear { get; set; } + + /// + /// Per Årsregnskabsloven §15: Reorganization allows fiscal year up to 18 months + /// + public bool? IsReorganization { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/CreateJournalEntryInputType.cs b/backend/Books.Api/GraphQL/InputTypes/CreateJournalEntryInputType.cs new file mode 100644 index 0000000..59966f0 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/CreateJournalEntryInputType.cs @@ -0,0 +1,52 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateJournalEntryInputType : InputObjectGraphType +{ + public CreateJournalEntryInputType() + { + Name = "CreateJournalEntryInput"; + Description = "Input for creating a journal entry (bilag) with double-entry bookkeeping"; + + Field(x => x.FiscalYearId).Description("Fiscal year ID for period locking (required)"); + Field(x => x.Description, nullable: true).Description("Description of the journal entry"); + Field(x => x.ReferenceId, nullable: true).Description("External reference (e.g. invoice number)"); + Field(x => x.End2EndId, nullable: true).Description("End-to-end tracking ID (generated if not provided)"); + Field(x => x.IdempotencyKey, nullable: true).Description("Idempotency key for duplicate prevention (generated if not provided)"); + Field>>>("postings") + .Description("The postings (posteringer) - must balance (debits = credits)"); + } +} + +public class JournalEntryPostingInputType : InputObjectGraphType +{ + public JournalEntryPostingInputType() + { + Name = "JournalEntryPostingInput"; + Description = "A single posting (postering) in a journal entry"; + + Field(x => x.AccountId).Description("Account ID (required)"); + Field(x => x.DebitAmount, nullable: true).Description("Debit amount (positive, mutually exclusive with creditAmount)"); + Field(x => x.CreditAmount, nullable: true).Description("Credit amount (positive, mutually exclusive with debitAmount)"); + Field(x => x.Currency, nullable: true).Description("Currency code (default: DKK)"); + } +} + +public class CreateJournalEntryInput +{ + public string FiscalYearId { get; set; } = string.Empty; + public string? Description { get; set; } + public string? ReferenceId { get; set; } + public string? End2EndId { get; set; } + public string? IdempotencyKey { get; set; } + public List Postings { get; set; } = []; +} + +public class JournalEntryPostingInput +{ + public string AccountId { get; set; } = string.Empty; + public decimal? DebitAmount { get; set; } + public decimal? CreditAmount { get; set; } + public string? Currency { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/CustomerInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/CustomerInputTypes.cs new file mode 100644 index 0000000..0a0ad6f --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/CustomerInputTypes.cs @@ -0,0 +1,80 @@ +using Books.Api.Domain.Customers; +using Books.Api.GraphQL.Types; +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateCustomerInputType : InputObjectGraphType +{ + public CreateCustomerInputType() + { + Name = "CreateCustomerInput"; + Description = "Input for creating a new customer"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field>("customerType") + .Description("Customer type: BUSINESS or PRIVATE (required)"); + Field(x => x.Name).Description("Customer name (required)"); + Field(x => x.Cvr, nullable: true).Description("CVR number (required for BUSINESS customers)"); + Field(x => x.Address, nullable: true).Description("Street address"); + Field(x => x.PostalCode, nullable: true).Description("Postal code"); + Field(x => x.City, nullable: true).Description("City"); + Field(x => x.Country, nullable: true).Description("Country code (ISO 2-letter, default: DK)"); + Field(x => x.Email, nullable: true).Description("Email address"); + Field(x => x.Phone, nullable: true).Description("Phone number"); + Field(x => x.PaymentTermsDays, nullable: true).Description("Payment terms in days (default: 30)"); + Field(x => x.DefaultRevenueAccountId, nullable: true).Description("Default revenue account for invoices"); + } +} + +public class CreateCustomerInput +{ + public string CompanyId { get; set; } = string.Empty; + public CustomerType CustomerType { get; set; } + public string Name { get; set; } = string.Empty; + public string? Cvr { get; set; } + public string? Address { get; set; } + public string? PostalCode { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public int? PaymentTermsDays { get; set; } + public string? DefaultRevenueAccountId { get; set; } +} + +public class UpdateCustomerInputType : InputObjectGraphType +{ + public UpdateCustomerInputType() + { + Name = "UpdateCustomerInput"; + Description = "Input for updating a customer"; + + Field(x => x.Id).Description("Customer ID (required)"); + Field(x => x.Name).Description("Customer name (required)"); + Field(x => x.Cvr, nullable: true).Description("CVR number (required for BUSINESS customers)"); + Field(x => x.Address, nullable: true).Description("Street address"); + Field(x => x.PostalCode, nullable: true).Description("Postal code"); + Field(x => x.City, nullable: true).Description("City"); + Field(x => x.Country, nullable: true).Description("Country code (ISO 2-letter, default: DK)"); + Field(x => x.Email, nullable: true).Description("Email address"); + Field(x => x.Phone, nullable: true).Description("Phone number"); + Field(x => x.PaymentTermsDays, nullable: true).Description("Payment terms in days"); + Field(x => x.DefaultRevenueAccountId, nullable: true).Description("Default revenue account for invoices"); + } +} + +public class UpdateCustomerInput +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Cvr { get; set; } + public string? Address { get; set; } + public string? PostalCode { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public int? PaymentTermsDays { get; set; } + public string? DefaultRevenueAccountId { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/InvoiceInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/InvoiceInputTypes.cs new file mode 100644 index 0000000..f99bf61 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/InvoiceInputTypes.cs @@ -0,0 +1,195 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateInvoiceInputType : InputObjectGraphType +{ + public CreateInvoiceInputType() + { + Name = "CreateInvoiceInput"; + Description = "Input for creating a new invoice"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.FiscalYearId).Description("Fiscal year ID (required)"); + Field(x => x.CustomerId).Description("Customer ID (required)"); + Field(x => x.InvoiceDate, nullable: true).Description("Invoice date (defaults to today)"); + Field(x => x.DueDate, nullable: true).Description("Due date (defaults based on payment terms)"); + Field(x => x.VatCode, nullable: true).Description("Default VAT code (defaults to U25)"); + Field(x => x.Notes, nullable: true).Description("Internal notes"); + Field(x => x.Reference, nullable: true).Description("Customer reference"); + } +} + +public class CreateInvoiceInput +{ + public string CompanyId { get; set; } = string.Empty; + public string FiscalYearId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public DateTime? InvoiceDate { get; set; } + public DateTime? DueDate { get; set; } + public string? VatCode { get; set; } + public string? Notes { get; set; } + public string? Reference { get; set; } +} + +public class AddInvoiceLineInputType : InputObjectGraphType +{ + public AddInvoiceLineInputType() + { + Name = "AddInvoiceLineInput"; + Description = "Input for adding a line to an invoice"; + + Field(x => x.InvoiceId).Description("Invoice ID (required)"); + Field(x => x.Description).Description("Description of product/service (required)"); + Field(x => x.Quantity).Description("Quantity (required, must be positive)"); + Field(x => x.UnitPrice).Description("Unit price excluding VAT (required)"); + Field(x => x.VatCode, nullable: true).Description("VAT code (defaults to invoice VAT code or U25)"); + Field(x => x.AccountId, nullable: true).Description("Revenue account ID (defaults to customer's default)"); + Field(x => x.Unit, nullable: true).Description("Unit of measurement (e.g., stk, timer)"); + Field(x => x.DiscountPercent, nullable: true).Description("Discount percentage (0-100)"); + } +} + +public class AddInvoiceLineInput +{ + public string InvoiceId { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public string? VatCode { get; set; } + public string? AccountId { get; set; } + public string? Unit { get; set; } + public decimal? DiscountPercent { get; set; } +} + +public class UpdateInvoiceLineInputType : InputObjectGraphType +{ + public UpdateInvoiceLineInputType() + { + Name = "UpdateInvoiceLineInput"; + Description = "Input for updating a line on an invoice"; + + Field(x => x.InvoiceId).Description("Invoice ID (required)"); + Field(x => x.LineNumber).Description("Line number to update (required)"); + Field(x => x.Description).Description("Description of product/service (required)"); + Field(x => x.Quantity).Description("Quantity (required)"); + Field(x => x.UnitPrice).Description("Unit price excluding VAT (required)"); + Field(x => x.VatCode, nullable: true).Description("VAT code"); + Field(x => x.AccountId, nullable: true).Description("Revenue account ID"); + Field(x => x.Unit, nullable: true).Description("Unit of measurement"); + Field(x => x.DiscountPercent, nullable: true).Description("Discount percentage (0-100)"); + } +} + +public class UpdateInvoiceLineInput +{ + public string InvoiceId { get; set; } = string.Empty; + public int LineNumber { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public string? VatCode { get; set; } + public string? AccountId { get; set; } + public string? Unit { get; set; } + public decimal? DiscountPercent { get; set; } +} + +public class ReceivePaymentInputType : InputObjectGraphType +{ + public ReceivePaymentInputType() + { + Name = "ReceivePaymentInput"; + Description = "Input for recording a payment received"; + + Field(x => x.InvoiceId).Description("Invoice ID (required)"); + Field(x => x.Amount).Description("Payment amount (required)"); + Field(x => x.BankAccountId).Description("Bank account ID for the payment (required)"); + Field(x => x.PaymentDate, nullable: true).Description("Payment date (defaults to today)"); + Field(x => x.BankTransactionId, nullable: true).Description("Bank transaction ID if matched"); + Field(x => x.PaymentReference, nullable: true).Description("Payment reference"); + } +} + +public class ReceivePaymentInput +{ + public string InvoiceId { get; set; } = string.Empty; + public decimal Amount { get; set; } + public string BankAccountId { get; set; } = string.Empty; + public DateTime? PaymentDate { get; set; } + public string? BankTransactionId { get; set; } + public string? PaymentReference { get; set; } +} + +public class VoidInvoiceInputType : InputObjectGraphType +{ + public VoidInvoiceInputType() + { + Name = "VoidInvoiceInput"; + Description = "Input for voiding an invoice"; + + Field(x => x.InvoiceId).Description("Invoice ID (required)"); + Field(x => x.Reason).Description("Reason for voiding (required)"); + } +} + +public class VoidInvoiceInput +{ + public string InvoiceId { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; +} + +// ===================================================== +// CREDIT NOTE INPUT TYPES +// ===================================================== + +public class CreateCreditNoteInputType : InputObjectGraphType +{ + public CreateCreditNoteInputType() + { + Name = "CreateCreditNoteInput"; + Description = "Input for creating a new credit note"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.FiscalYearId).Description("Fiscal year ID (required)"); + Field(x => x.CustomerId).Description("Customer ID (required)"); + Field(x => x.CreditNoteDate, nullable: true).Description("Credit note date (defaults to today)"); + Field(x => x.OriginalInvoiceId, nullable: true).Description("Original invoice ID being credited"); + Field(x => x.CreditReason, nullable: true).Description("Reason for the credit"); + Field(x => x.VatCode, nullable: true).Description("Default VAT code (defaults to U25)"); + Field(x => x.Notes, nullable: true).Description("Internal notes"); + Field(x => x.Reference, nullable: true).Description("Customer reference"); + } +} + +public class CreateCreditNoteInput +{ + public string CompanyId { get; set; } = string.Empty; + public string FiscalYearId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public DateTime? CreditNoteDate { get; set; } + public string? OriginalInvoiceId { get; set; } + public string? CreditReason { get; set; } + public string? VatCode { get; set; } + public string? Notes { get; set; } + public string? Reference { get; set; } +} + +public class ApplyCreditNoteInputType : InputObjectGraphType +{ + public ApplyCreditNoteInputType() + { + Name = "ApplyCreditNoteInput"; + Description = "Input for applying a credit note to an invoice"; + + Field(x => x.CreditNoteId).Description("Credit note ID (required)"); + Field(x => x.InvoiceId).Description("Target invoice ID (required)"); + Field(x => x.Amount).Description("Amount to apply (required)"); + } +} + +public class ApplyCreditNoteInput +{ + public string CreditNoteId { get; set; } = string.Empty; + public string InvoiceId { get; set; } = string.Empty; + public decimal Amount { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/JournalEntryDraftInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/JournalEntryDraftInputTypes.cs new file mode 100644 index 0000000..32c6d81 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/JournalEntryDraftInputTypes.cs @@ -0,0 +1,83 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateJournalEntryDraftInputType : InputObjectGraphType +{ + public CreateJournalEntryDraftInputType() + { + Name = "CreateJournalEntryDraftInput"; + Description = "Input for creating a new journal entry draft (kassekladde)"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.Name).Description("Draft name, e.g. 'Januar udgifter' (required)"); + } +} + +public class CreateJournalEntryDraftInput +{ + public string CompanyId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; +} + +public class UpdateJournalEntryDraftInputType : InputObjectGraphType +{ + public UpdateJournalEntryDraftInputType() + { + Name = "UpdateJournalEntryDraftInput"; + Description = "Input for updating a journal entry draft (auto-save)"; + + Field(x => x.Id).Description("Draft ID (required)"); + Field(x => x.Name, nullable: true).Description("Draft name"); + Field(x => x.DocumentDate, nullable: true).Description("Bilagsdato - the document/transaction date (e.g., invoice date)"); + Field(x => x.Description, nullable: true).Description("Entry description"); + Field(x => x.FiscalYearId, nullable: true).Description("Fiscal year ID"); + Field>>>("lines") + .Description("The posting lines (posteringslinjer)"); + Field>>("attachmentIds") + .Description("References to attached documents (bilag) - required by Bogføringsloven § 6"); + } +} + +public class UpdateJournalEntryDraftInput +{ + public string Id { get; set; } = string.Empty; + public string? Name { get; set; } + /// + /// Bilagsdato - the date of the transaction/document (e.g., invoice date) + /// + public DateOnly? DocumentDate { get; set; } + public string? Description { get; set; } + public string? FiscalYearId { get; set; } + public List Lines { get; set; } = []; + /// + /// References to attached documents (bilag) - required by Bogføringsloven § 6. + /// + public List? AttachmentIds { get; set; } +} + +public class DraftLineInputType : InputObjectGraphType +{ + public DraftLineInputType() + { + Name = "DraftLineInput"; + Description = "A single posting line in a journal entry draft"; + + Field(x => x.LineNumber).Description("Line number for ordering"); + Field(x => x.AccountId, nullable: true).Description("Account ID (may be null while editing)"); + Field(x => x.DebitAmount).Description("Debit amount"); + Field(x => x.CreditAmount).Description("Credit amount"); + Field(x => x.Description, nullable: true).Description("Line description"); + Field(x => x.VatCode, nullable: true).Description("VAT code (momskode) - e.g. U25, I25, IEUV"); + } +} + +public class DraftLineInput +{ + public int LineNumber { get; set; } + public string? AccountId { get; set; } + public decimal DebitAmount { get; set; } + public decimal CreditAmount { get; set; } + public string? Description { get; set; } + public string? VatCode { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/OrderInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/OrderInputTypes.cs new file mode 100644 index 0000000..69ebea6 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/OrderInputTypes.cs @@ -0,0 +1,174 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateOrderInputType : InputObjectGraphType +{ + public CreateOrderInputType() + { + Name = "CreateOrderInput"; + Description = "Input for creating a new order (Opret ordre)"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.FiscalYearId).Description("Fiscal year ID (required)"); + Field(x => x.CustomerId).Description("Customer ID (required)"); + Field(x => x.OrderDate, nullable: true).Description("Order date (defaults to today)"); + Field(x => x.ExpectedDeliveryDate, nullable: true).Description("Expected delivery date"); + Field(x => x.VatCode, nullable: true).Description("Default VAT code for lines (defaults to U25)"); + Field(x => x.Notes, nullable: true).Description("Internal notes"); + Field(x => x.Reference, nullable: true).Description("Customer reference"); + } +} + +public class CreateOrderInput +{ + public string CompanyId { get; set; } = string.Empty; + public string FiscalYearId { get; set; } = string.Empty; + public string CustomerId { get; set; } = string.Empty; + public DateTime? OrderDate { get; set; } + public DateTime? ExpectedDeliveryDate { get; set; } + public string? VatCode { get; set; } + public string? Notes { get; set; } + public string? Reference { get; set; } +} + +public class AddOrderLineInputType : InputObjectGraphType +{ + public AddOrderLineInputType() + { + Name = "AddOrderLineInput"; + Description = "Input for adding a line to an order (Tilføj ordrelinje)"; + + Field(x => x.OrderId).Description("Order ID (required)"); + Field(x => x.ProductId, nullable: true).Description("Product ID (optional, for linking to a product)"); + Field(x => x.Description).Description("Description of product/service (required)"); + Field(x => x.Quantity).Description("Quantity (required, must be positive)"); + Field(x => x.UnitPrice).Description("Unit price excluding VAT (required)"); + Field(x => x.VatCode, nullable: true).Description("VAT code (defaults to U25)"); + Field(x => x.AccountId, nullable: true).Description("Revenue account ID (defaults to customer's default)"); + Field(x => x.Unit, nullable: true).Description("Unit of measurement (e.g., stk, timer)"); + Field(x => x.DiscountPercent, nullable: true).Description("Discount percentage (0-100)"); + } +} + +public class AddOrderLineInput +{ + public string OrderId { get; set; } = string.Empty; + public string? ProductId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public string? VatCode { get; set; } + public string? AccountId { get; set; } + public string? Unit { get; set; } + public decimal? DiscountPercent { get; set; } +} + +public class UpdateOrderLineInputType : InputObjectGraphType +{ + public UpdateOrderLineInputType() + { + Name = "UpdateOrderLineInput"; + Description = "Input for updating a line on an order (Opdater ordrelinje)"; + + Field(x => x.OrderId).Description("Order ID (required)"); + Field(x => x.LineNumber).Description("Line number to update (required)"); + Field(x => x.ProductId, nullable: true).Description("Product ID (optional)"); + Field(x => x.Description).Description("Description of product/service (required)"); + Field(x => x.Quantity).Description("Quantity (required)"); + Field(x => x.UnitPrice).Description("Unit price excluding VAT (required)"); + Field(x => x.VatCode, nullable: true).Description("VAT code"); + Field(x => x.AccountId, nullable: true).Description("Revenue account ID"); + Field(x => x.Unit, nullable: true).Description("Unit of measurement"); + Field(x => x.DiscountPercent, nullable: true).Description("Discount percentage (0-100)"); + } +} + +public class UpdateOrderLineInput +{ + public string OrderId { get; set; } = string.Empty; + public int LineNumber { get; set; } + public string? ProductId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } + public string? VatCode { get; set; } + public string? AccountId { get; set; } + public string? Unit { get; set; } + public decimal? DiscountPercent { get; set; } +} + +public class RemoveOrderLineInputType : InputObjectGraphType +{ + public RemoveOrderLineInputType() + { + Name = "RemoveOrderLineInput"; + Description = "Input for removing a line from an order (Fjern ordrelinje)"; + + Field(x => x.OrderId).Description("Order ID (required)"); + Field(x => x.LineNumber).Description("Line number to remove (required)"); + } +} + +public class RemoveOrderLineInput +{ + public string OrderId { get; set; } = string.Empty; + public int LineNumber { get; set; } +} + +public class ConfirmOrderInputType : InputObjectGraphType +{ + public ConfirmOrderInputType() + { + Name = "ConfirmOrderInput"; + Description = "Input for confirming an order (Bekræft ordre)"; + + Field(x => x.OrderId).Description("Order ID (required)"); + } +} + +public class ConfirmOrderInput +{ + public string OrderId { get; set; } = string.Empty; +} + +public class InvoiceOrderLinesInputType : InputObjectGraphType +{ + public InvoiceOrderLinesInputType() + { + Name = "InvoiceOrderLinesInput"; + Description = "Input for invoicing order lines (Fakturer ordrelinjer)"; + + Field(x => x.OrderId).Description("Order ID (required)"); + Field>>>("lineNumbers") + .Description("Line numbers to include in the invoice (required, empty for all uninvoiced lines)"); + Field(x => x.InvoiceDate, nullable: true).Description("Invoice date (defaults to today)"); + Field(x => x.DueDate, nullable: true).Description("Due date (defaults based on payment terms)"); + } +} + +public class InvoiceOrderLinesInput +{ + public string OrderId { get; set; } = string.Empty; + public List LineNumbers { get; set; } = []; + public DateTime? InvoiceDate { get; set; } + public DateTime? DueDate { get; set; } +} + +public class CancelOrderInputType : InputObjectGraphType +{ + public CancelOrderInputType() + { + Name = "CancelOrderInput"; + Description = "Input for cancelling an order (Annuller ordre)"; + + Field(x => x.OrderId).Description("Order ID (required)"); + Field(x => x.Reason).Description("Reason for cancellation (required)"); + } +} + +public class CancelOrderInput +{ + public string OrderId { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; +} diff --git a/backend/Books.Api/GraphQL/InputTypes/PaymentMatchingInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/PaymentMatchingInputTypes.cs new file mode 100644 index 0000000..99a73a1 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/PaymentMatchingInputTypes.cs @@ -0,0 +1,25 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class ConfirmPaymentMatchInputType : InputObjectGraphType +{ + public ConfirmPaymentMatchInputType() + { + Name = "ConfirmPaymentMatchInput"; + Description = "Input for confirming a payment match"; + + Field(x => x.BankTransactionId).Description("Bank transaction ID (required)"); + Field(x => x.InvoiceId).Description("Invoice ID (required)"); + Field(x => x.Amount).Description("Payment amount (required)"); + Field(x => x.MatchMethod, nullable: true).Description("Match method: manual, auto_amount, auto_reference, auto_name (default: manual)"); + } +} + +public class ConfirmPaymentMatchInput +{ + public string BankTransactionId { get; set; } = string.Empty; + public string InvoiceId { get; set; } = string.Empty; + public decimal Amount { get; set; } + public string? MatchMethod { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/ProductInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/ProductInputTypes.cs new file mode 100644 index 0000000..43cd778 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/ProductInputTypes.cs @@ -0,0 +1,71 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class CreateProductInputType : InputObjectGraphType +{ + public CreateProductInputType() + { + Name = "CreateProductInput"; + Description = "Input for creating a new product"; + + Field(x => x.CompanyId).Description("Company ID (required)"); + Field(x => x.ProductNumber, nullable: true).Description("Product number/SKU (optional, must be unique if provided)"); + Field(x => x.Name).Description("Product name (required)"); + Field(x => x.Description, nullable: true).Description("Product description"); + Field(x => x.UnitPrice).Description("Unit price excl. VAT (required)"); + Field(x => x.VatCode).Description("VAT code (required, e.g., U25, IEUV)"); + Field(x => x.Unit, nullable: true).Description("Unit of measure (e.g., stk, time, kg)"); + Field(x => x.DefaultAccountId, nullable: true).Description("Default revenue account for this product"); + Field(x => x.Ean, nullable: true).Description("EAN/barcode number"); + Field(x => x.Manufacturer, nullable: true).Description("Manufacturer/brand name"); + } +} + +public class CreateProductInput +{ + public string CompanyId { get; set; } = string.Empty; + public string? ProductNumber { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal UnitPrice { get; set; } + public string VatCode { get; set; } = "U25"; + public string? Unit { get; set; } + public string? DefaultAccountId { get; set; } + public string? Ean { get; set; } + public string? Manufacturer { get; set; } +} + +public class UpdateProductInputType : InputObjectGraphType +{ + public UpdateProductInputType() + { + Name = "UpdateProductInput"; + Description = "Input for updating a product"; + + Field(x => x.Id).Description("Product ID (required)"); + Field(x => x.ProductNumber, nullable: true).Description("Product number/SKU (optional, must be unique if provided)"); + Field(x => x.Name).Description("Product name (required)"); + Field(x => x.Description, nullable: true).Description("Product description"); + Field(x => x.UnitPrice).Description("Unit price excl. VAT (required)"); + Field(x => x.VatCode).Description("VAT code (required, e.g., U25, IEUV)"); + Field(x => x.Unit, nullable: true).Description("Unit of measure (e.g., stk, time, kg)"); + Field(x => x.DefaultAccountId, nullable: true).Description("Default revenue account for this product"); + Field(x => x.Ean, nullable: true).Description("EAN/barcode number"); + Field(x => x.Manufacturer, nullable: true).Description("Manufacturer/brand name"); + } +} + +public class UpdateProductInput +{ + public string Id { get; set; } = string.Empty; + public string? ProductNumber { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal UnitPrice { get; set; } + public string VatCode { get; set; } = "U25"; + public string? Unit { get; set; } + public string? DefaultAccountId { get; set; } + public string? Ean { get; set; } + public string? Manufacturer { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/UpdateAccountInputType.cs b/backend/Books.Api/GraphQL/InputTypes/UpdateAccountInputType.cs new file mode 100644 index 0000000..64a5ea2 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/UpdateAccountInputType.cs @@ -0,0 +1,25 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class UpdateAccountInputType : InputObjectGraphType +{ + public UpdateAccountInputType() + { + Name = "UpdateAccountInput"; + Description = "Input for updating an existing account"; + + Field(x => x.Name).Description("Account name (required)"); + Field(x => x.ParentId, nullable: true).Description("Parent account ID for hierarchy"); + Field(x => x.Description, nullable: true).Description("Account description"); + Field(x => x.VatCodeId, nullable: true).Description("Default VAT code ID"); + } +} + +public class UpdateAccountInput +{ + public string Name { get; set; } = string.Empty; + public string? ParentId { get; set; } + public string? Description { get; set; } + public string? VatCodeId { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/UpdateCompanyBankDetailsInputType.cs b/backend/Books.Api/GraphQL/InputTypes/UpdateCompanyBankDetailsInputType.cs new file mode 100644 index 0000000..34c4591 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/UpdateCompanyBankDetailsInputType.cs @@ -0,0 +1,27 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class UpdateCompanyBankDetailsInputType : InputObjectGraphType +{ + public UpdateCompanyBankDetailsInputType() + { + Name = "UpdateCompanyBankDetailsInput"; + Description = "Input for updating company bank/payment details for invoices"; + + Field(x => x.BankName, nullable: true).Description("Bank name"); + Field(x => x.BankRegNo, nullable: true).Description("Danish bank registration number (4 digits)"); + Field(x => x.BankAccountNo, nullable: true).Description("Danish bank account number (7-10 digits)"); + Field(x => x.BankIban, nullable: true).Description("IBAN - International Bank Account Number"); + Field(x => x.BankBic, nullable: true).Description("BIC/SWIFT code"); + } +} + +public class UpdateCompanyBankDetailsInput +{ + public string? BankName { get; set; } + public string? BankRegNo { get; set; } + public string? BankAccountNo { get; set; } + public string? BankIban { get; set; } + public string? BankBic { get; set; } +} diff --git a/backend/Books.Api/GraphQL/InputTypes/UserCompanyAccessInputTypes.cs b/backend/Books.Api/GraphQL/InputTypes/UserCompanyAccessInputTypes.cs new file mode 100644 index 0000000..336d225 --- /dev/null +++ b/backend/Books.Api/GraphQL/InputTypes/UserCompanyAccessInputTypes.cs @@ -0,0 +1,65 @@ +using Books.Api.Domain.UserAccess; +using Books.Api.GraphQL.Types; +using GraphQL.Types; + +namespace Books.Api.GraphQL.InputTypes; + +public class GrantUserAccessInputType : InputObjectGraphType +{ + public GrantUserAccessInputType() + { + Name = "GrantUserAccessInput"; + Description = "Input for granting a user access to a company"; + + Field(x => x.UserId).Description("User ID (Keycloak ID or email)"); + Field(x => x.CompanyId).Description("Company ID to grant access to"); + Field>("role") + .Description("Role to grant (OWNER, ACCOUNTANT, VIEWER)"); + } +} + +public class GrantUserAccessInput +{ + public string UserId { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public CompanyRole Role { get; set; } +} + +public class ChangeUserAccessRoleInputType : InputObjectGraphType +{ + public ChangeUserAccessRoleInputType() + { + Name = "ChangeUserAccessRoleInput"; + Description = "Input for changing a user's role for a company"; + + Field(x => x.UserId).Description("User ID"); + Field(x => x.CompanyId).Description("Company ID"); + Field>("newRole") + .Description("New role to assign"); + } +} + +public class ChangeUserAccessRoleInput +{ + public string UserId { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public CompanyRole NewRole { get; set; } +} + +public class RevokeUserAccessInputType : InputObjectGraphType +{ + public RevokeUserAccessInputType() + { + Name = "RevokeUserAccessInput"; + Description = "Input for revoking a user's access to a company"; + + Field(x => x.UserId).Description("User ID"); + Field(x => x.CompanyId).Description("Company ID"); + } +} + +public class RevokeUserAccessInput +{ + public string UserId { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; +} diff --git a/backend/Books.Api/GraphQL/Scalars/DateOnlyGraphType.cs b/backend/Books.Api/GraphQL/Scalars/DateOnlyGraphType.cs new file mode 100644 index 0000000..1d36c00 --- /dev/null +++ b/backend/Books.Api/GraphQL/Scalars/DateOnlyGraphType.cs @@ -0,0 +1,54 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.Scalars; + +/// +/// GraphQL scalar type for DateOnly values. +/// Serializes as ISO 8601 date string (YYYY-MM-DD). +/// +public class DateOnlyGraphType : ScalarGraphType +{ + public DateOnlyGraphType() + { + Name = "Date"; + Description = "A date without time (ISO 8601 format: YYYY-MM-DD)"; + } + + public override object? ParseLiteral(GraphQLParser.AST.GraphQLValue value) + { + if (value is GraphQLParser.AST.GraphQLStringValue stringValue) + { + return ParseValue(stringValue.Value.ToString()); + } + return null; + } + + public override object? ParseValue(object? value) + { + if (value == null) return null; + + if (value is DateOnly dateOnly) + return dateOnly; + + if (value is string str && DateOnly.TryParse(str, out var parsed)) + return parsed; + + if (value is DateTime dt) + return DateOnly.FromDateTime(dt); + + return null; + } + + public override object? Serialize(object? value) + { + if (value == null) return null; + + if (value is DateOnly dateOnly) + return dateOnly.ToString("yyyy-MM-dd"); + + if (value is DateTime dt) + return DateOnly.FromDateTime(dt).ToString("yyyy-MM-dd"); + + return value.ToString(); + } +} diff --git a/backend/Books.Api/GraphQL/Types/AccountBalanceType.cs b/backend/Books.Api/GraphQL/Types/AccountBalanceType.cs new file mode 100644 index 0000000..64cacfb --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/AccountBalanceType.cs @@ -0,0 +1,51 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +/// +/// GraphQL type for account balance information within a period. +/// Combines account metadata with aggregated debit/credit totals from the Ledger service. +/// +public class AccountBalanceType : ObjectGraphType +{ + public AccountBalanceType() + { + Name = "AccountBalance"; + Description = "Account with balance information for a period"; + + Field(x => x.Id, type: typeof(IdGraphType)) + .Description("The account ID"); + Field(x => x.AccountNumber) + .Description("The account number (e.g., 1000, 56100)"); + Field(x => x.Name) + .Description("The account name"); + Field(x => x.AccountType) + .Description("The account type (Asset, Liability, Equity, Revenue, Expense, etc.)"); + Field(x => x.IsActive) + .Description("Whether the account is active"); + Field(x => x.TotalDebits) + .Description("Total debit amount for the period"); + Field(x => x.TotalCredits) + .Description("Total credit amount for the period"); + Field(x => x.NetChange) + .Description("Net change (debits - credits) for the period"); + Field(x => x.EntryCount) + .Description("Number of journal entries affecting this account in the period"); + } +} + +/// +/// DTO for account balance data returned by the accountBalances query. +/// +public class AccountBalanceDto +{ + public string Id { get; set; } = ""; + public string AccountNumber { get; set; } = ""; + public string Name { get; set; } = ""; + public string AccountType { get; set; } = ""; + public bool IsActive { get; set; } + public decimal TotalDebits { get; set; } + public decimal TotalCredits { get; set; } + public decimal NetChange { get; set; } + public int EntryCount { get; set; } +} diff --git a/backend/Books.Api/GraphQL/Types/AccountType.cs b/backend/Books.Api/GraphQL/Types/AccountType.cs new file mode 100644 index 0000000..aab65d6 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/AccountType.cs @@ -0,0 +1,27 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class AccountGraphType : ObjectGraphType +{ + public AccountGraphType() + { + Name = "Account"; + Description = "An account in the chart of accounts (kontoplan)"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this account belongs to"); + Field(x => x.AccountNumber).Description("Account number (4-10 digits)"); + Field(x => x.Name).Description("Account name"); + Field(x => x.AccountType).Description("Type of account (asset, liability, equity, revenue, etc.)"); + Field(x => x.ParentId, nullable: true).Description("Parent account ID for hierarchical structure"); + Field(x => x.Description, nullable: true).Description("Account description"); + Field(x => x.VatCodeId, nullable: true).Description("Default VAT code for this account"); + Field(x => x.IsActive).Description("Whether the account is active"); + Field(x => x.IsSystemAccount).Description("Whether this is a system account (cannot be modified)"); + Field(x => x.StandardAccountNumber, nullable: true).Description("Erhvervsstyrelsens standardkontonummer for SAF-T rapportering"); + Field(x => x.CreatedAt).Description("When the account was created"); + Field(x => x.UpdatedAt).Description("When the account was last updated"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/AccountTypeEnumType.cs b/backend/Books.Api/GraphQL/Types/AccountTypeEnumType.cs new file mode 100644 index 0000000..d7f835a --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/AccountTypeEnumType.cs @@ -0,0 +1,23 @@ +using Books.Api.Domain.Accounts; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class AccountTypeEnumType : EnumerationGraphType +{ + public AccountTypeEnumType() + { + Name = "AccountType"; + Description = "Type of account in the chart of accounts (Danish Standardkontoplan)"; + + Add("ASSET", AccountType.Asset, "Aktiver (1000-1999)"); + Add("LIABILITY", AccountType.Liability, "Passiver (2000-2999)"); + Add("EQUITY", AccountType.Equity, "Egenkapital (3000-3999)"); + Add("REVENUE", AccountType.Revenue, "Omsaetning (4000-4999)"); + Add("COGS", AccountType.Cogs, "Vareforbrug (5000-5999)"); + Add("EXPENSE", AccountType.Expense, "Driftsomkostninger (6000-6999)"); + Add("PERSONNEL", AccountType.Personnel, "Personaleomkostninger (7000-7999)"); + Add("FINANCIAL", AccountType.Financial, "Finansielle poster (8000-8999)"); + Add("EXTRAORDINARY", AccountType.Extraordinary, "Ekstraordinaere poster (9000-9999)"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/AttachmentType.cs b/backend/Books.Api/GraphQL/Types/AttachmentType.cs new file mode 100644 index 0000000..6777fd3 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/AttachmentType.cs @@ -0,0 +1,37 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.Infrastructure.FileStorage; +using GraphQL.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace Books.Api.GraphQL.Types; + +public class AttachmentGraphType : ObjectGraphType +{ + public AttachmentGraphType() + { + Name = "Attachment"; + Description = "A document attachment (bilag) - required by Bogføringsloven § 6"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this attachment belongs to"); + Field(x => x.FileName).Description("Stored filename"); + Field(x => x.OriginalFileName).Description("Original filename as uploaded"); + Field(x => x.ContentType).Description("MIME type (e.g., application/pdf)"); + Field(x => x.FileSize).Description("File size in bytes"); + Field(x => x.UploadedBy).Description("User who uploaded the attachment"); + Field(x => x.UploadedAt).Description("When the attachment was uploaded"); + Field(x => x.DraftId, nullable: true).Description("Reference to journal entry draft (before posting)"); + Field(x => x.TransactionId, nullable: true).Description("Reference to posted transaction (after posting)"); + Field(x => x.RetentionEndDate).Description("5-year retention period end date (Bogføringsloven § 6)"); + Field(x => x.IsDeleted).Description("Whether the attachment has been deleted"); + + // Download URL is dynamically generated + Field>("url") + .Description("URL to download the attachment") + .Resolve(ctx => + { + var fileStorage = ctx.RequestServices!.GetRequiredService(); + return fileStorage.GetDownloadUrl(ctx.Source.StoragePath); + }); + } +} diff --git a/backend/Books.Api/GraphQL/Types/BankConnectionType.cs b/backend/Books.Api/GraphQL/Types/BankConnectionType.cs new file mode 100644 index 0000000..54c7afe --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/BankConnectionType.cs @@ -0,0 +1,92 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class BankConnectionType : ObjectGraphType +{ + public BankConnectionType() + { + Name = "BankConnection"; + Description = "A bank connection for open banking access"; + + Field(x => x.Id).Description("The bank connection ID"); + Field(x => x.CompanyId).Description("The company ID"); + Field(x => x.AspspName).Description("The bank name (ASPSP)"); + Field(x => x.Status).Description("Connection status: initiated, established, failed, disconnected"); + Field(x => x.ValidUntil, nullable: true).Description("When the connection expires"); + Field(x => x.FailureReason, nullable: true).Description("Failure reason if status is failed"); + Field(x => x.CreatedAt).Description("When the connection was initiated"); + Field(x => x.UpdatedAt).Description("When the connection was last updated"); + Field(x => x.IsActive).Description("Whether the connection is currently active"); + Field(x => x.IsArchived).Description("Whether this connection is archived"); + + Field>("accounts") + .Description("Available bank accounts from this connection") + .Resolve(ctx => ctx.Source.Accounts); + } +} + +public class BankAccountInfoType : ObjectGraphType +{ + public BankAccountInfoType() + { + Name = "BankAccountInfo"; + Description = "Information about a bank account from Open Banking"; + + Field(x => x.AccountId).Description("The bank's account identifier"); + Field(x => x.Iban).Description("The IBAN number"); + Field(x => x.Currency).Description("The account currency"); + Field(x => x.Name, nullable: true).Description("The account name"); + Field(x => x.LinkedAccountId, nullable: true).Description("The linked chart of accounts account ID"); + Field(x => x.ImportFromDate, nullable: true).Description("Date to start importing transactions from"); + + Field("linkedAccount") + .Description("The linked chart of accounts account") + .ResolveAsync(async ctx => + { + if (string.IsNullOrEmpty(ctx.Source.LinkedAccountId)) + return null; + + var accountRepo = ctx.RequestServices!.GetRequiredService(); + return await accountRepo.GetByIdAsync(ctx.Source.LinkedAccountId, ctx.CancellationToken); + }); + } +} + +public class AspspType : ObjectGraphType +{ + public AspspType() + { + Name = "Aspsp"; + Description = "A bank (ASPSP) available for connection via Open Banking"; + + Field(x => x.Name).Description("The bank name"); + Field(x => x.Country).Description("The country code"); + Field(x => x.Logo).Description("URL to the bank logo"); + Field>("psuTypes") + .Description("Supported PSU types (personal, business)") + .Resolve(ctx => ctx.Source.PsuTypes); + Field(x => x.PersonalAccounts).Description("Whether personal accounts are supported"); + Field(x => x.BusinessAccounts).Description("Whether business accounts are supported"); + } +} + +public class StartBankConnectionResultType : ObjectGraphType +{ + public StartBankConnectionResultType() + { + Name = "StartBankConnectionResult"; + Description = "Result from starting a bank connection"; + + Field(x => x.ConnectionId).Description("The bank connection ID"); + Field(x => x.AuthorizationUrl).Description("URL to redirect the user for bank authorization"); + } +} + +public class StartBankConnectionResult +{ + public string ConnectionId { get; set; } = string.Empty; + public string AuthorizationUrl { get; set; } = string.Empty; +} diff --git a/backend/Books.Api/GraphQL/Types/BankTransactionType.cs b/backend/Books.Api/GraphQL/Types/BankTransactionType.cs new file mode 100644 index 0000000..77788b7 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/BankTransactionType.cs @@ -0,0 +1,113 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +/// +/// GraphQL type for bank transactions synced from Enable Banking. +/// Used in Hurtig Bogføring for quick booking of transactions. +/// +public class BankTransactionType : ObjectGraphType +{ + public BankTransactionType() + { + Name = "BankTransaction"; + Description = "A bank transaction from Enable Banking"; + + Field(x => x.Id, type: typeof(IdGraphType)) + .Description("The transaction ID"); + + Field(x => x.CompanyId) + .Description("The company ID"); + + Field(x => x.BankConnectionId) + .Description("The bank connection ID"); + + Field(x => x.BankAccountId) + .Description("The bank account ID from Enable Banking"); + + Field(x => x.ExternalId) + .Description("External transaction ID from the bank (for deduplication)"); + + Field(x => x.Amount) + .Description("Transaction amount (positive = credit, negative = debit)"); + + Field(x => x.Currency) + .Description("Currency code (e.g., DKK)"); + + Field(x => x.TransactionDate, type: typeof(DateTimeGraphType)) + .Description("The transaction date"); + + Field(x => x.BookingDate, nullable: true) + .Description("The booking date (when the bank processed it)"); + + Field(x => x.ValueDate, nullable: true) + .Description("The value date"); + + Field(x => x.Description, nullable: true) + .Description("Transaction description/remittance information"); + + Field(x => x.CounterpartyName, nullable: true) + .Description("Name of the counterparty"); + + Field(x => x.CounterpartyAccount, nullable: true) + .Description("Account number of the counterparty"); + + Field(x => x.Reference, nullable: true) + .Description("Payment reference (end-to-end ID)"); + + Field(x => x.CreditorName, nullable: true) + .Description("Creditor name (for outgoing payments)"); + + Field(x => x.DebtorName, nullable: true) + .Description("Debtor name (for incoming payments)"); + + Field(x => x.Status) + .Description("Status: pending | booked | ignored"); + + Field(x => x.JournalEntryDraftId, nullable: true) + .Description("Reference to journal entry draft when booked"); + + Field(x => x.CreatedAt, type: typeof(DateTimeGraphType)) + .Description("When the transaction was synced"); + + Field(x => x.UpdatedAt, type: typeof(DateTimeGraphType)) + .Description("When the transaction was last updated"); + + Field(x => x.DisplayCounterparty) + .Description("Display name for the counterparty"); + + Field(x => x.IsIncome) + .Description("True if this is an income (positive amount)"); + } +} + +/// +/// GraphQL type for sync result. +/// +public class BankTransactionSyncResultType : ObjectGraphType +{ + public BankTransactionSyncResultType() + { + Name = "BankTransactionSyncResult"; + Description = "Result of a bank transaction sync operation"; + + Field(x => x.TotalConnections) + .Description("Number of bank connections processed"); + + Field(x => x.TotalAccounts) + .Description("Number of bank accounts processed"); + + Field(x => x.NewTransactions) + .Description("Number of new transactions synced"); + + Field(x => x.SkippedDuplicates) + .Description("Number of duplicate transactions skipped"); + + Field(x => x.Errors) + .Description("Number of errors encountered"); + + Field(x => x.ErrorMessages, type: typeof(ListGraphType)) + .Description("Error messages if any"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/CustomerType.cs b/backend/Books.Api/GraphQL/Types/CustomerType.cs new file mode 100644 index 0000000..2c70023 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/CustomerType.cs @@ -0,0 +1,41 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class CustomerGraphType : ObjectGraphType +{ + public CustomerGraphType() + { + Name = "Customer"; + Description = "A customer for invoicing (B2B or B2C)"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this customer belongs to"); + Field(x => x.CustomerNumber).Description("Customer number (e.g., 0001)"); + Field(x => x.CustomerType).Description("Type: Business (B2B) or Private (B2C)"); + Field(x => x.Name).Description("Customer name"); + Field(x => x.Cvr, nullable: true).Description("CVR number (required for Business customers)"); + Field(x => x.Address, nullable: true).Description("Street address"); + Field(x => x.PostalCode, nullable: true).Description("Postal code"); + Field(x => x.City, nullable: true).Description("City"); + Field(x => x.Country).Description("Country code (ISO 2-letter, default: DK)"); + Field(x => x.Email, nullable: true).Description("Email address"); + Field(x => x.Phone, nullable: true).Description("Phone number"); + Field(x => x.PaymentTermsDays).Description("Payment terms in days (default: 30)"); + Field(x => x.DefaultRevenueAccountId, nullable: true).Description("Default revenue account for invoices"); + Field(x => x.SubLedgerAccountId).Description("Sub-ledger account ID (1900-XXXX)"); + Field(x => x.IsActive).Description("Whether the customer is active"); + Field(x => x.CreatedAt).Description("When the customer was created"); + Field(x => x.UpdatedAt).Description("When the customer was last updated"); + } +} + +public class CustomerTypeEnumType : EnumerationGraphType +{ + public CustomerTypeEnumType() + { + Name = "CustomerType"; + Description = "Type of customer"; + } +} diff --git a/backend/Books.Api/GraphQL/Types/FiscalYearStatusEnumType.cs b/backend/Books.Api/GraphQL/Types/FiscalYearStatusEnumType.cs new file mode 100644 index 0000000..7bea397 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/FiscalYearStatusEnumType.cs @@ -0,0 +1,17 @@ +using Books.Api.Domain.FiscalYears; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class FiscalYearStatusEnumType : EnumerationGraphType +{ + public FiscalYearStatusEnumType() + { + Name = "FiscalYearStatus"; + Description = "Status of a fiscal year"; + + Add("OPEN", FiscalYearStatus.Open, "Fiscal year is open for transactions"); + Add("CLOSED", FiscalYearStatus.Closed, "Fiscal year is closed (can be reopened)"); + Add("LOCKED", FiscalYearStatus.Locked, "Fiscal year is permanently locked"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/FiscalYearType.cs b/backend/Books.Api/GraphQL/Types/FiscalYearType.cs new file mode 100644 index 0000000..c337d2d --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/FiscalYearType.cs @@ -0,0 +1,29 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class FiscalYearGraphType : ObjectGraphType +{ + public FiscalYearGraphType() + { + Name = "FiscalYear"; + Description = "A fiscal year (regnskabsaar)"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this fiscal year belongs to"); + Field(x => x.Name).Description("Fiscal year name (e.g., '2024' or '2024/2025')"); + Field(x => x.StartDate).Description("First day of the fiscal year"); + Field(x => x.EndDate).Description("Last day of the fiscal year"); + Field(x => x.Status).Description("Current status (open, closed, locked)"); + Field(x => x.OpeningBalancePosted).Description("Whether opening balance has been posted"); + Field(x => x.ClosingDate, nullable: true).Description("When the fiscal year was closed"); + Field(x => x.ClosedBy, nullable: true).Description("User who closed the fiscal year"); + Field(x => x.ReopenedDate, nullable: true).Description("When the fiscal year was reopened"); + Field(x => x.ReopenedBy, nullable: true).Description("User who reopened the fiscal year"); + Field(x => x.LockedDate, nullable: true).Description("When the fiscal year was locked"); + Field(x => x.LockedBy, nullable: true).Description("User who locked the fiscal year"); + Field(x => x.CreatedAt).Description("When the fiscal year was created"); + Field(x => x.UpdatedAt).Description("When the fiscal year was last updated"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/InvoicePdfResultType.cs b/backend/Books.Api/GraphQL/Types/InvoicePdfResultType.cs new file mode 100644 index 0000000..4033247 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/InvoicePdfResultType.cs @@ -0,0 +1,28 @@ +using Books.Api.Invoicing.Pdf; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +/// +/// GraphQL type for invoice PDF generation result. +/// +public class InvoicePdfResultGraphType : ObjectGraphType +{ + public InvoicePdfResultGraphType() + { + Name = "InvoicePdfResult"; + Description = "Result of invoice PDF generation"; + + Field(x => x.Success).Description("Whether the PDF was generated successfully"); + + Field("pdfBase64") + .Description("Base64-encoded PDF bytes (null if failed)") + .Resolve(ctx => ctx.Source.PdfBytes != null + ? Convert.ToBase64String(ctx.Source.PdfBytes) + : null); + + Field(x => x.FileName, nullable: true).Description("Suggested filename for the PDF"); + Field(x => x.ErrorCode, nullable: true).Description("Error code if generation failed"); + Field(x => x.ErrorMessage, nullable: true).Description("Error message if generation failed"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/InvoiceType.cs b/backend/Books.Api/GraphQL/Types/InvoiceType.cs new file mode 100644 index 0000000..08a5009 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/InvoiceType.cs @@ -0,0 +1,116 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class InvoiceGraphType : ObjectGraphType +{ + public InvoiceGraphType() + { + Name = "Invoice"; + Description = "An invoice or credit note for a customer"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this invoice belongs to"); + Field(x => x.FiscalYearId, nullable: true).Description("Fiscal year for this invoice"); + Field(x => x.CustomerId).Description("Customer ID"); + Field(x => x.CustomerName).Description("Customer name at time of invoice creation"); + Field(x => x.CustomerNumber).Description("Customer number"); + Field(x => x.InvoiceNumber).Description("Invoice/credit note number (Momsloven §52 compliant)"); + + // Document type fields + Field(x => x.InvoiceType).Description("Document type: invoice or credit_note"); + Field(x => x.IsCreditNote).Description("True if this is a credit note"); + Field(x => x.OriginalInvoiceId, nullable: true).Description("For credit notes: ID of the original invoice"); + Field(x => x.OriginalInvoiceNumber, nullable: true).Description("For credit notes: number of the original invoice"); + Field(x => x.CreditReason, nullable: true).Description("For credit notes: reason for issuing the credit"); + + Field("invoiceDate") + .Description("Invoice/credit note date") + .Resolve(ctx => ctx.Source.InvoiceDate.HasValue + ? DateOnly.FromDateTime(ctx.Source.InvoiceDate.Value) + : null); + Field("dueDate") + .Description("Payment due date (not applicable to credit notes)") + .Resolve(ctx => ctx.Source.DueDate.HasValue + ? DateOnly.FromDateTime(ctx.Source.DueDate.Value) + : null); + Field(x => x.Status).Description("Status: draft, sent, issued, partially_paid, partially_applied, paid, fully_applied, voided"); + Field(x => x.AmountExVat).Description("Total amount excluding VAT (negative for credit notes)"); + Field(x => x.AmountVat).Description("Total VAT amount (negative for credit notes)"); + Field(x => x.AmountTotal).Description("Total amount including VAT (negative for credit notes)"); + Field(x => x.AmountPaid).Description("Amount paid so far (for invoices)"); + Field(x => x.AmountApplied).Description("Amount applied so far (for credit notes)"); + Field(x => x.AmountRemaining).Description("Remaining amount to pay/apply"); + Field(x => x.Currency).Description("Currency code (default: DKK)"); + Field(x => x.VatCode, nullable: true).Description("Default VAT code for this invoice"); + Field(x => x.PaymentTermsDays).Description("Payment terms in days"); + Field>("lines") + .Description("Invoice lines") + .Resolve(ctx => ctx.Source.GetLines()); + Field(x => x.LedgerTransactionId, nullable: true).Description("Ledger transaction ID when sent/issued"); + Field(x => x.Notes, nullable: true).Description("Internal notes"); + Field(x => x.Reference, nullable: true).Description("Customer reference"); + Field(x => x.SentAt, nullable: true).Description("When the invoice was sent"); + Field(x => x.IssuedAt, nullable: true).Description("When the credit note was issued"); + Field(x => x.PaidAt, nullable: true).Description("When the invoice was fully paid"); + Field(x => x.VoidedAt, nullable: true).Description("When the invoice was voided"); + Field(x => x.VoidedReason, nullable: true).Description("Reason for voiding"); + Field(x => x.VoidedBy, nullable: true).Description("User who voided the invoice"); + Field(x => x.CreatedBy).Description("User who created the invoice"); + Field(x => x.UpdatedBy, nullable: true).Description("User who last updated the invoice"); + Field(x => x.CreatedAt).Description("When the invoice was created"); + Field(x => x.UpdatedAt).Description("When the invoice was last updated"); + } +} + +public class InvoiceLineGraphType : ObjectGraphType +{ + public InvoiceLineGraphType() + { + Name = "InvoiceLine"; + Description = "A line on an invoice"; + + Field(x => x.LineNumber).Description("Line number (1-based)"); + Field(x => x.Description).Description("Description of product/service"); + Field(x => x.Quantity).Description("Quantity"); + Field(x => x.Unit, nullable: true).Description("Unit of measurement"); + Field(x => x.UnitPrice).Description("Price per unit excluding VAT"); + Field(x => x.DiscountPercent).Description("Discount percentage (0-100)"); + Field(x => x.VatCode).Description("VAT code (e.g., U25, UEU, UEXP)"); + Field(x => x.AccountId, nullable: true).Description("Revenue account ID"); + Field(x => x.AmountExVat).Description("Line amount excluding VAT"); + Field(x => x.AmountVat).Description("Line VAT amount"); + Field(x => x.AmountTotal).Description("Line total including VAT"); + } +} + +public class InvoiceStatusEnumType : EnumerationGraphType +{ + public InvoiceStatusEnumType() + { + Name = "InvoiceStatus"; + Description = "Status of an invoice or credit note"; + + Add("DRAFT", "draft", "Invoice/credit note is being prepared"); + Add("SENT", "sent", "Invoice has been sent and posted to ledger"); + Add("ISSUED", "issued", "Credit note has been issued and posted to ledger"); + Add("PARTIALLY_PAID", "partially_paid", "Partial payment received (invoice)"); + Add("PARTIALLY_APPLIED", "partially_applied", "Partial credit applied (credit note)"); + Add("PAID", "paid", "Invoice is fully paid"); + Add("FULLY_APPLIED", "fully_applied", "Credit note is fully applied"); + Add("VOIDED", "voided", "Invoice/credit note has been cancelled"); + } +} + +public class InvoiceTypeEnumType : EnumerationGraphType +{ + public InvoiceTypeEnumType() + { + Name = "InvoiceDocumentType"; + Description = "Type of invoice document"; + + Add("INVOICE", "invoice", "Regular invoice with positive amounts"); + Add("CREDIT_NOTE", "credit_note", "Credit note with negative amounts"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/JournalEntryDraftType.cs b/backend/Books.Api/GraphQL/Types/JournalEntryDraftType.cs new file mode 100644 index 0000000..a0298d4 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/JournalEntryDraftType.cs @@ -0,0 +1,54 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class JournalEntryDraftType : ObjectGraphType +{ + public JournalEntryDraftType() + { + Name = "JournalEntryDraft"; + Description = "A journal entry draft (kassekladde) - work in progress before posting"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this draft belongs to"); + Field(x => x.Name).Description("Draft name"); + Field(x => x.VoucherNumber).Description("Bilagsnummer - unique document number (required by Bogføringsloven § 7)"); + Field(x => x.DocumentDate, nullable: true).Description("Bilagsdato - the document/transaction date (e.g., invoice date)"); + Field(x => x.Description, nullable: true).Description("Entry description"); + Field(x => x.FiscalYearId, nullable: true).Description("Fiscal year ID"); + Field(x => x.Status).Description("Draft status: active, posted, or discarded"); + Field(x => x.TransactionId, nullable: true).Description("Transaction ID after posting"); + Field(x => x.CreatedBy).Description("User who created the draft"); + Field(x => x.CreatedAt).Description("When the draft was created"); + Field(x => x.UpdatedAt).Description("When the draft was last updated"); + + Field>>>("lines") + .Description("The posting lines with VAT codes") + .Resolve(ctx => ctx.Source.GetLines()); + + Field>>>("attachmentIds") + .Description("References to attached documents (bilag - required by Bogføringsloven § 6)") + .Resolve(ctx => ctx.Source.GetAttachmentIds()); + + Field("extractionData") + .Description("Full AI extraction data as JSON (vendor CVR, amounts, VAT, due date, payment reference, line items)") + .Resolve(ctx => ctx.Source.ExtractionData); + } +} + +public class DraftLineType : ObjectGraphType +{ + public DraftLineType() + { + Name = "DraftLine"; + Description = "A single posting line in a journal entry draft"; + + Field(x => x.LineNumber).Description("Line number for ordering"); + Field(x => x.AccountId, nullable: true).Description("Account ID"); + Field(x => x.DebitAmount).Description("Debit amount"); + Field(x => x.CreditAmount).Description("Credit amount"); + Field(x => x.Description, nullable: true).Description("Line description"); + Field(x => x.VatCode, nullable: true).Description("VAT code (momskode) - e.g. U25, I25, IEUV for SKAT compliance"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/JournalEntryResultType.cs b/backend/Books.Api/GraphQL/Types/JournalEntryResultType.cs new file mode 100644 index 0000000..1864462 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/JournalEntryResultType.cs @@ -0,0 +1,23 @@ +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class JournalEntryResultType : ObjectGraphType +{ + public JournalEntryResultType() + { + Name = "JournalEntryResult"; + Description = "Result of creating a journal entry"; + + Field(x => x.TransactionId).Description("The Ledger transaction ID"); + Field(x => x.End2EndId).Description("End-to-end tracking ID"); + Field(x => x.IdempotencyKey).Description("Idempotency key used"); + } +} + +public class JournalEntryResult +{ + public Guid TransactionId { get; set; } + public string End2EndId { get; set; } = string.Empty; + public string IdempotencyKey { get; set; } = string.Empty; +} diff --git a/backend/Books.Api/GraphQL/Types/OrderType.cs b/backend/Books.Api/GraphQL/Types/OrderType.cs new file mode 100644 index 0000000..a063ca8 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/OrderType.cs @@ -0,0 +1,113 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class OrderGraphType : ObjectGraphType +{ + public OrderGraphType() + { + Name = "Order"; + Description = "An order for a customer (Ordre)"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this order belongs to"); + Field(x => x.FiscalYearId).Description("Fiscal year for this order"); + Field(x => x.CustomerId).Description("Customer ID"); + Field(x => x.CustomerName).Description("Customer name at time of order creation"); + Field(x => x.CustomerNumber).Description("Customer number (Kundenummer)"); + Field(x => x.OrderNumber).Description("Order number (Ordrenummer)"); + + Field("orderDate") + .Description("Order date (Ordredato)") + .Resolve(ctx => ctx.Source.OrderDate.HasValue + ? DateOnly.FromDateTime(ctx.Source.OrderDate.Value) + : null); + + Field("expectedDeliveryDate") + .Description("Expected delivery date (Forventet leveringsdato)") + .Resolve(ctx => ctx.Source.ExpectedDeliveryDate.HasValue + ? DateOnly.FromDateTime(ctx.Source.ExpectedDeliveryDate.Value) + : null); + + Field(x => x.Status).Description("Status: draft, confirmed, partially_invoiced, fully_invoiced, cancelled"); + Field(x => x.AmountExVat).Description("Total amount excluding VAT (Beløb eks. moms)"); + Field(x => x.AmountVat).Description("Total VAT amount (Momsbeløb)"); + Field(x => x.AmountTotal).Description("Total amount including VAT (Beløb inkl. moms)"); + Field(x => x.Currency).Description("Currency code (default: DKK)"); + + Field>("lines") + .Description("Order lines (Ordrelinjer)") + .Resolve(ctx => ctx.Source.GetLines()); + + Field>("uninvoicedLines") + .Description("Order lines not yet invoiced (Ikke-fakturerede ordrelinjer)") + .Resolve(ctx => ctx.Source.GetUninvoicedLines()); + + Field("uninvoicedLineCount") + .Description("Count of lines not yet invoiced") + .Resolve(ctx => ctx.Source.UninvoicedLineCount); + + Field("uninvoicedAmount") + .Description("Total amount of uninvoiced lines") + .Resolve(ctx => ctx.Source.UninvoicedAmount); + + Field(x => x.InvoiceId, nullable: true).Description("Most recent invoice ID (when converted)"); + Field(x => x.InvoiceNumber, nullable: true).Description("Most recent invoice number (when converted)"); + Field(x => x.Notes, nullable: true).Description("Internal notes (Interne noter)"); + Field(x => x.Reference, nullable: true).Description("Customer reference (Kundens reference)"); + + Field(x => x.ConfirmedAt, nullable: true).Description("When the order was confirmed"); + Field(x => x.ConfirmedBy, nullable: true).Description("User who confirmed the order"); + Field(x => x.InvoicedAt, nullable: true).Description("When the order was last invoiced"); + Field(x => x.InvoicedBy, nullable: true).Description("User who last invoiced the order"); + Field(x => x.CancelledAt, nullable: true).Description("When the order was cancelled"); + Field(x => x.CancelledBy, nullable: true).Description("User who cancelled the order"); + Field(x => x.CancelledReason, nullable: true).Description("Reason for cancellation"); + + Field(x => x.CreatedBy).Description("User who created the order"); + Field(x => x.UpdatedBy, nullable: true).Description("User who last updated the order"); + Field(x => x.CreatedAt).Description("When the order was created"); + Field(x => x.UpdatedAt).Description("When the order was last updated"); + } +} + +public class OrderLineGraphType : ObjectGraphType +{ + public OrderLineGraphType() + { + Name = "OrderLine"; + Description = "A line on an order (Ordrelinje)"; + + Field(x => x.LineNumber).Description("Line number (1-based)"); + Field(x => x.ProductId, nullable: true).Description("Product ID if linked to a product"); + Field(x => x.Description).Description("Description of product/service (Beskrivelse)"); + Field(x => x.Quantity).Description("Quantity (Antal)"); + Field(x => x.Unit, nullable: true).Description("Unit of measurement (Enhed)"); + Field(x => x.UnitPrice).Description("Price per unit excluding VAT (Stykpris)"); + Field(x => x.DiscountPercent).Description("Discount percentage (Rabat %)"); + Field(x => x.VatCode).Description("VAT code (e.g., U25, UEU, UEXP)"); + Field(x => x.AccountId, nullable: true).Description("Revenue account ID"); + Field(x => x.AmountExVat).Description("Line amount excluding VAT"); + Field(x => x.AmountVat).Description("Line VAT amount"); + Field(x => x.AmountTotal).Description("Line total including VAT"); + Field(x => x.IsInvoiced).Description("Whether this line has been invoiced"); + Field(x => x.InvoiceId, nullable: true).Description("Invoice ID where this line was invoiced"); + Field(x => x.InvoicedAt, nullable: true).Description("When this line was invoiced"); + } +} + +public class OrderStatusEnumType : EnumerationGraphType +{ + public OrderStatusEnumType() + { + Name = "OrderStatus"; + Description = "Status of an order (Ordrestatus)"; + + Add("DRAFT", "draft", "Order is being prepared (Kladde)"); + Add("CONFIRMED", "confirmed", "Order has been confirmed and ready for invoicing (Bekræftet)"); + Add("PARTIALLY_INVOICED", "partially_invoiced", "Some lines have been invoiced (Delvist faktureret)"); + Add("FULLY_INVOICED", "fully_invoiced", "All lines have been invoiced (Fuldt faktureret)"); + Add("CANCELLED", "cancelled", "Order has been cancelled (Annulleret)"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/PaymentMatchingTypes.cs b/backend/Books.Api/GraphQL/Types/PaymentMatchingTypes.cs new file mode 100644 index 0000000..d89f153 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/PaymentMatchingTypes.cs @@ -0,0 +1,96 @@ +using Books.Api.Invoicing.Services; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class SuggestedPaymentMatchType : ObjectGraphType +{ + public SuggestedPaymentMatchType() + { + Name = "SuggestedPaymentMatch"; + Description = "A suggested match between a bank transaction and an invoice"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company ID"); + Field(x => x.BankTransactionId).Description("Bank transaction ID"); + Field(x => x.InvoiceId).Description("Invoice ID"); + + // Bank transaction details + Field(x => x.BankTransactionAmount).Description("Bank transaction amount"); + Field("bankTransactionDate") + .Description("Bank transaction date") + .Resolve(ctx => ctx.Source.BankTransactionDate); + Field(x => x.BankTransactionDescription, nullable: true).Description("Bank transaction description"); + Field(x => x.BankTransactionCounterparty, nullable: true).Description("Bank transaction counterparty name"); + + // Invoice details + Field(x => x.InvoiceNumber).Description("Invoice number"); + Field(x => x.CustomerName).Description("Customer name"); + Field(x => x.InvoiceAmountRemaining).Description("Remaining amount on invoice"); + Field("invoiceDueDate") + .Description("Invoice due date") + .Resolve(ctx => ctx.Source.InvoiceDueDate); + + // Match scoring + Field(x => x.Confidence).Description("Match confidence score (0.0 - 1.0)"); + Field(x => x.SuggestedAmount).Description("Suggested payment amount"); + Field>("matchReasons") + .Description("Reasons for the suggested match") + .Resolve(ctx => ctx.Source.MatchReasons); + + // Status + Field(x => x.Status).Description("Match status: pending, accepted, rejected, expired"); + Field(x => x.CreatedAt).Description("When the suggestion was created"); + } +} + +public class MatchReasonType : ObjectGraphType +{ + public MatchReasonType() + { + Name = "MatchReason"; + Description = "A reason why a match was suggested"; + + Field(x => x.Reason).Description("Reason code: exact_amount, close_amount, reference_match, name_match, recent_invoice"); + Field(x => x.Description).Description("Human-readable description (in Danish)"); + Field(x => x.Score).Description("Contribution to the confidence score"); + } +} + +public class PaymentAllocationType : ObjectGraphType +{ + public PaymentAllocationType() + { + Name = "PaymentAllocation"; + Description = "A confirmed match between a bank transaction and an invoice"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company ID"); + Field(x => x.BankTransactionId).Description("Bank transaction ID"); + Field(x => x.InvoiceId, nullable: true).Description("Invoice ID"); + Field(x => x.CreditNoteId, nullable: true).Description("Credit note ID"); + Field(x => x.Amount).Description("Allocated amount"); + Field(x => x.AllocationType).Description("Type: payment, credit_note, refund"); + Field(x => x.MatchMethod).Description("How the match was made: manual, auto_amount, auto_reference, auto_name"); + Field(x => x.MatchConfidence, nullable: true).Description("Confidence score when match was made"); + Field(x => x.LedgerTransactionId, nullable: true).Description("Ledger transaction ID"); + Field(x => x.CreatedAt).Description("When the allocation was created"); + Field(x => x.CreatedBy).Description("User who created the allocation"); + } +} + +public class PaymentAllocationResultType : ObjectGraphType +{ + public PaymentAllocationResultType() + { + Name = "PaymentAllocationResult"; + Description = "Result of confirming a payment match"; + + Field(x => x.Success).Description("Whether the operation succeeded"); + Field("allocation") + .Description("The created allocation (if successful)") + .Resolve(ctx => ctx.Source.Allocation); + Field(x => x.ErrorCode, nullable: true).Description("Error code (if failed)"); + Field(x => x.ErrorMessage, nullable: true).Description("Error message (if failed)"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/ProductType.cs b/backend/Books.Api/GraphQL/Types/ProductType.cs new file mode 100644 index 0000000..b217956 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/ProductType.cs @@ -0,0 +1,28 @@ +using Books.Api.EventFlow.ReadModels; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class ProductGraphType : ObjectGraphType +{ + public ProductGraphType() + { + Name = "Product"; + Description = "A product in the product catalog for invoicing"; + + Field(x => x.Id).Description("Unique identifier"); + Field(x => x.CompanyId).Description("Company this product belongs to"); + Field(x => x.ProductNumber, nullable: true).Description("Product number/SKU (optional)"); + Field(x => x.Name).Description("Product name"); + Field(x => x.Description, nullable: true).Description("Product description"); + Field(x => x.UnitPrice).Description("Unit price excl. VAT"); + Field(x => x.VatCode).Description("VAT code (e.g., U25, IEUV)"); + Field(x => x.Unit, nullable: true).Description("Unit of measure (e.g., stk, time, kg)"); + Field(x => x.DefaultAccountId, nullable: true).Description("Default revenue account for this product"); + Field(x => x.Ean, nullable: true).Description("EAN/barcode number"); + Field(x => x.Manufacturer, nullable: true).Description("Manufacturer/brand name"); + Field(x => x.IsActive).Description("Whether the product is active"); + Field(x => x.CreatedAt).Description("When the product was created"); + Field(x => x.UpdatedAt).Description("When the product was last updated"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/SaftExportResultType.cs b/backend/Books.Api/GraphQL/Types/SaftExportResultType.cs new file mode 100644 index 0000000..6641871 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/SaftExportResultType.cs @@ -0,0 +1,22 @@ +using Books.Api.Saft.Models; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +/// +/// GraphQL type for SAF-T export result. +/// +public class SaftExportResultType : ObjectGraphType +{ + public SaftExportResultType() + { + Name = "SaftExportResult"; + Description = "Result of SAF-T XML export generation"; + + Field(x => x.Success).Description("Whether the SAF-T file was generated successfully"); + Field(x => x.XmlContent, nullable: true).Description("The SAF-T XML content (null if failed)"); + Field(x => x.FileName, nullable: true).Description("Suggested filename for the SAF-T file"); + Field(x => x.ErrorCode, nullable: true).Description("Error code if generation failed"); + Field(x => x.ErrorMessage, nullable: true).Description("Error message if generation failed"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/UserCompanyAccessType.cs b/backend/Books.Api/GraphQL/Types/UserCompanyAccessType.cs new file mode 100644 index 0000000..552da80 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/UserCompanyAccessType.cs @@ -0,0 +1,49 @@ +using Books.Api.Domain.Companies; +using Books.Api.Domain.UserAccess; +using Books.Api.EventFlow.Repositories; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +public class UserCompanyAccessType : ObjectGraphType +{ + public UserCompanyAccessType() + { + Name = "UserCompanyAccess"; + Description = "User access to a company"; + + Field(x => x.Id).Description("Access record ID"); + Field(x => x.UserId).Description("User ID (Keycloak ID or email)"); + Field(x => x.CompanyId).Description("Company ID"); + Field("role").Description("User's role for this company") + .Resolve(ctx => ctx.Source.Role); + Field(x => x.GrantedBy).Description("Who granted this access"); + Field(x => x.GrantedAt).Description("When access was granted"); + Field(x => x.IsActive).Description("Whether access is currently active"); + Field(x => x.RevokedAt, nullable: true).Description("When access was revoked"); + Field(x => x.RevokedBy, nullable: true).Description("Who revoked access"); + + // Nested company data for convenience + Field("company") + .Description("The company this access is for") + .ResolveAsync(async ctx => + { + var companyRepo = ctx.RequestServices!.GetRequiredService(); + var companies = await companyRepo.GetByIds([CompanyId.With(ctx.Source.CompanyId)], ctx.CancellationToken); + return companies.FirstOrDefault(); + }); + } +} + +public class CompanyRoleEnumType : EnumerationGraphType +{ + public CompanyRoleEnumType() + { + Name = "CompanyRole"; + Description = "Role a user can have for a company"; + + Add("OWNER", CompanyRole.Owner, "Full access - can manage users, delete company"); + Add("ACCOUNTANT", CompanyRole.Accountant, "Can create journal entries, manage accounts, close fiscal years"); + Add("VIEWER", CompanyRole.Viewer, "Read-only access to all data"); + } +} diff --git a/backend/Books.Api/GraphQL/Types/VatReportType.cs b/backend/Books.Api/GraphQL/Types/VatReportType.cs new file mode 100644 index 0000000..8a10936 --- /dev/null +++ b/backend/Books.Api/GraphQL/Types/VatReportType.cs @@ -0,0 +1,39 @@ +using Books.Api.Reporting; +using GraphQL.Types; + +namespace Books.Api.GraphQL.Types; + +/// +/// GraphQL type for VAT (moms) reports. +/// Represents SKAT VAT declaration boxes and summary. +/// +public class VatReportType : ObjectGraphType +{ + public VatReportType() + { + Name = "VatReport"; + Description = "VAT (moms) report for SKAT compliance"; + + // SKAT boxes (VAT amounts) + Field(x => x.BoxA).Description("Rubrik A: Salgsmoms - Output VAT from domestic sales (25%)"); + Field(x => x.BoxB).Description("Rubrik B: Købsmoms - Input VAT deduction from purchases (25%)"); + Field(x => x.BoxC).Description("Rubrik C: EU-varekøb moms - VAT on goods from EU (reverse charge)"); + Field(x => x.BoxD).Description("Rubrik D: Ydelseskøb moms - VAT on services from abroad (reverse charge)"); + + // Basis/turnover fields + Field(x => x.Basis1).Description("Felt 1: Salg med moms - Turnover with VAT (domestic)"); + Field(x => x.Basis2).Description("Felt 2: Salg uden moms - Turnover without VAT (exports, exempt)"); + Field(x => x.Basis3).Description("Felt 3: EU-varekøb - Purchases of goods from EU"); + Field(x => x.Basis4).Description("Felt 4: Ydelseskøb fra udland - Purchases of services from abroad"); + + // Summary + Field(x => x.TotalOutputVat).Description("Total output VAT (A + C + D)"); + Field(x => x.TotalInputVat).Description("Total input VAT (B - deductible)"); + Field(x => x.NetVat).Description("Net VAT to pay/receive (positive = pay, negative = refund)"); + + // Period info + Field(x => x.PeriodStart, type: typeof(DateOnlyGraphType)).Description("Start date of the VAT period"); + Field(x => x.PeriodEnd, type: typeof(DateOnlyGraphType)).Description("End date of the VAT period"); + Field(x => x.TransactionCount).Description("Number of transactions with VAT in the period"); + } +} diff --git a/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs b/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs new file mode 100644 index 0000000..cbc16de --- /dev/null +++ b/backend/Books.Api/Infrastructure/FileStorage/IFileStorageService.cs @@ -0,0 +1,63 @@ +namespace Books.Api.Infrastructure.FileStorage; + +/// +/// Service for storing and retrieving attachment files. +/// Abstraction allows switching between local storage and cloud blob storage. +/// +public interface IFileStorageService +{ + /// + /// Store a file and return the storage path. + /// + /// Company ID for organizing files + /// Original filename + /// MIME type + /// File content stream + /// Cancellation token + /// Storage path for the file + Task StoreAsync( + string companyId, + string fileName, + string contentType, + Stream content, + CancellationToken cancellationToken = default); + + /// + /// Retrieve a file by its storage path. + /// + /// Storage path returned from StoreAsync + /// Cancellation token + /// File content and metadata + Task GetAsync(string storagePath, CancellationToken cancellationToken = default); + + /// + /// Delete a file from storage. + /// + /// Storage path to delete + /// Cancellation token + Task DeleteAsync(string storagePath, CancellationToken cancellationToken = default); + + /// + /// Get a URL for downloading/viewing a file. + /// May return a signed URL with expiration for cloud storage. + /// + /// Storage path + /// URL expiration time + /// Download URL + string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null); +} + +public record StorageResult +{ + public required string StoragePath { get; init; } + public required string StoredFileName { get; init; } + public required long FileSize { get; init; } +} + +public record FileResult +{ + public required Stream Content { get; init; } + public required string ContentType { get; init; } + public required string FileName { get; init; } + public required long FileSize { get; init; } +} diff --git a/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs b/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs new file mode 100644 index 0000000..b28da9c --- /dev/null +++ b/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs @@ -0,0 +1,138 @@ +namespace Books.Api.Infrastructure.FileStorage; + +/// +/// Local file system storage implementation. +/// Suitable for development and small deployments. +/// For production, consider Azure Blob Storage or AWS S3. +/// +public class LocalFileStorageService : IFileStorageService +{ + private readonly string _basePath; + private readonly string _baseUrl; + + public LocalFileStorageService(IConfiguration configuration) + { + _basePath = configuration["FileStorage:LocalPath"] + ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads"); + _baseUrl = configuration["FileStorage:BaseUrl"] + ?? "/api/attachments"; + + // Ensure base directory exists + if (!Directory.Exists(_basePath)) + { + Directory.CreateDirectory(_basePath); + } + } + + public async Task StoreAsync( + string companyId, + string fileName, + string contentType, + Stream content, + CancellationToken cancellationToken = default) + { + // Create company directory + var companyPath = Path.Combine(_basePath, companyId); + if (!Directory.Exists(companyPath)) + { + Directory.CreateDirectory(companyPath); + } + + // Generate unique filename to prevent collisions + var extension = Path.GetExtension(fileName); + var sanitizedName = SanitizeFileName(Path.GetFileNameWithoutExtension(fileName)); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var storedFileName = $"{sanitizedName}_{uniqueId}{extension}"; + + var filePath = Path.Combine(companyPath, storedFileName); + + // Store file + await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write); + await content.CopyToAsync(fileStream, cancellationToken); + + // Storage path is relative to base (company/filename) + var storagePath = $"{companyId}/{storedFileName}"; + + return new StorageResult + { + StoragePath = storagePath, + StoredFileName = storedFileName, + FileSize = fileStream.Length + }; + } + + public Task GetAsync(string storagePath, CancellationToken cancellationToken = default) + { + var filePath = Path.Combine(_basePath, storagePath); + + if (!File.Exists(filePath)) + return Task.FromResult(null)!; + + var fileInfo = new FileInfo(filePath); + var contentType = GetContentType(fileInfo.Extension); + + // Open for reading (caller is responsible for disposing) + var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + return Task.FromResult(new FileResult + { + Content = stream, + ContentType = contentType, + FileName = fileInfo.Name, + FileSize = fileInfo.Length + }); + } + + public Task DeleteAsync(string storagePath, CancellationToken cancellationToken = default) + { + var filePath = Path.Combine(_basePath, storagePath); + + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + return Task.CompletedTask; + } + + public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null) + { + // For local storage, return API endpoint URL + // The actual download is handled by a controller + return $"{_baseUrl}/{Uri.EscapeDataString(storagePath)}"; + } + + private static string SanitizeFileName(string fileName) + { + // Remove invalid characters + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = string.Join("", fileName.Split(invalid)); + + // Limit length + if (sanitized.Length > 50) + sanitized = sanitized[..50]; + + // Default if empty + if (string.IsNullOrWhiteSpace(sanitized)) + sanitized = "attachment"; + + return sanitized; + } + + private static string GetContentType(string extension) + { + return extension.ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".doc" => "application/msword", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls" => "application/vnd.ms-excel", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + _ => "application/octet-stream" + }; + } +} diff --git a/backend/Books.Api/Invoicing/Pdf/IInvoicePdfGenerator.cs b/backend/Books.Api/Invoicing/Pdf/IInvoicePdfGenerator.cs new file mode 100644 index 0000000..c4ad8bf --- /dev/null +++ b/backend/Books.Api/Invoicing/Pdf/IInvoicePdfGenerator.cs @@ -0,0 +1,14 @@ +namespace Books.Api.Invoicing.Pdf; + +/// +/// Generates PDF documents for invoices using QuestPDF. +/// +public interface IInvoicePdfGenerator +{ + /// + /// Generates a PDF document for the given invoice data. + /// + /// Invoice data including company, customer, and line items + /// PDF document as byte array + byte[] Generate(InvoicePdfData data); +} diff --git a/backend/Books.Api/Invoicing/Pdf/IInvoicePdfService.cs b/backend/Books.Api/Invoicing/Pdf/IInvoicePdfService.cs new file mode 100644 index 0000000..9fb9a8b --- /dev/null +++ b/backend/Books.Api/Invoicing/Pdf/IInvoicePdfService.cs @@ -0,0 +1,33 @@ +namespace Books.Api.Invoicing.Pdf; + +/// +/// Service for generating invoice PDFs with data fetching orchestration. +/// +public interface IInvoicePdfService +{ + /// + /// Generates a PDF for the specified invoice. + /// + /// The invoice ID + /// Cancellation token + /// Result containing PDF bytes or error information + Task GenerateAsync(string invoiceId, CancellationToken ct = default); +} + +/// +/// Result of PDF generation operation. +/// +public record InvoicePdfResult +{ + public bool Success { get; init; } + public byte[]? PdfBytes { get; init; } + public string? FileName { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } + + public static InvoicePdfResult Succeeded(byte[] pdfBytes, string fileName) => + new() { Success = true, PdfBytes = pdfBytes, FileName = fileName }; + + public static InvoicePdfResult Failed(string errorCode, string errorMessage) => + new() { Success = false, ErrorCode = errorCode, ErrorMessage = errorMessage }; +} diff --git a/backend/Books.Api/Invoicing/Pdf/InvoicePdfData.cs b/backend/Books.Api/Invoicing/Pdf/InvoicePdfData.cs new file mode 100644 index 0000000..c3c3863 --- /dev/null +++ b/backend/Books.Api/Invoicing/Pdf/InvoicePdfData.cs @@ -0,0 +1,99 @@ +using Books.Api.Domain.Invoices; + +namespace Books.Api.Invoicing.Pdf; + +/// +/// Data transfer object containing all information needed to render an invoice or credit note PDF. +/// +public record InvoicePdfData +{ + // Document type (Invoice or CreditNote) + public InvoiceType DocumentType { get; init; } = InvoiceType.Invoice; + + /// + /// For credit notes: Reference to the original invoice number. + /// + public string? OriginalInvoiceNumber { get; init; } + + /// + /// For credit notes: Reason for issuing the credit. + /// + public string? CreditReason { get; init; } + + // Company information + public required string CompanyName { get; init; } + public string? CompanyCvr { get; init; } + public string? CompanyAddress { get; init; } + public string? CompanyPostalCode { get; init; } + public string? CompanyCity { get; init; } + public string CompanyCountry { get; init; } = "DK"; + + // Company bank details for payment information + public string? CompanyBankName { get; init; } + public string? CompanyBankRegNo { get; init; } // Danish reg.nr (4 digits) + public string? CompanyBankAccountNo { get; init; } // Danish account number + public string? CompanyIban { get; init; } // International bank account + public string? CompanyBic { get; init; } // BIC/SWIFT code + + // Customer information + public required string CustomerName { get; init; } + public string? CustomerCvr { get; init; } + public string? CustomerAddress { get; init; } + public string? CustomerPostalCode { get; init; } + public string? CustomerCity { get; init; } + public string CustomerCountry { get; init; } = "DK"; + public string? CustomerNumber { get; init; } + + // Invoice/Credit note details + public required string InvoiceNumber { get; init; } + public required DateOnly InvoiceDate { get; init; } + public required DateOnly DueDate { get; init; } + public int PaymentTermsDays { get; init; } + public string Currency { get; init; } = "DKK"; + public string? Reference { get; init; } + public string? Notes { get; init; } + + // Line items + public required IReadOnlyList Lines { get; init; } + + // Calculated totals + public decimal AmountExVat { get; init; } + public decimal AmountVat { get; init; } + public decimal AmountTotal { get; init; } + + // VAT summary (grouped by rate) + public required IReadOnlyList VatSummary { get; init; } + + /// + /// Returns true if this is a credit note. + /// + public bool IsCreditNote => DocumentType == InvoiceType.CreditNote; +} + +/// +/// Line item for the invoice PDF. +/// +public record InvoicePdfLineItem +{ + public int LineNumber { get; init; } + public required string Description { get; init; } + public decimal Quantity { get; init; } + public string? Unit { get; init; } + public decimal UnitPrice { get; init; } + public decimal DiscountPercent { get; init; } + public string VatCode { get; init; } = "U25"; + public decimal AmountExVat { get; init; } + public decimal AmountVat { get; init; } + public decimal AmountTotal { get; init; } +} + +/// +/// VAT summary line for the totals section. +/// +public record VatSummaryLine +{ + public string VatCode { get; init; } = "U25"; + public decimal VatRate { get; init; } + public decimal BasisAmount { get; init; } + public decimal VatAmount { get; init; } +} diff --git a/backend/Books.Api/Invoicing/Pdf/InvoicePdfGenerator.cs b/backend/Books.Api/Invoicing/Pdf/InvoicePdfGenerator.cs new file mode 100644 index 0000000..ef59b98 --- /dev/null +++ b/backend/Books.Api/Invoicing/Pdf/InvoicePdfGenerator.cs @@ -0,0 +1,355 @@ +using System.Globalization; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace Books.Api.Invoicing.Pdf; + +/// +/// Generates professional Danish invoice PDFs using QuestPDF. +/// +public class InvoicePdfGenerator : IInvoicePdfGenerator +{ + // Danish color scheme - professional and minimal + private static readonly string PrimaryColor = "#2C3E50"; // Dark blue-gray + private static readonly string AccentColor = "#3498DB"; // Blue accent + private static readonly string LightGray = "#ECF0F1"; // Light background + private static readonly string TextColor = "#2C3E50"; // Main text + private static readonly string MutedColor = "#7F8C8D"; // Secondary text + + // Danish culture for number formatting + private static readonly CultureInfo DanishCulture = new("da-DK"); + + public byte[] Generate(InvoicePdfData data) + { + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.MarginTop(2, Unit.Centimetre); + page.MarginBottom(2, Unit.Centimetre); + page.MarginHorizontal(2, Unit.Centimetre); + page.DefaultTextStyle(x => x.FontSize(10).FontColor(TextColor)); + + page.Header().Element(c => ComposeHeader(c, data)); + page.Content().Element(c => ComposeContent(c, data)); + page.Footer().Element(c => ComposeFooter(c, data)); + }); + }); + + return document.GeneratePdf(); + } + + private void ComposeHeader(IContainer container, InvoicePdfData data) + { + container.Row(row => + { + // Left: Company info + row.RelativeItem(2).Column(col => + { + col.Item().Text(data.CompanyName) + .FontSize(16).Bold().FontColor(PrimaryColor); + + if (!string.IsNullOrEmpty(data.CompanyAddress)) + col.Item().Text(data.CompanyAddress); + + var cityLine = FormatCityLine(data.CompanyPostalCode, data.CompanyCity); + if (!string.IsNullOrEmpty(cityLine)) + col.Item().Text(cityLine); + + if (!string.IsNullOrEmpty(data.CompanyCvr)) + col.Item().Text($"CVR: {data.CompanyCvr}").FontSize(9).FontColor(MutedColor); + }); + + // Right: FAKTURA/KREDITNOTA title + number + row.RelativeItem(1).AlignRight().Column(col => + { + var documentTitle = data.IsCreditNote ? "KREDITNOTA" : "FAKTURA"; + col.Item().Text(documentTitle) + .FontSize(28).Bold().FontColor(AccentColor); + col.Item().Text($"Nr. {data.InvoiceNumber}") + .FontSize(12).FontColor(MutedColor); + + // For credit notes: show reference to original invoice + if (data.IsCreditNote && !string.IsNullOrEmpty(data.OriginalInvoiceNumber)) + { + col.Item().Text($"Ref. faktura: {data.OriginalInvoiceNumber}") + .FontSize(10).FontColor(MutedColor); + } + }); + }); + } + + private void ComposeContent(IContainer container, InvoicePdfData data) + { + container.PaddingVertical(20).Column(col => + { + // Customer address block + Invoice metadata (side by side) + col.Item().Row(row => + { + // Customer address + row.RelativeItem().Column(custCol => + { + custCol.Item().Text("Faktureres til:").FontSize(9).FontColor(MutedColor); + custCol.Item().PaddingTop(5).Column(addrCol => + { + addrCol.Item().Text(data.CustomerName).Bold(); + + if (!string.IsNullOrEmpty(data.CustomerAddress)) + addrCol.Item().Text(data.CustomerAddress); + + var cityLine = FormatCityLine(data.CustomerPostalCode, data.CustomerCity); + if (!string.IsNullOrEmpty(cityLine)) + addrCol.Item().Text(cityLine); + + if (!string.IsNullOrEmpty(data.CustomerCvr)) + addrCol.Item().Text($"CVR: {data.CustomerCvr}").FontSize(9); + }); + }); + + // Invoice/Credit note metadata + row.RelativeItem().AlignRight().Table(table => + { + table.ColumnsDefinition(cols => + { + cols.RelativeColumn(); + cols.RelativeColumn(); + }); + + var dateLabel = data.IsCreditNote ? "Kreditnotadato:" : "Fakturadato:"; + AddMetadataRow(table, dateLabel, data.InvoiceDate.ToString("dd-MM-yyyy")); + + if (!data.IsCreditNote) + { + AddMetadataRow(table, "Forfaldsdato:", data.DueDate.ToString("dd-MM-yyyy")); + AddMetadataRow(table, "Betalingsbetingelser:", $"Netto {data.PaymentTermsDays} dage"); + } + + if (!string.IsNullOrEmpty(data.CustomerNumber)) + AddMetadataRow(table, "Kundenr.:", data.CustomerNumber); + + if (!string.IsNullOrEmpty(data.Reference)) + AddMetadataRow(table, "Reference:", data.Reference); + + // For credit notes: show credit reason if available + if (data.IsCreditNote && !string.IsNullOrEmpty(data.CreditReason)) + AddMetadataRow(table, "Årsag:", data.CreditReason); + }); + }); + + col.Item().PaddingVertical(20); + + // Line items table + col.Item().Element(c => ComposeLineItemsTable(c, data)); + + col.Item().PaddingVertical(10); + + // VAT summary and totals (right-aligned) + col.Item().Row(row => + { + row.RelativeItem(); // Spacer + row.ConstantItem(250).Element(c => ComposeTotals(c, data)); + }); + + // Notes section + if (!string.IsNullOrEmpty(data.Notes)) + { + col.Item().PaddingTop(20).Column(notesCol => + { + notesCol.Item().Text("Bemærkninger:").FontSize(9).FontColor(MutedColor); + notesCol.Item().PaddingTop(5).Text(data.Notes); + }); + } + }); + } + + private void ComposeLineItemsTable(IContainer container, InvoicePdfData data) + { + container.Table(table => + { + // Column definitions + table.ColumnsDefinition(cols => + { + cols.ConstantColumn(50); // Antal + cols.RelativeColumn(3); // Beskrivelse + cols.ConstantColumn(80); // Enhedspris + cols.ConstantColumn(50); // Rabat % + cols.ConstantColumn(50); // Moms % + cols.ConstantColumn(80); // Beløb + }); + + // Header row + table.Header(header => + { + header.Cell().Background(LightGray).Padding(5) + .Text("Antal").FontSize(9).Bold(); + header.Cell().Background(LightGray).Padding(5) + .Text("Beskrivelse").FontSize(9).Bold(); + header.Cell().Background(LightGray).Padding(5).AlignRight() + .Text("Enhedspris").FontSize(9).Bold(); + header.Cell().Background(LightGray).Padding(5).AlignRight() + .Text("Rabat").FontSize(9).Bold(); + header.Cell().Background(LightGray).Padding(5).AlignRight() + .Text("Moms").FontSize(9).Bold(); + header.Cell().Background(LightGray).Padding(5).AlignRight() + .Text("Beløb").FontSize(9).Bold(); + }); + + // Data rows + foreach (var line in data.Lines) + { + table.Cell().BorderBottom(1).BorderColor(LightGray).Padding(5) + .Text(FormatQuantity(line.Quantity, line.Unit)); + table.Cell().BorderBottom(1).BorderColor(LightGray).Padding(5) + .Text(line.Description); + table.Cell().BorderBottom(1).BorderColor(LightGray).Padding(5).AlignRight() + .Text(FormatCurrency(line.UnitPrice, data.Currency)); + table.Cell().BorderBottom(1).BorderColor(LightGray).Padding(5).AlignRight() + .Text(line.DiscountPercent > 0 ? $"{line.DiscountPercent:N0}%" : "-"); + table.Cell().BorderBottom(1).BorderColor(LightGray).Padding(5).AlignRight() + .Text(FormatVatCode(line.VatCode)); + table.Cell().BorderBottom(1).BorderColor(LightGray).Padding(5).AlignRight() + .Text(FormatCurrency(line.AmountExVat, data.Currency)); + } + }); + } + + private void ComposeTotals(IContainer container, InvoicePdfData data) + { + container.Column(col => + { + // Subtotal + col.Item().Row(row => + { + row.RelativeItem().Text("Subtotal ex. moms:"); + row.ConstantItem(100).AlignRight().Text(FormatCurrency(data.AmountExVat, data.Currency)); + }); + + // VAT summary lines + foreach (var vatLine in data.VatSummary) + { + col.Item().Row(row => + { + var vatLabel = vatLine.VatRate > 0 + ? $"Moms {FormatVatCode(vatLine.VatCode)} ({vatLine.VatRate:P0}):" + : $"Moms {FormatVatCode(vatLine.VatCode)}:"; + row.RelativeItem().Text(vatLabel); + row.ConstantItem(100).AlignRight().Text(FormatCurrency(vatLine.VatAmount, data.Currency)); + }); + } + + col.Item().PaddingVertical(5).LineHorizontal(1).LineColor(PrimaryColor); + + // Grand total + col.Item().Row(row => + { + row.RelativeItem().Text("Total inkl. moms:").Bold().FontSize(12); + row.ConstantItem(100).AlignRight() + .Text(FormatCurrency(data.AmountTotal, data.Currency)).Bold().FontSize(12); + }); + }); + } + + private static void ComposeFooter(IContainer container, InvoicePdfData data) + { + container.Column(col => + { + col.Item().LineHorizontal(1).LineColor(LightGray); + col.Item().PaddingTop(10).Row(row => + { + row.RelativeItem().Column(payCol => + { + if (data.IsCreditNote) + { + // Credit note footer - different message + payCol.Item().Text("Kreditnota").FontSize(9).Bold(); + payCol.Item().PaddingTop(5) + .Text("Denne kreditnota modregnes automatisk i udestående fakturaer.") + .FontSize(8).FontColor(MutedColor); + + if (!string.IsNullOrEmpty(data.OriginalInvoiceNumber)) + { + payCol.Item().Text($"Vedrører faktura: {data.OriginalInvoiceNumber}") + .FontSize(8).FontColor(MutedColor); + } + } + else + { + // Invoice footer - payment info with bank details + payCol.Item().Text("Betalingsoplysninger:").FontSize(9).Bold(); + + if (!string.IsNullOrEmpty(data.CompanyBankName)) + payCol.Item().Text($"Bank: {data.CompanyBankName}").FontSize(9); + + if (!string.IsNullOrEmpty(data.CompanyBankRegNo) && + !string.IsNullOrEmpty(data.CompanyBankAccountNo)) + { + payCol.Item().Text($"Reg.nr.: {data.CompanyBankRegNo} Kontonr.: {data.CompanyBankAccountNo}").FontSize(9); + } + + if (!string.IsNullOrEmpty(data.CompanyIban)) + payCol.Item().Text($"IBAN: {data.CompanyIban}").FontSize(9); + + if (!string.IsNullOrEmpty(data.CompanyBic)) + payCol.Item().Text($"BIC/SWIFT: {data.CompanyBic}").FontSize(9); + + payCol.Item().PaddingTop(5) + .Text($"Ved betaling angiv venligst fakturanr.: {data.InvoiceNumber}") + .FontSize(8).FontColor(MutedColor); + } + }); + + // Page number + row.RelativeItem().AlignRight().AlignBottom() + .Text(x => + { + x.Span("Side ").FontSize(9); + x.CurrentPageNumber().FontSize(9); + x.Span(" af ").FontSize(9); + x.TotalPages().FontSize(9); + }); + }); + }); + } + + #region Formatting Helpers + + private static void AddMetadataRow(TableDescriptor table, string label, string value) + { + table.Cell().Text(label).FontSize(9).FontColor(MutedColor); + table.Cell().AlignRight().Text(value).FontSize(9); + } + + private static string FormatCityLine(string? postalCode, string? city) + { + var parts = new[] { postalCode, city }.Where(p => !string.IsNullOrEmpty(p)); + return string.Join(" ", parts); + } + + private string FormatCurrency(decimal amount, string currency) + { + // Danish format: 1.234,56 kr + var formatted = amount.ToString("N2", DanishCulture); + return currency == "DKK" ? $"{formatted} kr" : $"{formatted} {currency}"; + } + + private static string FormatQuantity(decimal quantity, string? unit) + { + var formatted = quantity % 1 == 0 + ? quantity.ToString("N0") + : quantity.ToString("N2"); + return unit != null ? $"{formatted} {unit}" : formatted; + } + + private static string FormatVatCode(string vatCode) => vatCode switch + { + "U25" or "I25" => "25%", + "UEU" or "IEU" => "EU 0%", + "UEXP" or "IEXP" => "Eksport", + "INGEN" => "0%", + _ => vatCode + }; + + #endregion +} diff --git a/backend/Books.Api/Invoicing/Pdf/InvoicePdfService.cs b/backend/Books.Api/Invoicing/Pdf/InvoicePdfService.cs new file mode 100644 index 0000000..75a088e --- /dev/null +++ b/backend/Books.Api/Invoicing/Pdf/InvoicePdfService.cs @@ -0,0 +1,154 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Microsoft.Extensions.Logging; + +namespace Books.Api.Invoicing.Pdf; + +/// +/// Service for generating invoice PDFs, orchestrating data fetching and PDF generation. +/// +public class InvoicePdfService( + IInvoiceRepository invoiceRepository, + ICustomerRepository customerRepository, + ICompanyRepository companyRepository, + IInvoicePdfGenerator pdfGenerator, + ILogger logger) : IInvoicePdfService +{ + public async Task GenerateAsync(string invoiceId, CancellationToken ct = default) + { + try + { + // 1. Fetch invoice + var invoice = await invoiceRepository.GetByIdAsync(invoiceId, ct); + if (invoice == null) + { + logger.LogWarning("Invoice not found: {InvoiceId}", invoiceId); + return InvoicePdfResult.Failed("INVOICE_NOT_FOUND", $"Faktura '{invoiceId}' blev ikke fundet"); + } + + // 2. Fetch company + var company = await companyRepository.GetByIdAsync(invoice.CompanyId, ct); + if (company == null) + { + logger.LogWarning("Company not found for invoice: {InvoiceId}, CompanyId: {CompanyId}", + invoiceId, invoice.CompanyId); + return InvoicePdfResult.Failed("COMPANY_NOT_FOUND", "Virksomhed blev ikke fundet"); + } + + // 3. Fetch customer (for full address details, fallback to invoice snapshot if deleted) + var customer = await customerRepository.GetByIdAsync(invoice.CustomerId, ct); + + // 4. Build PDF data + var pdfData = BuildPdfData(invoice, company, customer); + + // 5. Generate PDF + var pdfBytes = pdfGenerator.Generate(pdfData); + + // 6. Generate filename + var fileName = $"Faktura_{invoice.InvoiceNumber}_{invoice.InvoiceDate:yyyy-MM-dd}.pdf"; + + logger.LogInformation( + "Generated PDF for invoice {InvoiceNumber}, size: {Size} bytes", + invoice.InvoiceNumber, pdfBytes.Length); + + return InvoicePdfResult.Succeeded(pdfBytes, fileName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to generate PDF for invoice {InvoiceId}", invoiceId); + return InvoicePdfResult.Failed("PDF_GENERATION_ERROR", $"PDF generering fejlede: {ex.Message}"); + } + } + + private static InvoicePdfData BuildPdfData( + InvoiceReadModelDto invoice, + CompanyReadModelDto company, + CustomerReadModelDto? customer) + { + var lines = invoice.GetLines(); + + // Group VAT by code for summary + var vatSummary = lines + .GroupBy(l => l.VatCode) + .Select(g => new VatSummaryLine + { + VatCode = g.Key, + VatRate = GetVatRate(g.Key), + BasisAmount = g.Sum(l => l.AmountExVat), + VatAmount = g.Sum(l => l.AmountVat) + }) + .Where(v => v.VatAmount > 0 || v.BasisAmount > 0) // Include 0% VAT lines if they have basis + .OrderByDescending(v => v.VatRate) + .ToList(); + + return new InvoicePdfData + { + // Company + CompanyName = company.Name, + CompanyCvr = company.Cvr, + CompanyAddress = company.Address, + CompanyPostalCode = company.PostalCode, + CompanyCity = company.City, + CompanyCountry = company.Country, + + // Company bank details + CompanyBankName = company.BankName, + CompanyBankRegNo = company.BankRegNo, + CompanyBankAccountNo = company.BankAccountNo, + CompanyIban = company.BankIban, + CompanyBic = company.BankBic, + + // Customer (prefer fresh data, fallback to invoice snapshot) + CustomerName = customer?.Name ?? invoice.CustomerName, + CustomerCvr = customer?.Cvr, + CustomerAddress = customer?.Address, + CustomerPostalCode = customer?.PostalCode, + CustomerCity = customer?.City, + CustomerCountry = customer?.Country ?? "DK", + CustomerNumber = invoice.CustomerNumber, + + // Invoice + InvoiceNumber = invoice.InvoiceNumber, + InvoiceDate = invoice.InvoiceDate.HasValue + ? DateOnly.FromDateTime(invoice.InvoiceDate.Value) + : DateOnly.FromDateTime(DateTime.Today), + DueDate = invoice.DueDate.HasValue + ? DateOnly.FromDateTime(invoice.DueDate.Value) + : DateOnly.FromDateTime(DateTime.Today.AddDays(invoice.PaymentTermsDays)), + PaymentTermsDays = invoice.PaymentTermsDays, + Currency = invoice.Currency, + Reference = invoice.Reference, + Notes = invoice.Notes, + + // Lines + Lines = lines.Select(l => new InvoicePdfLineItem + { + LineNumber = l.LineNumber, + Description = l.Description, + Quantity = l.Quantity, + Unit = l.Unit, + UnitPrice = l.UnitPrice, + DiscountPercent = l.DiscountPercent, + VatCode = l.VatCode, + AmountExVat = l.AmountExVat, + AmountVat = l.AmountVat, + AmountTotal = l.AmountTotal + }).ToList(), + + // Totals + AmountExVat = invoice.AmountExVat, + AmountVat = invoice.AmountVat, + AmountTotal = invoice.AmountTotal, + VatSummary = vatSummary + }; + } + + private static decimal GetVatRate(string vatCode) => vatCode switch + { + "U25" or "I25" => 0.25m, + "UEU" or "IEU" => 0m, + "UEXP" or "IEXP" => 0m, + "INGEN" => 0m, + _ => 0.25m + }; +} diff --git a/backend/Books.Api/Invoicing/Services/CustomerSubLedgerService.cs b/backend/Books.Api/Invoicing/Services/CustomerSubLedgerService.cs new file mode 100644 index 0000000..3180002 --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/CustomerSubLedgerService.cs @@ -0,0 +1,73 @@ +using Books.Api.Commands.Accounts; +using Books.Api.Domain.Accounts; +using Books.Api.EventFlow.Repositories; +using EventFlow; +using Microsoft.Extensions.Logging; + +namespace Books.Api.Invoicing.Services; + +/// +/// Implementation of customer sub-ledger account management. +/// Creates accounts in the format 1900-XXXX as children of the main Debitorer account (1900). +/// +public class CustomerSubLedgerService( + ICommandBus commandBus, + IAccountRepository accountRepository, + ILogger logger) : ICustomerSubLedgerService +{ + private const string DebitorsControlAccountNumber = "1900"; + + public async Task CreateSubLedgerAccountAsync( + string companyId, + string customerNumber, + string customerName, + CancellationToken cancellationToken = default) + { + // Generate sub-ledger account number: 1900XXXX (e.g., 19000001 for customer 0001) + // Remove leading zeros and pad to ensure valid account number format + var customerNum = customerNumber.TrimStart('0'); + if (string.IsNullOrEmpty(customerNum)) customerNum = "0"; + var subLedgerAccountNumber = $"1900{customerNum.PadLeft(4, '0')}"; + + // Create deterministic account ID based on company and account number + var accountId = AccountId.FromCompanyAndNumber(companyId, subLedgerAccountNumber); + + // Get the parent Debitorer account (1900) for hierarchy + var parentAccount = await accountRepository.GetByCompanyAndNumberAsync( + companyId, DebitorsControlAccountNumber, cancellationToken); + + var command = new CreateAccountCommand( + accountId, + companyId, + subLedgerAccountNumber, + $"Debitor: {customerName}", + AccountType.Asset, // Sub-ledger accounts are assets (receivables) + parentAccount?.Id, // Parent is 1900 Debitorer + $"Sub-ledger account for customer {customerNumber}", + vatCodeId: null, // No VAT on sub-ledger accounts + isSystemAccount: true); // System-managed, cannot be manually edited + + await commandBus.PublishAsync(command, cancellationToken); + + logger.LogInformation( + "Created sub-ledger account {AccountNumber} for customer {CustomerNumber} in company {CompanyId}", + subLedgerAccountNumber, customerNumber, companyId); + + return accountId.Value; + } + + public async Task GetCustomerBalanceAsync( + string subLedgerAccountId, + DateOnly? asOfDate = null, + CancellationToken cancellationToken = default) + { + // For now, return 0 - will be implemented when Ledger query integration is added + // This would query the Ledger service using: + // var query = new EntriesQuery { AccountIds = [accountGuid], To = asOfDate, Aggregate = true }; + // var result = await ledgerService.QueryEntriesAsync(query, cancellationToken); + // return result.Aggregates?.FirstOrDefault()?.NetChange ?? 0m; + + await Task.CompletedTask; + return 0m; + } +} diff --git a/backend/Books.Api/Invoicing/Services/DocumentPostingService.cs b/backend/Books.Api/Invoicing/Services/DocumentPostingService.cs new file mode 100644 index 0000000..1fd00c6 --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/DocumentPostingService.cs @@ -0,0 +1,336 @@ +using Books.Api.EventFlow.Repositories; +using Ledger.Core.Models; +using Ledger.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Books.Api.Invoicing.Services; + +/// +/// Unified implementation for posting sales documents (invoices and credit notes) to the Ledger. +/// Uses the document type to determine entry direction - invoice and credit note logic +/// is identical except for debit/credit being swapped. +/// +public class DocumentPostingService( + ILedgerService ledgerService, + ICustomerRepository customerRepository, + IAccountRepository accountRepository, + ILogger logger) : IDocumentPostingService +{ + private const string OutputVatAccountNumber = "5611"; // Salgsmoms + + public async Task PostSalesDocumentAsync( + SalesDocumentPostingRequest request, + CancellationToken ct = default) + { + try + { + // Get customer to find sub-ledger account + var customer = await customerRepository.GetByIdAsync(request.CustomerId, ct); + if (customer == null) + { + return PostingResult.Failed("CUSTOMER_NOT_FOUND", + $"Kunde med ID {request.CustomerId} blev ikke fundet"); + } + + // Parse sub-ledger account ID to GUID + if (!TryParseAccountGuid(customer.SubLedgerAccountId, out var customerAccountGuid)) + { + return PostingResult.Failed("INVALID_SUBLEDGER_ACCOUNT", + $"Ugyldigt sub-ledger konto format: {customer.SubLedgerAccountId}"); + } + + // Get output VAT account + var vatAccount = await accountRepository.GetByCompanyAndNumberAsync( + customer.CompanyId, OutputVatAccountNumber, ct); + + Guid? vatAccountGuid = null; + if (vatAccount != null && TryParseAccountGuid(vatAccount.Id, out var parsedVatGuid)) + { + vatAccountGuid = parsedVatGuid; + } + + // Build transaction legs + var legs = new List(); + + // Calculate totals + var totalExVat = request.Lines.Sum(l => l.AmountExVat); + var totalVat = request.Lines.Sum(l => l.AmountVat); + var totalAmount = request.Lines.Sum(l => l.AmountTotal); + + // Determine entry types based on document type + // Invoice: Debit customer, Credit revenue/VAT + // Credit Note: Credit customer, Debit revenue/VAT + var isInvoice = request.DocumentType == SalesDocumentType.Invoice; + var customerEntryType = isInvoice ? EntryType.Debit : EntryType.Credit; + var revenueVatEntryType = isInvoice ? EntryType.Credit : EntryType.Debit; + + // 1. Customer sub-ledger account + legs.Add(new TransactionLeg + { + AccountId = customerAccountGuid, + EntryType = customerEntryType, + Amount = totalAmount, + Currency = "DKK" + }); + + // 2. Revenue accounts for each line (group by account to consolidate) + var defaultRevenueAccountId = request.DefaultRevenueAccountId ?? customer.DefaultRevenueAccountId; + var linesByAccount = request.Lines.GroupBy(l => l.AccountId ?? defaultRevenueAccountId); + + foreach (var group in linesByAccount) + { + var accountId = group.Key; + if (string.IsNullOrEmpty(accountId)) + { + // Fallback to standard revenue account + var defaultRevenue = await accountRepository.GetByCompanyAndNumberAsync( + request.CompanyId, "1000", ct); + accountId = defaultRevenue?.Id; + } + + if (string.IsNullOrEmpty(accountId) || !TryParseAccountGuid(accountId, out var revenueAccountGuid)) + { + return PostingResult.Failed("INVALID_REVENUE_ACCOUNT", + $"Ugyldigt omsætningskonto format: {accountId}"); + } + + var amount = group.Sum(l => l.AmountExVat); + if (amount > 0) + { + legs.Add(new TransactionLeg + { + AccountId = revenueAccountGuid, + EntryType = revenueVatEntryType, + Amount = amount, + Currency = "DKK" + }); + } + } + + // 3. VAT account for total VAT + if (totalVat > 0 && vatAccountGuid.HasValue) + { + legs.Add(new TransactionLeg + { + AccountId = vatAccountGuid.Value, + EntryType = revenueVatEntryType, + Amount = totalVat, + Currency = "DKK" + }); + } + + // Parse fiscal year ID for period + var periodId = ParseFiscalYearId(request.FiscalYearId); + + // Determine description and idempotency key based on document type + var documentTypePrefix = isInvoice ? "invoice" : "creditnote"; + var documentTypeLabel = isInvoice ? "Faktura" : "Kreditnota"; + var description = request.Description ?? $"{documentTypeLabel} {request.DocumentNumber} - {customer.Name}"; + + // Create transaction request + var txRequest = new TransactionRequest + { + IdempotencyKey = $"{documentTypePrefix}-post-{request.DocumentId}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Auto, + ReferenceId = request.DocumentId, + Description = description, + Legs = legs, + PeriodId = periodId + }; + + logger.LogInformation( + "Posting {DocumentType} {DocumentNumber} to Ledger: {CustomerEntry} {CustomerAccount} {Total:N2}, {RevenueEntry} revenue {ExVat:N2}, {RevenueEntry} VAT {Vat:N2}", + documentTypeLabel, request.DocumentNumber, + customerEntryType, customer.SubLedgerAccountId, + totalAmount, revenueVatEntryType, totalExVat, revenueVatEntryType, totalVat); + + var result = await ledgerService.ProcessTransactionAsync(txRequest, ct); + + if (!result.Success) + { + logger.LogError( + "Failed to post {DocumentType} {DocumentNumber} to Ledger: {ErrorCode} - {ErrorMessage}", + documentTypeLabel, request.DocumentNumber, result.ErrorCode, result.ErrorMessage); + return PostingResult.Failed( + result.ErrorCode ?? "LEDGER_ERROR", + result.ErrorMessage ?? "Ukendt fejl"); + } + + logger.LogInformation( + "Successfully posted {DocumentType} {DocumentNumber} to Ledger with transaction {TransactionId}", + documentTypeLabel, request.DocumentNumber, result.TransactionId); + + return PostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while posting document {DocumentId} to Ledger", request.DocumentId); + return PostingResult.Failed("POSTING_EXCEPTION", ex.Message); + } + } + + public async Task ReverseSalesDocumentAsync( + string documentId, + string originalTransactionId, + string reason, + CancellationToken ct = default) + { + try + { + if (string.IsNullOrEmpty(originalTransactionId)) + { + // Draft document, no reversal needed + return PostingResult.Succeeded(string.Empty); + } + + logger.LogInformation( + "Reversing document {DocumentId} transaction {TransactionId}: {Reason}", + documentId, originalTransactionId, reason); + + var request = new TransactionRequest + { + IdempotencyKey = $"document-void-{documentId}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Void, + ReferenceId = originalTransactionId, + Description = $"Annullering: {reason}", + Legs = [] // Ledger should handle reversal based on ReferenceId + }; + + var result = await ledgerService.ProcessTransactionAsync(request, ct); + + if (!result.Success) + { + logger.LogError( + "Failed to reverse document {DocumentId}: {ErrorCode} - {ErrorMessage}", + documentId, result.ErrorCode, result.ErrorMessage); + return PostingResult.Failed( + result.ErrorCode ?? "REVERSAL_ERROR", + result.ErrorMessage ?? "Ukendt fejl ved tilbageførsel"); + } + + return PostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while reversing document {DocumentId}", documentId); + return PostingResult.Failed("REVERSAL_EXCEPTION", ex.Message); + } + } + + public async Task PostPaymentAsync( + PaymentPostingRequest request, + CancellationToken ct = default) + { + try + { + // Get customer to find sub-ledger account + var customer = await customerRepository.GetByIdAsync(request.CustomerId, ct); + if (customer == null) + { + return PostingResult.Failed("CUSTOMER_NOT_FOUND", + $"Kunde med ID {request.CustomerId} blev ikke fundet"); + } + + // Parse account IDs + if (!TryParseAccountGuid(customer.SubLedgerAccountId, out var customerAccountGuid)) + { + return PostingResult.Failed("INVALID_SUBLEDGER_ACCOUNT", + $"Ugyldigt sub-ledger konto format: {customer.SubLedgerAccountId}"); + } + + if (!TryParseAccountGuid(request.BankAccountId, out var bankAccountGuid)) + { + return PostingResult.Failed("INVALID_BANK_ACCOUNT", + $"Ugyldigt bankkonto format: {request.BankAccountId}"); + } + + // Parse fiscal year ID for period + var periodId = ParseFiscalYearId(request.FiscalYearId); + + // Build payment transaction: + // Debit: Bank account + // Credit: Customer sub-ledger account + var legs = new List + { + new() + { + AccountId = bankAccountGuid, + EntryType = EntryType.Debit, + Amount = request.Amount, + Currency = "DKK" + }, + new() + { + AccountId = customerAccountGuid, + EntryType = EntryType.Credit, + Amount = request.Amount, + Currency = "DKK" + } + }; + + var txRequest = new TransactionRequest + { + IdempotencyKey = $"payment-{request.DocumentId}-{Guid.NewGuid():N}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Auto, + ReferenceId = request.DocumentId, + Description = $"Betaling - {customer.Name}" + (request.Reference != null ? $" ({request.Reference})" : ""), + Legs = legs, + PeriodId = periodId + }; + + logger.LogInformation( + "Posting payment for document {DocumentId}: {Amount:N2} DKK", + request.DocumentId, request.Amount); + + var result = await ledgerService.ProcessTransactionAsync(txRequest, ct); + + if (!result.Success) + { + logger.LogError( + "Failed to post payment for document {DocumentId}: {ErrorCode} - {ErrorMessage}", + request.DocumentId, result.ErrorCode, result.ErrorMessage); + return PostingResult.Failed( + result.ErrorCode ?? "PAYMENT_ERROR", + result.ErrorMessage ?? "Ukendt betalingsfejl"); + } + + return PostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while posting payment for document {DocumentId}", request.DocumentId); + return PostingResult.Failed("PAYMENT_EXCEPTION", ex.Message); + } + } + + private static Guid? ParseFiscalYearId(string? fiscalYearId) + { + if (string.IsNullOrEmpty(fiscalYearId)) + return null; + + if (fiscalYearId.StartsWith("fiscalyear-")) + { + return Guid.Parse(fiscalYearId.Replace("fiscalyear-", "")); + } + + return Guid.TryParse(fiscalYearId, out var parsed) ? parsed : null; + } + + private static bool TryParseAccountGuid(string accountId, out Guid guid) + { + guid = Guid.Empty; + + // AccountId format: "account-{guid}" + if (accountId.StartsWith("account-", StringComparison.Ordinal)) + { + var guidString = accountId["account-".Length..]; + return Guid.TryParse(guidString, out guid); + } + + // Try parsing as raw GUID + return Guid.TryParse(accountId, out guid); + } +} diff --git a/backend/Books.Api/Invoicing/Services/ICustomerSubLedgerService.cs b/backend/Books.Api/Invoicing/Services/ICustomerSubLedgerService.cs new file mode 100644 index 0000000..bebee3e --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/ICustomerSubLedgerService.cs @@ -0,0 +1,29 @@ +namespace Books.Api.Invoicing.Services; + +/// +/// Service for managing customer sub-ledger accounts. +/// Each customer gets a sub-ledger account (1900-XXXX) under the main Debitorer account (1900). +/// This enables per-customer balance tracking through the existing Ledger system. +/// +public interface ICustomerSubLedgerService +{ + /// + /// Creates a sub-ledger account for a customer (e.g., 1900-0001 for customer 0001). + /// The account is automatically synced to the Ledger via LedgerAccountSyncSubscriber. + /// + /// The created account ID + Task CreateSubLedgerAccountAsync( + string companyId, + string customerNumber, + string customerName, + CancellationToken cancellationToken = default); + + /// + /// Gets the current balance for a customer from their sub-ledger account. + /// Positive balance = customer owes money (asset). + /// + Task GetCustomerBalanceAsync( + string subLedgerAccountId, + DateOnly? asOfDate = null, + CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/Invoicing/Services/IDocumentPostingService.cs b/backend/Books.Api/Invoicing/Services/IDocumentPostingService.cs new file mode 100644 index 0000000..3281406 --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/IDocumentPostingService.cs @@ -0,0 +1,99 @@ +namespace Books.Api.Invoicing.Services; + +/// +/// Unified service for posting sales documents (invoices and credit notes) to the Ledger. +/// Both document types use the same posting logic, just with opposite entry directions: +/// - Invoice: Debit Customer, Credit Revenue, Credit VAT +/// - Credit Note: Credit Customer, Debit Revenue, Debit VAT +/// +public interface IDocumentPostingService +{ + /// + /// Posts a sales document (invoice or credit note) to the Ledger. + /// + Task PostSalesDocumentAsync(SalesDocumentPostingRequest request, CancellationToken ct = default); + + /// + /// Reverses a previously posted sales document. + /// + Task ReverseSalesDocumentAsync( + string documentId, + string originalTransactionId, + string reason, + CancellationToken ct = default); + + /// + /// Posts a payment against a customer sub-ledger account. + /// + Task PostPaymentAsync(PaymentPostingRequest request, CancellationToken ct = default); +} + +/// +/// Type of sales document - determines the direction of ledger entries. +/// +public enum SalesDocumentType +{ + /// + /// Invoice: Debit Customer, Credit Revenue, Credit VAT + /// + Invoice = 1, + + /// + /// Credit Note: Credit Customer, Debit Revenue, Debit VAT (opposite of invoice) + /// + CreditNote = -1 +} + +/// +/// Request to post a sales document to the ledger. +/// +public record SalesDocumentPostingRequest( + string DocumentId, + SalesDocumentType DocumentType, + string CustomerId, + string CompanyId, + string? FiscalYearId, + string DocumentNumber, + IReadOnlyList Lines, + string? DefaultRevenueAccountId, + string? Description = null +); + +/// +/// A line item for sales document posting. +/// +public record SalesLineItem( + decimal AmountExVat, + decimal AmountVat, + decimal AmountTotal, + string? AccountId +); + +/// +/// Request to post a payment. +/// +public record PaymentPostingRequest( + string DocumentId, + string CustomerId, + string BankAccountId, + decimal Amount, + string? FiscalYearId, + string? Reference = null +); + +/// +/// Result of a posting operation. +/// +public record PostingResult +{ + public bool Success { get; init; } + public string? TransactionId { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } + + public static PostingResult Succeeded(string transactionId) => + new() { Success = true, TransactionId = transactionId }; + + public static PostingResult Failed(string errorCode, string errorMessage) => + new() { Success = false, ErrorCode = errorCode, ErrorMessage = errorMessage }; +} diff --git a/backend/Books.Api/Invoicing/Services/IInvoiceNumberService.cs b/backend/Books.Api/Invoicing/Services/IInvoiceNumberService.cs new file mode 100644 index 0000000..f22304e --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/IInvoiceNumberService.cs @@ -0,0 +1,26 @@ +namespace Books.Api.Invoicing.Services; + +/// +/// Service for generating sequential numbers for customers, invoices, and credit notes. +/// Uses atomic PostgreSQL UPSERT for thread-safe number generation. +/// Invoices and credit notes use year-based sequences per Momsloven §52. +/// +public interface IInvoiceNumberService +{ + /// + /// Gets the next customer number for a company (e.g., "0001", "0002"). + /// + Task GetNextCustomerNumberAsync(string companyId, CancellationToken cancellationToken = default); + + /// + /// Gets the next invoice number for a company and year (e.g., "INV-2024-0001"). + /// Invoice numbers reset at the start of each year per Momsloven §52. + /// + Task GetNextInvoiceNumberAsync(string companyId, int year, CancellationToken cancellationToken = default); + + /// + /// Gets the next credit note number for a company and year (e.g., "CN-2024-0001"). + /// Credit note numbers reset at the start of each year. + /// + Task GetNextCreditNoteNumberAsync(string companyId, int year, CancellationToken cancellationToken = default); +} diff --git a/backend/Books.Api/Invoicing/Services/IInvoicePostingService.cs b/backend/Books.Api/Invoicing/Services/IInvoicePostingService.cs new file mode 100644 index 0000000..caa9fb0 --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/IInvoicePostingService.cs @@ -0,0 +1,109 @@ +using Books.Api.Domain.Invoices; + +namespace Books.Api.Invoicing.Services; + +/// +/// Service for posting invoices to the Ledger. +/// Handles the creation of ledger transactions when invoices are sent. +/// +public interface IInvoicePostingService +{ + /// + /// Posts an invoice to the Ledger. + /// Creates entries: + /// - Debit: Customer sub-ledger account (1900-XXXX) + /// - Credit: Revenue account(s) per line + /// - Credit: VAT account (5611 Salgsmoms) if applicable + /// + /// The invoice ID + /// The customer ID (for looking up sub-ledger account) + /// The fiscal year to post to + /// The invoice number for description + /// The invoice lines + /// Default revenue account if line doesn't specify one + /// Cancellation token + /// The ledger transaction ID if successful + Task PostInvoiceAsync( + string invoiceId, + string customerId, + string fiscalYearId, + string invoiceNumber, + IReadOnlyList lines, + string defaultRevenueAccountId, + CancellationToken cancellationToken = default); + + /// + /// Reverses an invoice posting in the Ledger. + /// Called when voiding an invoice that has already been sent. + /// + /// The invoice ID + /// The original ledger transaction ID to reverse + /// Reason for voiding + /// Cancellation token + /// The reversal transaction ID if successful + Task ReverseInvoiceAsync( + string invoiceId, + string originalTransactionId, + string reason, + CancellationToken cancellationToken = default); + + /// + /// Posts a payment receipt to the Ledger. + /// Creates entries: + /// - Debit: Bank account + /// - Credit: Customer sub-ledger account (1900-XXXX) + /// + Task PostPaymentAsync( + string invoiceId, + string customerId, + string bankAccountId, + decimal amount, + string fiscalYearId, + string? reference, + CancellationToken cancellationToken = default); + + /// + /// Posts a credit note to the Ledger. + /// Creates entries (opposite of invoice): + /// - Credit: Customer sub-ledger account (1900-XXXX) + /// - Debit: Revenue account(s) per line + /// - Debit: VAT account (5611 Salgsmoms) if applicable + /// + Task PostCreditNoteAsync( + string creditNoteId, + string customerId, + string fiscalYearId, + string creditNoteNumber, + IReadOnlyList lines, + string defaultRevenueAccountId, + CancellationToken cancellationToken = default); + + /// + /// Posts the application of a credit note to an invoice. + /// Creates entries: + /// - Debit: Customer sub-ledger account (from credit note) + /// - Credit: Customer sub-ledger account (from invoice) + /// This effectively offsets the two balances. + /// + Task PostCreditApplicationAsync( + string creditNoteId, + string invoiceId, + string customerId, + decimal amount, + string fiscalYearId, + CancellationToken cancellationToken = default); +} + +public record InvoicePostingResult +{ + public bool Success { get; init; } + public string? TransactionId { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } + + public static InvoicePostingResult Succeeded(string transactionId) => + new() { Success = true, TransactionId = transactionId }; + + public static InvoicePostingResult Failed(string errorCode, string errorMessage) => + new() { Success = false, ErrorCode = errorCode, ErrorMessage = errorMessage }; +} diff --git a/backend/Books.Api/Invoicing/Services/IPaymentMatchingService.cs b/backend/Books.Api/Invoicing/Services/IPaymentMatchingService.cs new file mode 100644 index 0000000..7fbc8cd --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/IPaymentMatchingService.cs @@ -0,0 +1,143 @@ +namespace Books.Api.Invoicing.Services; + +/// +/// Service for matching bank transactions to invoices. +/// Uses multiple signals: amount, reference, counterparty name. +/// +public interface IPaymentMatchingService +{ + /// + /// Generates suggested matches for a specific bank transaction. + /// + Task> SuggestMatchesAsync( + string bankTransactionId, + CancellationToken cancellationToken = default); + + /// + /// Generates suggested matches for all pending bank transactions in a company. + /// + Task> SuggestMatchesForCompanyAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Confirms a suggested match and creates a payment allocation. + /// Also updates invoice payment status and marks bank transaction as booked. + /// + Task ConfirmMatchAsync( + string bankTransactionId, + string invoiceId, + decimal amount, + string matchMethod, + string userId, + CancellationToken cancellationToken = default); + + /// + /// Rejects a suggested match. + /// + Task RejectMatchAsync( + string bankTransactionId, + string invoiceId, + string userId, + CancellationToken cancellationToken = default); + + /// + /// Removes a payment allocation (unmatch). + /// + Task RemoveAllocationAsync( + string allocationId, + string userId, + CancellationToken cancellationToken = default); + + /// + /// Gets all pending suggested matches for a company. + /// + Task> GetPendingSuggestionsAsync( + string companyId, + CancellationToken cancellationToken = default); + + /// + /// Gets all payment allocations for an invoice. + /// + Task> GetAllocationsForInvoiceAsync( + string invoiceId, + CancellationToken cancellationToken = default); +} + +/// +/// A suggested match between a bank transaction and an invoice. +/// +public record SuggestedPaymentMatch +{ + public string Id { get; init; } = string.Empty; + public string CompanyId { get; init; } = string.Empty; + public string BankTransactionId { get; init; } = string.Empty; + public string InvoiceId { get; init; } = string.Empty; + + // Bank transaction details (denormalized for UI) + public decimal BankTransactionAmount { get; init; } + public DateOnly BankTransactionDate { get; init; } + public string? BankTransactionDescription { get; init; } + public string? BankTransactionCounterparty { get; init; } + + // Invoice details (denormalized for UI) + public string InvoiceNumber { get; init; } = string.Empty; + public string CustomerName { get; init; } = string.Empty; + public decimal InvoiceAmountRemaining { get; init; } + public DateOnly? InvoiceDueDate { get; init; } + + // Match scoring + public decimal Confidence { get; init; } + public decimal SuggestedAmount { get; init; } + public IReadOnlyList MatchReasons { get; init; } = []; + + // Status + public string Status { get; init; } = "pending"; + public DateTimeOffset CreatedAt { get; init; } +} + +/// +/// A reason why a match was suggested, with its contribution to the confidence score. +/// +public record MatchReason +{ + public string Reason { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public decimal Score { get; init; } +} + +/// +/// A confirmed payment allocation between a bank transaction and an invoice. +/// +public record PaymentAllocation +{ + public string Id { get; init; } = string.Empty; + public string CompanyId { get; init; } = string.Empty; + public string BankTransactionId { get; init; } = string.Empty; + public string? InvoiceId { get; init; } + public string? CreditNoteId { get; init; } + public decimal Amount { get; init; } + public string AllocationType { get; init; } = "payment"; + public string MatchMethod { get; init; } = "manual"; + public decimal? MatchConfidence { get; init; } + public string? LedgerTransactionId { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public string CreatedBy { get; init; } = string.Empty; +} + +/// +/// Result of confirming a payment match. +/// +public record PaymentAllocationResult +{ + public bool Success { get; init; } + public PaymentAllocation? Allocation { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } + + public static PaymentAllocationResult Succeeded(PaymentAllocation allocation) => + new() { Success = true, Allocation = allocation }; + + public static PaymentAllocationResult Failed(string errorCode, string errorMessage) => + new() { Success = false, ErrorCode = errorCode, ErrorMessage = errorMessage }; +} diff --git a/backend/Books.Api/Invoicing/Services/InvoiceNumberService.cs b/backend/Books.Api/Invoicing/Services/InvoiceNumberService.cs new file mode 100644 index 0000000..4dabb4c --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/InvoiceNumberService.cs @@ -0,0 +1,53 @@ +using Dapper; +using Npgsql; + +namespace Books.Api.Invoicing.Services; + +/// +/// PostgreSQL implementation of invoice number generation. +/// Uses database-level locking via UPSERT to ensure unique sequential numbers. +/// +public class InvoiceNumberService(NpgsqlDataSource dataSource) : IInvoiceNumberService +{ + public async Task GetNextCustomerNumberAsync(string companyId, CancellationToken cancellationToken = default) + { + var number = await GetNextNumberAsync(companyId, "customer", "", cancellationToken); + return number.ToString("D4"); // 0001, 0002, etc. + } + + public async Task GetNextInvoiceNumberAsync(string companyId, int year, CancellationToken cancellationToken = default) + { + var number = await GetNextNumberAsync(companyId, $"invoice-{year}", $"INV-{year}-", cancellationToken); + return $"INV-{year}-{number:D4}"; // INV-2024-0001 + } + + public async Task GetNextCreditNoteNumberAsync(string companyId, int year, CancellationToken cancellationToken = default) + { + var number = await GetNextNumberAsync(companyId, $"creditnote-{year}", $"CN-{year}-", cancellationToken); + return $"CN-{year}-{number:D4}"; // CN-2024-0001 + } + + private async Task GetNextNumberAsync( + string companyId, + string sequenceKey, + string prefix, + CancellationToken cancellationToken) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + // Use UPSERT with RETURNING to atomically get and increment the sequence + const string sql = """ + INSERT INTO number_series (company_id, sequence_key, last_number, prefix, updated_at) + VALUES (@CompanyId, @SequenceKey, 1, @Prefix, NOW()) + ON CONFLICT (company_id, sequence_key) + DO UPDATE SET + last_number = number_series.last_number + 1, + updated_at = NOW() + RETURNING last_number + """; + + return await connection.QuerySingleAsync( + sql, + new { CompanyId = companyId, SequenceKey = sequenceKey, Prefix = prefix }); + } +} diff --git a/backend/Books.Api/Invoicing/Services/InvoicePostingService.cs b/backend/Books.Api/Invoicing/Services/InvoicePostingService.cs new file mode 100644 index 0000000..e984604 --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/InvoicePostingService.cs @@ -0,0 +1,568 @@ +using Books.Api.Domain.Invoices; +using Books.Api.EventFlow.Repositories; +using Ledger.Core.Models; +using Ledger.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Books.Api.Invoicing.Services; + +/// +/// Implementation of invoice posting to the Ledger. +/// Uses the existing Ledger service for creating transactions. +/// +public class InvoicePostingService( + ILedgerService ledgerService, + ICustomerRepository customerRepository, + IAccountRepository accountRepository, + ILogger logger) : IInvoicePostingService +{ + private const string OutputVatAccountNumber = "5611"; // Salgsmoms + + public async Task PostInvoiceAsync( + string invoiceId, + string customerId, + string fiscalYearId, + string invoiceNumber, + IReadOnlyList lines, + string defaultRevenueAccountId, + CancellationToken cancellationToken = default) + { + try + { + // Get customer to find sub-ledger account + var customer = await customerRepository.GetByIdAsync(customerId, cancellationToken); + if (customer == null) + { + return InvoicePostingResult.Failed("CUSTOMER_NOT_FOUND", $"Customer '{customerId}' not found"); + } + + // Parse sub-ledger account ID to GUID + if (!TryParseAccountGuid(customer.SubLedgerAccountId, out var customerAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_SUBLEDGER_ACCOUNT", + $"Invalid sub-ledger account format: {customer.SubLedgerAccountId}"); + } + + // Get output VAT account + var vatAccount = await accountRepository.GetByCompanyAndNumberAsync( + customer.CompanyId, OutputVatAccountNumber, cancellationToken); + + Guid? vatAccountGuid = null; + if (vatAccount != null && TryParseAccountGuid(vatAccount.Id, out var parsedVatGuid)) + { + vatAccountGuid = parsedVatGuid; + } + + // Build transaction legs + var legs = new List(); + + // Calculate totals + var totalExVat = lines.Sum(l => l.AmountExVat); + var totalVat = lines.Sum(l => l.AmountVat); + var totalAmount = lines.Sum(l => l.AmountTotal); + + // 1. Debit customer sub-ledger account (1900-XXXX) for total amount + legs.Add(new TransactionLeg + { + AccountId = customerAccountGuid, + EntryType = EntryType.Debit, + Amount = totalAmount, + Currency = "DKK" + }); + + // 2. Credit revenue accounts for each line (ex VAT) + // Group by account to consolidate legs + var linesByAccount = lines.GroupBy(l => l.AccountId ?? defaultRevenueAccountId); + + foreach (var group in linesByAccount) + { + var accountId = group.Key; + if (!TryParseAccountGuid(accountId, out var revenueAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_REVENUE_ACCOUNT", + $"Invalid revenue account format: {accountId}"); + } + + var amount = group.Sum(l => l.AmountExVat); + if (amount > 0) + { + legs.Add(new TransactionLeg + { + AccountId = revenueAccountGuid, + EntryType = EntryType.Credit, + Amount = amount, + Currency = "DKK" + }); + } + } + + // 3. Credit VAT account for total VAT + if (totalVat > 0 && vatAccountGuid.HasValue) + { + legs.Add(new TransactionLeg + { + AccountId = vatAccountGuid.Value, + EntryType = EntryType.Credit, + Amount = totalVat, + Currency = "DKK" + }); + } + + // Parse fiscal year ID for period + Guid? periodId = null; + if (fiscalYearId.StartsWith("fiscalyear-")) + { + periodId = Guid.Parse(fiscalYearId.Replace("fiscalyear-", "")); + } + else if (Guid.TryParse(fiscalYearId, out var parsedPeriodId)) + { + periodId = parsedPeriodId; + } + + // Create transaction request + var request = new TransactionRequest + { + IdempotencyKey = $"invoice-send-{invoiceId}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Auto, + ReferenceId = invoiceId, + Description = $"Faktura {invoiceNumber} - {customer.Name}", + Legs = legs, + PeriodId = periodId + }; + + logger.LogInformation( + "Posting invoice {InvoiceNumber} to Ledger: Debit {CustomerAccount} {Total:N2}, Credit revenue {ExVat:N2}, Credit VAT {Vat:N2}", + invoiceNumber, customer.SubLedgerAccountId, totalAmount, totalExVat, totalVat); + + var result = await ledgerService.ProcessTransactionAsync(request, cancellationToken); + + if (!result.Success) + { + logger.LogError( + "Failed to post invoice {InvoiceNumber} to Ledger: {ErrorCode} - {ErrorMessage}", + invoiceNumber, result.ErrorCode, result.ErrorMessage); + return InvoicePostingResult.Failed( + result.ErrorCode ?? "LEDGER_ERROR", + result.ErrorMessage ?? "Unknown ledger error"); + } + + logger.LogInformation( + "Successfully posted invoice {InvoiceNumber} to Ledger with transaction {TransactionId}", + invoiceNumber, result.TransactionId); + + return InvoicePostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while posting invoice {InvoiceId} to Ledger", invoiceId); + return InvoicePostingResult.Failed("POSTING_EXCEPTION", ex.Message); + } + } + + public async Task ReverseInvoiceAsync( + string invoiceId, + string originalTransactionId, + string reason, + CancellationToken cancellationToken = default) + { + try + { + // For reversal, we need to query the original transaction and create opposite entries + // This is a simplified implementation - in production, you might want to use Ledger's + // built-in reversal functionality if available + + logger.LogInformation( + "Reversing invoice {InvoiceId} transaction {TransactionId}: {Reason}", + invoiceId, originalTransactionId, reason); + + // Create a reversal transaction request + // Note: This is a placeholder - the actual implementation depends on how the Ledger + // service handles reversals. You might need to: + // 1. Query the original transaction + // 2. Create opposite entries + // 3. Link to the original transaction + + // For now, we'll create a simple request that marks this as a reversal + var request = new TransactionRequest + { + IdempotencyKey = $"invoice-void-{invoiceId}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Void, + ReferenceId = originalTransactionId, + Description = $"Annullering: {reason}", + Legs = [] // Ledger should handle reversal based on ReferenceId + }; + + var result = await ledgerService.ProcessTransactionAsync(request, cancellationToken); + + if (!result.Success) + { + logger.LogError( + "Failed to reverse invoice {InvoiceId}: {ErrorCode} - {ErrorMessage}", + invoiceId, result.ErrorCode, result.ErrorMessage); + return InvoicePostingResult.Failed( + result.ErrorCode ?? "REVERSAL_ERROR", + result.ErrorMessage ?? "Unknown reversal error"); + } + + return InvoicePostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while reversing invoice {InvoiceId}", invoiceId); + return InvoicePostingResult.Failed("REVERSAL_EXCEPTION", ex.Message); + } + } + + public async Task PostPaymentAsync( + string invoiceId, + string customerId, + string bankAccountId, + decimal amount, + string fiscalYearId, + string? reference, + CancellationToken cancellationToken = default) + { + try + { + // Get customer to find sub-ledger account + var customer = await customerRepository.GetByIdAsync(customerId, cancellationToken); + if (customer == null) + { + return InvoicePostingResult.Failed("CUSTOMER_NOT_FOUND", $"Customer '{customerId}' not found"); + } + + // Parse account IDs + if (!TryParseAccountGuid(customer.SubLedgerAccountId, out var customerAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_SUBLEDGER_ACCOUNT", + $"Invalid sub-ledger account format: {customer.SubLedgerAccountId}"); + } + + if (!TryParseAccountGuid(bankAccountId, out var bankAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_BANK_ACCOUNT", + $"Invalid bank account format: {bankAccountId}"); + } + + // Parse fiscal year ID for period + Guid? periodId = null; + if (fiscalYearId.StartsWith("fiscalyear-")) + { + periodId = Guid.Parse(fiscalYearId.Replace("fiscalyear-", "")); + } + else if (Guid.TryParse(fiscalYearId, out var parsedPeriodId)) + { + periodId = parsedPeriodId; + } + + // Build payment transaction: + // Debit: Bank account + // Credit: Customer sub-ledger account + var legs = new List + { + new() + { + AccountId = bankAccountGuid, + EntryType = EntryType.Debit, + Amount = amount, + Currency = "DKK" + }, + new() + { + AccountId = customerAccountGuid, + EntryType = EntryType.Credit, + Amount = amount, + Currency = "DKK" + } + }; + + var request = new TransactionRequest + { + IdempotencyKey = $"invoice-payment-{invoiceId}-{Guid.NewGuid():N}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Auto, + ReferenceId = invoiceId, + Description = $"Betaling faktura - {customer.Name}" + (reference != null ? $" ({reference})" : ""), + Legs = legs, + PeriodId = periodId + }; + + logger.LogInformation( + "Posting payment for invoice {InvoiceId}: {Amount:N2} DKK", + invoiceId, amount); + + var result = await ledgerService.ProcessTransactionAsync(request, cancellationToken); + + if (!result.Success) + { + logger.LogError( + "Failed to post payment for invoice {InvoiceId}: {ErrorCode} - {ErrorMessage}", + invoiceId, result.ErrorCode, result.ErrorMessage); + return InvoicePostingResult.Failed( + result.ErrorCode ?? "PAYMENT_ERROR", + result.ErrorMessage ?? "Unknown payment error"); + } + + return InvoicePostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while posting payment for invoice {InvoiceId}", invoiceId); + return InvoicePostingResult.Failed("PAYMENT_EXCEPTION", ex.Message); + } + } + + public async Task PostCreditNoteAsync( + string creditNoteId, + string customerId, + string fiscalYearId, + string creditNoteNumber, + IReadOnlyList lines, + string defaultRevenueAccountId, + CancellationToken cancellationToken = default) + { + try + { + // Get customer to find sub-ledger account + var customer = await customerRepository.GetByIdAsync(customerId, cancellationToken); + if (customer == null) + { + return InvoicePostingResult.Failed("CUSTOMER_NOT_FOUND", $"Customer '{customerId}' not found"); + } + + // Parse sub-ledger account ID to GUID + if (!TryParseAccountGuid(customer.SubLedgerAccountId, out var customerAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_SUBLEDGER_ACCOUNT", + $"Invalid sub-ledger account format: {customer.SubLedgerAccountId}"); + } + + // Get output VAT account + var vatAccount = await accountRepository.GetByCompanyAndNumberAsync( + customer.CompanyId, OutputVatAccountNumber, cancellationToken); + + Guid? vatAccountGuid = null; + if (vatAccount != null && TryParseAccountGuid(vatAccount.Id, out var parsedVatGuid)) + { + vatAccountGuid = parsedVatGuid; + } + + // Build transaction legs (OPPOSITE of invoice posting) + var legs = new List(); + + // Calculate totals + var totalExVat = lines.Sum(l => l.AmountExVat); + var totalVat = lines.Sum(l => l.AmountVat); + var totalAmount = lines.Sum(l => l.AmountTotal); + + // 1. CREDIT customer sub-ledger account (opposite of invoice) + legs.Add(new TransactionLeg + { + AccountId = customerAccountGuid, + EntryType = EntryType.Credit, + Amount = totalAmount, + Currency = "DKK" + }); + + // 2. DEBIT revenue accounts for each line (opposite of invoice) + var linesByAccount = lines.GroupBy(l => l.AccountId ?? defaultRevenueAccountId); + + foreach (var group in linesByAccount) + { + var accountId = group.Key; + if (!TryParseAccountGuid(accountId, out var revenueAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_REVENUE_ACCOUNT", + $"Invalid revenue account format: {accountId}"); + } + + var amount = group.Sum(l => l.AmountExVat); + if (amount > 0) + { + legs.Add(new TransactionLeg + { + AccountId = revenueAccountGuid, + EntryType = EntryType.Debit, + Amount = amount, + Currency = "DKK" + }); + } + } + + // 3. DEBIT VAT account for total VAT (opposite of invoice) + if (totalVat > 0 && vatAccountGuid.HasValue) + { + legs.Add(new TransactionLeg + { + AccountId = vatAccountGuid.Value, + EntryType = EntryType.Debit, + Amount = totalVat, + Currency = "DKK" + }); + } + + // Parse fiscal year ID for period + Guid? periodId = null; + if (fiscalYearId.StartsWith("fiscalyear-")) + { + periodId = Guid.Parse(fiscalYearId.Replace("fiscalyear-", "")); + } + else if (Guid.TryParse(fiscalYearId, out var parsedPeriodId)) + { + periodId = parsedPeriodId; + } + + // Create transaction request + var request = new TransactionRequest + { + IdempotencyKey = $"creditnote-issue-{creditNoteId}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Auto, + ReferenceId = creditNoteId, + Description = $"Kreditnota {creditNoteNumber} - {customer.Name}", + Legs = legs, + PeriodId = periodId + }; + + logger.LogInformation( + "Posting credit note {CreditNoteNumber} to Ledger: Credit {CustomerAccount} {Total:N2}, Debit revenue {ExVat:N2}, Debit VAT {Vat:N2}", + creditNoteNumber, customer.SubLedgerAccountId, totalAmount, totalExVat, totalVat); + + var result = await ledgerService.ProcessTransactionAsync(request, cancellationToken); + + if (!result.Success) + { + logger.LogError( + "Failed to post credit note {CreditNoteNumber} to Ledger: {ErrorCode} - {ErrorMessage}", + creditNoteNumber, result.ErrorCode, result.ErrorMessage); + return InvoicePostingResult.Failed( + result.ErrorCode ?? "LEDGER_ERROR", + result.ErrorMessage ?? "Unknown ledger error"); + } + + logger.LogInformation( + "Successfully posted credit note {CreditNoteNumber} to Ledger with transaction {TransactionId}", + creditNoteNumber, result.TransactionId); + + return InvoicePostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while posting credit note {CreditNoteId} to Ledger", creditNoteId); + return InvoicePostingResult.Failed("POSTING_EXCEPTION", ex.Message); + } + } + + public async Task PostCreditApplicationAsync( + string creditNoteId, + string invoiceId, + string customerId, + decimal amount, + string fiscalYearId, + CancellationToken cancellationToken = default) + { + try + { + // Get customer to find sub-ledger account + var customer = await customerRepository.GetByIdAsync(customerId, cancellationToken); + if (customer == null) + { + return InvoicePostingResult.Failed("CUSTOMER_NOT_FOUND", $"Customer '{customerId}' not found"); + } + + // Parse sub-ledger account ID + if (!TryParseAccountGuid(customer.SubLedgerAccountId, out var customerAccountGuid)) + { + return InvoicePostingResult.Failed("INVALID_SUBLEDGER_ACCOUNT", + $"Invalid sub-ledger account format: {customer.SubLedgerAccountId}"); + } + + // Parse fiscal year ID for period + Guid? periodId = null; + if (fiscalYearId.StartsWith("fiscalyear-")) + { + periodId = Guid.Parse(fiscalYearId.Replace("fiscalyear-", "")); + } + else if (Guid.TryParse(fiscalYearId, out var parsedPeriodId)) + { + periodId = parsedPeriodId; + } + + // Credit application is an internal offset: + // The credit note's credit to customer account offsets the invoice's debit + // This is a zero-sum transaction within the same customer sub-ledger account + // We create a memo transaction to track the application + + var legs = new List + { + // Debit customer (reduces credit note credit balance) + new() + { + AccountId = customerAccountGuid, + EntryType = EntryType.Debit, + Amount = amount, + Currency = "DKK" + }, + // Credit customer (reduces invoice debit balance) + new() + { + AccountId = customerAccountGuid, + EntryType = EntryType.Credit, + Amount = amount, + Currency = "DKK" + } + }; + + var request = new TransactionRequest + { + IdempotencyKey = $"credit-application-{creditNoteId}-{invoiceId}-{Guid.NewGuid():N}", + End2EndId = Guid.NewGuid().ToString(), + TransactionType = TransactionType.Auto, + ReferenceId = $"{creditNoteId}:{invoiceId}", + Description = $"Kredit anvendt fra kreditnota til faktura - {customer.Name}", + Legs = legs, + PeriodId = periodId + }; + + logger.LogInformation( + "Posting credit application from {CreditNoteId} to {InvoiceId}: {Amount:N2} DKK", + creditNoteId, invoiceId, amount); + + var result = await ledgerService.ProcessTransactionAsync(request, cancellationToken); + + if (!result.Success) + { + logger.LogError( + "Failed to post credit application: {ErrorCode} - {ErrorMessage}", + result.ErrorCode, result.ErrorMessage); + return InvoicePostingResult.Failed( + result.ErrorCode ?? "APPLICATION_ERROR", + result.ErrorMessage ?? "Unknown application error"); + } + + return InvoicePostingResult.Succeeded(result.TransactionId!.Value.ToString()); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception while posting credit application"); + return InvoicePostingResult.Failed("APPLICATION_EXCEPTION", ex.Message); + } + } + + private static bool TryParseAccountGuid(string accountId, out Guid guid) + { + guid = Guid.Empty; + + // AccountId format: "account-{guid}" + if (accountId.StartsWith("account-", StringComparison.Ordinal)) + { + var guidString = accountId["account-".Length..]; + return Guid.TryParse(guidString, out guid); + } + + // Try parsing as raw GUID + return Guid.TryParse(accountId, out guid); + } +} diff --git a/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs b/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs new file mode 100644 index 0000000..184a327 --- /dev/null +++ b/backend/Books.Api/Invoicing/Services/PaymentMatchingService.cs @@ -0,0 +1,618 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Books.Api.Banking; +using Books.Api.EventFlow.Repositories; +using Dapper; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Books.Api.Invoicing.Services; + +/// +/// Implementation of payment matching service. +/// Uses multiple signals to suggest matches between bank transactions and invoices. +/// +public partial class PaymentMatchingService( + NpgsqlDataSource dataSource, + IBankTransactionRepository bankTransactionRepo, + IInvoiceRepository invoiceRepo, + ICustomerRepository customerRepo, + IInvoicePostingService invoicePostingService, + ILogger logger) : IPaymentMatchingService +{ + // Confidence thresholds + private const decimal AutoMatchThreshold = 0.95m; + private const decimal HighConfidenceThreshold = 0.80m; + + // Scoring weights + private const decimal ExactAmountScore = 0.50m; + private const decimal CloseAmountScore = 0.30m; + private const decimal ReferenceMatchScore = 0.40m; + private const decimal NameMatchScore = 0.30m; + private const decimal RecentInvoiceBonus = 0.10m; + + public async Task> SuggestMatchesAsync( + string bankTransactionId, + CancellationToken cancellationToken = default) + { + var transaction = await bankTransactionRepo.GetByIdAsync(bankTransactionId, cancellationToken); + if (transaction == null) + { + logger.LogWarning("Bank transaction {TransactionId} not found", bankTransactionId); + return []; + } + + // Only match incoming payments (positive amounts) + if (transaction.Amount <= 0) + { + return []; + } + + // Get unpaid invoices for the company + var unpaidInvoices = await invoiceRepo.GetUnpaidByCompanyIdAsync(transaction.CompanyId, cancellationToken); + if (unpaidInvoices.Count == 0) + { + return []; + } + + var suggestions = new List(); + + foreach (var invoice in unpaidInvoices) + { + var reasons = new List(); + decimal totalScore = 0; + + // 1. Amount matching + var amountScore = CalculateAmountScore(transaction.Amount, invoice.AmountRemaining); + if (amountScore > 0) + { + reasons.Add(new MatchReason + { + Reason = amountScore >= ExactAmountScore ? "exact_amount" : "close_amount", + Description = amountScore >= ExactAmountScore + ? "Beløbet matcher præcist" + : $"Beløbet er tæt på ({transaction.Amount:N2} vs {invoice.AmountRemaining:N2})", + Score = amountScore + }); + totalScore += amountScore; + } + + // 2. Reference matching (look for invoice number in transaction reference) + var referenceScore = CalculateReferenceScore( + transaction.Reference, + transaction.Description, + invoice.InvoiceNumber); + if (referenceScore > 0) + { + reasons.Add(new MatchReason + { + Reason = "reference_match", + Description = $"Fakturanummer '{invoice.InvoiceNumber}' fundet i reference", + Score = referenceScore + }); + totalScore += referenceScore; + } + + // 3. Name matching (compare counterparty to customer name) + var customer = await customerRepo.GetByIdAsync(invoice.CustomerId, cancellationToken); + if (customer != null) + { + var nameScore = CalculateNameScore(transaction.CounterpartyName, customer.Name); + if (nameScore > 0) + { + reasons.Add(new MatchReason + { + Reason = "name_match", + Description = $"Modpartsnavn ligner kundenavn", + Score = nameScore + }); + totalScore += nameScore; + } + } + + // 4. Recent invoice bonus (invoices from last 30 days get a small boost) + if (invoice.InvoiceDate.HasValue) + { + var invoiceDate = DateOnly.FromDateTime(invoice.InvoiceDate.Value); + var daysSinceInvoice = (DateOnly.FromDateTime(DateTime.Today).DayNumber - invoiceDate.DayNumber); + if (daysSinceInvoice <= 30) + { + reasons.Add(new MatchReason + { + Reason = "recent_invoice", + Description = "Faktura udstedt inden for de sidste 30 dage", + Score = RecentInvoiceBonus + }); + totalScore += RecentInvoiceBonus; + } + } + + // Only suggest if we have at least one matching signal + if (reasons.Count > 0 && totalScore >= 0.20m) + { + // Cap confidence at 1.0 + var confidence = Math.Min(totalScore, 1.0m); + + suggestions.Add(new SuggestedPaymentMatch + { + Id = Guid.NewGuid().ToString(), + CompanyId = transaction.CompanyId, + BankTransactionId = bankTransactionId, + InvoiceId = invoice.Id, + BankTransactionAmount = transaction.Amount, + BankTransactionDate = DateOnly.FromDateTime(transaction.BookingDate ?? transaction.ValueDate ?? DateTime.Today), + BankTransactionDescription = transaction.Description, + BankTransactionCounterparty = transaction.CounterpartyName, + InvoiceNumber = invoice.InvoiceNumber, + CustomerName = invoice.CustomerName, + InvoiceAmountRemaining = invoice.AmountRemaining, + InvoiceDueDate = invoice.DueDate.HasValue ? DateOnly.FromDateTime(invoice.DueDate.Value) : null, + Confidence = confidence, + SuggestedAmount = Math.Min(transaction.Amount, invoice.AmountRemaining), + MatchReasons = reasons, + Status = "pending", + CreatedAt = DateTimeOffset.UtcNow + }); + } + } + + // Sort by confidence descending + return suggestions.OrderByDescending(s => s.Confidence).ToList(); + } + + public async Task> SuggestMatchesForCompanyAsync( + string companyId, + CancellationToken cancellationToken = default) + { + // Get all pending bank transactions + var pendingTransactions = await bankTransactionRepo.GetPendingByCompanyIdAsync(companyId, cancellationToken); + + var allSuggestions = new List(); + + foreach (var transaction in pendingTransactions) + { + var suggestions = await SuggestMatchesAsync(transaction.Id, cancellationToken); + allSuggestions.AddRange(suggestions); + } + + // Sort by confidence and store in database + var sortedSuggestions = allSuggestions + .OrderByDescending(s => s.Confidence) + .ToList(); + + // Store suggestions in database for later retrieval + await StoreSuggestionsAsync(sortedSuggestions, cancellationToken); + + return sortedSuggestions; + } + + public async Task ConfirmMatchAsync( + string bankTransactionId, + string invoiceId, + decimal amount, + string matchMethod, + string userId, + CancellationToken cancellationToken = default) + { + try + { + // Validate bank transaction + var transaction = await bankTransactionRepo.GetByIdAsync(bankTransactionId, cancellationToken); + if (transaction == null) + { + return PaymentAllocationResult.Failed("TRANSACTION_NOT_FOUND", "Bank transaction not found"); + } + + if (transaction.Status != "pending") + { + return PaymentAllocationResult.Failed("TRANSACTION_NOT_PENDING", + $"Bank transaction is not pending (status: {transaction.Status})"); + } + + // Validate invoice + var invoice = await invoiceRepo.GetByIdAsync(invoiceId, cancellationToken); + if (invoice == null) + { + return PaymentAllocationResult.Failed("INVOICE_NOT_FOUND", "Invoice not found"); + } + + if (invoice.Status != "sent" && invoice.Status != "partially_paid") + { + return PaymentAllocationResult.Failed("INVOICE_NOT_PAYABLE", + $"Invoice cannot receive payment (status: {invoice.Status})"); + } + + if (amount > invoice.AmountRemaining) + { + return PaymentAllocationResult.Failed("AMOUNT_EXCEEDS_REMAINING", + $"Amount ({amount:N2}) exceeds remaining ({invoice.AmountRemaining:N2})"); + } + + // Get bank account for posting + var bankAccountId = transaction.BankAccountId; + if (string.IsNullOrEmpty(bankAccountId)) + { + return PaymentAllocationResult.Failed("NO_BANK_ACCOUNT", "Bank transaction has no associated account"); + } + + // Post payment to ledger + var postingResult = await invoicePostingService.PostPaymentAsync( + invoiceId, + invoice.CustomerId, + bankAccountId, + amount, + invoice.FiscalYearId!, + transaction.Reference, + cancellationToken); + + if (!postingResult.Success) + { + return PaymentAllocationResult.Failed( + postingResult.ErrorCode ?? "POSTING_FAILED", + postingResult.ErrorMessage ?? "Failed to post payment"); + } + + // Create allocation record + var allocationId = Guid.NewGuid().ToString(); + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(""" + INSERT INTO payment_allocations ( + id, company_id, bank_transaction_id, invoice_id, amount, + allocation_type, match_method, ledger_transaction_id, created_by + ) VALUES ( + @Id, @CompanyId, @BankTransactionId, @InvoiceId, @Amount, + 'payment', @MatchMethod, @LedgerTransactionId, @CreatedBy + ) + """, new + { + Id = allocationId, + CompanyId = transaction.CompanyId, + BankTransactionId = bankTransactionId, + InvoiceId = invoiceId, + Amount = amount, + MatchMethod = matchMethod, + LedgerTransactionId = postingResult.TransactionId, + CreatedBy = userId + }); + + // Mark bank transaction as booked (with journal entry draft ID for tracking) + await bankTransactionRepo.UpdateStatusAsync(bankTransactionId, "booked", null, cancellationToken); + + // Update suggestion status if exists + await connection.ExecuteAsync(""" + UPDATE suggested_payment_matches + SET status = 'accepted', reviewed_at = NOW(), reviewed_by = @UserId + WHERE bank_transaction_id = @BankTransactionId + AND invoice_id = @InvoiceId + AND status = 'pending' + """, new { BankTransactionId = bankTransactionId, InvoiceId = invoiceId, UserId = userId }); + + logger.LogInformation( + "Confirmed match: Bank transaction {TransactionId} -> Invoice {InvoiceId}, Amount: {Amount:N2}", + bankTransactionId, invoiceId, amount); + + return PaymentAllocationResult.Succeeded(new PaymentAllocation + { + Id = allocationId, + CompanyId = transaction.CompanyId, + BankTransactionId = bankTransactionId, + InvoiceId = invoiceId, + Amount = amount, + AllocationType = "payment", + MatchMethod = matchMethod, + LedgerTransactionId = postingResult.TransactionId, + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = userId + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error confirming match for transaction {TransactionId}", bankTransactionId); + return PaymentAllocationResult.Failed("CONFIRM_FAILED", ex.Message); + } + } + + public async Task RejectMatchAsync( + string bankTransactionId, + string invoiceId, + string userId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(""" + UPDATE suggested_payment_matches + SET status = 'rejected', reviewed_at = NOW(), reviewed_by = @UserId + WHERE bank_transaction_id = @BankTransactionId + AND invoice_id = @InvoiceId + AND status = 'pending' + """, new { BankTransactionId = bankTransactionId, InvoiceId = invoiceId, UserId = userId }); + + logger.LogInformation( + "Rejected match: Bank transaction {TransactionId} -> Invoice {InvoiceId}", + bankTransactionId, invoiceId); + } + + public async Task RemoveAllocationAsync( + string allocationId, + string userId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + // Get allocation details first + var allocation = await connection.QuerySingleOrDefaultAsync(""" + SELECT id AS Id, company_id AS CompanyId, bank_transaction_id AS BankTransactionId, + invoice_id AS InvoiceId, amount AS Amount + FROM payment_allocations + WHERE id = @AllocationId + """, new { AllocationId = allocationId }); + + if (allocation == null) + { + return false; + } + + // Delete allocation + await connection.ExecuteAsync( + "DELETE FROM payment_allocations WHERE id = @AllocationId", + new { AllocationId = allocationId }); + + // Mark bank transaction as pending again + if (!string.IsNullOrEmpty(allocation.BankTransactionId)) + { + await bankTransactionRepo.UpdateStatusAsync(allocation.BankTransactionId, "pending", null, cancellationToken); + } + + logger.LogInformation( + "Removed allocation {AllocationId}: Bank transaction {TransactionId} -> Invoice {InvoiceId}", + allocationId, allocation.BankTransactionId, allocation.InvoiceId); + + return true; + } + + public async Task> GetPendingSuggestionsAsync( + string companyId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var results = await connection.QueryAsync(""" + SELECT + spm.id, spm.company_id, spm.bank_transaction_id, spm.invoice_id, + spm.confidence, spm.match_reasons, spm.suggested_amount, spm.status, spm.created_at, + bt.amount AS bank_amount, bt.booking_date AS bank_date, + bt.description AS bank_description, bt.counterparty_name AS bank_counterparty, + inv.invoice_number, inv.customer_name, inv.amount_remaining AS invoice_remaining, + inv.due_date AS invoice_due_date + FROM suggested_payment_matches spm + JOIN bank_transactions bt ON bt.id = spm.bank_transaction_id + JOIN invoice_read_models inv ON inv.aggregate_id = spm.invoice_id + WHERE spm.company_id = @CompanyId + AND spm.status = 'pending' + ORDER BY spm.confidence DESC + """, new { CompanyId = companyId }); + + return results.Select(MapToSuggestedMatch).ToList(); + } + + public async Task> GetAllocationsForInvoiceAsync( + string invoiceId, + CancellationToken cancellationToken = default) + { + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + var results = await connection.QueryAsync(""" + SELECT + id AS Id, company_id AS CompanyId, bank_transaction_id AS BankTransactionId, + invoice_id AS InvoiceId, credit_note_id AS CreditNoteId, amount AS Amount, + allocation_type AS AllocationType, match_method AS MatchMethod, + match_confidence AS MatchConfidence, ledger_transaction_id AS LedgerTransactionId, + created_at AS CreatedAt, created_by AS CreatedBy + FROM payment_allocations + WHERE invoice_id = @InvoiceId + ORDER BY created_at DESC + """, new { InvoiceId = invoiceId }); + + return results.ToList(); + } + + #region Private Methods + + private static decimal CalculateAmountScore(decimal transactionAmount, decimal invoiceRemaining) + { + if (transactionAmount == invoiceRemaining) + { + return ExactAmountScore; + } + + // Allow 5% tolerance for close matches + var tolerance = invoiceRemaining * 0.05m; + if (Math.Abs(transactionAmount - invoiceRemaining) <= tolerance) + { + return CloseAmountScore; + } + + // Partial payment matching + if (transactionAmount < invoiceRemaining && transactionAmount > 0) + { + return CloseAmountScore * 0.5m; // Lower score for partial + } + + return 0; + } + + private decimal CalculateReferenceScore(string? reference, string? description, string invoiceNumber) + { + if (string.IsNullOrWhiteSpace(invoiceNumber)) + return 0; + + var searchText = $"{reference} {description}".ToUpperInvariant(); + + // Look for exact invoice number + if (searchText.Contains(invoiceNumber.ToUpperInvariant())) + { + return ReferenceMatchScore; + } + + // Look for invoice number without prefix (e.g., "2024-0001" instead of "INV-2024-0001") + var numberMatch = InvoiceNumberRegex().Match(invoiceNumber); + if (numberMatch.Success) + { + var shortNumber = numberMatch.Groups[1].Value; + if (searchText.Contains(shortNumber)) + { + return ReferenceMatchScore * 0.8m; + } + } + + return 0; + } + + private static decimal CalculateNameScore(string? counterpartyName, string customerName) + { + if (string.IsNullOrWhiteSpace(counterpartyName) || string.IsNullOrWhiteSpace(customerName)) + return 0; + + var normalizedCounterparty = NormalizeName(counterpartyName); + var normalizedCustomer = NormalizeName(customerName); + + // Exact match + if (normalizedCounterparty == normalizedCustomer) + { + return NameMatchScore; + } + + // Contains match + if (normalizedCounterparty.Contains(normalizedCustomer) || + normalizedCustomer.Contains(normalizedCounterparty)) + { + return NameMatchScore * 0.8m; + } + + // Word overlap + var counterpartyWords = normalizedCounterparty.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var customerWords = normalizedCustomer.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + var overlap = counterpartyWords.Intersect(customerWords).Count(); + if (overlap > 0) + { + var overlapRatio = (decimal)overlap / Math.Max(counterpartyWords.Length, customerWords.Length); + return NameMatchScore * overlapRatio * 0.6m; + } + + return 0; + } + + private static string NormalizeName(string name) + { + // Remove common suffixes and normalize + return name + .ToUpperInvariant() + .Replace("APS", "") + .Replace("A/S", "") + .Replace("ApS", "") + .Replace("IVS", "") + .Replace("K/S", "") + .Trim(); + } + + private async Task StoreSuggestionsAsync( + IReadOnlyList suggestions, + CancellationToken cancellationToken) + { + if (suggestions.Count == 0) return; + + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + foreach (var suggestion in suggestions) + { + await connection.ExecuteAsync(""" + INSERT INTO suggested_payment_matches ( + id, company_id, bank_transaction_id, invoice_id, + confidence, match_reasons, suggested_amount, status + ) VALUES ( + @Id, @CompanyId, @BankTransactionId, @InvoiceId, + @Confidence, @MatchReasons::jsonb, @SuggestedAmount, 'pending' + ) + ON CONFLICT (bank_transaction_id, invoice_id) WHERE status = 'pending' + DO UPDATE SET + confidence = EXCLUDED.confidence, + match_reasons = EXCLUDED.match_reasons, + suggested_amount = EXCLUDED.suggested_amount + """, new + { + suggestion.Id, + suggestion.CompanyId, + suggestion.BankTransactionId, + suggestion.InvoiceId, + suggestion.Confidence, + MatchReasons = JsonSerializer.Serialize(suggestion.MatchReasons), + suggestion.SuggestedAmount + }); + } + } + + private static SuggestedPaymentMatch MapToSuggestedMatch(SuggestedPaymentMatchDto dto) + { + var reasons = string.IsNullOrEmpty(dto.MatchReasons) + ? [] + : JsonSerializer.Deserialize>(dto.MatchReasons) ?? []; + + return new SuggestedPaymentMatch + { + Id = dto.Id, + CompanyId = dto.CompanyId, + BankTransactionId = dto.BankTransactionId, + InvoiceId = dto.InvoiceId, + BankTransactionAmount = dto.BankAmount, + BankTransactionDate = dto.BankDate.HasValue + ? DateOnly.FromDateTime(dto.BankDate.Value) + : DateOnly.FromDateTime(DateTime.Today), + BankTransactionDescription = dto.BankDescription, + BankTransactionCounterparty = dto.BankCounterparty, + InvoiceNumber = dto.InvoiceNumber, + CustomerName = dto.CustomerName, + InvoiceAmountRemaining = dto.InvoiceRemaining, + InvoiceDueDate = dto.InvoiceDueDate.HasValue + ? DateOnly.FromDateTime(dto.InvoiceDueDate.Value) + : null, + Confidence = dto.Confidence, + SuggestedAmount = dto.SuggestedAmount, + MatchReasons = reasons, + Status = dto.Status, + CreatedAt = dto.CreatedAt + }; + } + + [GeneratedRegex(@"(\d{4}-\d+)")] + private static partial Regex InvoiceNumberRegex(); + + #endregion +} + +internal class SuggestedPaymentMatchDto +{ + public string Id { get; set; } = string.Empty; + public string CompanyId { get; set; } = string.Empty; + public string BankTransactionId { get; set; } = string.Empty; + public string InvoiceId { get; set; } = string.Empty; + public decimal Confidence { get; set; } + public string? MatchReasons { get; set; } + public decimal SuggestedAmount { get; set; } + public string Status { get; set; } = "pending"; + public DateTimeOffset CreatedAt { get; set; } + + // Bank transaction fields + public decimal BankAmount { get; set; } + public DateTime? BankDate { get; set; } + public string? BankDescription { get; set; } + public string? BankCounterparty { get; set; } + + // Invoice fields + public string InvoiceNumber { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public decimal InvoiceRemaining { get; set; } + public DateTime? InvoiceDueDate { get; set; } +} diff --git a/backend/Books.Api/Ledger/LedgerAccountSyncSubscriber.cs b/backend/Books.Api/Ledger/LedgerAccountSyncSubscriber.cs new file mode 100644 index 0000000..72212b5 --- /dev/null +++ b/backend/Books.Api/Ledger/LedgerAccountSyncSubscriber.cs @@ -0,0 +1,137 @@ +using Books.Api.Domain.Accounts; +using Books.Api.Domain.Accounts.Events; +using Books.Api.EventFlow.Repositories; +using EventFlow.Aggregates; +using EventFlow.Subscribers; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Books.Api.Ledger; + +/// +/// Async subscriber that syncs accounts from Books.Api to Ledger when created. +/// This ensures every account in the chart of accounts has a corresponding Ledger account +/// for posting transactions. +/// +public class LedgerAccountSyncSubscriber( + NpgsqlDataSource dataSource, + ICompanyRepository companyRepository, + ILogger logger) + : ISubscribeAsynchronousTo +{ + private const string InsertAccountSql = """ + INSERT INTO accounts (id, customer_id, account_type, currency, balance, status) + VALUES ($1, $2, $3, $4, 0, 'active') + ON CONFLICT (id) DO NOTHING + """; + + public async Task HandleAsync( + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + var accountIdString = domainEvent.AggregateIdentity.Value; + + // Extract UUID from AccountId format "account-{guid}" + if (!TryParseAccountGuid(accountIdString, out var accountGuid)) + { + logger.LogWarning( + "Cannot sync account {AccountId} to Ledger - invalid ID format", + accountIdString); + return; + } + + // Look up company to get currency + var company = await companyRepository.GetByIdAsync(e.CompanyId, cancellationToken); + if (company == null) + { + logger.LogWarning( + "Cannot sync account {AccountId} to Ledger - company {CompanyId} not found", + accountIdString, e.CompanyId); + return; + } + + // Map Books.Api AccountType to Ledger account_type + var ledgerAccountType = MapAccountType(e.AccountType); + + // Parse company ID to GUID for customer_id + if (!TryParseCompanyGuid(e.CompanyId, out var customerGuid)) + { + logger.LogWarning( + "Cannot sync account {AccountId} to Ledger - invalid company ID format: {CompanyId}", + accountIdString, e.CompanyId); + return; + } + + try + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = new NpgsqlCommand(InsertAccountSql, conn) + { + Parameters = + { + new() { Value = accountGuid }, + new() { Value = customerGuid }, + new() { Value = ledgerAccountType }, + new() { Value = company.Currency } + } + }; + + var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken); + + if (rowsAffected > 0) + { + logger.LogInformation( + "Synced account {AccountNumber} ({AccountName}) to Ledger with ID {LedgerAccountId}", + e.AccountNumber, e.Name, accountGuid); + } + else + { + logger.LogDebug( + "Account {AccountNumber} already exists in Ledger (ID {LedgerAccountId})", + e.AccountNumber, accountGuid); + } + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to sync account {AccountId} ({AccountNumber}) to Ledger", + accountIdString, e.AccountNumber); + throw; // Re-throw to trigger retry via resilience strategy + } + } + + private static bool TryParseAccountGuid(string accountId, out Guid guid) + { + guid = Guid.Empty; + + // AccountId format: "account-{guid}" + if (!accountId.StartsWith("account-", StringComparison.Ordinal)) + return false; + + var guidString = accountId["account-".Length..]; + return Guid.TryParse(guidString, out guid); + } + + private static bool TryParseCompanyGuid(string companyId, out Guid guid) + { + guid = Guid.Empty; + + // CompanyId format: "company-{guid}" + if (!companyId.StartsWith("company-", StringComparison.Ordinal)) + return false; + + var guidString = companyId["company-".Length..]; + return Guid.TryParse(guidString, out guid); + } + + private static string MapAccountType(AccountType accountType) => accountType switch + { + AccountType.Asset => "asset", + AccountType.Liability => "liability", + AccountType.Equity => "equity", + AccountType.Revenue => "revenue", + AccountType.Expense => "expense", + _ => "other" + }; +} diff --git a/backend/Books.Api/Ledger/LedgerJobsExtensions.cs b/backend/Books.Api/Ledger/LedgerJobsExtensions.cs new file mode 100644 index 0000000..b8ebb3e --- /dev/null +++ b/backend/Books.Api/Ledger/LedgerJobsExtensions.cs @@ -0,0 +1,41 @@ +using Books.Api.Banking; +using Hangfire; + +namespace Books.Api.Ledger; + +public static class LedgerJobsExtensions +{ + /// + /// Configures Ledger-related Hangfire jobs. + /// Should be called after Hangfire is fully initialized. + /// + public static void ConfigureLedgerJobs(this IApplicationBuilder app) + { + // Use DI-based IRecurringJobManager instead of static RecurringJob + // This works correctly in test environments where JobStorage may not be initialized + var recurringJobManager = app.ApplicationServices.GetService(); + if (recurringJobManager == null) + { + // In test environment, Hangfire may not be fully configured + return; + } + + // Poll Ledger outbox every 5 seconds for new transaction events + recurringJobManager.AddOrUpdate( + "ledger-outbox-consumer", + x => x.ProcessPendingEventsAsync(), + "*/5 * * * * *"); // Every 5 seconds (cron with seconds) + + // Sync bank transactions from Enable Banking every 30 minutes + recurringJobManager.AddOrUpdate( + "bank-transaction-sync", + x => x.SyncAllActiveConnectionsAsync(default), + "*/30 * * * *"); // Every 30 minutes + + // Reconcile bank accounts with ledger accounts daily at 6:00 AM + recurringJobManager.AddOrUpdate( + "bank-reconciliation", + x => x.ReconcileAllAsync(default), + "0 6 * * *"); // Daily at 06:00 + } +} diff --git a/backend/Books.Api/Ledger/LedgerOutboxConsumer.cs b/backend/Books.Api/Ledger/LedgerOutboxConsumer.cs new file mode 100644 index 0000000..29c75ac --- /dev/null +++ b/backend/Books.Api/Ledger/LedgerOutboxConsumer.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using Hangfire; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Books.Api.Ledger; + +/// +/// Hangfire job that polls the Ledger outbox table and processes transaction events. +/// Events are transformed and can be used to update read models or trigger other domain actions. +/// +public class LedgerOutboxConsumer( + NpgsqlDataSource dataSource, + ILogger logger) +{ + private const int BatchSize = 100; + + private const string PollOutboxSql = """ + SELECT id, sequence_number, event_type, aggregate_id, end2end_id, payload, created_at + FROM outbox + WHERE processed_at IS NULL + ORDER BY sequence_number + LIMIT $1 + FOR UPDATE SKIP LOCKED + """; + + private const string MarkProcessedSql = """ + UPDATE outbox + SET processed_at = now() + WHERE id = $1 + """; + + /// + /// Processes pending events from the Ledger outbox. + /// Called periodically by Hangfire. + /// + [DisableConcurrentExecution(timeoutInSeconds: 30)] + [AutomaticRetry(Attempts = 3)] + public async Task ProcessPendingEventsAsync() + { + var processedCount = 0; + var errorCount = 0; + + try + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + await using var pollCmd = new NpgsqlCommand(PollOutboxSql, conn, tx) + { + Parameters = { new() { Value = BatchSize } } + }; + + await using var reader = await pollCmd.ExecuteReaderAsync(); + + var events = new List(); + while (await reader.ReadAsync()) + { + events.Add(new OutboxEvent + { + Id = reader.GetGuid(0), + SequenceNumber = reader.GetInt64(1), + EventType = reader.GetString(2), + AggregateId = reader.GetString(3), + End2EndId = reader.GetString(4), + Payload = reader.GetString(5), + CreatedAt = reader.GetDateTime(6) + }); + } + + await reader.CloseAsync(); + + foreach (var evt in events) + { + try + { + await ProcessEventAsync(evt, conn, tx); + await MarkAsProcessedAsync(evt.Id, conn, tx); + processedCount++; + } + catch (Exception ex) + { + errorCount++; + logger.LogError(ex, + "Failed to process outbox event {EventId} ({EventType})", + evt.Id, evt.EventType); + // Continue with other events - failed events will be retried + } + } + + await tx.CommitAsync(); + + if (processedCount > 0) + { + logger.LogInformation( + "Processed {ProcessedCount} Ledger outbox events ({ErrorCount} errors)", + processedCount, errorCount); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error polling Ledger outbox"); + throw; + } + } + + private Task ProcessEventAsync(OutboxEvent evt, NpgsqlConnection conn, NpgsqlTransaction tx) + { + // Parse the event and take appropriate action + // Currently just logging - can be extended to emit DDD commands + return evt.EventType switch + { + "transaction.auto" => HandleTransactionAutoAsync(evt), + "transaction.reserve" => HandleTransactionReserveAsync(evt), + "transaction.capture" => HandleTransactionCaptureAsync(evt), + "transaction.void" => HandleTransactionVoidAsync(evt), + _ => HandleUnknownEventAsync(evt) + }; + } + + private Task HandleTransactionAutoAsync(OutboxEvent evt) + { + var payload = JsonSerializer.Deserialize(evt.Payload); + logger.LogDebug( + "Transaction.Auto: {IdempotencyKey} with {LegCount} legs for period {PeriodId}", + payload?.IdempotencyKey, payload?.Legs?.Count ?? 0, payload?.PeriodId); + + // Future: Emit PostingRecordedCommand to DDD for read model updates + // var command = new RecordPostingCommand(...); + // await commandBus.PublishAsync(command); + + return Task.CompletedTask; + } + + private Task HandleTransactionReserveAsync(OutboxEvent evt) + { + logger.LogDebug("Transaction.Reserve: {AggregateId}", evt.AggregateId); + return Task.CompletedTask; + } + + private Task HandleTransactionCaptureAsync(OutboxEvent evt) + { + logger.LogDebug("Transaction.Capture: {AggregateId}", evt.AggregateId); + return Task.CompletedTask; + } + + private Task HandleTransactionVoidAsync(OutboxEvent evt) + { + logger.LogDebug("Transaction.Void: {AggregateId}", evt.AggregateId); + return Task.CompletedTask; + } + + private Task HandleUnknownEventAsync(OutboxEvent evt) + { + logger.LogWarning("Unknown outbox event type: {EventType}", evt.EventType); + return Task.CompletedTask; + } + + private static async Task MarkAsProcessedAsync(Guid eventId, NpgsqlConnection conn, NpgsqlTransaction tx) + { + await using var cmd = new NpgsqlCommand(MarkProcessedSql, conn, tx) + { + Parameters = { new() { Value = eventId } } + }; + await cmd.ExecuteNonQueryAsync(); + } + + private sealed class OutboxEvent + { + public required Guid Id { get; init; } + public required long SequenceNumber { get; init; } + public required string EventType { get; init; } + public required string AggregateId { get; init; } + public required string End2EndId { get; init; } + public required string Payload { get; init; } + public required DateTime CreatedAt { get; init; } + } + + private sealed class TransactionPayload + { + public string? IdempotencyKey { get; init; } + public string? End2EndId { get; init; } + public string? TransactionType { get; init; } + public string? ReferenceId { get; init; } + public List? Legs { get; init; } + public Guid? PeriodId { get; init; } + } + + private sealed class LegPayload + { + public Guid AccountId { get; init; } + public string? Type { get; init; } + public decimal Amount { get; init; } + public string? Currency { get; init; } + } +} diff --git a/backend/Books.Api/Ledger/LedgerPeriodSyncSubscriber.cs b/backend/Books.Api/Ledger/LedgerPeriodSyncSubscriber.cs new file mode 100644 index 0000000..ff2879b --- /dev/null +++ b/backend/Books.Api/Ledger/LedgerPeriodSyncSubscriber.cs @@ -0,0 +1,90 @@ +using Books.Api.Domain.FiscalYears; +using Books.Api.Domain.FiscalYears.Events; +using EventFlow.Aggregates; +using EventFlow.Subscribers; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Books.Api.Ledger; + +/// +/// Async subscriber that syncs fiscal years from Books.Api to Ledger's accounting_periods table. +/// This ensures every fiscal year has a corresponding Ledger period for posting transactions. +/// +public class LedgerPeriodSyncSubscriber( + NpgsqlDataSource dataSource, + ILogger logger) + : ISubscribeAsynchronousTo +{ + private const string InsertPeriodSql = """ + INSERT INTO accounting_periods (id, name, start_date, end_date, is_locked, created_at) + VALUES ($1, $2, $3, $4, false, now()) + ON CONFLICT (id) DO NOTHING + """; + + public async Task HandleAsync( + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + var e = domainEvent.AggregateEvent; + var fiscalYearIdString = domainEvent.AggregateIdentity.Value; + + // Extract UUID from FiscalYearId format "fiscalyear-{guid}" + if (!TryParseFiscalYearGuid(fiscalYearIdString, out var periodGuid)) + { + logger.LogWarning( + "Cannot sync fiscal year {FiscalYearId} to Ledger - invalid ID format", + fiscalYearIdString); + return; + } + + try + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = new NpgsqlCommand(InsertPeriodSql, conn) + { + Parameters = + { + new() { Value = periodGuid }, + new() { Value = e.Name }, + new() { Value = e.StartDate }, + new() { Value = e.EndDate } + } + }; + + var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken); + + if (rowsAffected > 0) + { + logger.LogInformation( + "Synced fiscal year {FiscalYearName} ({StartDate} - {EndDate}) to Ledger accounting_periods with ID {PeriodId}", + e.Name, e.StartDate, e.EndDate, periodGuid); + } + else + { + logger.LogDebug( + "Fiscal year {FiscalYearName} already exists in Ledger accounting_periods (ID {PeriodId})", + e.Name, periodGuid); + } + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to sync fiscal year {FiscalYearId} ({FiscalYearName}) to Ledger", + fiscalYearIdString, e.Name); + throw; // Re-throw to trigger retry via resilience strategy + } + } + + private static bool TryParseFiscalYearGuid(string fiscalYearId, out Guid guid) + { + guid = Guid.Empty; + + // FiscalYearId format: "fiscalyear-{guid}" + if (!fiscalYearId.StartsWith("fiscalyear-", StringComparison.Ordinal)) + return false; + + var guidString = fiscalYearId["fiscalyear-".Length..]; + return Guid.TryParse(guidString, out guid); + } +} diff --git a/backend/Books.Api/Reporting/IVatReportService.cs b/backend/Books.Api/Reporting/IVatReportService.cs new file mode 100644 index 0000000..d48d365 --- /dev/null +++ b/backend/Books.Api/Reporting/IVatReportService.cs @@ -0,0 +1,21 @@ +namespace Books.Api.Reporting; + +/// +/// Service for generating VAT (moms) reports for SKAT compliance. +/// +public interface IVatReportService +{ + /// + /// Generates a VAT report for a company within a specified period. + /// + /// The company ID + /// Start date of the VAT period (inclusive) + /// End date of the VAT period (inclusive) + /// Cancellation token + /// VAT report with SKAT boxes and summary + Task GenerateReportAsync( + string companyId, + DateOnly periodStart, + DateOnly periodEnd, + CancellationToken ct = default); +} diff --git a/backend/Books.Api/Reporting/VatReportDto.cs b/backend/Books.Api/Reporting/VatReportDto.cs new file mode 100644 index 0000000..aad8584 --- /dev/null +++ b/backend/Books.Api/Reporting/VatReportDto.cs @@ -0,0 +1,54 @@ +namespace Books.Api.Reporting; + +/// +/// VAT (Moms) report data for SKAT compliance. +/// Contains SKAT boxes A-D for VAT amounts and basis fields 1-4 for turnover. +/// +public class VatReportDto +{ + // SKAT VAT boxes (Momsbeløb) + /// Rubrik A: Salgsmoms (Output VAT from domestic sales at 25%) + public decimal BoxA { get; set; } + + /// Rubrik B: Købsmoms (Input VAT deduction from purchases at 25%) + public decimal BoxB { get; set; } + + /// Rubrik C: EU-varekøb moms (VAT on goods purchased from EU - reverse charge) + public decimal BoxC { get; set; } + + /// Rubrik D: Ydelseskøb moms (VAT on services purchased from abroad - reverse charge) + public decimal BoxD { get; set; } + + // Basis/turnover fields (Omsætning) + /// Felt 1: Salg med moms (Turnover with VAT - domestic sales) + public decimal Basis1 { get; set; } + + /// Felt 2: Salg uden moms (Turnover without VAT - exports, exempt) + public decimal Basis2 { get; set; } + + /// Felt 3: EU-varekøb (Purchases of goods from EU) + public decimal Basis3 { get; set; } + + /// Felt 4: Ydelseskøb fra udland (Purchases of services from abroad) + public decimal Basis4 { get; set; } + + // Summary calculations + /// Total output VAT (A + C + D) + public decimal TotalOutputVat { get; set; } + + /// Total input VAT (B - deductible) + public decimal TotalInputVat { get; set; } + + /// Net VAT (TotalOutputVat - TotalInputVat). Positive = payment to SKAT, Negative = refund + public decimal NetVat { get; set; } + + // Period info + /// Start date of the VAT period + public DateOnly PeriodStart { get; set; } + + /// End date of the VAT period + public DateOnly PeriodEnd { get; set; } + + /// Number of transactions with VAT in the period + public int TransactionCount { get; set; } +} diff --git a/backend/Books.Api/Reporting/VatReportService.cs b/backend/Books.Api/Reporting/VatReportService.cs new file mode 100644 index 0000000..50d2d97 --- /dev/null +++ b/backend/Books.Api/Reporting/VatReportService.cs @@ -0,0 +1,161 @@ +using Books.Api.EventFlow.Repositories; +using Ledger.Core.Models; +using Ledger.Core.Services; + +namespace Books.Api.Reporting; + +/// +/// Service for generating VAT (moms) reports from ledger data. +/// Aggregates VAT account balances for SKAT compliance. +/// +public class VatReportService( + IAccountRepository accountRepository, + ILedgerService ledgerService, + ILogger logger) : IVatReportService +{ + // Standard Danish VAT account numbers + private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms) + private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms) + + public async Task GenerateReportAsync( + string companyId, + DateOnly periodStart, + DateOnly periodEnd, + CancellationToken ct = default) + { + // Validate period + if (periodEnd < periodStart) + { + throw new ArgumentException("Slutdato skal være efter startdato", nameof(periodEnd)); + } + + var daysDiff = periodEnd.DayNumber - periodStart.DayNumber; + if (daysDiff > 366) + { + throw new ArgumentException("Perioden må ikke overstige ét år (366 dage)", nameof(periodEnd)); + } + + logger.LogInformation( + "Generating VAT report for company {CompanyId} from {PeriodStart} to {PeriodEnd}", + companyId, periodStart, periodEnd); + + // Look up VAT accounts + var inputVatAccount = await accountRepository.GetByCompanyAndNumberAsync( + companyId, InputVatAccountNumber, ct); + var outputVatAccount = await accountRepository.GetByCompanyAndNumberAsync( + companyId, OutputVatAccountNumber, ct); + + var report = new VatReportDto + { + PeriodStart = periodStart, + PeriodEnd = periodEnd + }; + + // If neither VAT account exists, return empty report + if (inputVatAccount == null && outputVatAccount == null) + { + logger.LogWarning( + "No VAT accounts found for company {CompanyId}. Returning empty report.", + companyId); + return report; + } + + // Collect account IDs for ledger query + var accountIds = new List(); + + if (inputVatAccount != null && TryParseAccountGuid(inputVatAccount.Id, out var inputGuid)) + { + accountIds.Add(inputGuid); + } + + if (outputVatAccount != null && TryParseAccountGuid(outputVatAccount.Id, out var outputGuid)) + { + accountIds.Add(outputGuid); + } + + if (accountIds.Count == 0) + { + logger.LogWarning("No valid VAT account GUIDs found for company {CompanyId}", companyId); + return report; + } + + // Query ledger for aggregated balances in the period + var query = new EntriesQuery + { + AccountIds = accountIds, + From = new DateTimeOffset(periodStart.ToDateTime(TimeOnly.MinValue)), + To = new DateTimeOffset(periodEnd.ToDateTime(TimeOnly.MaxValue)), + Aggregate = true + }; + + var result = await ledgerService.QueryEntriesAsync(query, ct); + + // Process aggregated balances + if (result.Aggregates != null) + { + foreach (var balance in result.Aggregates) + { + if (inputVatAccount != null && + TryParseAccountGuid(inputVatAccount.Id, out var checkInputGuid) && + balance.AccountId == checkInputGuid) + { + // Input VAT (5610): Debits are VAT receivable/deductions + // Box B = total input VAT deduction + report.BoxB = balance.TotalDebits; + report.TransactionCount += balance.EntryCount; + + logger.LogDebug( + "Input VAT (5610): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}", + balance.TotalDebits, balance.TotalCredits, balance.NetChange); + } + else if (outputVatAccount != null && + TryParseAccountGuid(outputVatAccount.Id, out var checkOutputGuid) && + balance.AccountId == checkOutputGuid) + { + // Output VAT (5611): Credits are VAT payable to SKAT + // Box A = total output VAT (salgsmoms) + report.BoxA = balance.TotalCredits; + report.TransactionCount += balance.EntryCount; + + logger.LogDebug( + "Output VAT (5611): TotalDebits={Debits}, TotalCredits={Credits}, NetChange={Net}", + balance.TotalDebits, balance.TotalCredits, balance.NetChange); + } + } + } + + // Calculate summary totals + // Box A = Salgsmoms (domestic output VAT) + // Box B = Købsmoms (input VAT - deductible) + // Box C = EU-varekøb moms (not yet supported - requires VAT code breakdown) + // Box D = Ydelseskøb moms (not yet supported - requires VAT code breakdown) + report.TotalOutputVat = report.BoxA + report.BoxC + report.BoxD; + 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 + if (report.BoxA > 0) + { + report.Basis1 = Math.Round(report.BoxA / 0.25m, 2); + } + + logger.LogInformation( + "VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}", + companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat); + + return report; + } + + private static bool TryParseAccountGuid(string accountId, out Guid guid) + { + // Account IDs are in format "account-{guid}" + if (accountId.StartsWith("account-")) + { + return Guid.TryParse(accountId.Replace("account-", ""), out guid); + } + + guid = Guid.Empty; + return false; + } +} diff --git a/backend/Books.Api/Saft/Models/SaftModels.cs b/backend/Books.Api/Saft/Models/SaftModels.cs new file mode 100644 index 0000000..21e0b49 --- /dev/null +++ b/backend/Books.Api/Saft/Models/SaftModels.cs @@ -0,0 +1,182 @@ +namespace Books.Api.Saft.Models; + +/// +/// Complete SAF-T DK audit file document +/// +public record SaftDocument( + SaftHeader Header, + SaftMasterFiles MasterFiles, + SaftGeneralLedgerEntries Entries); + +// ============================================================================= +// HEADER SECTION +// ============================================================================= + +/// +/// SAF-T Header section - metadata about the audit file +/// +public record SaftHeader( + string AuditFileVersion, + string AuditFileDateCreated, + string SoftwareCompanyName, + string SoftwareID, + string SoftwareVersion, + SaftCompany Company, + SaftSelectionCriteria SelectionCriteria); + +/// +/// Company information for SAF-T Header +/// +public record SaftCompany( + string RegistrationNumber, // CVR number + string Name, + SaftAddress Address, + SaftContact? Contact); + +/// +/// Address structure used throughout SAF-T +/// +public record SaftAddress( + string? StreetName, + string? City, + string? PostalCode, + string Country); + +/// +/// Contact information +/// +public record SaftContact( + string? Telephone, + string? Email, + string? Website); + +/// +/// Selection criteria defining the export period +/// +public record SaftSelectionCriteria( + string PeriodStart, + string PeriodEnd); + +// ============================================================================= +// MASTER FILES SECTION +// ============================================================================= + +/// +/// Master files containing reference data +/// +public record SaftMasterFiles( + List GeneralLedgerAccounts, + List Customers, + List Suppliers); + +/// +/// General ledger account for MasterFiles +/// +public record SaftAccount( + string AccountID, + string AccountDescription, + string? StandardAccountID, // Erhvervsstyrelsen mapping + string AccountType, // Asset, Liability, Equity, Income, Expense + decimal OpeningDebitBalance, + decimal OpeningCreditBalance, + decimal ClosingDebitBalance, + decimal ClosingCreditBalance); + +/// +/// Customer master data +/// +public record SaftCustomer( + string CustomerID, + string? AccountID, // Associated GL account + string Name, + string? RegistrationNumber, // CVR for B2B customers + SaftAddress? Address, + SaftContact? Contact); + +/// +/// Supplier master data +/// +public record SaftSupplier( + string SupplierID, + string? AccountID, // Associated GL account + string Name, + string? RegistrationNumber, // CVR for B2B suppliers + SaftAddress? Address, + SaftContact? Contact); + +// ============================================================================= +// GENERAL LEDGER ENTRIES SECTION +// ============================================================================= + +/// +/// Container for all general ledger entries +/// +public record SaftGeneralLedgerEntries( + int NumberOfEntries, + decimal TotalDebit, + decimal TotalCredit, + List Journals); + +/// +/// Journal grouping transactions +/// +public record SaftJournal( + string JournalID, + string Description, + string Type, + List Transactions); + +/// +/// Individual transaction/posting +/// +public record SaftTransaction( + string TransactionID, + string Period, // MM format (01-12) + string TransactionDate, // YYYY-MM-DD + string Description, + string SystemEntryDate, // When entered in system + string GLPostingDate, // When posted to GL + List Lines); + +/// +/// Individual line within a transaction +/// +public record SaftTransactionLine( + string RecordID, + string AccountID, + string? Description, + decimal? DebitAmount, + decimal? CreditAmount, + string? CustomerID, + string? SupplierID, + SaftTaxInformation? TaxInfo); + +/// +/// Tax/VAT information for a transaction line +/// +public record SaftTaxInformation( + string TaxCode, + decimal? TaxPercentage, + decimal? TaxBase, + decimal TaxAmount); + +// ============================================================================= +// EXPORT RESULT +// ============================================================================= + +/// +/// Result of SAF-T export operation +/// +public record SaftExportResult( + bool Success, + string? XmlContent, + string? FileName, + string? ErrorCode, + string? ErrorMessage) +{ + public static SaftExportResult Ok(string xmlContent, string fileName) => + new(true, xmlContent, fileName, null, null); + + public static SaftExportResult Error(string errorCode, string errorMessage) => + new(false, null, null, errorCode, errorMessage); +} diff --git a/backend/Books.Api/Saft/Services/ISaftExportService.cs b/backend/Books.Api/Saft/Services/ISaftExportService.cs new file mode 100644 index 0000000..b3ce4da --- /dev/null +++ b/backend/Books.Api/Saft/Services/ISaftExportService.cs @@ -0,0 +1,22 @@ +using Books.Api.Saft.Models; + +namespace Books.Api.Saft.Services; + +/// +/// Service for generating SAF-T (Standard Audit File for Tax) exports. +/// Required for Danish companies from January 1, 2027 per Bogføringsloven. +/// +public interface ISaftExportService +{ + /// + /// Generates a SAF-T XML file for the specified company and fiscal year. + /// + /// The company to export data for + /// The fiscal year defining the export period + /// Cancellation token + /// Export result containing XML content or error information + Task GenerateAsync( + string companyId, + string fiscalYearId, + CancellationToken ct = default); +} diff --git a/backend/Books.Api/Saft/Services/SaftExportService.cs b/backend/Books.Api/Saft/Services/SaftExportService.cs new file mode 100644 index 0000000..cb10273 --- /dev/null +++ b/backend/Books.Api/Saft/Services/SaftExportService.cs @@ -0,0 +1,391 @@ +using Books.Api.EventFlow.ReadModels; +using Books.Api.EventFlow.Repositories; +using Books.Api.Saft.Models; +using Ledger.Core.Models; +using Ledger.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Books.Api.Saft.Services; + +/// +/// Service for generating SAF-T (Standard Audit File for Tax) exports. +/// Implements the Danish SAF-T DK standard based on OECD SAF-T 2.0. +/// +public class SaftExportService( + ICompanyRepository companyRepository, + IFiscalYearRepository fiscalYearRepository, + IAccountRepository accountRepository, + ICustomerRepository customerRepository, + ILedgerService ledgerService, + ILogger logger) : ISaftExportService +{ + private const string AuditFileVersion = "1.0"; + private const string SoftwareCompanyName = "Books ApS"; + private const string SoftwareId = "books-api"; + private const string SoftwareVersion = "1.0.0"; + + public async Task GenerateAsync( + string companyId, + string fiscalYearId, + CancellationToken ct = default) + { + try + { + logger.LogInformation( + "Generating SAF-T export for company {CompanyId}, fiscal year {FiscalYearId}", + companyId, fiscalYearId); + + // 1. Load company data + var company = await companyRepository.GetByIdAsync(companyId, ct); + if (company == null) + { + return SaftExportResult.Error("COMPANY_NOT_FOUND", $"Company '{companyId}' not found"); + } + + // Validate CVR number (required for SAF-T DK) + if (string.IsNullOrWhiteSpace(company.Cvr) || !IsValidCvr(company.Cvr)) + { + return SaftExportResult.Error("INVALID_CVR", + "Et gyldigt CVR-nummer (8 cifre) er påkrævet for SAF-T eksport. Opdater venligst virksomhedens CVR-nummer i indstillinger."); + } + + // 2. Load fiscal year + var fiscalYear = await fiscalYearRepository.GetByIdAsync(fiscalYearId, ct); + if (fiscalYear == null) + { + return SaftExportResult.Error("FISCAL_YEAR_NOT_FOUND", $"Fiscal year '{fiscalYearId}' not found"); + } + + if (fiscalYear.CompanyId != companyId) + { + return SaftExportResult.Error("FISCAL_YEAR_MISMATCH", + "Fiscal year does not belong to the specified company"); + } + + // 3. Load accounts + var accounts = await accountRepository.GetByCompanyIdAsync(companyId, ct); + + // 4. Load customers + var customers = await customerRepository.GetByCompanyIdAsync(companyId, ct); + + // 5. Query ledger entries for the fiscal year period + var periodStart = new DateTimeOffset(fiscalYear.StartDate); + var periodEnd = new DateTimeOffset(fiscalYear.EndDate.AddDays(1).AddTicks(-1)); // End of day + + var accountGuids = accounts + .Select(a => ParseAccountGuid(a.Id)) + .Where(g => g.HasValue) + .Select(g => g!.Value) + .ToList(); + + var ledgerEntries = new List(); + var accountBalances = new Dictionary(); + var openingBalances = new Dictionary(); + + if (accountGuids.Any()) + { + // Query individual entries (Aggregate=false) + var entriesQuery = new EntriesQuery + { + AccountIds = accountGuids, + From = periodStart, + To = periodEnd, + Aggregate = false, + Limit = 100000 // High limit for full export + }; + + var entriesResult = await ledgerService.QueryEntriesAsync(entriesQuery, ct); + ledgerEntries = entriesResult.Entries?.ToList() ?? []; + + // Query aggregates for closing balances (within period) + var aggregatesQuery = new EntriesQuery + { + AccountIds = accountGuids, + From = periodStart, + To = periodEnd, + Aggregate = true + }; + + var aggregatesResult = await ledgerService.QueryEntriesAsync(aggregatesQuery, ct); + accountBalances = aggregatesResult.Aggregates? + .ToDictionary(a => a.AccountId, a => a) + ?? []; + + // Query historical aggregates for opening balances (all transactions BEFORE period start) + var openingQuery = new EntriesQuery + { + AccountIds = accountGuids, + To = periodStart.AddTicks(-1), // End just before period starts + Aggregate = true + }; + + var openingResult = await ledgerService.QueryEntriesAsync(openingQuery, ct); + openingBalances = openingResult.Aggregates? + .ToDictionary(a => a.AccountId, a => a) + ?? []; + } + + // 6. Build SAF-T document + var document = BuildSaftDocument( + company, + fiscalYear, + accounts.ToList(), + customers.ToList(), + ledgerEntries, + accountBalances, + openingBalances); + + // 7. Generate XML + var xmlBuilder = new SaftXmlBuilder(); + var xmlContent = xmlBuilder.Build(document); + + // 8. Generate filename + var fileName = GenerateFileName(company, fiscalYear); + + logger.LogInformation( + "SAF-T export generated successfully: {FileName}, {AccountCount} accounts, {EntryCount} entries", + fileName, accounts.Count(), ledgerEntries.Count); + + return SaftExportResult.Ok(xmlContent, fileName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to generate SAF-T export for company {CompanyId}", companyId); + return SaftExportResult.Error("EXPORT_FAILED", ex.Message); + } + } + + private SaftDocument BuildSaftDocument( + CompanyReadModelDto company, + FiscalYearReadModelDto fiscalYear, + List accounts, + List customers, + List ledgerEntries, + Dictionary accountBalances, + Dictionary openingBalances) + { + var header = BuildHeader(company, fiscalYear); + var masterFiles = BuildMasterFiles(accounts, customers, accountBalances, openingBalances); + var entries = BuildGeneralLedgerEntries(ledgerEntries, accounts); + + return new SaftDocument(header, masterFiles, entries); + } + + private SaftHeader BuildHeader(CompanyReadModelDto company, FiscalYearReadModelDto fiscalYear) + { + var address = new SaftAddress( + company.Address, + company.City, + company.PostalCode, + company.Country); + + var saftCompany = new SaftCompany( + company.Cvr ?? "", + company.Name, + address, + null); // Contact info not stored yet + + var selectionCriteria = new SaftSelectionCriteria( + fiscalYear.StartDate.ToString("yyyy-MM-dd"), + fiscalYear.EndDate.ToString("yyyy-MM-dd")); + + return new SaftHeader( + AuditFileVersion, + DateTime.UtcNow.ToString("yyyy-MM-dd"), + SoftwareCompanyName, + SoftwareId, + SoftwareVersion, + saftCompany, + selectionCriteria); + } + + private SaftMasterFiles BuildMasterFiles( + List accounts, + List customers, + Dictionary accountBalances, + Dictionary openingBalances) + { + var saftAccounts = accounts.Select(acc => + { + var guid = ParseAccountGuid(acc.Id); + accountBalances.TryGetValue(guid ?? Guid.Empty, out var periodBalance); + openingBalances.TryGetValue(guid ?? Guid.Empty, out var openingBalance); + + // Opening balance = net balance from all transactions before period start + // Net balance is calculated as: Debits - Credits for Asset/Expense, Credits - Debits for Liability/Equity/Income + var openingDebit = openingBalance.TotalDebits; + var openingCredit = openingBalance.TotalCredits; + + // Closing balance = Opening balance + Period movements + var closingDebit = openingDebit + periodBalance.TotalDebits; + var closingCredit = openingCredit + periodBalance.TotalCredits; + + return new SaftAccount( + acc.AccountNumber, + acc.Name, + acc.StandardAccountNumber, + MapAccountType(acc.AccountType), + openingDebit, + openingCredit, + closingDebit, + closingCredit); + }).ToList(); + + var saftCustomers = customers.Select(cust => + { + var address = new SaftAddress( + cust.Address, + cust.City, + cust.PostalCode, + cust.Country); + + var contact = new SaftContact( + cust.Phone, + cust.Email, + null); + + return new SaftCustomer( + cust.CustomerNumber, + null, // AccountID - could map to sub-ledger + cust.Name, + cust.Cvr, + address, + contact); + }).ToList(); + + // Suppliers not implemented yet + var suppliers = new List(); + + return new SaftMasterFiles(saftAccounts, saftCustomers, suppliers); + } + + private SaftGeneralLedgerEntries BuildGeneralLedgerEntries( + List ledgerEntries, + List accounts) + { + // Group entries by End2EndId (transaction grouping) + var transactionGroups = ledgerEntries + .GroupBy(e => e.End2EndId) + .OrderBy(g => g.Min(e => e.CreatedAt)); + + var accountLookup = accounts.ToDictionary( + a => ParseAccountGuid(a.Id) ?? Guid.Empty, + a => a.AccountNumber); + + var journals = new List(); + var transactions = new List(); + var totalDebit = 0m; + var totalCredit = 0m; + + foreach (var group in transactionGroups) + { + var firstEntry = group.First(); + var transactionDate = firstEntry.CreatedAt; + var period = transactionDate.Month.ToString("D2"); + + var lines = group.Select((entry, idx) => + { + accountLookup.TryGetValue(entry.AccountId, out var accountNumber); + + var isDebit = entry.EntryType.Equals("Debit", StringComparison.OrdinalIgnoreCase); + var debitAmount = isDebit ? entry.Amount : (decimal?)null; + var creditAmount = !isDebit ? entry.Amount : (decimal?)null; + + if (isDebit) totalDebit += entry.Amount; + else totalCredit += entry.Amount; + + return new SaftTransactionLine( + (idx + 1).ToString(), + accountNumber ?? entry.AccountId.ToString(), + entry.Description, + debitAmount, + creditAmount, + null, // CustomerID - could parse from reference + null, // SupplierID + null); // TaxInfo - would need VAT code tracking + }).ToList(); + + transactions.Add(new SaftTransaction( + group.Key, + period, + transactionDate.ToString("yyyy-MM-dd"), + firstEntry.Description ?? "Postering", + transactionDate.ToString("yyyy-MM-ddTHH:mm:ss"), + transactionDate.ToString("yyyy-MM-dd"), + lines)); + } + + // Single journal for general ledger + if (transactions.Any()) + { + journals.Add(new SaftJournal( + "GL", + "Hovedbog", + "GL", + transactions)); + } + + return new SaftGeneralLedgerEntries( + ledgerEntries.Count, + totalDebit, + totalCredit, + journals); + } + + private static string MapAccountType(string accountType) + { + return accountType.ToLowerInvariant() switch + { + "asset" => "Asset", + "liability" => "Liability", + "equity" => "Equity", + "revenue" => "Income", + "cogs" => "Expense", + "expense" => "Expense", + "personnel" => "Expense", + "financial" => "Income", // Could be either, defaulting to Income + "extraordinary" => "Expense", + _ => "Asset" + }; + } + + private static string GenerateFileName(CompanyReadModelDto company, FiscalYearReadModelDto fiscalYear) + { + // SAF-T DK naming convention: ___SAF-T.xml + var cvr = company.Cvr ?? "00000000"; + var startDate = fiscalYear.StartDate.ToString("yyyyMMdd"); + var endDate = fiscalYear.EndDate.ToString("yyyyMMdd"); + return $"{cvr}_{startDate}_{endDate}_SAF-T.xml"; + } + + private static Guid? ParseAccountGuid(string accountId) + { + if (accountId.StartsWith("account-", StringComparison.Ordinal)) + { + var guidString = accountId["account-".Length..]; + if (Guid.TryParse(guidString, out var guid)) + return guid; + } + else if (Guid.TryParse(accountId, out var guid)) + { + return guid; + } + return null; + } + + /// + /// Validates a Danish CVR number. + /// A valid CVR is exactly 8 digits. + /// + private static bool IsValidCvr(string cvr) + { + if (string.IsNullOrWhiteSpace(cvr)) + return false; + + // Remove any spaces or dashes + var cleanCvr = cvr.Replace(" ", "").Replace("-", ""); + + // Must be exactly 8 digits + return cleanCvr.Length == 8 && cleanCvr.All(char.IsDigit); + } +} diff --git a/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs b/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs new file mode 100644 index 0000000..6c7b57f --- /dev/null +++ b/backend/Books.Api/Saft/Services/SaftXmlBuilder.cs @@ -0,0 +1,341 @@ +using System.Text; +using System.Xml; +using Books.Api.Saft.Models; + +namespace Books.Api.Saft.Services; + +/// +/// Builds SAF-T DK compliant XML documents. +/// Based on OECD SAF-T 2.0 with Danish customizations. +/// +public class SaftXmlBuilder +{ + private const string SaftNamespace = "urn:StandardAuditFile-Taxation-Financial:DK"; + + /// + /// Builds a SAF-T XML document from the provided data. + /// + public string Build(SaftDocument document) + { + var settings = new XmlWriterSettings + { + Indent = true, + IndentChars = " ", + Encoding = new UTF8Encoding(false), // UTF-8 without BOM + OmitXmlDeclaration = false + }; + + using var stream = new MemoryStream(); + using (var writer = XmlWriter.Create(stream, settings)) + { + writer.WriteStartDocument(); + writer.WriteStartElement("AuditFile", SaftNamespace); + + WriteHeader(writer, document.Header); + WriteMasterFiles(writer, document.MasterFiles); + WriteGeneralLedgerEntries(writer, document.Entries); + + writer.WriteEndElement(); // AuditFile + writer.WriteEndDocument(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static void WriteHeader(XmlWriter writer, SaftHeader header) + { + writer.WriteStartElement("Header"); + + writer.WriteElementString("AuditFileVersion", header.AuditFileVersion); + writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated); + writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName); + writer.WriteElementString("SoftwareID", header.SoftwareID); + writer.WriteElementString("SoftwareVersion", header.SoftwareVersion); + + WriteCompany(writer, header.Company); + WriteSelectionCriteria(writer, header.SelectionCriteria); + + writer.WriteEndElement(); // Header + } + + private static void WriteCompany(XmlWriter writer, SaftCompany company) + { + writer.WriteStartElement("Company"); + + writer.WriteElementString("RegistrationNumber", company.RegistrationNumber); + writer.WriteElementString("Name", company.Name); + + WriteAddress(writer, company.Address); + + if (company.Contact != null) + { + WriteContact(writer, company.Contact); + } + + writer.WriteEndElement(); // Company + } + + private static void WriteAddress(XmlWriter writer, SaftAddress address) + { + writer.WriteStartElement("Address"); + + if (!string.IsNullOrEmpty(address.StreetName)) + writer.WriteElementString("StreetName", address.StreetName); + + if (!string.IsNullOrEmpty(address.City)) + writer.WriteElementString("City", address.City); + + if (!string.IsNullOrEmpty(address.PostalCode)) + writer.WriteElementString("PostalCode", address.PostalCode); + + writer.WriteElementString("Country", address.Country); + + writer.WriteEndElement(); // Address + } + + private static void WriteContact(XmlWriter writer, SaftContact contact) + { + writer.WriteStartElement("Contact"); + + if (!string.IsNullOrEmpty(contact.Telephone)) + writer.WriteElementString("Telephone", contact.Telephone); + + if (!string.IsNullOrEmpty(contact.Email)) + writer.WriteElementString("Email", contact.Email); + + if (!string.IsNullOrEmpty(contact.Website)) + writer.WriteElementString("Website", contact.Website); + + writer.WriteEndElement(); // Contact + } + + private static void WriteSelectionCriteria(XmlWriter writer, SaftSelectionCriteria criteria) + { + writer.WriteStartElement("SelectionCriteria"); + + writer.WriteElementString("PeriodStart", criteria.PeriodStart); + writer.WriteElementString("PeriodEnd", criteria.PeriodEnd); + + writer.WriteEndElement(); // SelectionCriteria + } + + private static void WriteMasterFiles(XmlWriter writer, SaftMasterFiles masterFiles) + { + writer.WriteStartElement("MasterFiles"); + + WriteGeneralLedgerAccounts(writer, masterFiles.GeneralLedgerAccounts); + WriteCustomers(writer, masterFiles.Customers); + WriteSuppliers(writer, masterFiles.Suppliers); + + writer.WriteEndElement(); // MasterFiles + } + + private static void WriteGeneralLedgerAccounts(XmlWriter writer, List accounts) + { + if (accounts.Count == 0) return; + + writer.WriteStartElement("GeneralLedgerAccounts"); + + // SAF-T DK 2027 requirement: StandardAccount metadata + // Ref: BEK nr. 97 af 26/01/2023, Erhvervsstyrelsen + writer.WriteElementString("NameOfStandardAccount", "Standardkontoplan"); + writer.WriteElementString("VersionOfStandardAccount", "20230131"); + + foreach (var account in accounts) + { + writer.WriteStartElement("Account"); + + writer.WriteElementString("AccountID", account.AccountID); + writer.WriteElementString("AccountDescription", account.AccountDescription); + + if (!string.IsNullOrEmpty(account.StandardAccountID)) + writer.WriteElementString("StandardAccountID", account.StandardAccountID); + + writer.WriteElementString("AccountType", account.AccountType); + + // Opening balances + if (account.OpeningDebitBalance != 0) + writer.WriteElementString("OpeningDebitBalance", FormatDecimal(account.OpeningDebitBalance)); + if (account.OpeningCreditBalance != 0) + writer.WriteElementString("OpeningCreditBalance", FormatDecimal(account.OpeningCreditBalance)); + + // Closing balances + if (account.ClosingDebitBalance != 0) + writer.WriteElementString("ClosingDebitBalance", FormatDecimal(account.ClosingDebitBalance)); + if (account.ClosingCreditBalance != 0) + writer.WriteElementString("ClosingCreditBalance", FormatDecimal(account.ClosingCreditBalance)); + + writer.WriteEndElement(); // Account + } + + writer.WriteEndElement(); // GeneralLedgerAccounts + } + + private static void WriteCustomers(XmlWriter writer, List customers) + { + if (customers.Count == 0) return; + + writer.WriteStartElement("Customers"); + + foreach (var customer in customers) + { + writer.WriteStartElement("Customer"); + + writer.WriteElementString("CustomerID", customer.CustomerID); + + if (!string.IsNullOrEmpty(customer.AccountID)) + writer.WriteElementString("AccountID", customer.AccountID); + + writer.WriteElementString("Name", customer.Name); + + if (!string.IsNullOrEmpty(customer.RegistrationNumber)) + writer.WriteElementString("RegistrationNumber", customer.RegistrationNumber); + + if (customer.Address != null) + WriteAddress(writer, customer.Address); + + if (customer.Contact != null) + WriteContact(writer, customer.Contact); + + writer.WriteEndElement(); // Customer + } + + writer.WriteEndElement(); // Customers + } + + private static void WriteSuppliers(XmlWriter writer, List suppliers) + { + if (suppliers.Count == 0) return; + + writer.WriteStartElement("Suppliers"); + + foreach (var supplier in suppliers) + { + writer.WriteStartElement("Supplier"); + + writer.WriteElementString("SupplierID", supplier.SupplierID); + + if (!string.IsNullOrEmpty(supplier.AccountID)) + writer.WriteElementString("AccountID", supplier.AccountID); + + writer.WriteElementString("Name", supplier.Name); + + if (!string.IsNullOrEmpty(supplier.RegistrationNumber)) + writer.WriteElementString("RegistrationNumber", supplier.RegistrationNumber); + + if (supplier.Address != null) + WriteAddress(writer, supplier.Address); + + if (supplier.Contact != null) + WriteContact(writer, supplier.Contact); + + writer.WriteEndElement(); // Supplier + } + + writer.WriteEndElement(); // Suppliers + } + + private static void WriteGeneralLedgerEntries(XmlWriter writer, SaftGeneralLedgerEntries entries) + { + writer.WriteStartElement("GeneralLedgerEntries"); + + writer.WriteElementString("NumberOfEntries", entries.NumberOfEntries.ToString()); + writer.WriteElementString("TotalDebit", FormatDecimal(entries.TotalDebit)); + writer.WriteElementString("TotalCredit", FormatDecimal(entries.TotalCredit)); + + foreach (var journal in entries.Journals) + { + WriteJournal(writer, journal); + } + + writer.WriteEndElement(); // GeneralLedgerEntries + } + + private static void WriteJournal(XmlWriter writer, SaftJournal journal) + { + writer.WriteStartElement("Journal"); + + writer.WriteElementString("JournalID", journal.JournalID); + writer.WriteElementString("Description", journal.Description); + writer.WriteElementString("Type", journal.Type); + + foreach (var transaction in journal.Transactions) + { + WriteTransaction(writer, transaction); + } + + writer.WriteEndElement(); // Journal + } + + private static void WriteTransaction(XmlWriter writer, SaftTransaction transaction) + { + writer.WriteStartElement("Transaction"); + + writer.WriteElementString("TransactionID", transaction.TransactionID); + writer.WriteElementString("Period", transaction.Period); + writer.WriteElementString("TransactionDate", transaction.TransactionDate); + writer.WriteElementString("Description", transaction.Description); + writer.WriteElementString("SystemEntryDate", transaction.SystemEntryDate); + writer.WriteElementString("GLPostingDate", transaction.GLPostingDate); + + foreach (var line in transaction.Lines) + { + WriteTransactionLine(writer, line); + } + + writer.WriteEndElement(); // Transaction + } + + private static void WriteTransactionLine(XmlWriter writer, SaftTransactionLine line) + { + writer.WriteStartElement("Line"); + + writer.WriteElementString("RecordID", line.RecordID); + writer.WriteElementString("AccountID", line.AccountID); + + if (!string.IsNullOrEmpty(line.Description)) + writer.WriteElementString("Description", line.Description); + + if (line.DebitAmount.HasValue && line.DebitAmount.Value != 0) + writer.WriteElementString("DebitAmount", FormatDecimal(line.DebitAmount.Value)); + + if (line.CreditAmount.HasValue && line.CreditAmount.Value != 0) + writer.WriteElementString("CreditAmount", FormatDecimal(line.CreditAmount.Value)); + + if (!string.IsNullOrEmpty(line.CustomerID)) + writer.WriteElementString("CustomerID", line.CustomerID); + + if (!string.IsNullOrEmpty(line.SupplierID)) + writer.WriteElementString("SupplierID", line.SupplierID); + + if (line.TaxInfo != null) + { + WriteTaxInformation(writer, line.TaxInfo); + } + + writer.WriteEndElement(); // Line + } + + private static void WriteTaxInformation(XmlWriter writer, SaftTaxInformation taxInfo) + { + writer.WriteStartElement("TaxInformation"); + + writer.WriteElementString("TaxCode", taxInfo.TaxCode); + + if (taxInfo.TaxPercentage.HasValue) + writer.WriteElementString("TaxPercentage", FormatDecimal(taxInfo.TaxPercentage.Value)); + + if (taxInfo.TaxBase.HasValue) + writer.WriteElementString("TaxBase", FormatDecimal(taxInfo.TaxBase.Value)); + + writer.WriteElementString("TaxAmount", FormatDecimal(taxInfo.TaxAmount)); + + writer.WriteEndElement(); // TaxInformation + } + + private static string FormatDecimal(decimal value) + { + // SAF-T requires decimal format with dot as decimal separator + return value.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Faktura5216698_7e153f92.pdf b/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Faktura5216698_7e153f92.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4ec015171ee73c40262509ff4a509aa72ff3caaf GIT binary patch literal 97584 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�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≻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 aPJ%C2f|(29AY?H}03K+0c@;DjY5xz0*Zf8R literal 0 HcmV?d00001 diff --git a/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Invoice-MVPAIAKP-0002_39db6245.pdf b/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Invoice-MVPAIAKP-0002_39db6245.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e175bd9d56be3dc1c0b52ecae9bfe6f80079de51 GIT binary patch literal 26274 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 B%2EIT literal 0 HcmV?d00001 diff --git a/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Microsoft E1 - E0800QGJD1_e4c59320.pdf b/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Microsoft E1 - E0800QGJD1_e4c59320.pdf new file mode 100644 index 0000000000000000000000000000000000000000..da98696804f23437089d753618a7a97446dcb50a GIT binary patch literal 205805 zcmbTc1yo$kwzeA}Sg_zOL7U+2?(Xh1jk~)9_u%dj+}#5NcXxMpw@bdg@7?G8XWakF z8a-CmsyVCXtkt!;o_@Q?Ch=T89KVzg6O2ptSxwXzksZb{`Stm_TR#=|Gx>t%)v~{%uK|<&PL0^K*Y+(NXy7X z#K6Hp%f|437v{eVM%2vG5oAv%YH8pI5(WWnj6PBVS(`YTessZ1&&%uJXb&>5`r?{i zuQ42TA`HKJuVw_B)^h7D9s>CzTpj!xLZ-z(04v9X?S;2tSXN~!nv^L*v)qw_GHJcv z*^A@#!v5)I5uO!^ZkjSEI~`Z>s-$Ui*ZxV?0=IrrHI3a|X>92Qxo(hT?fGrDa$1hA zP)|bl2?F+b`?Yx)iTJOPnO1zcrO@Vc6_x zR&FY~z~f~0%+q5&ShtvBqB!`r$}N!%chbFhP?%`;vVYdEikB&8!6$uVZ0wF?1+F?> zwQ(I#etKq&A=m`%Tzq3&gBQTu73}{4BelFdVkx77{obiKqR(9&(;jAgAws1e!fuCJ zmI;3H_2`zf<~KGii8q&d-w_z!hG0bzTF}NdeMctm>y>#B;k(9(_>cKU&nijWOU|zx zd1U01SNP6m)iRBxY;ABFh0g~dJ9lA@WUZkkwR*UE;y1T)Wj|t_No_;Uc!3w}`7KUn zV5e*wY^K_oV>qOK@*HkQ@XZCUBs#$pD;2uGtTvZ6mC^uS7+)$!dZ?|w%HZB^#2*Fo z#_I4$E6j6^KU086&9l=2Q_nn8j+Z~@a8$@>lDZ+ha^IkN`C{JBS4EZ^!Ut&vl%yOt zgrh@3j=id)Kxwa%i6?DoH_#SZ4Q^R2WCVQbt2Hu});&fFy=Z;XI7(=#8=|+EV>RCQ z&V-iWi+f(=MF!i9zRE}3$TUAo5|K^K8#rB>)D3d4h8NgVzL25LBP;18n5um8IjQRK ze!bq=(hpbgrODCf0i#cty0~>ubp?HfAMJV4RBM5`MJ}&1pob05a4XpHUWUa9|AGw} z_xVW#<-zO5j#M0eIzX4ukbVF>ZWjbm<@@`l&Zd z7uiC^P_}!8ka2{*@Wz4)k9OS{8M++(fG%BaUyYlUs$L$i+YI91&;Mwj!&m1`fvd+m;nedw&}Ux@w`F z+*g>EG3L-A+Pe;E+cS?GrnF9v5l%}(lx3tI3b5H~D`f6Im^b-oqawVsvBcPk&M^sX zqWzGzh%(E`S&^l=IXLdy8we6ZKA#n)f^s&`1s>?6niAD0q4GSGU5p_D!-Kb!?76%%&9_VZyLid#h83wLm1c zcAZ01%OnL!Yy4iBrgnrglQ#sFdJ^-8Q!%$P$$geqBXdT0NrW5lF%vfGBVQm%NMxpF z`{wR{Y(YzW)5UAc=UHGz{Q4`Cz*}tmwI)Zy{`LI0m0b(b?W{1ScClF`74dNo+k{R8 z&xEF|F#g8p%&&!#w4y6YRgwhuYl^m{?WV(7(m{tbO}$l}M$h&2B1-w~GJe%mrmb6F z+j#}9pBiYm=g64qgf-CyT?}LHkz_U|(0nN0$thr(t|UbARg%M_YD(ivT(+)jW=Hw> z!ID)7sYDV9@U9$sAHYbdceCnu3hS+o z`$&oTX~k{u59=__kyPWoRTaJ}%rLBLv^39F{dMKDIEL%*B91)Yii}Qf%n7P~ zsu~pU=dIM8Q>E@Xu^yl#8HvLI5GF8Co=s@?%Vz>^{3~x={3}kVn$Id4ltNt?koBeV zB6jL}0_*F^HfpS?W)C6zw;raRn$s6TIW^RxAgIb}C?DA$TBr;u5!0Zd-xRXwB)>4c z?aYI}d!}+BL6CCWFil6$s2{6Xa*hu=5#%}SEsUy(M-tj`}FmF z=IP_4>nwQ~Ru+`crTFoXYXZW%b(5N9=%t~qeoXxoK#ewN;Hk6-3+-8P`n1r3jUA!) z$UHhX&CID%yI?g^_5wXiJ-_f58veaJ{~I6v!pHv!Dc06Djt*M?aEl_*KU|@xLnmZo z?Fh1VbRham0E%=nAR{vaK^xb95QdF|k%*OnQHM_6!2ZLBi18m6MUaDylRXgR@Bu!e zAFcnseNe_fHc>{Rzr3PIC;9;#O#doqsm^58kNH-In5kcENq8rE@xDhY2 z68z}`%#b~?lMsH?tALsxF%Rm_n+15TbfUT+55L65MvkY+l7at>O7l- zML*M2z9?O|ar*FjJ?z$|c)t|RfHn8XU)Op6U8{5L-L7~6afL?4=mPw(w^zrvj;-4k zF_rT}>_vQ~crmnX!F&fWqzCUrgPYJQLkEmzqy@Oq!_0B>va+Wk*ePW-sG_V}+~wG# z2;VT3tHeX{1p<~R(Gh-7ihu`_zN-%T4d5UsdCQi2wasON!}ln0I{&Oct}|?z`@TOP zxpFIO-TTQ{2R+MF_h7LNT4Akm_H4j?#y8BmzW6TA= zW`A<3Q9`3M3q?c{$*=k+C>3Sy%n+Q7?KT522+pr6%0to=IG{eqeD@#yxH`O7OLg@u zeSJUI584rrzW%8`&_E+V8Fwoa()Ln9TZ_(}tbqBRfPh@~L&5H4+1PQ$G54yUg}=lG zae1eFqk&I5=Yns%sS%qA6URL{!KEi>{s^}rdFW;wkdv4}P4 zKxTnA6?cSt1KtV~!4i9~7VZ2~wkjvUIyWvpT!`}{S9}*;!PtO4Hia-*GKzSjQ^M9) zO)n`yHQoHRqmm-KGU&sRpDWasze5ogZBJ>eu7p3Ka>x?5r$xtvRFG2s0iVNhE}N-; zqK)*wAOhNc`^=}6(63vky6)T9NZtjQzLIDB#5<3-&WXmI|@%A_4L;aOfEF z^0x1=-Rh3D0*N{oDIbnG*}Fqu?Gc=`%*=ro+JY40f*!fTlQlqvN7^u$g1tsN0sgDk zQ_jpQ|CZp7!~Sy4!JiL3IBiVBN&SkS!B6*L&}Z(y>e+;IW0XfDE~n*t9w9tCdmf;? zz%&t3Q`y2dIo+>JN`;W1y#54`{*W!nE9*rO`nlTBFG#fQ0@Uxl7vQ*-2FL44=4abd z0jD%f*dGIjt{BQe-9OsP9}^3_Xb=|@e#)_4B)k1SCYO!?t1FyMcoM*G4{~{tsb%m0 zetmgs7Usea=Sug>%!&|WV~KDUElqZ7eDGT-loEP@xFmX~&;9n6vw3NZ9HOO z{yG(XwNf`37yf2g2CtC5ku|FY=w*QWyz!Uiohg2}TgTd**8nwkr%j?0GWIu}D3^zG z7Ab6xd1@J8BG$O(iOWL4hV$tZvvX+9_G*(+(vRhOsp*A+yYU#7#a-}MjMkkwuU6)A zoz#1{h|^fRXJ<2SOQ=@D+HgM2mPX`wNtgb~-!hK~DFlTtQpWfQGpU%D6YEIfQZaV~jmlVxM zKZETvd;MJb7WwL2@232GflN1wcevft68#$!J&fuSr;<9db>o$mgAOoH{!oXOJ)9!sF?asJkGNzgU2>Whx8BnxsQGY_B6f-zCs-DJ z+|V7kjMdtf^x9CX&B&;&x1J$7Jm}QB6Rzo+tZ<39o&lifx>gjkMjLFov^=Qrt?MEA zrlB>YOOmCZ6sgd5F*B3k(hYgOFwu7iDyF!0;!``Ok(x_}p}N9~Dj7M>kUQ%OEMvyP z$uGK0-ebXi2sCie%D1$_Z9a1_Ha!PWd%3MU2W;7_(ahS!fyUU(-oerKW5Cudl_9kZ54gg0RTTXgf1};MzM@Jj0e~OkMW5be-YjU96oNTBhg1LbV3rsN}vzm{ufLO8Q6;dbEk+{ znHcDVLI3ijVf=9XQ0YuQrW7k90~_lK~*GV*GIb*HiFOA!}d-q7(n;pb|2$G&8g}qY<>RG@_IEn3-lk0c#UW5D`6{k|W4U zm5BXs!Oar%!SC`0);10xZhH2Q$-+o)OwaalX8M?m%pYe)dggyX{x7rt4f;1>dox={ z8~gtN0Kc2Khdn)%ny}yr8whkJ{t=?p=j{b6q z{rMHfNP;AgA6yuP=xYgpNF2IgL0&-^xfLeSLWt~h?}9>gxcjWdd9Gn-ySCrS8?VEj zr8@Zc`!ySn*LQ)n$Mh5?ld&5&JqDRa4Rw668PZ?riMTjbY(Dm ztZnT+av^3x-HYc=E$@$I@v2B>II~P{Wo>p?tj+AgZRPHY=4LE^UF*=_9DyA7>|}>N z;}PDRm&;;v>N+Zlk_ydC`F!7-*T1(%i0Dcbf6xvAvo34Y<~QKQf&b`$@z$EzFzt0i z#3MUCZE=Ij^liuO2FpuQZ!+331evME7NdXau=6DP8PACTa>!$VKSnsyKa+0N#kGS} z(cjaPHke9Lf@5nN_R{hG)XBmR&b+$z7;t;dQN#9OOk>+t!CaN`udBj$~9OW>Pm)}MS8<&4WP1_Q=3PsRDbne$=NzPLRzkrv&j11u zo-=__-ke7}!pi4pp|C8uDP8xl%wHx48t#ZR+mP-tcau^#SDjA##kzzaKgHf;D|5@+ zW#6;e4rJWZ)(Vgggq}ip1xlaylAmq9{o#VS?jSe<20TS{2oG2Xplu2#-`BN5*J0`Q z*Ah;8?}>HNha$#6tF_%#%$OhGloPm}UM@9@P?_iwa&7FfIBMCfu0%3+;NA}EygX^P zvz`eoWB>$g9`QS8bf+xD;_rK4dWL_jFBT^CGP%oKxDuRt-9et}7T&F(wpSBSD#Y~o zJj9QA@PZz&y($tS06cd0;)j=CpOJ%-7^AljYF5(>e;y4=_*n?{6Ub;<64*ntTGs?``pqm*@A{Uhd_dG7xj2kd&gM zOAE&{2<-EsLK(;zQdYz&q8`iv(jce^Gw+eCl;jl18X{u@)dxH;itKyY3kH{w{^tG4 z$ABt%jG#-#SdnThjBq=qOfq_0hbrMLkI8nIlcsGW32Kc_%p4x<)#oyVZ^T~RziPP~ z;Q5#|5QlqjKdBBt)flRRXIVB~ZxTd!xTPj4k;vpVJIeDAwTC?P(3hMzs>r3Ru$pZJ z%q8yG0sEO*q6lW`YdR_#jD?+4Kq8N}YKv7l1wv3s@ZV{1lGEZc5)xz6cckk?0wJ|% z{J_Y4`0@l)j6xMu6KY#l*J7sDa`M?N%xjvLInW7|yttRRPA*D#Ii-W!b>NKoIP>AY zncTu2qI}b7|CTbVD_O9pareEj9jI3@z1B<4T)*KiR8Tv+Hd(I2W;!n!wg(f?v-unK zuB2!&;SO=my7H(F{+b8dNRx zdZi2a!`0f!v1C$m1{8026MTs^scuZRUH6a?$5jk@;lSgj!E|ai#lge3NYe~hmYp`_ z8caxAT>4gj&E?VN(w+90QNPC39^J@=-&RtxblWLqBIZ<b$p0{4(Qgw1uV%FGGXhJO+txFlLYZiNIMG zx-i0Ol3?y*C zgSGTsZKHU#Jyj)*>q6;ekA`j-uBvj<-VTeVuDOjt^$tQ%eZ z>T=2FCXc@Qypc*&yQj|VAo683m0cXj#JP?WlFJ0_!Adr&jh5X=Y$(K(xP#v=L}kf_ zCI!A3a&PvR*7kWw$%>8#W(Als)9HpQ+##CRHL{y;>y_tTd28%>#KK^lrAx)vsq0E z@Ss6j2-Qxl{NvS3;j+-|-_Hz%$VcOO0zsQwzoa8_J+U&~ie={Qt;;7m-AcM?a{DB4 zp%joMb-nE)FQHJY@CVDf^n}UvHhuBqq41zO*GBUz`J*@2mEKb%WRJK!Nb29*vy-l| zhP*+&{{~v=lzzZ%>O@9>aQOBmI9lR(IRmS3B3l!X82Srv4LH>y7;nPp?^rlfg-%@} zrOzqo;X?&>rfLV5L|p_SL8ex+a^MB+bs-uORzTJuvaEv?W)=e75b!7i*V#6Bs;Zz;t;K>+YqN_Ne<)~{of z32y8NWRx%??P&W4x>3t}jsBcQ{oYwB#(L?!2D`9Byy89S?+^LJ6V={5Kj2_3+B`hC z-NN0-p~mj{W@N(gxbqvAyqDKENT&Sw+1*ikWMu5>_!zgyty1qC)Aa>f*zFPW?mKt& z&7Ts-m={{r7)I>k031`aNRMRjMGR^d=vvIP^0J#@R^Bg#!p?W#k6rmITVGW2e=jUB z?(_Hb+AG;wrCb`C;U(O9Uc5~|RMX{?x)2U~bv{R0TZ~V4ku|SYx*!bzyQ1_XtB3|o znX%GCIi`Nk#OMC_@nSIbyBp2K@Mr!bItDsY9NZNuDaXh$lk}D~d8dbKo%U=TX}K*t zHWxoAWiX~eo{w)a@oTcSe&Y-qd-#;0a_=}%sdwK@uD!{1P0ODoX@z3D%VGs`Q%}6Q zeOQ)*jcD_UoJ+~woZtcoGMb)Pcq(uR(%E$zDNRAwDc`1?Tfanp762t8TJp9&>KAEOywRxjN$>%aXZX-q*fvm;vPzULEI#1-j$wSxb&DX>LNM?obxYImuh&mA5^|0)WWQ3O51ViW1p4uKXahwMSQyi z3Z~8)hkd7_f(xa_M$`Tp5gUl0^>un8V?5V^qOn)_beK-v>20l@+WdTn)sBNGnvn?@ zE!*eyvc>1N?AtanGPe@~qhM29(}P9Ig{q_&7M4;}CZ4C{4B#3K3K4+#B8f6G|AQ#NJ*DO955k04j|y!c`!fxTIBVuN5LDJO60YZx+O``rX87<#~- zU-)2}=RBLonn*Ehf)N!E2Q{f`^SG2hd>#%9gM^e#d-ya~7XO3ROvHp=+8i^^1p!N6 z`e=$NbEY>|=WXs+H{k61iQ!=e?KZp7SL`9`<1yj87AbXW7jJ7e95ZoP9J7-#AEs0= zw{p{ku<4j&M*60zqThbRu=_MT0>*(HvD4TiX>JEGQU!p5T&u2Bg|lV+#YIHb`V*VN zB+*@EBaB9sg68wEp;CPJwOz7=MPzhXe~KCHcT+pB>c|YO`i*=8vz%g$p_8(ix?fEF zyHVxTb*WckSwp0LE^>h2q z>|LLoRlb)~();@qr3!VswLk|pIYJ_x<;B`^U88!lV>LFp7K6)+o+L!hS7+%lTyE&y z!vUr5JVf?&(^yMnR?Z@y`;-LR7)HBsA{6%cS*J`9e((BQatFG1tVbSCOqdEK?dR#h z7)%_og+wwtR)^lVprqy2H;hk@l`^CL;NCw&pxrFzn?rxm!?Lj%{Oqv|OWU58(OC4e z*O$o0g=%?&5G~t$K76F%WhVYdz;DZ7=7i_(Y-IUjDXWdixl<@8Q-Z*3zG*S(6F3F%@>*FjDu(j_n* zmXjEjgOO&`kb#2m2FQ6-inqyO2i>DjFxz+x$FauwOHb{J3+LY4RFsw3zSACxsQnD@ zR_bS$o?Bp;TdOY2|4iwQFR_I`yc9PcxA%ozw`$GtSAKrY22c^N3`u6w0Ez^Nj678J zNlW4BBGxp|BXBV-DF8eZlZmSb%XnN)hN><_D0fwBfdl!i7_18n3e_-U*1^iAm=~MK z9%tgZAsz+GQgOrLi>##6{@qFzLHOI;!J}fNY8)k6d2M57poCm@C)q|}XzuMA>S`}} z^9v0yL&5N;vDE<&1%72`#sTV@>Mj$=d=&xJ!d3mppXYR(Ro9-RdF|WFgxd)Vy7z|? zsC%}%p!!p#acjo~v5Jf#m6j{f`>}>eHvE;2UHSeU1-91)k2$O1p!te6x2<9qYPwR6 zYn!5N#_^=9-T@E|&yJN+PeJLK)R5c|TmO|p$_NKiwdHqm)ZsOJ?TB|`$#w{HGt6P< zl;Hl7Lm)|AnXqFu#(G({bt{v+uG{?KhBmpH&ua;&v${@pm zwNR68syi&sPPka6*Z57C2#t^-IGRs5Wl^@Bx7FH!pPd`Rw6UDUK&fU;J&TFrb`^cz z_rbp3zP@co61^!EBNr=^Ovd9_3H@UkPN#!`$PIsz!8jTsl0S*1k}v|*B~=%FN7`~m z!_$^n>vlhjkOe@Ba00ic4M*&v7A|(WAEfPkkTvIF;-3{CWo~FNr7+3X4+%9E$Yk1* z6qU?ulazOBpoU{k3dJ!NL9|A}a2f_lCeg!LNLqn`96d}e>hV-}dBWtCk}E#}%_z92 z%cvl!Tp9&Ns?bnL?jQ!WpV+;agdh;YCA7m)kUdu)VN}ku&K^`?u6;WVt&%#7m6g&` zOLP6tMQ3-H#O9=^Vc6l}~w^t1KndhLhJX4`Sg&8Md z0dIxsHfCxj5-y%5*B%Ly#K>{nmaR3=w!c#Feu504hW;#6aMRJ?b#S`9h{v4GsgT_F zFaibEDwci>BNm);0g|A2iuti}?D`_!Vn7}>3H67r1|TP!N<=%ChLN$+WjoCHnGfDi zq^9sKt3b`8Ls?vhADcP8V&-^u2jDJl-XD@enQ?qoXsa~IADp}Amgb?0S?U7$Yo=FK zODxg!v^_t3Hm9A{nR3sH!OC!d1f*P(HD z(2}|l-*u=2K}{~gpaHI2C=t;~?qX_~Nn?7`q9Y|Ki_b&^s0NI_s_uro^l4zlnRDw6 zB0eJEh-=-TMC8qg74)JBD~v#0Qyhi}m>k2GD-js6Fn=m%_=Ni4OHK2vp;Oi}y-QcK z9{1>O?1Js>e2tM&8Oi%@CT%#x_z*FP1%fyH$>1txW_7v!Qw2D)(Q?T_VUwYyUHy`1 zW`=+cLGq%)F@lq>s2;~>wPl}uf zFuB>#W^ONKolRFwvXrV$F=TS}DD7vz%H>2VO+;xCq$~Gbhz-skOfIx92&7di%Hzx? zWl@D4x?sh)nbl14QP!!%*Y>ESDiXjythW}HqMY8NnY?{{UuE}1_`1%UMu@z)e6SOb zww_Y1?mdZ?kf|>hj#$)UK2?TLZU#BcDpRbdfs@VB%zoXKdnf`SY7`WluZGbF4*=9q zxTnqEv@n<|OuNe^ion<6B(+&AqRE53yLu?WL)edKhO5h)$2z`ca~DU}OJ{o#(7QYA zOhd?Md5B*~yTD0xZsN8T--g^giNci}r!vLL&A`k!gS8bBRVAYSBbV4;N{zS4d=a>> zl0>)2y6AlrmY)N3n7q&9fi5VW-Bp$hztIe>LV=H=Y`tQPmC>R0R+|h-Mzf)k7HC{O z9V)GkuA4LMBeijV1Rd;oz`5UHoXnQdPQ&uB*?3uo1GoM;vor&7N^DI0$(P%z+ZQ z+S++EZlZ2KYb&zP200g1`;U0N$42D0_xAg00;;Lasd1lsI&3kp#{erhv>M$#%g}?J zNpnUEwL^roNk!=i=eWuyDFbC!e=v3KCJl4>mjTDt7I2i5)5f~Iusw@+LF6A>BahAR zyd3!PpO2FW-CWEb!*^p9$Ebat-Uh`KZI!{7+L*AulMxG!=us9!MKmJE#K77&MNewO zQ{_~NKU4PMig|ygl0K~e@_@rj#=;^#r*kEP{IO3>5Ux;{oz_9QiKC8a|1@Sel@Xe5 zt=ecal42i<>&h~vStMmFV#^FOV$jsKYGK3no+Pq<&_7lPM=T#+5)offse!5w!#tUz z*hpD7c_lLmm1Ty(ymw*bBKvoK$b-?KoNPt1wr0mp{tKzvF~69Zoww1>!OAx!rHpBF zv5d)dtE+MQcim~M3=P9lJp2TO6$eG<%={qEQ`ote?tyr1UhB~=99w_RFMR?2C2gg# zFoz6bdA18!P+>O%PumL~{W?TJS8fqW1YwcoH)A)t2>7fOZUYm0 zb_JnpqEwZC7Q$oZ3=Q2#ppi=OIr2aAxUtOLA$6gA=dN+b^FrxG+z74+12`SRHBJt(6nbM^Vo2X$a*}-rm7-vN3 zu}tLvd&#X`v~>0OcqX&##sz7HLsk#b{Nl5#zOBTPmyNohr>B{@gxkBdtN0(a$54H0 zYBwhjK*C4q^Vz}3$e5IZ!IU0FAt^cCb$)j~S9GVJawdmPKaq#vc>KWSA>#E&jUIz4SHH0~_FOqrV6+WC1HVhA`bJ46il7SMt|WH(sYt&A z&1YfXmdj~)^u?Q*gb(D=I(%nHaDq6e8W2O)#-pycKc?@h_1Npn{cAjO=}}tJ&MxXC z13KIdQVc5+wx;?g;inKX-AZHviFiM_p*l9P)Z6W*qwO-aD@ z@EENOYqX!*pT?lyFvoll8FN#i1+YlaoI;Wm@#MnGB99N)^I8xjd7P+y<2p+92bnbb`CtsWsbB!Z47U_+28TI&DE9GHMdt6>m0E}+) zD#2V;g@eW*B@jye;ps6W zZ0Y5fhT)$Wf~mVii~|zcFi3~UVQB`8xY2lvSO^fR9;g0^uLRE=$R0FTpx%92zqu5$ zi62>b!7`}pRmSk@^uP;sa|wl6?g>97q)wOo(w&Pc*v(YK@aG0wGrb22Aq-BsN({0n zjC7u2_fK+DSGscQ?DbQ%|M1JpCMCw=femBuwa%61c+yn#fESIIn~@k(p^I4U6eOJR zpaI8oI0JH82Mn$A<+r*8UFyLl*8GC_1VeG>UURLO=SNl}oq&XM2ZhhI-;w$5Sii(a zcVI65vJ#aw8?JM1+&ovn&7ghuxnBO*93XetAG??oF`A_whiRbtGV%tNQn8o&$FJC6 zPzW~_Cy0Dsj@L)K&s0sdD2sSlv^L8}Qo>8uuwylq(b94Hz<`q4U}sCAIB}rp;-t*g zq=|v^8##cShWy(!K~nN=s{CFm6Jrk^igk6sGBFUfCt_1!Q-EQNUMq-aEN0hSghs@@ zBZgMNc}l-UduHmjtIJPlYA|K=GNOHZJsXs zLHcu@L{2t^kBxVgx-cLA;=3%VVWx6a=LVO%2G*tywN%pQ0QQgN^+>K%A{`Z{FKxFN z?9%Yfvpc^WHfsbM#W)&~6$c#tXsB#OK6!*PFDcq!JQRJM>0qlQjP@-;9kpfI+#>Q# z6zGZ~X#u&u833{TStYS_k(ymTT-qTg7RqGu7Oxvx(T+wRsY|q-~73h;LRO<(s_p$&39v zaI%>};oQO^80+|%;p@)x8?WDuH_+(iQ#u`f)B*^-)B6b4?yRCX)?$NK>2lP-zF&>vkUtawIP}|Cn)@q6ot7xdRWHj~~ z+5!v>OQuSu<;A+#GPk;=DJINEPV1gmbgZuUY0TW?N#R`ALZCJJW97#3av7HXCe=t$DS(4dBC-QZ9Xm$!-CJDPF;iRKT^?`~`H z5noEw7_ec3w0(-Z>^7lVYM_gvdeV$jCuJ8b(}JQ9=y~X32;BSA$~+YO*|sqf6i3+9 zbU2=I?L>BmH3uy1U{)$oTccgBtkyQ7p>!6+Pw&6deA^hSRur5#>lcHgCt$(i0(iD!coRC!b>i`aN zJ+h23$;Z^9U@n@fH5m?SIGzNm@=gGvPk02cvJrIURF{CsuVsAVCGIQoR_P8ggjRWx z5fI-I7gZf?kiWcqMO6@fp3Y~mT4yjQkT{}Xa-yC&C7%hrR3clX8!(ckEE%=Xl(#ZL zCPUAw`K7axy6GI8pRnJDj=mQtwi`7tT09hLh(B$urI4^@yY!Ss9@XZw47%2S6Zqyg zH_=3|<(^MDVNT2Q_#;NZ&cIKEiB>~?f}Va34F#XsWSiY&tE##!Q&L{e#&o|ebZ7r%3{vjJ$_32eik?m+5n^{WcHd z0?c&p^~L?uTz^R}` zmZIB0m&}g04vv(`M-r2=JJ&WV9mauiRJv*VCPJZTU7Bgh_EWW*2QZx}P%8is z@=&}H^8T@wAWFh7#@4Ci8`>kKFj}Klta7QIv9{Tr zKS1IR>4wcDfdvt(v58z^h{I!LFiBRNuo> zjY7YbKX<7O`0o1~obNV{?`wRC@MH%e;qB7-Z89+wVL21mIAR=etbhXMC)XmdIsuYQ zYP?6drB6eib!g1K##O@i>qP5;Cb`9TSR)tv%0E>k^AQwtO~9EUHDS$v1L%><0)2?Z zK-E9Zb{iOL^Knrw!^|Rmsf(Tx4ewvP1LvP-wSo>3cnj#*OCV;$8N#Y1i~G zWK`vGWtq-J2%L!U(7-NfTd0>j{s2b8P1!DA=dgWIk=|N<&N919JU|7A2}HSBHzx1@ zJdPJ!`kQO!CFukc%YGu!<13v!udg1XGQD3Zch+}DEc%<4@oe>tLW?qc%nXli_Dzs%0L(tBFpwG z=7mp3X8DHsEvN!5zPSi;@^zhjB2;vb+T-Uv8eS8x2(8Z-mV}P)dEIRK0qxxsXO&Sa zN+gaYfF<^n+{rQEbMEe~x+>LeX8Dlv`1uK2YLE9db#Nblp}%1j+6YAW#yPYq72~J^ z)U=tR@4ilk+f^FC0iOVBM=G3|CT;nW}6Vkn6&ifW_xSvGz?29DD<{a@x1@){F-$f&*fXbw=f~fhD zIG^#Ud4&8Isn95NqN@+S%IH&>F>}pdH`GDTOEF-Kh%Bsk(JANh^k;oL(EkpCzQoIn zc?)X5lyW{qw51xdrtj;BW5~>aYq->SuEO>FKFZs7j@k@!>sKw;A$#45Gjg!)LY6#; z*y9De@VR2sI8l_@0d@2#M77Q~lC9^uuQC zm5N6%aNeK#zzZb$g6FSyN(;D~*^;g2;21&jro;J%6f2#DKS)ONG$x$QGJcK53R6gF z=z8?l{#+&!pN0Zafxh5&w+D`!b?e$z?l{y|VXh?guws=jf_VV?xYmc)mHd-?crf1J zvl@3PilWB53_BdzY+15T9z4y}C5P$9=S$#L`EJL;J!#>)*a?NB)|%mg7UylZR4n0c za1G(JOrmMgD1Zg7^MR!4hqdM%cLtvUDzkW2!iR{}BO!5!KBrq6PMJq60}doZ>D{`V ztf(A@D-v-wN!bnvAnVT#V$7#&^GJy*G1ge(*~u^biWw&8A@h9+uOYbG!7pe_?(BPk zpG!{gQLTwv86%7VsKuHDQQn!tv5XlQ$JF3vN3+U^-zPg>X6a0ep8%HpL^?&Y1;p=V zvraMb!lmxmA>P0OJG^pU%qcQWFWN~BKh9K9a>6vb{7pn0>Q1Si8N-$7$DBXcLr@QW}Sywjt*;nec~~5 zTp-hJ=oG8+!E>J}xHPM)gDU}wDtz(|V;a2T39l)B+=80`(K4A{2syCmKGfMVIeT2C zK3g-*NA+e2&ofhk$cK0SHfpppZ%p==J+CvB#aD0AGyH6BA?hvj01xaXqe9oDHmKY7 z#3OhpZNi`o=J9K;th9tpH0t$$w!`dl-L9C5yzwq4yhmQm&wci2sP)<2x==}jab}q- z(kexb;^i_(`Bt%Us>NJ~(hsAPFHR!(qZ6mxGESZ9hjxGN*;hAJ>Pl5@6V!GgNe4m6 zX?N9-R|d|!D|WU>fe7>gB!qV_-3*9%4xWDN=L7qDYrKs(hw3s)1s0}oSkWBCFzLkg zQ{41AnxbjHNBF)_IJ7>}*ZUW5knU>W_;cb$AAgqb=Ka0m&RQ0-ul4IfzAtsFYp}Z`GV^I6*5WLHJwu8F{m#@Jqxy>{XI_ULx?# zJKi20lV!vG^Apqm?)P35~u7ge3D)2B%o&od7 zPfJ&%P!WGN=&{PdYm&0lA#D$Jz78`kovAe`0*iQ%%j|8-3^$1@fQ+Z0>2r{xpU;ql z$WMs>CL@xLsqn)QXX!CC=;IGL5|c@yThA(NNUuj2K**3H=RU0;_R!Y>(#=co)( zO9Ib-&w(|~UADpTvTyM!EPQNnGo9&N>N#Asc?7?G3*7(Y`nioR+kB*0W8(+k@-;mv zKN}&mmtOZ*H_xc0@BY=v!{+%-g5O5jwXpqZ*s}Ox2>Nsmr?l z@TwzQra#mF;)Ts#G2XJ^hQdVeixYzX1 zUR~6`+mLqp6~^$cCofn&2fIZI3S7n*kEn(LE|7crR`Wi z!c6CFpG(=Ww}mG`jQihbcqw27Qbt5>RJ&zupYPrzCSCX>lDzYeyPtc^TU@M@pXWQY zkKZl!8nRcf z>xXv%{opv+ZQ=Du>-TSh*MvVV9f|iUMFXr7&#&5YXuI1kiIz^O%e0J@su&?G!r3EI zRGRwxXO%8Je>7y(3wUGSeV@xyZxyk4?)E$#y!SLYdT!DKmfu0B0M7__@4>7tsB#d3 z9Bti>{5>d2m0;X3IjUB3)QsJxGpVW_jYWGesdAtX6V&% zY*~#2&t|&lTR>8gk2^0U3dK`ZV`NHWbVMZ~xzfgEl42iMOb%f@tV)T}@V!`eho8Fz zAi3^KX4H1kS2f(fba|{1!R&{58tC4X-DQ$Jq*K6uKGnz2vvKFxqt3B;yKw-|V)}vL zH3l>EmT(Q`jkw`~k$w7pZ1RcM;|=nvpi1Lj31;rJYe^H^O>MiK+})LG_VibyV5ez2oiEd2EBM#}fgna2`Taj|!V^g86;7 zN5qjduuKkaT`xL}OZPjNw65#oDuflfYcJIWu=OhweIRY(njrQ}-uX387Z(6xDd0`3 za^dT~e*U6`!_6YaE~~F2++JzV!29q4*Z)D;T>v%Kt$V_6!L{)Q8h3YhcXxLhhsHhK zXyd`%-Q6X)LmGE?Xxt&d@SbzNdvDcD)qGQVDwV42NL8Mlm7TTM@2^*~UW0X>wg0HC zmwyGR65kMl;%m-SM`03LLUp?&JHBAW8@Bi2!R2)0Hy{HkEKs*IFBo^0cFue!O0M>a zX>azu$?}Tw*jP;mWX30YN9x@!^ZMmX(g;3}XvfTm`k|(=MO9aJ-Yh8zx})mcFWiUX z{1Q$$=^=aW!(-L5N7e4T{wP(w0b@Yrd2iu&Z`t^weV%29!s3TIujF$uleN;w3?Uqq zM;PSuTzL&ME!YsyqQN1yUmD&~=D%heOgYk?@s%sRqHdV6{p0R0k$EAICckLG1eBAJ z!fR4(;MKh*Lh-RnyP_TZOJ~yF;AtqfVI#80Y|SGi)Z0=#&D&S#+H{3<*2Bm~-V5Uc z2M({#qP0gwZEA5Zx~{DO?{3_(rQBK}o}k@(v9Br>x%uNyp?Z*XNkA5W$u8z+F-R)~n!9;y z8z5d}fbM+j4s>^SF!vb2j=F8JhW6)SD*%{^sE5}g2k@N$>dsxE`#Cfo)un)C*M^Gl z<0!GLuzo+d;zB>63FOx7t|)mW2v%CwZT)Zp;$IH&uv0tB!S#(9Xdf!8Upy%6x+r_Ua&GRb9~ zKTaHsGuHJCLI>li9YoQ-UXqac(eM32XG!V zSt5E;t&yto7kZ!h0_(N3stLf=)tFz;X1v6-s{O##Mpy?fB|{*7FiX+vEiTH_8Lhn? zei+9WqUS%~_YiL`9m_cPIeyHN-;Ai0>aQ4mLk?%j;)>TvjbwJiSXq;TlspbF_$1Z!vS99FD6*Ud+e)sV(ad?Eb zr*5L6 z_Y?8Lc6pK5jT6jQS#CYD-Xu$yVx`^vUi|k5ICuSeW`jD?ZTf4%5c!?bIA}@RMALXJ*gaWSPffMFd6MXq=1@LhZ_4S&7LV){Hs<7@jO*t? zk3x+f8L&t!Z@iIBFG1)T%T}4K1?M~r>!8+-$?g@QA%^Vvx!8N5f0~23-PWqkLkVp! z5geQ56qCJh<)ZIDm{w-w0zzN(r!dXlYVkq#Fg5ImXM{zAAnAA*7cT%-q zc1m->>{8WQi^37*w z?>jl7U9=v5I&yQ1o-pCz{90w(GyWsuec-6p*?@5s`1YHAHqx?s{j*5mKX>JVJ`C;e0$cun9uj<5Qf&42))$6e@)8dHl@;BYV6F@9 z*Pf%Dsci0*VC>h=73b7eiN1zkM3Z{7`gg*qZ$I%e>(EGLO|Q<(j;%)Y+#XvHQmsO5 zcN~x$i^kY|-S0w@R|Yu0i5VOURIquaV)bh9Cc$O%b%Bg;lR z;&Na7N=~F15Y~<@n1mSp(m^<7qUAMMvs_3FJOyHlp#nTw@WW!lpTB?A*>v}^_qZD!c0GIDCk8P-VrG&ZYQ)FE5tJ6nr*~2!A@RVQJx7~fVtGQ#) z<*>vt5LFrB4O{VfF!ErC;jZE_E71gQt~`SJ^A;J7i)S4`q70PB$C)Gow!+&h>ECUCaTnBfXb#w|oN4jGZ1uR%g5lwUvW z(FtQ(LL9=C*+F}{2}%hOoJRr?+n*}^#k+beqy+mA>%39YhGiEHJAlu96(<X7_4% zM&)!$zZ9SJ1^@C;8^{JuZQ!XGE}zqlv`-}klw_VimmUDuxZzLvuwO3lMp^sK6kI5S zFZ>Vh83tE0vcyKkN;YCjHFt6duQ_ke`hxJ#4`6tm0q9(=4SVyF6+J)F%J(?`F0 ztCLhj{T3j-F!b@~fSK`bh+sYKV1@IhTXsauVpILH5u+~q$C6bbbWes|**^?P7Gr;^ z$I5p77HdQ9>q01>02|M!l)60x*Y6So>^N91kd4PRB@@DWGxqC3F5fkh%YEnc5LL)@ z(olBnfvd(JoWD+#BT1N)Pu`Q{nUP`TQ=Ui%ESjRG3fo3P&6@!Cw%kH9Bm(X`LOgB$ z2-+aU|2ac5WGvS0i$A;v*?^SHdQ)l@3B*1}1~vU)S;l)y^7=UNP${0GKF*+o(BS?O z*~I9FyT_t3TiYJ?R+Tm@3orxcylUmGk7s$$aJFeZ!rI5ZiX1pOt-%{~zq}#j^>O4O z-->(d#ow=lue+H}PdOZsXM1g;sZ83Ctwc0#x;knT!dy#z%JU)cCC-cxjGLvJ6v{cz zUcVW~aCklKxSM$4&s2EwcqF^AMLEs}oW}?q1hBroWd|}IMYJvX;kC%G{e>U$*7&$( zdW!J0?9Ds0sK@*ME1Bz4q*&KoP|Vs-XG3_&K==SLhzItcMATe=)K0S1P?YBqDW{a^ zedU;((6>pE=$s*P?hb^C!51ay(DUh5jMZFFwpxi=4x8xrQbnw{wLrm(3+mgxlicE? z^JkF*TBj(_(7{hm^;uo|uY8yRS+%4c*kC2dJ(;WJ<;K{D^ON1*z84KvKN$W1q|BoI z{dh~TExcRseRGRjW#0@US0p+F^gc>X^@aAgbBwxaK6hl-qeY(fQNG+uSKVFuAb}@m zQ;V)!72_veGaHSgV%|yv`iu~~?+z;#aI4u=oE}C5>7m};lkW+NC5k}B%$x(k!wm_Y zZ?9OqszZ7QtpR<{t{rP?8s3Y4qw%|6}u#(M<|K&l7;AQs#(v9Q0Me3g26e!zNN*aBz?y+Afd zHdg@z*Y)DRgtEUTRpi&639tlgJb@GrUjdmla<6{9e!t;(1Y=XU@*N({@)q7WSbSo> z9C!qI_Dzwmx|uGnKW6v69*6B5Ld?*)ZfBH)UT$WKe>e7g83^115im?$RwYYw(;LFMplZOn*gR+06tpMOoX)fx2 zB0n4eq20wH5sL5;f?tRc_5X6!2|N$-r4w~We3bO`XV|49F+$)W-bEzIMtqbXew~B@ z3>Wv_h5=*vcEpH@+y3~PL?Pkh-9aF}hilavHV|z>Y^`T= z_^^vY(j0N%fZ<#Y1i`mT3{QwUpvP=bo2LUSMIFA>NethT9-jp1(-DFshv!7A(47l` zbp0;r!TOBm@xZYleOmK;pq%(De*X>*VypD9Hwg|xt1!P)7tl>~62rL)=n|B%hN}n1 zBR8x|VjEO}T_-!7DmwY4PIVZ8WGt8-qfUJoog^Zd9=%R^_(&8RE_DN!?Td37&`We5 zA(d=boun8pm0{PMq&{dIlT~ckm1HJJ9&4J>JRCS8x*Q~r&Z;sDMN*kLH2}mW*$K`d zFVGq0CNT?+M4KirP#Lx&$q!rV7Yzu4LSR9J|6+>1&9r@kVTzdrLsIrd;y1lGdT$Vra-6K6 zp+IWbgd{ZR8|GCz5P^Sz&Vct}+6II`rY?;@@W{G;&jv*`LsWJy5B z3a$#Wm(=iz=sLOzh-McVZAnPnyc*F30D~lw@OE zRY*hFM68MI24*Y%&4R2wG9-1_RVbeg%lM3S3gW?%${QT5eIC^KG9Cb=|O}&D#`J)DwAKNLe65}rvfiY|g>?-ov zrO$LZnAGHF@yAwANYH;;YIDd_G7rxfp z8QaOm%c!Jn3!ZhG&~3iQJcyzCnBsF)Z zZ>n6td4A=GB81XY###tuY|>1)8J#p<-_`ovtHWrX3M>>&f-C)^$~bD$@+BF_O41jE zgr$RmgHB1FF!s5QUM`byg&}4XeGT#MlMZEU5?fFtR->R~L4}NTK5$A@65jd?tEzM- zke8%U)FvqMiz-8btaKqzoCGD-v;9jt5Q^kdbUUaYAq)+jfn3&{O;tMRgx3m{8wurC z;HHQAg;0;dncP-U<@D1&AEE=*iSPDCD;-6a8rR3Cb&rv!bm@tSCUg`t z;b|5VW6eyqDB6SLa<)nAF?w}?5wP5D4aH800=#>Mmm@FZ{YucmUfe@#W0UKb)*BZ8pe8Kmv z`*_A?p98=hGu0OtT45Zsdz7GXO%%7B(a|<5fi2f-`wo7hl80ep2ko^WtxBk%7x=_sUu-)XICp@aZ#(gkAjOCGJdSunmz^25_6#Pr&mU4?eq)30Q5hEAYwz^ zFK;T4`^&34R(yj+p2J*W}!ZC6kuaiu-(F&*Tee7NSVl8s9Y)r$n zhy{_okm^Bzc0^2~$7j%E;G@5?8p9m(z>bkvGBM!`AVl=$ktQTyr?Bz|+QDx-EFB|^ z;wsz2H$b;Z?!t@~w*IpCmH&A!>eo0{O1#l2g1l@cBU=oo63dw0#JGi`O!e#JE{hB! zhJ)|H`DHTyYoxj5_sA41(&tNF)2$|(h9Ltqc?)I+_h|g|RGkfro5<4h3)_sv9>zmy z!ZAQw8l~%3#>1R_cnm#vR(F{bMM{mxcQn8;jd=CIeo=Vu@|NJzOpmu|pn2Y-Vjwue z$kR#Wl8>yUgTv)2k0Jt_VNyRKjL7(k^nu1CudG|MU9t688KaBA_D6POghChTex^hHf8r6qXvs5vGRm zotL6`?nqiU;l_oPLT+T=+}2s!S^X+%WF_86-k<03dX_`Sr9Np-d>z{*eFwPh-X=hJ z`sNmuv1py%p>0T$9g(3=xtC(cj_?VHv;3yiUVd)` zojnHd2UMMd3L&>%59fc)-l8n>ln{Tny$DV8&%K^eo?Y)1if>GkJXxMl5oDu~KJETh zn)gk6v|jx_vHrcwBzqR1dM`tKpw7aG`8 zqkY}+ado@tuC0?-mB)T;;Z4S$g9e$%&X3;1R+y)fjVtpB5kWkS2XjbDx5^_ zd7(0`g$g5bkt&{KqPoN_dzP;CCxs$hZ2`u-frbFhX*{pW4q-vLb^2=aHF;BP`5YGd zUxl@joWB}t)fV+Rvl&d}mT28c+J762Gg>UvJued8ar>kR^cg0Ala%7YvDL!ICwqK* z?Ynxr1c!UcGLnxlcj5ecr4q-@GQX}SWE&w$j~;jVtdi^G*)$fzpjyyaF}03tNE}lP zDOP?^EMz!)+&ou0ycnu3Ht@>o!=4(+fGHBL3^IGTq*}hJ=SzwM@7b_&b!=8S~wn!is7q z*b@z^bmxrIMym4Tm$1{u2yi*?g;B^Io6c+W5-P>EkV; zK#p#Hx0)xR?NF$UJq8%$z?>e67BRSv{2SNa`mVd_$4T2$a1N4d z9P2dAsCY3ATV^i3Yf{}b@94WB{IH`iF_X^K&aWEw7m%eLpS4#QX7Q86tVCWCx9 z@ARuFw^1dok`HSI0j?1UQ}(p-9wl&Vh0<+O5=VjpIiWYh=Tb38@L2iVHwm)JGTxHs z$~4}|vTC@ra(jmY;d%F^{C?6{hh$~aL@p+by|MZU5kGz~zMC1C$@{GONn<}SlMfK| zW9fcaRP}1K;%Ob=>Brl;5LAubwg_@p5%AcIUJc`O!8sdUROF?zWzWs9O%i6x|N&J-^ZOA>Vgon z8Ot9ob^VoaY{!#dX)}yJjOrRQYjno5Eix9_f^12fGq$#_)Q)n;jziC&3CLhUx1`$? z3tv}hN3}ABQkVa>3ZUWpm6&OPv!vl3A6D1?m&9k(u&((p+4HD>osjv@03ep^2Vp}V zVU&cxU1J_`ltdZl6#1k+i|}u`%E6eW!nJX-G?kHcqop;n#na_TwE3e2tmX8y<)cM) z&q5ENCnjR3BYjNs5a~aRpPYsL-x|v|XR`-5wD^E8%F&NM-EI0muud1{?SZ+tD%0I!BMY@H%MYxrj5H>L2 zNno{V-eO=2};9 zjbFp$np5y#Tczh3nsB>b6Kx)5_%XJM&^%iC<75@4dBombZ_T}VhP>p}<|iIpx-myZ za9s@{tteIrN)!npr5G+Xtco%rtuRFil147xY>=)p{;{w@2`n-(o@ovVdw{kT!UQpj zjWEVnn0#Y;`5+1))ClksnK5}}2n#a`WjAID!i+KR_)i^X*!^DL6j(%K68hl1nopX& zC%?bSd1{ndE^<=!pU0u{Oi z_boWy2_~!e2RD?~7Ny;<${OURKE_4lXJesnKLeeR6uW7-k-u;8>;H^!!ma9FaUyW& zW@th7*koJ{e7^WV*kfiAgk=(|XpXKU?j9?)i`gnJs3MGs9xPEnO)2|3F0X-_ZZrZR zFRz5Y>~~pSS_z}g@3g$860*8*ySyrCCYvx-(wJd7QgIO#@mY*V2?rHL^*W5d{GOD@ zD`DnSSKZlul?F zdSvaMW@wpwWbd9XXc=>4dza>KnSJDVm;Q18C$1Grn#ev7*A69}WdAp=O>`Q$%QzC- zkU|}gNk}@T%XB1j@wl|hawO~baVwYkNS1?fZI^X@=ACf~wsn10jByLLDSejsaSgU5 zeKxi670a?#hoV$`%eGc)%M?A!qE>s$R6fh5R@?m)AIqv%$Nf}T%Pub~>=Y@>TrWH9 zR4U6_FPo&4@0O)rL-cItX?yzu^}QXAnrW!xwAZ;h_JXXo<3pB}kNp*F*=e`?Y*{^Q zUfAG$9{%KmOJBooL@zX^eTt5RV1xmq=ifG^=^DG4A4Nj7oy{yeMhFp zh2r`^yBF&Do?k~c>V=44f9N9|(Vm%ahSG(M;6U*sQudyUZx+*qL|1?EBZASMEriqP z0N*#>@zTDl&*PDB;{X-{B)T;7?^Sxl1@)a@`9K5#-9%SX5Eejh_SJU?>MPY-#M?yn zH4cOVFkp1e1z`XTZCs;5$lm&eu9+ZoZ-d}RkJmt>i)={T+qM=&*LymdI9MDvj{Nh8 zsw_n#`?M@oA8V#O6WeekYy3EU{cr_q`Z%M@a0YAgIK%aD2dn6Q{G-K39cApeU<)){ znut-;x)yp8xlz@+270owQD;`!q+~2}2^|^DBn@*79RSrC zyz^4d7h$(UsWLes6@jPJ;~A#epZ*$-<>9!^AB^hcCmX@j`|0!X)uRNAtp(9HSTH zSBZEZW1Hvn6O*~2vZ}-ov)v-qDOVHI-J;Z~WE1nPz(131SEXJ%-a$%k%bTU?0bkIbDA-0we;B8U<(1^=Vj6D%8m`q!&9g$;~2vyzXE$G!-HXYHn7cf-==`ARZ zTizX~QJ2*_Fdc#E7laP|?Rf0gvlYQ8uF`TGViPL$v-})t6B_k%zL{zVQ>|`6lZDr1 zl{TTvRqmzsHpPrN5r?8}C5$CxZYBoury8(x{%+c*YN4|q-Nd!#INaO}^t5IgU6Bo> z)|5SF8QhfC)IH|x-Hf>A72GPKXB1r7ZpAzEvr6x0)o!aC3RjLf+d3U8SB{|8MEuHp z4h7qi&x(DHyV{!1s$VO+REw*A+X{@cTTjn`WbWrTW;MO{09JD%~uefv(h1U0VQS{@96gTOM!jWQp2w25;$P z+01bVVP3pjoZ^(&t&@O!jIvOf(fgEn>yyk&k-rM)U5T5l>f=r4B zn+kxRKcNEtazMSt~ZgyP4>tL#YF{92kjqE01pfz;(bX@CQKv)J#Imym; z+19Q&o9Qa`J|NDnxqiQ8x>dSGy-j`k)4)8TKqerII!|^>ETHR`v#zyKem;v@lp9tn zVOPLl6S1`26vSXHw=~@p$zVISME#ySQtKlTS4kWv5&qShZfP{H^N<+b9&1X+6>ZI4 zqgI$6)9kNcg^g{vr=A?d5;YuWTj^32LSY!TaF`qO^V8HDl$i8a;(YO7JY-$huY;~B zglxu=dPDP#w|0Ij-I6O`SN?F;McHF?Ff}b{ugr4R|KMEbY-X^NTcOn_*XRC*%Sam? zhTNo}j1ggmOCM&zf!d73Ksb9W<MCP+{kIwuf55wTa-D(>9Hr|Af`M}OXju=aDTDQo|2rxEKdfO<6BmnrOyd8edHio# z+1%vaNq*nNMUVAgX=?^d5)SVvXz!{ulZ^R0&}{AM#UTA(X>I>F-powujv5Zu|3sw` zv;NyE{}=E5&$IkDdCc~YLjM2Z-v1l*%=T}O{QpWlv$6h9=9!uGALf~h_&>}uE9d`Y zp4t8bo4>z{{{!><|0+Clyt~f-T>k$G&;NS$|GV(a!^X++U&1pd_d7oRKZNIoci~xo zpi#(wKk2mm)WAZE?o?Ly)&@(i+|o^NK!P=i;Xc4;$i4fkS$D`Er2Yis>)$ji#9B}x z##b&+Y+Do|?-M)1WhC4m)rRoYxJKf?+cHoHL`uD3;Uk*KG@t(CoV8puPxVL5g!qKU}q%_j@9=10X10?~ZaH9$arQMSWQH`LZIV zIkC|;ztAJ};li6L`o78lw)CM9@j_Gd(rJ|2knpePJ(B|ehH;eOE7E(%zo7D)0h)sf%FuZ&~=AZk8;Z7B^cP}0Wxm^qV zg5>k2D4Cz_;?_XGP;7sOU4g$yKtuz=^^ijDsM_1w6wHY*J1-N<~T5z>av0Jblo=4~HFP9A$VVie& z0R(4|=_i66U5~Ae_ASEQ&=)77Rn!gPE>AeZtUu2mP5@uJ;A2R^e{Anf!1)!D)m=~< zq45{={IJDe8E{DF$iw>&>w>177^aP3CohVExRMT=DHJPDew?{F`0S-R47?h^d)Or% zNlUE zrksi7ib2{LrofO4DU98EkT$)U7gjK=YF;`g5M0>3 zj|MIYyl8{+M}#&Bx`vMpDj1=(#bS`NQO_?p`I0%jLPvnjUY?`-7Cz~rX%}8njcJmA z9a9BE#&2_0wPgd#PDy}Ml{gJK?cgz+D&EmwvPvNL6tGZ8|AUrR95Z?}Kri~7dbB4a6KTb^`Yi^$v zq73f6fxgz)qWvu(5z+h<{@W*3dP}^Q_0BgaU$3_T)WQ4lWFO4bLBOZVX_~>2panCx zZjU~${>Mk(8-u&3Ad`=rAs%-(a`sI07vBP%M68Tc@bk1L`>Pf$|IqQ7X>B*7KKi+q zriJbT(vx{YJs2R3-EzXv?7iM=XW3E__b3MG3!3v_K=e28=G z-T-<2tcu&<&Uiub&y4XSYezibG;y_K&TM5>+dDnrhkP(#_L0-$Fh3{&i|=b`M%URtqld32(DgC<=77#{gbWm)!c!sZnS{o-eNOHN zPVP!$PwP@{YWDh}){7YJQ$h{Ofi%#8sHCh(04(ddd1Fg>?fu`kCkF&ki2B+NX?Z*Q z{+yoPGu@#+#D?~+y|_9g%-i|#JwXTr>YtZv#`IbJ^yS(FWo(cGQJIGD^vSL^6M$4r z6%}i}%a@iXL1Xr{W9yx?0I3bbCnpd@_+lHSSBCDB{q?;9Yx4GdM!@uhkVV&q?Tz9R zAAr2grDr{V>teleLTs$;kPTuVargB6WXu28z~>pXza$A7MItCL^A zj`+7F;UNYI1Hgdf^17TKq;?p>@j8IqrwjOeTAlymrV0wUql;zvok{$oM1GlXSBfVX zX%3Z;w3bwf=!hUpviSaa(u)!00XVl$YZ8-50{Y{jJ}wJ`PR} znpoXxZPm$vF+bl#Ai&(Ey$sknZfpKAUl&DupQpaLl@lPe+fGK77FRvKn810uobDxI zQq}tz>^FQIM?dFTkTpP!9^7pT6D8FJ=c?;lq*;xtd9d^OlBm-5`X~vLE%Ec`>9P8G zhN}+{hn*Vi4QimU>?2G8ilI&ZdBB@Pk5-|WQDMvx?k~eP%>??J!ln2GPcQB4B~GGX zkShq=2NG|}RbCSLXB5m$cT0OT?k4Ra=<+0e1OMjM6(r=<>RoM{IHSoJTtO2GZDo z8<#EbDTJdhKcx(RMji%Z@RA+f#0)2POW*Wi=vG*nVIgbwI_QqEPb&#L&(?ySuty4# zT^bAM`iO=hp^D`$Ma2&s-ljONIc&!*2axIq!12KgFK9Aj+BS=1@@HEcuZx9+NY7@@ zh>CS@S!us#?V@@BCMt5sA`x6L>!~|5nHTW>Ql>*l;0tv%F?uF}tk5|IXX$SiHU@^7 zJnZ?%S8M3%+J|^GR%CIBXn1ce=DGA8Mf9fljkt5gCZ9Mc!&0^Q`70m2j^Ta^1KU+epyD?}O+3WH7I=B}a&=`d>_L_n( z+ZZAj>^bcj2{Pnh52kxsU7g-f#v*B*Lkio-&vHPhDcw#FAbvutr>Hl*n>3|IR0Eyz?47 zaHs62xRkM9FJbQmL zNq{$RaoKMcp(OE#B?KiyhtW>(80DRkPT1h32DvHwpX5O1d(b8^HOmJCmKpRDvK47LA^Quy+6;A38 z2cFg``h#nL@lsx+aU)8=kF6-9$jMC;%KKhNC>}Ph4I%YT({nR@bRyfxA%S8df>yu- zN8mxs9<1xD_G{wg1~GVpxfd&&nwp|;5-pD>Hp@^)*(c3PdB$4$u69)L1a3q?mpQ)b z{_rt^GVI%xgOCunCui51MH?}0iE5pWn#xOpRthzavPOT2MV_;2y3+E;G;WTjr^h7{k)9gx*|RU!Se7E(~jR9T&hGmipASDQXu?t z#5aQSDE~<)#4{-7%fICLJkf$pWN*f=DW?|MgLW~cDeP?K7M%l#S7e)TC)RC0##rAWo@zHphmEgMhxF!0lvY6dasC*QipgR6`(j zKU|BRIy?+?h^Qdqb-Ht~^808vZEk<{KZ<(^BZu?mec+VV{7|r|Z?}d{?w^5MO8i%t zrixJDWH|bL!I%xiRa*d?WAysFS@qKZNh6I;(pLDF(F;6obWW7Hs>;2a;Na+hPYPW# zM})1i(DFEx9Qo44Gk*xmbNMvIJSsxYw2=+0kuBsJe)lqt=Hu=O=oX6^ZPJfw6{%o$T-p2XBc-X8^MnKJJt5id}zSLNZ@QV^iDI~ z(Q(KcD%)UMB@z9;Cz>4j(^}7;Y&%%-2#Kewe%oCyh3LASyqqS#5^gXkeJ`~Awq%_zL~p>& zuFZV9GShONlNdb@i-3SP@!JrS@wXtRj>saKyB-a4MM7@HtayVe#?*1busXV?PcG2n zJqc~QXfz{@mTp5`=dozCUqN6Z@h6Oai4C%!e_MTj>W;!d4}2WU*&>S^OO^GMe#amG z^OfgwFEb;x=hx=@4X@Ye{rDc+u3Gm~u6+wwOQ9P7wLq8pmS>_?SJ(B5zU*)O7mqI2 zmi{N_N|pXz8?#5v*@kVe<6Tup9$n&VOEtGvA);Bo|EYCn1g|lg-7sou zL~ePS1pa?YGBeAyNco%$njzB6cvSp(yG1m&&)Ozt#oHBrKe3;9@De)-kf%dI{&fg~ z+N+nfkmo4Pf1MnrUr3rDlp2gr;bzM zjP=tbg{je3JJ)uM4zT`k0wiQGKeIn|pvC$1P9@W*|0$fGDHH@mqvtx?b?#tmSg7Pu zs}WPT=GGP_cTmO3Z+^%KmMpG8v0F9JbA9XmO7-{oF|#u1?{Y@dT&0et#_q6qk@mSX z8;!49y{)Rtcf!|&9$2CLjLSnMpGQTuVUwEJ39$V9QQnVo5@jYS3v z*Z$ajE^+n6e5N;q9dp9Uv_yp&J8`n?I_8yi(%Wh}qQifBoFnR1u2@4SUX(*Ef)oUn z-f}$CKU1b9&FngMNSI=nihzjj4sbqPBPtVhI`tZpXWp`g9a~J#jiI+0Qfj=wCF7%D zg)(SP>wJ^sqs!;c*FN|$?IWOm>J=&Z^sCwP(Am>)H@xJG@65zjg=ne)sCjEd2%44(; z(ZIn7$+n)M3Y~galK^duL0sR$AD@*?@Xyt$AA+OwP>|=VQ#DTihtNugV2(1vPvA0%uw%vtQu62_Lw5@)8>NRcWT1!oR zKYE~>sfUzetx;g9l}E_h6j!IuyY!9o2hvUCaquwgn@8|2Wyh&50(Ar%i(1T`H+c2Q zKFbap5g!!yZ69Qs90#XT&nYVJ+?Neda0tmyu~E?+(2dV-$;VQdvgp1Mv26P?ne*d^ zx2Kzh0OZk`KcC=?E36aMW7Sf&PeB~^#o3syBOC{DfBYIvu&b5c;#Oc^Fs!+8-ciIoLNHVJNy1FDB8fnYdyJW2KGTa3t znR)NwHaFjUCxchdWUY5T7#*};T_3akI@Uk68(fD{OZs|n*F&7%V`liE`s|i1&BQG! z%(j~>#L5uJl_e>aB*PRf3NtGI1in*eg7xHrgd$K}_6itVoSB1G8KVML()&wOkoja*X}(0iO;4h?8w>TeOYR!1A#HP!8r6stj^&2#e_T= ze2AbjT9;yg=H)npQFQvVs<v(+zJ2@! zIm`6fLgiDwL~5XKF>md`fylS&YAvN^3LnG~#tJ+H>2B$s;Q^|I54}WN?{vH`>AEg0 zOAFE!JM9U<@r%E2SR_~^DyTMXgNPNG?;vYKX;vxM$?QzQZrl06h^AM&PM7@X9^OW@ z$7ka+FCWMPFe4Py(h5r%W>*E2$6j3OXecOhlPwvQY5L2r-;BOF+I46|$&sJ*4bkn~c1{?*Bj%OMv&)m;2n$a!F)TeGFtzX8Qb`DuUCv0?&#@mWelZ!gl zLhx%lXm-G@$p(9eQfN2$=&v+Mi%gs!_Bot*Kk2HRb2?PG*y)bXmN}WW4x1?xv`jh& za_8DJF+6OnRccwMqR%id+jn1A8mQ*hN?5Et&610}B`g`b9qmQ-tL`~CX5*Xpyg7WS zUxQl_97xu8!W6){W8MSrh``s}8pNjMM7zKNbgi@$6rK()iBB5ZlK4 zXtPBD*G8I=eR_a-7tPk))MR@ZUn25t1q~(e7J*0`A465(2bI1qdsTJ18BY>$Q$91Y0SDB03QE z8#8((EwK+MA9e<7SQdyNiNqG(eutERX){8s&ayO|6&Jv=W^ZoHt`D0VW}lid-A?-B z?G$TA#HCg_m3tehM0s>J zf%G_nc*ZRoQP_%_NvQQSwbX2D0xAunxc@ExG>bcp)97mUb$;{DEX9ggk-hfobn->j zg-Whgy8$mDw~&{!X6bCDj7hJs)QSz(dh0z{HdZ!k*V3(eOFndi`OlF^@5u|~7D0F# zPLuDL!Z2&LSh7ZTYUAzj?l=h(YC3HW9`*PLZdeyS>ztWw@u7iR1eaZ1?ZVo(5*J^R z@nKKMREjtLQEg-en$aNx_eJVn{@tOYgA{}0=n*PkGOw}7!~-DJDZ!^`zL39iO@bc& zyJRW9y~nID{}96GtlZg0C|GtN0wRJ$0%*A~8J}GaE}5_vztee-SxyP|zHPk;7qM1| zo9Qt&Zq+GZdLuufiP4tQ9rZ;t-wRBItO>C`?7(Or(*Uv`VARYAa%xJRist7iqRfxH z!H&7e&PX|pnybN|!JEu-OpyG7c|j?PcZi9bIg?h4N$Nl;8Y-X}L8XZ5D@q*xxTk*Y z6z!Q7Dzdk>^*(6|S*qf!C0shDzQsUCGc7hwoNTLO z`b!q|OQu-d=tfUp%FpnIMWhNmy338{%B>;mbR&ZcW6AwF>a#IY9bBp9@t}Bl;-&1< zQ?~ErHzIJUI!hrD3!ma)L6*wCjnk1F5gY)C)7~sIrBvY=7jsDSAZsy(tro8`!^zoF z9<9`NTi;;y5Jef(l-P}!+v0%4;>f7wl3$&;A6e9CG$=b9GV;O*9E%20w)M>7()9K8 z2Pf;yiMvJ3<6?3ig&*CmG8MOSrac?Ux^bt|Q|{T<&-DYV1>J(!TdnYKAeBudjyUhu zU@Tpj^%KB_49i;&s+(8VxV)-=@i_)px5;3AFo`}5xT8GSimmOFwM>-6-yR+x6^haP zz0;`si0eq5TjvyqSW5{SHjZD7l`dg6XQs|!6||6R zjkJ#Bwbg+enU!Cp$-C)~GuO|vNCA*I5S3+#|B$kAQVu1S{psyO(;xs-;ZRSF%)WPw z6X!8PoM{h%)|!svB1o~l;@NpTDY5NmB366A~>A}e$#bQ^HwoTpf7 z)soMYJ4%glyTDFXkaAoCb_e`u;+JsW@x=)tyIobxB<&8vgUrdF~*zMkJJau6Hiznd3Q}RPSg$d z_?V~LMv4uWkTVZbS`7j~wc^I;kF1ssYJPVIJ#((IZ(MsX8x~`I=lei>|c`8ElOsd=M5-H`W=L5X#4v!Azt2IY_PYEt2^IQ1Mio|qLt*k z8-GO#+gBFgMcIm|>2B$8k=ZJ0>XcCHf1n1uB{_`G06x1CvZmHRr=Qj4P)e|4r2j54d>_^HO$hpobMU9WLtP>#8VWQl4@My6b zSX+vsM$~)`z_2{r5W8{UQL|Mol{HPI9Wm!k(OC}EY9S+_F+O-7$`w@{U~)ZE;^o3Bb;Y|kH&`fPm-;=zMo%2A6k z=1S&Tb*3$)$!e*n@LGBw{f6~Vvu5FCO?=6w!mr4KMAhy9y(OFiVo{43*#ah(*(pWD zSxRAG0YTrdu38+?)gdfelgZ>hN5)V1bgX3rd_ibxZbjQcI zQ#zGg5g2-zH=cvHpjmFqg)CPs39MIAE)Or1Qrj1NcgwAGc? zeshUksZdLWJjR+Pme=R;YE(x}gC=n+TOnb<1Icr)W_XDV4^)YCRr77|+Y=;22a zYh%X~_2!xfSN&kv+-&cp*qVOLNqxkvrs5-S_I(WAwcb^al}$7|U3%Ln-9<%M(5ys> z#6f-!RWUANS>T_*B3|jZ>zX6M-~eHR(UK#DfMI54dBrr;-kn5~ZBZDL;Dx+L-=0J3 z28EYCFg~S^yjJDRV;CKYeTI07R&w?rjUtCaqh+liW984VpX3c)#qF70dZLQ~7CBYwK_jYDa@qY&hd8gk7#;IG|7=j&OyI;1 z-Wu7b%;>p;uVTSFOr;j{;l{;&sgokznRUg#AO8IsH~sOMD%gc#{(+6NsmdRGGSv1p zN#h$VO?E1ddDEN^qF-wMzDW(vT4YRXCO-z=fhb;%EIYW4_ z^1E3?d}d}egfHxhNdHYKG*idzsU1y93h?fX<^|t?KzqUNnO4axs{Zwu5$jJ?^9%L|ZzXfdGA`wB zk)@AuD0vMs+X01X4Yj|W>gF*jxu?ma7ij|TP&3MJ)cbazp2_?wGW}?Hz z5DOCd4C{05z0umw%yls>DmH104;;4i7e{8^N!vtkEb9lk5|%gRTJz48aqRDuxo8CYlS{ZKNqWV?~N{W_2xe z6C3ekEQU1&N-|;Z5JmgjEA8!$;u2w-;PKpGCN%@JY+=*k>{&0l@iB4S7(Z~tZUf6( zHIYcZb|0d@t+2Q$(LQN=*?SL{=ktw(&BQrjKVPw;_y076e~}G+_$p<@UCfPBU1H+8 zB`9fn+L*#xVr&18*Mnv<9l2`Gye>WmnA;R1d)9PRKSOmhMce}*xIX0BfuluEOIifSHp!oPBzZIb1zv+ zMupb_>dx<7qhNw&q|CiJU+EnneF*M;@QC$r_FDVsB%F$UE%-`#S6|{P?+v67fHKRN zC2-lzns3sqlTXsvrV9t!TW_9C6rqkk6d%n1Y?;T%owg$taNAcR3I08)M=>q>?@{n9 zIvk?$bmbo^Om0^M>J-h%g)UXml~#?Es4XbQZbjx!5ytV|Vc&4+fi5T%K!Jmc1RbC#rK?OxW682D$Q zF(v>Qfyg84%2TC-K2|#s-D#uSZI;Nl~dQnPb_CPUDfXam%P_M z8w(hmezjKTIisus#ggaa200Zq!~mz_8&FHXnor{soYqE%!vV7jqUEFFrIFhRX52w^ z4Vb#&;SMM7Z$U>ljPj)eNcj#Ma39Tl7d1sUMSZR=eJ9>2 zzJ|G?J_=(-^jF8%!6Gotte$}S9;mkxx~_zAMj7>xIOx8NI4f<)CvRyOGHVyMDz0xO ztv}u&b*>Mx){8ue?yTOhOw>v?0l>P(z6&VL4S|{$E{D z?x-o-Xx9Cp1ieGlaaSgvET)^)%wEai{`&_O2d8^*5i(9GZDHP&8AX2xYrjrA(c>)q ztrVNQ3X^$h@NA+C#+|4(=tP0NNuGIioudM3%&fk?^mP;E6TkRp0tMZ!eZ=$C)$^>Y z^Ok~+tdBqDm~GM>{bD7T=S9A4b@k|}+J+Vr`8lYV}{FBN7x_PZgXdE*{j9s5h)Yr!V;FJaZ20OSzX2HhW2Ws z%X`+EZm&UZ8y#7tJj*#np+mBv&731OqAl(lk5O;z!>sw+ ztoc06cnoYJ8L!^TU6`A4LBiI-;-qAe^ooRLr3*0fMv?aw=}GZRBE|)m898BqTktgc z&7&cTM40jerKr{B9ybDFn(xT0oX6Q;aTU%m`6~BaC}|m4GC?sfmS!_tn#0D%;P+4g zOq31>r&!q>6`Dn7IH{Lx!)b?L#cvM($#doc>JnGoq(Y30((W4Oc3P7biHUBf9MS0# zPHEW!*&7&oC}DXEblvqb(S?9{otSy=UvBLfk0B2$2c(IT^-86f$oOGpc?@+*bj?g7 zh9ySzBskerBmLd-lymL3B9{=;lKnG^IRa7zJH--7I<{m0-Onm5(JiKay8TN)^qv#z7oU;ivagVnh_KETkL?#2=y`Qi4o(C zqVwTg#fWqxrhdD^Ht`3=5_RRa%iWJH*+VoKX6IR;20xvK1@ELcx5r znJC@3~dR=$=!g1YM$X6CpYzdcqn7; zvA5&`MM-i-Gap%`4JPs97ZQJVXjAL(~Yd&X6@SsMU37y*di`|q*uo3QwZ$SMsaNp(vvYCTGiY{qhueIqTv@> zHU=@Y`S2|Nc$#o>x|+SUeUb!$_NF;3Lv!g}j!WtcRKusTw6to#4mT{4Zy- z>&suV2F$Dro{Hqv%S|ewr@9`}xot1bPX}2x+>Ac{Wl2$4QJPCgn9UiJdN6gKG%pS{2&CcTmkwJ{%iWp2hy)DT*rmqPv`)Uvu{BBSY$}xW0;?p|BW>-+A|62!9~Me& z1W3(NOqlkZ`-i^PExO1b|JKbsIf-@-Q!-25GR?%NXPi~Tnr-c9T0B!jMmQKwR_{n7*dIDzEZ%nmA?%I7Q0dn2Yc*SJaF#R-4erT!8=7O4*W z$OkSKk*gzY00a}1?jyqo)Mb5`D#of_LsGIMF&sTI_nIfSMcSQU1pA+hI}9fgvQt=N zgta6$s6ShQ0KfM=@5<=SKu^M;ZlBS~0%}VYS$Z~#WqR49^vvXgswhj3+W;Oe6WL2d zQm#40))A@z(Z+NV%FpIrmXIZ2e83pKrjoG(N5Y@qEi3zsWm=RrwsW(Ot8Px_oG!mj zuia*%nv9G@E>YX+UpxwCQgwSNp9w&V-UZh|r-%;bwlV2_p^Z}p_ervT7btwjjXx7i zX~jP5mqcQ+)lX;JlRBr&P=MzxYm2_nIjc(*gk(hi*`wJ1mq*`R&l|6|+%*8Fkx?0k zI@zHW>%;mwDbX?XJN-LWYwr@n`e_JJ9KS*pxG%0tv(1KaX85Ha)t?i7H+7S*fjoi< z9}VZ?{;!{M_^secgC_sC#DrI-(HvD^!y#G>^-09ipJPI*X=Sw41^`W|D%zn@Vexqw zAy7;|A*I1GK3Gio`imvlm*6*X~^h_%RA z#IrmyWjN&hpH(Z8U&OEZzuSB2VwGFusWvjkL5iF!IeWm4O#kbCRGL zTK#@^bxVD!-Y_-WB2BPM>hi%Aw65l_E&jDj#Acn zI`iMWJ?G1T6EXaamP!pv=8U;@XP0<01{}_6K5p`=TKAC)FD8OjZnD3Y3!6ep&b%%4 zwR)@h^{3!6X*B+|zYcFn{1IPNUDh+Lfc!mecSrjSNMV}Vf$}wl9qX65keZ@kL8A62 zwi*Q*+ldkymxnVr_YmmhrBNE21lmA0xg~htckgDrP7#3z%fLNFl-MgJ zez0Nni5`01ulL+n&^Er$p;!{qsHR~#kU*jBZVd|VnXf`5lRo4;=tEs(g6$^6Z4e?D ze3;LlxB|>$2WVKZ$ahF=qaR|=rrV*V0hYf7TLY(rLiMj5e&X%~nsFtotEmu&BW;A5 zhh8k_ghRnRZMSmqk8B`&Ne=hEA-@y$MZN!Q>-xS~-exC!1MBHLiJmL`r?=N9!W;$7 zCm=|W(;HJz%lE;%QtG&XY_diq7=98%8e|D1aqTama>Ch zn!ICj!1vF!3F%1KM~n!{F7yk!vK=LGzz(WTaQWu|+Pf_7U(_uTs!o6d@=7Q~+ZWPS z)YFB3Qi?wHqixVykQkvfudmR4fEiyh9gQZkKtG%?y#d~129j(KXh^FDT3XZ0+6%!> z1M)f-T#KDg{29US-%gMaG#Jm%4SQUEE8_>U&=UQT?tuuDYYwUBs~s1jzyh`IP$%e# z%1j7rl>9GYPZV23egGd*lsMV#9^N&@u6~>6k2;BJT?lV9&v1>>RH%8(gLj!$6Vy7~ zbfZ$W7l`pR-+AhLMhX~Gs3N-`Pg7P{O2`cc)(6WRW< z2c?Sh*5i84qcwvl>I-MzVW}W`4b)rch{9%ZO2+!fOr!Ng1JR0J(>|VO$KDat;lkJX) zU8P#x4oD0IyOz49dBUt?jTA>>ZX_{?TQ4*frP_ycf7506>ZCfXB?>4{{|k2AI=Jq= zp=lz;H_2ctL}w*UdK=pheAj`aP+f87f_Q?4y}+qLr($P|@yA{_$zK9(wYnOXj95eO zsCD?PRJ?GU;fMt5#7byIdh$IzF&BR<-bopmIOHUU$37$A6bsX=v^v4KA>j+F5El7d zY$dA0m7hYNvr&K>5wahuc};*scBt!$_>stV%kS#ZtMUu%H?-HxRH@D2gY#{Ly$v8% zwOgczN0@nC=o$vx2gOEo+}IVz%>#;#IQlk159ie1ZFq`q<(kH@W+xz@)*5i<* zR)HpRo*Ly-P^(fcvi)xr7`nz6tyY5lc$XhL-VvNT9Q7&P!r24O7;A3p5c5P(pD3#d zP{y?DDNyHck=Yo#gy&^OeTd|3DJ%;WZ{WDa{63FlZ|qA^WB4&A zF|Ri{ci0>CeGnE+^(CxJyeqqX&4!!_k}JM3!<8wwK4RJ*6qj39xnIu|(H>|Q%mj^P zlJY8Af-j#Q`}U}XP;dJ9PpFAw1pj^tBi@Q}e!kg>jMilm|J6f9aGeqT^2_{PlIHbBUZP{0S|hML%z6J)Cl(cTl{|!y`W|fNMC*{`6^YNk3{EOf#1sGK*DfyDK#c&?VunJCl1H^N z3%W*~ZS<{Jt4@l1jjq3JKN4|ZD)ka1Qfr7^RpA*E5)&m~#Faobpp#LM3fRVJvagTej}M5V#TQ``Smtf>zP%V+-zO%zmHC^sa2N16T*csT+r~TWX7eZMULb&gvsY9Ri0yL~Q zAJm3gG{l#>qH-kB#G<6Ra#%G(Z-=DEDT1F7$2CUpC^x99F|TC&PUER^sGYv9K9&7l z0V#P=NsM3HruOS}DN1IPXE@Qxr5D3? zxlxYvqrh;y=T9>xg`DVB+<9A>`w`R+n|qS@Hom?kH}KO-glEnt(p_gOHvKp3XHJgD zXXwy%uH_hOeRQ3*KnF+E)65n%FV@pd^>x|QZ^8^emj%X?haKz$1-0yM!xNEcE{^rM z0*t-{b2`+gGR!Qc$jEG*gw-!%GMJ7lt^3oT)BF#Jh@)@=SiqT`VIKM z_5{%e-RS!IpDMV|S@To*pQv*~p^#)-u!P~fnuVy{cd*cLoX`bZMX}vW;ZxnvQaHOh zFrNamlpFLDn(nrzmNS6cj(2>OSM5ZEKL~YKnNWVd%7leMyip$M(IOfhzXkHKE=;%H zgkU~Np7qb&$94NY1Kt&0t7kt(Iphm6g~o*WvvwcM5`Ug`L^8b4q5 zqw@PofqfuILT0iRfM4=}-f8#7j(YlwO%p`;i*;l_xa4zdQ<~)4J{s=7nC9DtOO#SS z5^p&dY@zUL;&~|+VH+DF=m@oyQ~Z1T@_icI9XO7GadOZ~ea&V*Tq7K^pTW$Z64~gM zez0cGukS$ye{j;cPMadBY_(O`Ebsvi7(FKK4nf<0xOVWsvi9$D^XeA0%ek|578ml~ zyN&pW61>~S^M-Xr+-XI5vpx(QU!hl|)hrNrfgjajcbE8jV|wXg42h-rU#$myx;RM_ z$js9N`L^$TD49jR#u5EPzEDqnKWfEWG0fK@{|NEncRyvU6Upb-G$485vW?m!W*|<; zh_lDW#{7bOpWFaA6G^J$3r_O&-@*gx&BE@e#3~~WP>>)GJO!}~py~o&GZ?j!rBWHX z)v^hR#A=NNqjL{@;t6uyeEYS2Jm~Ln_^STHhO`^|GQ}+z$8&MB^R#)u01JH$8_3Ra-h!kpY%PnDdUC~%oedUmI>X13~5 z&wTimrKk29?q*7G4W^O|>Ymq+spBVn_tLD9+~B)S8CO9tPk7d23UFEp0u(4ju;gJ{ z7&d7{*4}U@6IN%lo+s-k7#VdWt$$pyJ_lc8QVy22Vg{obt+o9366KrVX(za z=ng6d2CcHNKQUu9pze+=<$rd`L<=av$!~E6_dn^ynlWIoCD)AI>~uoG&$4i05SVOZ z@o`fR`Q=1?MhZqxERr;j)vfZplklS$HqhYXM2wj1u~0(5{-NRBd$WS;3}z-F5Fpqa zU?aq-*`TNCfBAr!34HRz9F2=!Sl7)9b|K19>4V#MLywvvtbCWtO?pXTHhpkh+zNSI zcHgmA>%9sx_GDX)`Kw3vnuSsQ%Q_h`9~pZ{39WCVcf%F_0&s(`7UdyHcI5u526y$_ zYA$n9VT4zRPhqrYJ?QbG(~kBFJ~hu19-+)+n>Q0aWIBW+|Cth3k*@C}^pV(G4z}Ut z()W#i?1OZF-=GcPS+bdW5xNCZXF6vYj%D_j);ECW@HyuAiPQtl`Z8-;gg3M!daDWJ zN=pApCz3b%PAzZ?^!)F&mpa0w4u)g;z*e+l?8Qph3xkR2tR7SjZM*Ky=@S9oW({)Ec6Y9V3QeQXMDJkhpz;)@I06 zd;k>TDYCCw%n_Pl-E!46VXxE~`xY*?X~Y2}^3oWGAioh2z-*j0I1o24Fzhbb(FfYC zTcdx&(2Wn`*VqdqJ2IV{OijX=Ox?PUfp^Toy@6Zj0pX|{H!G%RaBx(d`P4Bl=10Vp ze%KXpW-p^p!tQM3(q9E{+<%{`8=J%=j5Vw?*;`~Jj3pRj1==FKM-~q3tu9vtV;Oa+ zx-4JQsqcpEKZx9?RZ|?I@dUB*v5j>*Ne!{wuUAQ82fL$Lg`w}xNI8OTL5$b8)ZsiS zNAvl_a<+L3yj;ByM>~RQbNoiSj$Kx~ycD?=h4F@WR}UCK4>I{c;@{smdGF55#yml7 zOmgc#K8|s;gzlA8E~BzVUR33b2}hH1Yq6aE!L?HVzDmXY9x4?BE{{S}7L^Kc36W zD)y1~cZTm32}b=qdDfg{_JiDPZTk5>fdaMRU-`3KW?wy+_6I5NUP-Ag<>@qpx}S#w zC;hUyh1d-8S)`0r-u1>2=i0> zUH_hO#QRM!?!8|4A z=(%_6p_G@IiheKMGGC4YKl)RF-OqX3#qR2fcs*21z8kA{JlY#`sXM&{fMyLg zP&NG3O3->R95IH}Etr{P-G$$%Je|zXSC# zH{|n3`H2L9WU-)1bm@dA?C_kokdHWF5BcqSg_-;>SRjqz5R{X{X$x|_YVvb zl|h4WK}co=JDzLCv87pVxeGNJvDNV;hXEQk>Fyz@ zC%HfdOYYhl$rwHtw^Ca}UQhQ7cNA~|J;dee|9S+%$IiGNdrzsJfF8#AAytVCf@O3$7EdoTYT zJEm-hJ^3u&(1;Og=a?Lg8*)pJkv<#_gXCus)}#_co8$rchHYZZNm`h$XaS0y4~f|& zJMxc|WckRLDv2qR=lV|{Y5qz+ih*Xe^Q#mGuAR@Yo9VDA0i0bl@New4V@y;Xiq|En zzR7+_qY)!GrwNjxNTYes(7%6!3R^jc=L%nh*k(35&*1kI&IR-`cNZr28Kf`jc+~3A zUeu$W8(F-zF=wnCq6wDrMB8|RU$EXNVW;l2+7 zjafcHd&etHLNiy;A;ccYu(jFI&%BFIWQ{JwuR}+;U9b292G`UWV%;TbxGNEAzE0;6 z-UMfF2G*Z~`$9Vh!*u<{A4fqhsq#h8B00zEAm|Nn0QV7 zV{aH9hOu7ZJn|jQURXTFbNGw0UiZ(ohZ^w`Y>a63pmPKmB>}N>!&@`;eO%S9-|*Z` zGiAMy#a=@@iC7^Mg)WbZc~G0AB`}X0eWm*QWGV-Q7YS>&!p_sq@9)FiL+4*T%l1zL zllg?jBgQpwi}X2iZIJfl20nss*@3JB{DL3lgL|K(TK0bscYpW)eD&c^TtgFrB2mSC zDbxDGG2kM`sl%;1hcP`I6k09GIA~z_YjoZvxE9Gk?EE`3t4yr2z|_p($oTW?-`Bq9 z)z>l2HS8C@Lsx~&VE^kQSoiyu{=`D0()_!JI7jpGPpk-fTd}D9fME|cy7(!p4Z0zi z*Zp53s@agyLm;K}vc?x*>R2D-J~#$~KJPTkU&v?~pFGu~hq_U?UDzDXEbWH*+(X|& z$w%YcY3wvUS$qR76Q6!7gk%z_Msk!h=ttpo?`a8VkXOj10YX{hqXFh~s~wbkm$Mt~ z7>T3hK--bJFZ69>Cq2Ek#R5BF4(PF1Bh|<(5#ergrq@+Ip|>4@W$~#ZqHJV7KN=W! zgy`%T)We-liGM?PnC!A;iMRZ9+=xCP5N5LBg1kzmI4;$6jB<59IQNeBC>HxT-4Ell z0}Sul)JK`NamUaCvO}zxfto)*&6x1^+3`OU zwNfmfTA2M-;A;p$2RYK)-4{ULTb^nxM8@aN$+o-roJ7&iSb#r7XaU26&F zxH_>TaMZ~J|A=m!UbOV;|9m1M_dnS|ZT+(ZJ6Og@bcyLPFj*tZL`A2Or(+cY%=)17 zT!pZc%7W5^^QaC_;)vdCuJEUhbCc%4(&RGk)VTsav?08BgFHp!Oc}S=wh4vK;fR}< zzWz<(M(;S{+74W`OQgqa$L=M63uQad8GoF;wCM5NGAguQjYF(}Lu81x^#_C$YpRJm%LcQO^iY1lNhfxO4MXRX4k%IL_7Bl{B#obWK|hH6 z?gQhI1PK#=P@?(Nyj!3Q+tjc8b-D8P@Bz5pzne~S7~-ijxf@H(Fu!PNdx8HFby|K* zg%3K~-%2RH{g9L|z`X4m9DnoR(^S~4cL{!T`8Z$lY_~#e_*%rg%nC7JqAXCEbT5`A zj4KamRK-Na#67!>-tqdt+V1SEUQBvWP)<8cl=RP$L=ce}V9Im* z`GGmv+3CXDrzSLiS6E*~EukPA$KNU&F(3b(^8|Nn#4|6$W>~U_7myTqc5~AuI0Mbj zz`L;rWYDTNbd;t@Xug5wuhqosy#Z1aK6cuBv5lC-LAszl<6AFS^msFW$89dLRlJGo z%n*58s@`0l4S0Arcae^&(7in*?KEF(|3R6APgRl;FMVR^9u2BAd~CPN(Ggj_(WveE z_qqATz9j~+9NjzBMm7R+=ZWXJ<;qTewdVtTv`mwVrFi3+ox|XFzmsmI@BJL5+7^Di zo0Z!+0^s-zPNjQnRUCRvO33}2+Io9y##4uW)u@;16vwEIFv`aq#=q9H{o?zXEw?!I zHa*ASX8Zckgbbd&*Vg>>eG*;-+;Q*DJ{nD?*QO_yQwQBE-s=M|oGtWiR;15fSD)Ui zZtqUs8+MaN$EzP(XLoyEYo~9nSR-B05-V>tARRTkF}DYp&jCloDy&mW?r+|EFX`uP z?G{M$JU? zb+NXsnUx`V0y|re^n0#BBDqCB$Q?`D>~)aKw@vaLx7sM+KzW0Z+}nTVBu2e2D9y+0kG=e%vzr*;b-n);*5hHjjr6M=o`@k{cvxRR^R;e;-oj&4KWBj_ta(wL{KH=ny#kgctU&r$4` zD`6RuGkvcqMADx-{Lq?~DEbu;O-l$@rrf68LT!r?wX$E_n_mPy1RzO0u*)hREbQwk z7m%MAH~U|37wq{2LXnd`1de<}(SLmi&Tjd{{(+N1Nmk)z>5!&=fE?+P?za>0eR=nu zzJqeUi(bl;A_7Hrvc9n0jylAXtB9&KVzlc#SrFi%V~k2>86; z-@im&#C&9fSb1%b6zi71j0fLAY+qhn6e)j}%|7u-DVK&-sFD{BstH;=k``ic(^rAU zG&$*MZcO}`4}A!KeMwn99*X(TH$C6%{qHt8J|L$bM#iVae)|-4s1S4#?no%iF|veI zdH6E+7lsMW=51y|P^iYKgnh19v&O&3`_!;zjee2znPG{JFenBi1K?8hQ{l=0g~JTM zU?KoqvVJTasPQjCAs?*Qk!jUnUch~FUJ4wK@iCE*3s(Ecv~n;V;7>AhQEiunM5nSUfSW2#(gsj82FND}8iYICv1i98V+=91LKN&Fg}DFbYk{ ztA=wjDy0>o#j+ShA{T zg>Ym>6m&w2SO!KER6@vDFk|xK!Ttc@F?s&rcYyE+5@moCLts0FQ9I$_ zJ^*g~PA(jzk*iSf2cUzlj~DB4v{o#518^R%Ujm1ZyyM%8Lrg|HU`in>4(8Z7^{)? z31VRkGw24p0mhT`qv0BiQ_1?Iu}p>;w1dq7(n<4vpkQy1EdtByoZQHhOp0;gI+qP|++y9N-i@kSa?~T}Rc}|>)$T(Gz zRh60V^HW=C52>msr7j(By_8zOSb60*V_GOFm9tBYt$Rc|6OvQ6oFS7_r=Ag6fLM9u zJp(!8S#aMt#66>N>5k+K^nh^2cI61>4A1^8KdfL2h{=!H%dyozmsvns(BE)@bVlSt z$lKz-6zhsv3-!DjT*6)`rH7@)ZoP_Dv;wrEwEfmF_DNu+ zudUYj7IYTy7OWQNR~#r>O!D&z^YTgxO58*`{E0k7Is%FEC_F?w0x0tPJVZ9~?Z1Da z8*)yeC;~I1Cx?W__U(%TMBN4314Z|CN#y@2{F5&$MO~1`<-|fD2*AF8It9lL3WOWr zf%wnWm;YAqwqxI=&~=mXIPlLiuYEsfQ(i|wM?PCYqEyyx4(~R@nZIc`(I!-=Wzcp3 zd=b0GhWbO)2A2$J5qS}0aRUVx!r}<($tScp=kN7qNBD0y;?la`t?Z>pR|$R=Cr}~n zeyqK!-pV`|QP00(!4@BX$$>52{)z=%eEL-qXyNWK5NJ`bC(k0k$iuO7o2~j!RYgwz z$3|2(mWz9?R)9h~hbo6CKY_9UE#HB%fS`(9AIl%}2#D|>+sP^75!gbZK7_iPnoKA~ zc5}1;fFG1+WLN*WY;h|Y$`Q*6sn$>qx{{&u?)K9Z*iA^`kNOp|N+G%F2&m|(aFZdD z*HKoGlEzV1piqyXq(OoU1m*imvr2K@_!03D!?J-XU=I1yTTA>s&sNIve~rcLwU)p= zE7vIrFrtdYw^sU9%hFGSv~%QwJGt`ue_+0Vy7s=Xqna zf_forgnViBy4GHjH}-(p0Br*;SLsgGRBcVw_*wR<>_9*0fcrvu0eit;PWR@5R zZa2YsqY22pCmXROtN~XQ(1kLr@~cbvr#_x%OIiC%9hL?J9Ry~l^_5|ZyDQwWdTW70 z?D{aP%GPdbj*a6t_3V^{WThiz$Drx)*jj+W(4bcZK@ZfE96(?Gu!N}eVY^{eVYu;-5mm=-mYCMZmv4) z?g?xRHPki8CtV;icl|6xyN#-4H?240yRQFKkaMtnNVP|i;)c^h(V#|TEZHVyAP(_U zJ#B2r2$$QmY3S7A4Q_$;kNdk}J5Y*`qF|^MiF1O$L)rzC5wF{VS!JW9U7|Tp<_;UC zo9xZL0&ancmoIB?zQ;=q2*`1w@=p<@7bK&Z<`y3^OwkWEqP zF!J{9Ix$>}qzFMpJMzc64hWhw47>pSGiPvx1C;gcMPWDX??>$w?S%{KBq3u%Dh54k z&LEm{Pn>7xFNp5mdWji~r=nHSe1Ze2XSaWX)xuX`3QSi@iE={Mk#m{GeGjZDXK2_AbsBfm;&dVRl_ z;XzzySy>uZNawO;evt$1`#b%`Wk44P9RdRQEA)#0{l$Qb30??7*(?7Sk^vL*7wj)^ zpih4ve!V<|8JrWahhH8*oBlTZYI!g-SmGd@znzt zpp$;Z{3v;hGmzpy*dTCzZ~Q!j_@yulz!soQei{6@@}Q;g%An;SDt=@9WQ6FYkjg+~ zpof0-{8)L+^3W7eNk9>xgMOHK6!O0)U{OJ!L1y`Z3HcDgghA+fp@e~6{W|&C;~+-h z4nX?-?i_b@&gODE1dwQ{YrXK zb{KD9ZkTRRZb*LASx9amAD}DHXTOeKydBRS)>|JNMb|exJCqmnR$wnsZHR90Zjc{S z*PpuAct>gnX2)@dX-8=XWyf%bWJhQR*$(Li$n9qY>V(LGm;yHSclBrL z2gZfi0*?U}^KbEE^C#)m-$ArPWC!X5zJSn$cmnqH{~2WdV$`DC0CD+A|K+NMTY>Hb zHv-b~Q}dtfCI3rO3+x7_j?f8P3AzMA4{YhT*h}@7tQJHao(41pM9Oclm-sJHEl?W5 z1n3cnqu**T&0lf`&@^~y&|(lJztKX^Nhj$0-2RUehHuIV-~YwtC{QIZ>JkvNB~UPn zUq3d#K3rZcxV$#V0;*u&?nR2Que7 zpT@}8&KG@Aj`@6%hINantDQvBY`eYBAZ!S%3Rp$OxbE_MTVu)s~3Upb~U zu9RD0s+3rPQQ;BLF8nw$eFt$AOT*CE8)$|+SulChd$Mz)J$qijR4^@^8`-UErd{_n z7?KLPo@AY&{ z>oI%M%QWFY+V#pF-&NPa=c@DC>wvf#*N&V_$5=aATV+?8vkkmMQTtxoQs-S8!pju- zZ6lpI#tMsr(P68`6#18F0vA=kD|t4c-8476g8vwV4aTU;j$Eri&NO$l)*w5ws5d>x z+JT3BT6@)0umRu1_f8q=`mn*f#|K%(_xf;CV%pbd+BI=^JE-SVn?SHSne3q1g%*5H zttHdbvpZXC2R&>rJ2%_68`@$=D zj#>FRkh81{L?^X^a;eoQFZfU}L?k#N7Bd+G6Ej^Ipi0TT715+vIbE3x;_&MLE)1p= zB@QzV1VDRZ1Cth-x~1C7xPy*QlAYZH-HLxhbF=J{cSSby&@^D{37*iqgJDa*flUE& z|10J8@kJW7D8F8LwZcl3nsPjej6rUQ(s98eLQ6t*jp8#|Ys~u4^DxFit*vB3%|{-e z0wH-xlDa8IQ>m(gSydG|`BywHDaJ%sKp`TfsAR%eAyt{|SzJ4*ex#YevH{hVloy-d`WX6_|zn}D_7nyPXE8&FLj1oXlB(LJX>=VQlBa+CC#W|4S zEyYRZ(wK>4B$%4OB}l!+_2yz>2@S<@+M}qHgG<`8*2-k@n1eWX%j`%e<+x@^*V0{z zUQfh1Q(^$8PTbjfL$j1l2%9i+(a-ZrPT&g~oM;1i*mb264s(Ra(vETR*wTV#vI&_X ziQLTe;RRsgKG-tQGXbO-=)`o`BGB_kg^=6<7{Ow9m~O)K@UbssJMld<%D)ctIZJ_5 z1Zk9E(DE%xe=Q23l~R5D{XB~B@qm-1gi8Nr z2nElBP-Umc-!}$em+~x#;^oVp0XhGq`N(AF`94bS zg%ELO4|{c8Lir9pCE3>U?BiH#c%l6EVk-Gi znR-Fb=|lU%R(n;o*c>skzVc;zKyHy+QQx3%v{Ejw0A=d(qFyB-nL-$>h%J8N9y(a1-!}whO?m zCsywveOO-|47Ww*?!|EcN`GjB`T;BEfO|Y5KTfm6n0k-P)4mQNcb;W;hwOJg46=Kv#<^l{aN^1fxj6AvM|Z4 zIx~`t$Pew;h)63^U>B)c&}2rk7UA+A+=??omRQ0GT}N1LR>lc~M_6uF>H*zbSZ`MD z0Rwb^pHUnZT}ZedlS)yz9FsgG63L(tlT0*{kys`QZC|(@lb$>*+#n&1rr7MU@l6x7 zDnBKUWe(d?rm;ANDyCc$rm85Vw5>^1)3PdhN!7BXX_<3|;1s2b#L+Cfk+iB|N#wlT zdA{9}r!hy9zN%$O?7ZU9kV739eJFx)C`;4V+K*Kprx0!_#B6j;o>oL^F~t0zvr5aU zcFVcovXc&*lalL63Vt5MnScj8PYHfy=t;guT8`AT%=KL81BQvAzp4g$KQz-nhdOSW z2osINst8rGiU^g-a{a|d3nq)mmc@oM&0AHTVe|_B#Tm(3Y>ntDa#f{@sKp5j8!!Lj&CALsRfq~eRYg@tRY_G#vXOIhySh%5jVgjxJ?$Xt1gE)o zpQ=_%rLHPLMa>U&W((H{t~I(Ozi#9QZZ0o zA5w{x*(+K1r;>(xc&s6UCPl?WMcr97aIh|;61`&btlmvEcF`10on$GEMPEjPC8al{ zKHdse$`nT9kCgtu6+6~|Xaj%-HqJ14y~KYA6CMwM|=;QuBt6rg3{-?&~voF;Fq?G2t<J44539EhDmM+Z8b!y4D zCcp2EnMSp3U0C%PJJN6_s*gk)GTTNkY;0IP(`qt!CTUIRnf_f^cbhyhwq$ruTAT1W zcC2nsFhitqP52iN;~HHtLu7VL5FObtt!@~?vpi=|9`Q0Q;+k~Zz_UGPl1#8o#2*1W zCb`exTI2s2P79qRG0Au>=2-F?$2G-gnV&Q~a=W*73~=AbGRtB3o&I}r^oY_i!hJ2v zqT5iGX*z9w(sdH+NW-z%eJaa1hs`weI^A&s=1BYA+cD?WpJThzjE;dm9cB_?QshYN z$l%`4G5j^WZQ)}<+r-C&j*X8^Eln?tdXj!p<;d8v$$e1UP!~;UkeN|u0+DHuhEZz* z$#IaIQI7@@W{|i}od!v2@K2p~B_j18Rh?!f5`B1+LAIMlB}PpcoB?2klr>Uq5Ivqq z7e#B3wNA$<0w=c zd5~={I(HoF>{#*)XG^ zEJJ2LLkA_J_%tIwJwvFW5r|eEX3oKkqv6SAg6Rg$VEH6VF$%>1wkT2q8pwW3gEsoYGZ$wJe) zDg?b`alyj8sfkllhjweMws~!zI zMmN}ZxZ+N$6T4@!;{Mtl&L?#Ewxg50XV~)gy_5CZe|Z&H~Lj+E6DV=*rh*nmD1M6v@=*|m-doMu5rC}eafA>BfF&;W^1VW zw%H}Sr2(h2Yx4l_0)A`O=2rFzD8~rlAp26FGm;15+G2XNV>Qs-)f4hsAK{SaQol2m z2jkj$dh&hc{-*oMZfiOx^||GTU%5c?%G(@;Sk>{WjuR(VjKc47`+<1FbNFlr{*unUerA$JSjcRJ+VAB zJUKkwSwy@wWK@iP-I!9&{o0J+ZE-%=hsj}#7 z7RRWyRG=(KI~TMIWfyuZjGoClN3=_2mtU!tlzA?kp6NP=w993ebyrU*K36m>vMq3& z0XtWF$Y+<}D`J$2Ed-xso)JINI@ft9>lENCrIy=Pj4$k;K|dC@EAkXoE6P<=EV?eR zoMHXR$Whi)+*VmGJ6>oy(+I@ckcZQ!2-&Q~%HUPWEt6X|J)eKjc!TwhL9!9Xe1%Po zU$#5Ld6;}7_m1pV@Le3SMg3C{O)Lrws8yJuD7Rg7Jz0Oueq-GPn4Wb+jfpw~%8^W! z87(juqcYwoKXpETbO;|!zKY!0X&kudAyE=QS^Qt-S^6`AQ-ZVHlhlXUXkkF~?3kHp zYjm@yC7{b^@WkeU)dSl*8efzEkUA^ZQ@y=ld(!e?`*8hW`?wyWeN4f0{H0p7V^#b{-OD& zf4~Y7r5$2^FpF_=|LFdv?VX=)*37y;8Db(uvP7Y;%p8G}{IL80>mA#zwQb4z2W4VN z6co^{uwH7sz;t#(aB_W)@$fuRY=Y8|K0A40;l#}OA?Ip(m!N_feCv4S-tBq0b^XAK z$a;1>#5k!q|DV7g2mJ9$xv>asWke*IBv$bmK}!`JS`al=;XG!HMoYjsbfLc1Y(btt z$M$5N%s0R3ZO-EOd;h)N&IfDk@&St{S6Au#miKYD^LA^^KbhSjyJXi1t`odRc(>tA zwJWI~D7xOEVj_sT&n5oElUOwL_XjEt_P|n*?y0Br=-@3)74eE#{1rLmDS3 z1;Oj%!;?n@@at=_spBHOPzfim9G3uaT6JKTVFf67DqYu|xUw#6L9q`*E z{^SMzcPtuQ1K-MqaTxagaA25|1G8R`m|Jt+K_0IVPxmM>L)2c^jc7kT?F1Bu^qLVC ztfcdgRR?*=5KZ|&2jx6P8vs;O_tfN)$igkVTwZ+gd!cN(La;{%fw5Vf_Z;mdDQW?G zdF8Hq-#5HBN$g}_qs9lXuX z)Sfr3*eNUi)1N2uEJOQ_09gF6F3fi9v0G!AmrdiWH=1vp6sXr_LUi)c47&XIQ@*uj zRxx5_L1q@~dDL4+(;3MaL*Wh|BF6K|Nn$XnV5c# zQT*S7N12%aa}53e3?60quRRd>AK}se^iPrg{}Uel!}^DjjrG69M;Y1wgO9TQ-}opK z$A5>9{s&!E{6ByGKfy<71#PX2{&xjZwjX_z>Bk@aUlvIJ8~0Vz+{($=fnL-~-^p0m z_&-wX|Amec{$XZf=J+oHDGLW1>whbdUUWfuDT^%edR;AL>&{6%@nkzac_Tm(nF}J< z$H5^>`|+!TA*~9?BeE%@VkmEbrP?bi3-U+p(b)h_qW)9^_WN6-qdH>v+j~lEHf1@1 zlha*~yuW+4F?L2BD{MB~RZ3*DIZMt&B?yU8tO>&%OlmZDN2;kSg{A<)oIUPRvN}%d z7|GAO9(?gcHw*L26YqEZ&EUl6TEbw5_s3IN_w#UE2q-GSyXgh5BRAW)GZ9WOb5Qpf zRqRZ*$8}6`x&q2B&!>^5)2O+xUq}UxfVghk*$Jjp;b19>_Z1k5YxR1otAyho-T2@K zQ{g_gXTum2pwsb;-^RyyOgy;a4{dOV9pMix$Za*neSF7z*OQHb_=RF8 zly9J2vfEIiNu6;yd9kN3Zz(a*q$k0Di6q5~zE=pIU&n~=QgcZ0f|1>0+<0BL_g}Nb z>s}7Dqm@gunA7&5ZgBB)j=R@3k9|Z;i8J3SWxSx;^k71H#0H>Ko>1!=-HF@>8*`@e@6AbcgfZsH;s!ZdyOZbulTI8u0%~(AjltE$ zmM5BPlFPG?PN19OYyvSYGgXZ;9m8`id)KOubRCXdniZNw zrRQbz!uBSd44aDI!$rZPx`#N%K-s_*^*e_vShGfeEh<5+C4`tNdNd z^?5LanMep(Kp5|NW5J*nOhXX>&pIi`04dr>^8$n3N3{~i^bljd6Z6^1b%e6PRLTJ? zn1}ew6N@Sc3Q3Uk!lV(AWB}S;uyeZW#-9s*!yXz+u)u|0*!z#y&Z>G6vH7aDVV4ZwGmUQ8L9Nl0SZZdUQu*vMHlklsw7uF+0U-^Z@;zZgOi}hL3 ze>7AH6fLccOzhvx3(Jc!HD%S+DAgW{SyC=gl!pNxM8(RfSIDE& zxn0d-l9f3eB_%~ofOe`sbk#~fDPR^lJni;89!+$^AWJ6g3w6}{qcW+Ft7fXM?d~y- zQxoQRJaf|AjS}Tsji&|po*r`L4duU`FY_6k+->AS8p_*$Z-$Rylf!rNpC}+(%MKS4 zKt{Xbe>s>$h3f|D(II3$}X^+~u zC&wp}#JE0BruxwVCbS$Hyjr9|=M8XLzm|F$Of7OJ}1Oz2<)y8ckE@thU z;_b8TKc;J#DHSaf6IS+*l~UobK&|(#Uk8hC=PCutV$RxBc;Rpr%L-WJD=r5vQPXJ} zG4`SOShJe8v=oU==f33Ri?`Nk2Ucd;bNMmN z#bnk(iXUGTIcNFo8&Bi|_A`AM58~Ig<)^n69)ucP7cSY)&4!7&4f=EBs%=UYakyy$ zCyjJvHb+GO5NHM#%fFjhEKJ$(nE;dd3ErPGlj+i?otdKSI!$lv%zl1k7z-M_}};3 zsTspxf8+X(ga#N|7GpT9+@`LwddfNyW$2*$p!4V^!o0dY4i{Vdo>|<88REo)}gq-g3t2A{VNIv%AFqExvlZ+A&3!NhXfXjft5Ht zU{7m5B@KASqD@akpI&m{8cge5oAO6ZJ_Or${bOY`qJExNI=&_ z6FJ>vNfX>Oeq$tTMWsx;>@8i`Koru>Ur5M1+ek&nbDPph7i2N31v4skNK|Sm7L0DM z7>)=AzYJH)Xjn;7g}+V7Zn?2I$EyNeH$SG$6p#`qSRn9owyF%T1^8m*msOr9Rc0*l zW}I-Nqy)2QR!PLJ^L&LnGwFZ4-jg4&i@6g#8M5{lF4pB9pJ_}_#&smEEFHggIm%>B z=T5->*1}CQP(1i2Z*{i8%ndNL@GuG4zR~t&@th~;`$pmGJv=<->m3n32`_bOd!Q>2 z4}VY=j9w77j!o#XgAtrm+%4i!QSnKC8;(aECF&IRv7@xL2OBH!H8KirkataAu&Ak+ z(IanG5cz(;<_RT$0FkUmfcycYefDdszBxQ4o^}I69UXspX6cB;i7cEubDDaksHbHq zC*|g<(V?T0a_rL`BCB1s7N`gMhw)$;h#Z7AmzkqEnWEV zcL)B!#83@+nibj^i#_i5?S|T4!C0ucweh2sa-|oAWw`t8|C+ z;EJJIjHU;hG{GCGP6%(G^&Za}TKvXu8+#2n{}_IVH&mpfSacC?;zGrQD;5QoBsFB6EQBqP&x_~ZO#&uDL7kfn-4S;9|2-v=zUiN0o0QL5zXprhXy8hRK6Y?*fNkakg_AH2FS zmi_&A+j*xUIury;o|cHEXC9%Ho?G{{{;kgnHAQMd%%?9rtxGA#xBZk*c|bUgswr-* z9w(Evn*}6s22X-VN+{H%W*XinB;FZ*5Dll(sCDy@vrJrc&ts+_qa-5hI}E2;k!Qp6 z9au6Y$n2VHYxXxuB7!Mlr=nXWxlIPr0jvuOSY<{;_h$w-w_vYF?w0Hf5oxr~3I6F_ z5oD$&Wnt{+{%yo^=KPrgxYsoYkfiQ={}d>42{{f}Qp7~y10~*<~@(pV3#5|7?F~t3}b-^>aazcLSQ7#ydcj;YMMIlvF9(J zPIHr!`LvCc_;LY6X#;qOR!9rx2ZI6}F%>NxQK3zwRFTCy2Ylp%fMx`x?GZoJ|Z`pIaB~x-I$41*wn#f{x zO==zgc|AS|GBL$z=ptQ=33n!x;zkkHfRLY85KYm?1 z{HvDF`ZS;W4LdnCIypC3+&I(>;U-o}OxXCVJtv0u;P9}HktfS^x}}cbDS>hI_4Uci z>Amm!LMx=STB%n2f=a^ppCd6^F;CLA?VlxG_dY!yR-0>F#W^n1>1a)+-!bmOGlilPC&c9iT5j~u zEAi6Y*gb%Iy=_hf_pZp)J{=shSIrl?aOG(*k{?Ql_V2i z#i@DE?hr&ktWSvj)xI_8x$fyS#uBo}wTE=1%%TS8dH9kj*vQ1Fq-4Lz+;u8Md(!4Q z8#~P!I*HlImXlc(w8AIsnlkRGNivtR6VQ$`bkVbO(~(<8ES$FWG_jI$+bs4=n-vsp z$t#dNOxR%T+feREHvB;y@7tq{H2$Dv*SFhW^f%w}OG!%Tr2HPGoV0iTMZx*365pm` zRjumA8w#2z?%X(SIXfJ&%##+E8fO}Ffns|_=7`RuH)w?V=XovimaTAtI-#DIXxsyGS|ORNQ10YL1)a5N?s9{r0ncP!<#9f(n@3XPJKqB0QQ_%~Bo?IJNmCP}GCH&v zz|dv6RkIRm3e`6PB>GZ8TPv zu9|fvi7;i8!{jTg6l2%di6ch~86F)wG8rKg$uL+n5L}%6+gSOa_&Ca3Z+5Mzjd~mz z9mNRBSyZ@0I1R8z9Ebu_F#q~_7}tTNyM-}x;byVi$vxLMzK&Q8PSrl)A%Ct$rxZgmU5S=4HT7OkHa-euO>QqNZhpBxFOse+c28<%dvu+8E2 zd8FhpT)NQkj^J^(k{!H&dSzXVjx0~_c6$i0>|pXa!)I!v@bVJTH&v}1EM8ux*%wnI z|Cx_kZ|o$>r>G*PrJ&*+Lykwq&P2S7f|9o0X1~n#3;JkPs4f7xb9-r}>KGsu2y$PH z%x^@Ysq;D{iE`y~o;O%)=c=yLxWG5F2txSJvt|kyixl z9+!%ivq$wHZq0oZg{B_SHsRr?U7>~UqLx*lvvYB2J+`Mkq6aulv0DT0<%px2KAhv0vuV9EdfH&TzauiaqbNDJPOOGD)$Wq$0SwmDX|h-5F5 z*x?WwE1kb+#AZRlgB@jRtd1}WZh~4C074OvVCX> zA_V*lbgy5?K&UdMR^HRj!rZ{ZToNe6&g&j3bmnLzhR#u?dX1N-y0&T|p20-1`F%S6 z(cVjWoI!2blQ&gDL(u$YxY@#S5mT*E?n1OPflhblk9=)iDTA!4Lnk>|iJY0;>+eOe zr4{m|QuNkBxzbQDn+MGuXmpAOeRenX%)g%Bo19)7;RieW|8VgS-SA+$cu0kHSjzq` zp(!zC4ypa_B^?|n4#AncaX@h_n7ma{X`*ak%TCziwUEO}CCEwCLsepLU}&LY5`S@c z+{dxJlqf1=T6CT_W1*C-`F98(q-SNm)#_2hG$>&! zpT7)q8eMQOJcwqL&p?!7Kq+wUESQV9j-yV`N4%g&M-v6L>u3|Q_8G;SU%YI*vw1DY zm*&$7!iT^_om^5K`aPe~icxTXJ6?Nr`chv4^OTNx5>bFaUByRszF=K~5zBQyr9m0J z4=n^6fojz78*SleGJ_y~J56tffogR7@6tFA(iDTGnRd`*lg)HQ^t2Vl@Ep1UoMT9; zi4aE3x}vA3w}F*nC8MGa4)$LmT~%}imF!?=Nk;+g1PALhhRW+0B6`!QXE$XVRwV zvW655u`Bk=EH2Arw*16F_%5Yj!C8(=mye00$D&Tsjp60u3ik1BH?Ak8jVWY-RP=+6i zF?u%y9qbiF|At_u-$qTO)X4kh#~WqR$eP7>7~6Mj{HrumW+m-^vcT-XrAYLqDC4~l zt6!lh(5c7lpAZBP!Ggi^brf_;Cix&rE$Lf7o@sJ+QGeb%&>i9R47*S%hksvpYq7xm zWIa1Nfyq&wST$ z+NxaH=+_2*8s7?Ew?7~L;?Ik7mviUJ zvNFsA*a%2BBhqRLxj(X8ecQgvcXy5;%VsNh|l!IG3qw@@7`<1Rde z$ecgJoSNRdSC#2Mp2i`zbyQy+iYU~em-M{9ANtT?&G}PZ!T#k11>HPSQ))nX;vkoc zc~#qGaaiBwa~U=Q!UEG!C&7&Ea48R2I?MD;w~SzZ5Ej`vWYgUBm{! z&yHlN&Bve;+8yirhXz=x(v$G5Zsj1uWl=v12khs^snlerr0ks0hmz5bgF{$NV{_6k zvQ(CxR6UDl<95H=n#s7kNSn(!XsTL$9ZX_Me{te5Hg^Wh6k3_IpnJqXW+s!fqu487 zZiYi5<>pa9hs5d^s_#}p=SoLdOOI{!L-ZekX z8m_I6?R7~~R|Q`N7d2rAXF(MK&d8(ah-UAr-!1gHG&A@)nE&Bil1Ior6lX%+yUV$; z9~wVGnO@3Hlmv&?;Yax&Dd{2=tyIR&?g}&F9KQIh!h#qm5=5 zD>yKHP%;1|PUxRFqNuX1)T{Kp3%KWGk~#95IhiRPKm-^#qNhrz5p2t-S3ZW(KKJcY zV{QPZ2E_*wvG{~WD6clN)3FRLPzVe>7l>}j$%^R8hj{%F0@{gKjbh*8+QqyJ47za$ ze%Fc7UNVUs7Zn~2gPm;J!V`$}P194*u~9PeEm<|S$3knEDB%_;i?FVI1hZ{WZq?yE zti*VeOt3_tc_)A{0YU{?Ygh1Y?&c2kgiNNDP^|Hjo5hZ=qd= z@kg(=6T5mg$a%@zWjzfNY%^?uX9HKb!(7hny=U+@n+yyb2;4AvXpJSNL*$4x_k9)* zZH2rAbqjpgApK)%?(QUaRMt5PveRpOx7td^<(_~Sky^Hr%)u>DSkl_Boq z_JCdt(KP*Ao|%MVUC#xB!c>ALAPXfe`}9%}RB0ZpTZcuWXozQ?KlE0i6~R@0e?b`y z2RE8+iGvGzhkBX*7(C0e|K|GT5aZPRe72C1GV@SB@Njp~Td%~%;&I$(i5bYvsfH8S z$WQ)&`A7d3#n8{Jk_X#{h4b`uW%#7AgYi;H@b3k<5eh#kifjljU4d_ExvvnTQ0PDE z9+SAqnQ>`2#_-Y<5@YGkog+4B%+5N7AAWg3Uv)j?=wXKB7f!T${8)3C&4!(dn~b^~ zPQ&c|#;B?w4N6QZ*}MIQ&rmBjMPo;w{CVO7V`$^m+dvlP+bqv zF8dKtTV~JwdXX;YR+HcRX3s5WH_4y9m{Yr+0h>zrK%rav+ zg9V!;PYd?$yKOXUmvkdlQ;_KJH9iK0RV=L3OFY&)%Ddh*1~lSWiP#IRb*|2%0pV?` z=$GA0)pRw^__=I*yw0w+;jFd^m9=SfOd{&hbwgHw1A44S+iDZpjJvi4{ zfJ)c%S`rpbz)H;|=Duwz!O>NYSw!jGZ20@mCeCL-zadD#{2xYz5E(?%*HQ(AFUA;# z!~l^dA|mSkk&X4B`lzqKVY&E0cf-WWgss2K5Dh4YoYTj9EhpZ1-n^|OIV`m#x`+Lo zRHjfGp%Hain0!qb9ce@qE~t?S?a|h>C}S%`%7yL8-eJ~;%b_bPvLwNftQ-gHz(syP zdx0+KoLwzN)!HxZFuvecDO@?e#%jPqRdQd zdAZ5^k>in(60gD{rtM_P>}a#%OyD7#H3JQWNcICC`t z>e5{oD7I(B&I!46e4i*gnA9(_Ebz^9F)<-&UAioZzI!r#tPV++{u4g6TLd?-qyiZ37#&Gv~;x2A2_#44DO~US~rjxyuw+!){ zU(<3WC+Y!LwLf?*ik0jhE?wO|WE?w9Bh^Z={~Wo$bE?0#3gv4RWTG3}T0^$@CHzSP z98YNR3Sq=XD`Rc&KWv3rY3Hww`;Rj9O~+4mw_Cs1N=<_z;&am+{sX=Lk>&5pfWE)^kSk?lUDZL5p^U_pa@tq*?KDVr&bvdsoQ8oL z(r~)lvChmFdA5A3%Pv+?lfP+V=!PY{7$QMeT`48NGM+Kg2ui^@i3B`uKjz_RqbWJ` z3w)YBAo!uMrSt*ZVI7+vzrl(b!P}KS>?k%)a66 zO@cP3=}8&0vbo|O3e^iPh`B`mSxWh!dh_Ojj8J4<{|VLzB#}V+NJ`Tzck}gF_O!+I zi|FqA2{zfY3_8(>s@nH_4N%|1oL5lfY$K&Y?{qp`T9`;$IH+7uc1``TcJcNx625w& zu^Xt=7+u90Ez?N9B9WfUzv_W>g|bJL=iQWg}%$cJy*rC@;80%^We!C*vS`Hh!umBIWtOXgw{} ze0&N45%raiZ>GX+L-4w@+ROSYT4y2aN+2Hlqn4Ia%U%9!i%0fj^c^<_tq%I=?EE~;g$1?6&feOMWc zC&+h+j{bN_co&kU@x^bqx@sOj8TWoFZ*pD0Ykq1kbar<)U^09Yr*qtjr?JM=S32Cr z`;~XXkt|)OmD#4`EUG3NpQu5n=m3tgQo0gniD)56O04B1#`F3q<`yo(qCZ^arG&rP zX*|7LRGn4RW|+>OEz6Z~rdOQa7oc3Ndo1F||3clgfQf<)7KdPeS|8Ob(dYaPhrXlF zaC}Yp80r@{sPyS0X)~=(%cU1T7F7wrt=8-yY@V zREf;-L^LiQzp)W!>4b5elCu9cf{mHPMB{5` zWFjQ(=q7BVVIr5O*L9hX&pB0c{cyUE7E|dqT|0Vh!hAG-rrUhj>3_KCgmPRGGOY1O zJ5>;*9Dip?7~hVg!c_d#VxDKxFPbxy6-FOgN(OP^9uIGgfh~LbTiTS% ztw(!%r{1tT0>duQ$qTxHeF3_|`l55Om}x!cTjU4L4T9a{B!Lj%l5=+209_E(Qr*Ey z(IsvB9K1pz9VL09n@6?BkX7PwTqc5tmXd~bV6R0?l@$0ZWB26z!cf_NZ8iPZLyZt- zg}a8&KA2(j-;B8u%3V~V7CEok!)<2DveD#)g`|T7n)ukTTp?P%^?%UnD-wR?x#%ac zMIN~f>7t1OY2C}b*uFv$%+XYL7RpFUx$P2I-T@c&T)2{W?~uY~xG%^?cZiz?iJb!> z)VVd%Y2XCX&-e%sNh`|E#iv&4A5HLiyQ-%>+n6>!D-~THHA*d?;M!JS`B(&+wo^N| zjj~)%h>v}Y35_jZ0o@|W)SObFupXl(Oh>Y5Y|X9KPK^y7eJ^BRz$UG}F86bDl!mxO zrz#p?^JjOHJ{Pr`KhY1lKB|`w6(+~SNjZv&&K^P$c1sgg_t2mK11o{}vYRSmLlTnu z;UbfzBe7GV1VaEy;0N*jP|0w#EJCb|(@9{0LI1v>mA?ekE>_}_G$v1tagxX#wN*L{ z-f2k80|h!&$uu*bLd35kd4{%5&OPLacj5a$N_R^4loPRm&$9pnMVXXxl%flziWj#* zwKGQ#CQUf+{>o60fBlUJD-gz$vRtpJYhI13e=Tf@inKs+cehg9i?z5GcXxMp2rhx*PH}e#?ry<@yIh|4+i^VqM>BGfA7pu|2Vkot9cvdb?0RKD4HUFetm^A zbHGra`MsfDB>VfUJowM}R=I1^fcPmxT2HZwb8gE){`WL`0?pj`y4CHe=TYVfQ|T;D z3hs2a4v9zhwSrys2s)cAf?p6{VF7isjigO`x{KawnWmx)lddZyk&a;EbJiah za`X>Dccf|*4OzZ&oLNZSyY(?=wVBZ|12!;F?bD8`_)RL5+;V%&?evl4b(4IS$I$U; zL&*TaW@hun=m@fdDUK8BZ&XTvHD{fWegw(D$-Ga?gA|?gQs0MjB*YxV=ACu>8SJ_* zczZ%%lV$VZSVrFD@XgkFfyK^3Ujq+Mzq!59$-}hY>7XWg_@ciSsj0JtjfRT1Ex)v#Z6&fAppOdg~^)%CshuN3Q-fVyAXEBCfgRku4^i z#XV*^=lzxT+e*dP%nGy_U;|O<#Ws5$_8&wI%g3zZ=L>n>c~V4g>us!L{&;y)4__#$okR2jL(6Lnn1YTyJv&vBJ7^o~FL z+*Ov=V1VbZJ=u0t<%R7h9UUjYpq3}J4>%ki0DD9}a*A3U%q*Zi{7m~DQgE{!4^{&J z#GAI`^?=^!Y|;CdCW6|un(o3z@lw-2j;J#ozYc3o|Jc%bWR^9GuM%BmpcOL?3;jcZ zSUPpZBTMRjZg$KI&HdLs66HII4X#a&tvzE4Iw7h&%mIw~7ZEh11cJgf7{YY;oK+Ke z5X=r69)axJ%P)4d(9iNDE{!ekFUa+;54O9|pPrS!h08>1Lou*J+$ zK(z+kXyYP3SbV12lp+9dqR^=}N6dstq<*=FHQ~q5RQ?x=mC{vs*{AFsD2UhUjqxvF zK`#4exnTpyrP7ylORA9{6>*e%7zCXafRIUXQuby-p(nrH3Zz>z$g_XM%RL$Bvp;A3 z^Vr(achURtEHTQ{+pBk?c25v=pNxe*k6V?ixIJ|}ysiA~DY!6(o?~+*4+8ZnH7Gxf zIDO-V5hS5S(14Ee-ygn*T^Reslq`3IHofPe+4uEGVOR&20d_B9OuY)LkXAZV=EMbD z>xSNAof7BX^mi@%@_|WydrGJ(HoG|o;gabWX!{Z0J2F|$HIh=;zbcE(wqZc4J4r2i z@W1W(N8iw1M5&LRPKjXBR5R@1K-)x@Zc`o7j^X!EZep&*H9o-J99<}D^YS}3o{L)E z_m=j!qOZ}2*166}nQlU?=2{yX237WGM`Hm@-5OkeUs&}mC09@wVVE_pS)lP{YNg{4 zCzY>lVHae@q@|#dEjQM?xgAJWoDl7sGw-o4I+4hOUio7mC`NtJ{3r=aH6U$J<^HNu zrmByWshc9PVmjZiyVrMqq^wFNDB^LnzeSo8U5ibXOk8oxf`x;h@taB<{CYZ4D<{_U z?e)<&^R#babBd54o-95JG2ZC+TFV^P>!~>X)l&a; zk!TSt|00AEu*+`=XhdsrF<5^}Sb1!P-yAr|D6NuWGr6x4n3;DT?f&J+p)XR@I9F7K zDBSdWIgGoziotH0=qikM>;lP}UV`A@=zAuuP^hbf;?Gt95X4Eiis^44?JKZuB?CM8}h{$ z=qRO#;@ynpdCQh34{gK3>`)(Tjn3R3YY{{sH`qdjr1NlF8Y>Su5$RUNf@RUISbx+H zAMSD}POVE6&$Rul!rje5m>{<{>daZ(RDBsPRiZ59pCr`u35_E+-`gF3`B@F222qLN z`331dz>-@%WM<(Bjp{1KJju^FWFCVuUFs8>k^Y2QXO?MiMxUIFgxvO2Xayh%f^NP> zGHbX{DoW4|dR4a~c)jKmMw>Dp=yj|Gc@cAxn}532H{rMKL-6o5I?yoG>$(`*>3wcn!g| zO$b#+C&cAp(8BWZRpBtni2uk=O8!`>lPVWhiC7_cV>C_n#|Kopz&1UaM=zKC9I4DcGo2eL8pu z4|*$)gdMd~2wm&Y*i^aV7C(!eoOCjM4L2!Ps`OBBRD7(sCz`kaG};Us2ovqek$;n5 z_?hUn-OOOJQc7T9R{j#96`mK~N)@|C^t4E_+uDK42KwMll`rCPVyKiP9a%nAKQDr? zUd^5qHW>+GY$Fu6KwI-ZO8>R17U_?ZKmBCu^N|L^(kT2WM>4P-o#c|x#22l0RF7Vw z6L!&ME5Z>`q5D=y6mRC#ll$f?;o|g+KAqZ1=rb0uU3W~QJ_@@A@5V(S>_QErS6@J#DFN4xr{FOobQLtezbggIA;OBkh5?!~ zGU+)MBM#_Hpu)inEpJDKGv>*CtdY1^&R-Z-AuQuCS? zSUJES1~2b>+7&0u?%XOm;(R+Og3X_-)-945s){)sz8$wG8})PKfoXo9GbyDeixnnE z`CIur5Gz49K}pqYu&#+0rgXvPPXEa!6JBHf+v}227tEe4B0;{O7U9S}e~4);AFBeb zjMe~s*-drUnsP+Qidl-Vl3@$GJPHoSmZQ~cM!dF%aaN{4JxI=aBpJsvuqvtgbhq_0 zFEf%A+yq4c?M38_IZeMV5ty46V?K?q`>8SstP**F;Y|v)NGu3}fnb&>;rFih-Tg`4 zTzCVTEILPy?U{SG-A&RyT+=Quy*1xp+D~aYP?rscAeZnhm zeoBH_dU+~1z8TPg4KnZMAoeTS_kr~;-)2Y!?~*4vhk$Ljyknq=qi!isc!$jW@;@Uj zy5o+iF$=M^d%=vB;Pfq~eV!92aq7y`RGJp-H}EE}KoXt|swo3O860rbrpH$5GTPq8 zjQH%@(#7{uQD)PH800V&aSVj{GfC&h@VU}w)7?;1Dn~vD5n4R{STnx23j)0enU9d) zGlzB@1=t8CO&Yk2Hg9JTVC%0-xz{goK4laMydjg7BH*nN}lbDgMI z9YwTxqereW#FQ|e0OS`S_7tjjv2j0WHN44uR)E?LIsvK>iS{Yyd!Y+=TS;8$^y-h~ z@2JTdkGlQB)|=02Tz;IV zM?Wj{nP^v;*TEhV+OaOX*oS9={Y=**%pfsqZ1^ng7_afFoKUdw+-t?eO!t+D{`0pjJeyQD`Z9WCB z#<@N|)G3hWKBZshjo0pok3wT7X7@K@rPnyN1Xw}j2PY9=sMRqkfDicw7dj}$3^WS% z%n=ot?DvvgYSZ$XUs{VWm#g{aC-b;*`{&`QpbFd0Sg7K%9aevgnM6on>vwEw^H=;F zMGgERxm=H`Pq|-^9W?8`QLpae_0BK_vMze>1$zKGq?^L9P32)b8Y`(0e5TnhQS4CBf6^yd8Z$asx0_r#bZTTbC=sRy{bE zMHe~u77L!jwz<5P{fVM0`u@$omR4fimE5QTl+YZaI{(^5lv6mPXT#$5c{S_yMa>Oi zlf=XeapzySIYtjyl=v;P)h=AC8R4y<6+%=0D$`_sXFlu>Pc5csd}F1YhO}wHK5C>Z zCC{s-TdmZf4-E4=s7GX~zG$yL|JRA^r}G{Vl%DTu1wv7*O#d@DHOg8&*@MQkv03LPKSJ$fYgNbQzgHWb zuZg3#+PL2{g2F7LN1xj8o!)6*)@)S?(_g4Wq6eA@`^rn>AFouudLRF374yupjdwGy z6l*W*oHZv;8|a`}`%Ttru6MG|bAPAbSLO(Nsu4sCB(T)ls?Uvf2c%kfO`+9mS)UEm zyzcGZ$2WT0___0KVg4f{S|iu^!lrTWukOBKJ~h~^vP-w&qrKUo?INbgVzT3$xg*Y=L1@KamYuS_*;Y~`gkyU+Hv0z0f5=~B%BE)K0eM8-eEZXD zgfg8UOj#1-6eUTTR9a>#4&p*YxaEA&xxE;?(=b3M>Ay*7NvLv0CgTk5aaT&)5+{># zhlCzXTiqWFo-Y_hPRcayFSeq}&H2e4-{qgQ1%j60beDo#S&C|XKiS;0!Cc$(9HWMu zxu<-LjT*o8=zpwuF0VR*I@ht>L3<@0!o<7`YOLQfA>mIWzj@k#60cXTXVzBjVD0lb zPg^lBV#%yF0?4mGFxv5TL=MSx9Q>6}PVSs*VaM?X&h@)M3(-&>`GWxTTU-rOL0eN#rVfL@?$R>RwT%2q<{6VmDf1?{kM}mFpeP)f8YPg3>P$8YO^*Bv}vhWpjD`#-E*W|62EXa-kaE300<$OFeoY?rS1g$-?E-yu#T8^x9*@X? z5i@;3VC0!L%NH#u>*SUET@phHg2K}wS|?KLDriqGVozTLXBXlW@mOQSE?BHE>)`i4 z$(n&GVU9FReY|01mj<*eBRk_J<6d@vN34T)yMc_XMy+e}%)G%Y#)Y{Zd9FbdbFX_t zn@4E=)covg3v+KHa?=QNLqSbg9yLM%u(7$f#N_>7T7?(57i5tW65dTO0w1C5-0O%g zsg33qJu?>f4B7^M#6|LThiPi@?AQ*& zg+*xxJrH78Z)6h~Ij933)0xlFzY{Q=R0T;}{ZQeJPT?LYAA-|4{!WUBqVKG0p_2FO z{6cyqRvL4;AgJ+mUwq!zN;@f<6;kR0q9xSV{7@>g{D;{&X61gs*{yh+-sT;&4@cmr*GlzPBUPtCo2~P80UaDVp{ffdGjaq;`5? zJ6?$hWiJNy{Zm~S>^>0<2I3WE_Ym}B$`+2HyMS+*&VF^n?)OKTt=$U|o1yBt6FBlP zeG7fqhwr@0t}lrIobI7*&6R05VeHTbSWm8~fHUnLZoRx5nRPh=OXYi?ejCoXqw*VE zquRK~jRemH7fAP8e5>ZSHbD_~>94vVXPu8zGYhULBGJ#b6-1IzYpU5@utoF*xAP|A z>FE*ytd~YvUwqzni+7yVz>dxD%su16f$DMUn2>(C zdCoZE4xo4~l*|Bb~z*-Q_Os`zjdf<$jL$w;Y}~v z+8NBD@w)T+M~!5k&Zb3AY~Z5&C%K02s#DBMQya2035V#J*3b>!S#MG#l3z&fcYhP{@Yo8=KpvC?|nuzrgs6!2MxrT?}}G|k9Wq@cc4 z6Dlehyk3na*A;!^ZIF3)7IwdJ)Z%8K^<=s}8(yFD@gcv34j5kDu1TN&yverl8lHTp z2hI3t(}|ogF;)|ji{k9jC7_Pqvbl)2O)7aVd*;cf9CDK7f8IheMy%m`nVl)r(E=gLw(n-ztxU@K_^ZPHw>_waW4ehulVq#cmK$`yYK0`VygV| zD}IsrcFI)TTf=$nt2?sU%D{PD$0|xZL+sKFKb#}{LplTrXElHt5d^U-c=1U?k4Pm zl{}FRpewp9*Esx}VN<#VHQt%11nOKW-S8{bXI9`H{^;MO$^o~w6FtlvhKws%jL z*zX^{xLRkl9hfY{Ce?yzj4{KW$&UH5s<bem^|mz`nu0z_G>IVs{b;JF+c2%8mT> z{q2r@`eL+F5x2Ej`vqZ>5u$MqwwC383DS;VwrbggCIh`j_Sj(u@X#p&DPBKu)j?+b zCtBHX`?@=Wj5miy#y1Z|(Lj5f#XemN57g z&IV+cP?_AQb>dWjw^0O?Bq3z3^_{L&u2fzsnVvx9@5YZGt{O#DhS9JicF zH4~mL`eq$Plvy`kzp5D)JMoNb!G9=lZ*6~MSlPeAR%&)4=2rWdlPMq^sS6?wf8BUR zKDFfY{*@hh8!7&U{_O`}oxKtT2@b%aO z>fP05xU<(hrAOve2vH9J?7G?OHSdHa5No~?$joRAdm)iH7;2$kK-uEc!rZc zS?^2sTBJ(@ZtFoz^^%_WD(l)w#u$a4DAyB&jX>n;rKoG;y4Dv}VL{zM5C1Dmh^nF8 z=chI{^3H+Gj!2DqJA>CZe+R$xUs~D-e9)c!YJ0)wm1)(IS(#sG5(=E-M8V)5TOZl- zqFkZs!kk}+QkAyEH)Q+8vVUiVf;`>}pPo5?EJeqRejlC3ao%M=Bz{iyK$d){l*}-x zU;^2fdcL+3r*)!u$nP7U+M0Hie{-FI1cTY$tk`VLl!6=t&Jy0`fA@>bKBBdp!?PGZ zcEJ}IH|m5vNp!62y*3$9D9RIN;?q1=z{WZ7F);P|_6OE?m}(PMkh7JZQ(SbqLmx=@ z*6{m5_r0Svm}4u|+kxlp&Iv4*3VhN;^xDk2AL=2mmtdH@z*oP&Jq%$I{|0>!>{A?L zXb3O=qQPA+;SrvnSny76&QOqEc#l8}?42k*JfO#4|H>mu?{!FiorEd5Qi+C4ySYCd zvXS4}e=f}DT>(vle8Ch^g`2|;)}%FL_aX-yJu|TgWAn8sEyh9f#;r; zf`!KK&!jOSE4Qk7p<_2ohFfZt6Z0z&qqT{XvrUBVNHT%Z&B_ARKF{2H4jpKD;!vg) zNP-R|=^id8yWB7}REe{}GQH9e);?D0&EoTrF!<01HSjjfJ*Jqx9h5Kd z+e^M40vFlkOSXE3!{;F@p5tq6_n6w<~}L!Emz|+y8{(at}*~ANNaj_GfXC zM?aG7&9{^AmPdVwJ@}yTE3t;n2y7=JmD89mw4qQxbcbg|?DrdGs&NrMD%;mhiJ5(x zxIGSEB02@i2lDMo>}Ufx%7=FX)$+Hf7Av@S0%^M^KUkuRHV*ym;kQ&uf=+{TuAt$# zcaiU#e)#Yz@6gF{9~Fc>NIzHu|Eu_aXY)Znxcdz>8W?1KLwFuJm`D&qJQFnj;9;U;*?1hnJznK)uleBYwRbb3lOf6JaF z00@I`h=59bKD|eF0F2u~LQ(STIVphfX$%IG&lCwM#*>wuGxwp7BE;bd73k)X;O2g8 zqiE;gnKVlRcpE>W050-%3?3-~-iD9V6{|0PeBQcY2d44926Wv?>RrJrf}A{KXX^oX zPM)E&vw)nP8!8};dv=_YXSBKSs0BGtidSg7EN)f;aOdb5KTD?A#&_$uGEb6%c)Z+S3?6AHHruym zX!_s)Ngj83>|1kYT>wpvt*Ihz=)iJ5n{8)2AOoLn&yl0vt=ZdyDS&yuA!3#qzmgJZOlVl~rWmWdb)u z&#EfQ@iLvq(86W+SYQKZ`Iz>ciGlEd11J8JSwTf7zKLzltXW1y4n7yB%pMDVAhY5u zufjfOh?=>bJ6mbcEFIvRlO=d=(Bcy?0`QBk(cbc4uD>i{7ETczaLhOF@OPgxM-3fl zsi+PZbojf+nVJ-(1`l*pH0DilAf6~oR+F*QLjHpbG*XlVpzz_@kAjnj=+e|;WfP}J zQbT@ngT{A~N9cmo_<@{?WdK!PYA4y9J|g#QU%-u^+K|jP zl~W>b1OQI4C~<3h?_dseL4qHcExJVED&=s8HO9Xw0$tuX}4lTp^YlD7Mjw%hl%2h?^uWh5RWB)qyH z@|M`yg^exzsm7N}D_-3R3sk!q_4d7#_B}iRGX&ih-sXUsW!ISwsL&xsnkVNO#4kp0 zkDvgnJ4Z4Hzp_N~6SK93=MViMj4#An?@CZ_PiIl_VsYVkshY8|mOMSnC>J3ICI>Eu zRU!5a-W6J?TX73|%XdrI0MVYs9@C!Z!fT6p%dI=Ro3#6^+os#qfW*Gs!S^(#8@>Cs z+ofA$>(>_XmZbrzJ)V8Bq3kH9+r ztH;8lI=gIvT~UsYX}ckPmI#3Fz@I%Ut_XCX-{ovw94t*%Lk6keyI>%& z5NJQ>4fqY{jlr6B_;zGL3_%1zlv7wTXxkp;HMARKl9r;yyOoU5>g`^>q*+VFa^A{4 z&R8|-v_36GW<@-}C|}IBF2(_FKVAHEUm{gRN7a%5dD{0C*i%HR2>0KUJDd@5Pv1A{ zG!&TtnS5LhmV2B5YOp|0MHhf2UkvzXUsn8Un2q5qW2Y~&}175p1M9D9(et5H7P}CqUp|AD-&_4yU>;32Kgz9-_9}@fs zP~11l82UJdd;qf#soCNi&{mOyR{=aXU_lHl1C-bw@6Gib;VLHaH9A@DCQr~sMe!%8 zApo5fEdZ)~#rCoX$^CQ*aSAXrG&CVe<1#x`j-MW)sTR)+Ft-qdgA6vI-q1e7=^^UD zzQMgk`I=?_N=Rl7<&{}bSD$W?2r@_CJQIV*7~T;!@ql0W$> zW?s2kd%o_{T5q-3l-p=*)7H-Yr}AU`sjc;OoV0vZp&b7D?@G#<4FhK7i*GS*L!He& zAca9gul|aH{l=|ot={onwhVM##9**rI?Y9*q4!S}t=A_(14Sd1w0Xv)Ok1U`!sjv8 z^z5oG&~)bzl7>vqh|>=+6(6fRH~lJmrk5J8+9ylEj2cl|%QU8a@ar2>rS4j%$mYhX^2|?{YO1gx%(^?N0I12%yg}0{lUMz{ zd5n@V7)mjS0^cNiMc8ks?CiSkpaHbb2J~@VsJP3}UO(fgYh3|C4lQ$2xr%*`W9iPa zwMox_c`thvO?P*sHfKq7zgB6^|Jc|~T3XtP`x7r&MG3ty>P*|UqD;S`jCjah7%ET1$#4jsIqgwL=fa4$;Y%+kE@QxIR z-u2b{_rr(f`&Uo;fEt(S4-{x9fb)FSh zisd!4SHb)0QFGQAhT7H%75J&C*35DmwlVV}3!7ZRtNQd~chwhmcWG4@1Uhfp9$d`t z!F!n~PW2w4+AC)GItN8_nbpx`g@^1rgPI6&VmE|viEv>Q@dmImzShyfTT8@;yXRP9 z*jv9&EwhqyHl~30^45o!Rv9alI%`S15wFgTibvDBYZ7N_C9bERg2x2hkat>|a`HkK z=mdSX4<>#Rtxw?ymF>PWK)b3;4(-? z;=0*c$ORO*Akrrr7294Y!rXUy4UxQhZ4yEzoE9?2?%(ca`25Bj%_TZ9F1==Y12T^S zfZI?b%{&5S>tDFp__+A^sJZ#LPeXK#9C@j!xn4j9oTp(Pma?9Tvh2tN5w9e&ED3-~ zRSpzt;J4z?RV4G^q$G~SS>p&I$+xPQciLB`#CN#q_ldCg_Y#0i#F3e6Zd*I(BUxDn zb?DCz9g`!X1F#G+bfPWOQX;Z9lF~lTgSBv)-Co*&GfPx?0z`Z-bJ!)J3%(xy8vX=6 z3t<&r08RkD4Q>b#6AlD#2Pc9djP^$8{V5VjG7KeDHgq(UKJ0JUacH7=pZK+eoCKo8 zkT_5x_lJjyc7fyfkb;6jlplC;^rZ8Q^mLY%bAL;1rWuI~V5c(*>m|Xl7(WOiVqws1@M9 z;mDM*ChANlfD)Wz&NBk2e&$&Pw87`RktCk65wOQcho2Cqefp7TY;}lvMU@;7Ec{B> zhvI%2{oD}uGE4u^?fgDH=hs;LvfglEclF*-D?fb39=w*;Y}}X60%?|4dUXBr1rq_w z(eAZUsF~rDqt#VqM6;>w9M=68l>B_I&S|N)mwmk5X1d$z!t5 z{CUtDUSzyELe`sFpDc$4=h0gwrg^*1!%H}RbNBuc_AOpyXY+dK1@VpYJ)(KnMpE}9 z9)4Hhk@(YwyNp>Phu}R*qBvD-GDV7vx@z_u^{up^g%p2$ELFk{nq1rnRY>$V90ol7RFSKnHh7@GlDwY%p836P=A4t9RDotHf-^?3AW2ez zKbx!|tPe5B_=br;OOGn@>-5i0Qs^V%90?Vi93zVLiPoH`Ba+wgYaduc$$zj0iT_{? zq4;0Fe6S6(sYOUOl94kDRH@(MjT&(16a|vl8%XG+yyCAKXv(C$5>Og=%Dz9xe~`gt zKM<#TiV#R;EvL7OzLEYop2Ahl`j+(MOl9$XC?3HXmsE~;)}e?xTm08_SrJ$EkCmB> zBAjfg)+zWRj%=mG!>=Z0}^F-B0}ph(r!ZGg+Nb2^aiu+vrF=Sy=GkFT@*xFZR$V-ICz} za>`g6q2l&LD1CUeFq+%MI$#_$#4DK+dGJ%6>(%Z(K( zLKgd9Uxk-=XiMvJ*scSNVm~J! zILQG;alnFCmpdNalyX^XaO_)M*+-`TDt*&mYwF))aLYfMQqD}gmc?9?vrWvOP+!$t z4K==vQZ1{yCas%dF00)oc9@bcYu+Wj?n9+!;2H2~eKuyKMI04#toi&kY}C-PMLv~| zT|G9QGDSHDW}o4wDqV4kiUuE^GB$nEXhmIV1%1|Nm18M@p1|k3UqKf~oMa0-BMHNJ z18VIWW2Ugnz7D+Rh`PhP4!Y*pyy09qgvPjsI5Em^i4QAt?BS= zGC4=BeQF-Bv@c&HZeFN#BU%5}ylmsblI3dYPh5&n8)MDo6_8?dqUE6xp>7(Zjmn*o zVp6P4q>-9pjHe~3k)duluZ^yeG;h?XP5M971&M|5|ELQb3jlTFOU8^Cvt2FX-@T#r zxF70*N#PP9cWR6|&N41{ih}X%G8%W(grU>2H+LM90qU}96QI(7VVSBaeZnkiiGE)9 zR`pCBab5*o!)H$VV?XM;>XFAqp{2~0>9VRLO+ZouujPQIW>S;qrJkm0QlsbP{H89_ z63_C^zg@EGmFk%DYA$M&KLcfd_Nj}^|7cV{TT*O_>KJRPJvmix$|W^GTvl-n>=?qR z_c?WO4e03Su4l4lSe z&CoT}T;_5O>gYpn7VM^U*T~5R@BH&1%Sn^Ed=dQMPeMFY$cCaWKegksjE!AhwSTSd zXSk+sr>`C?zs_vO2lY5ztq9^&_e5PwlkI)c^C#GGapwdL$z7wk7Gw`?fL&}dv+Zb> z;a%%={{WXHUH|G@1DB~?TXjbjFTb9m>kUrUjGfB2txeW3oLaZdOxBj4>a?xIx?rwJ z+;=?p#5Uu|)6{P`bCbvu)F*U|b7XzLHqvU^Flo(9ljo^lcINplk6Ax|!?`NYa$R?1 z)08D_7ItLin(1qnc4Vi=20L!cF?GroG_J?7bjl$y?#nUvnYC|RjAQLH`^Z6!44ahv zUn0ZU%!Gq4RV*W5vwcgJdFe#Vii9c0Q|b>6#PYuQO#hz~fZctr^xEtZL&~ zW`FtYN-~N1S0(*GU&%`mUZcsyv;Hn2?(IUubcZnwhE zx*z{^-KlLU%sMt6X7=xKxPp~DE9C&Ee$?AiBMUT6eErX($J=lrGs`UdUkHc&x5?gR zUPSPYARrFa{tI*OKnq;j4!ce)gFSUs?|ch_es^OF#^Nr*S~$zjH*aK~UHrAk)eFuyXu#T$0B|M~%6 zuRF>ed2?{g9lm;pt38(Ig4CN}v+w>A_619%H|`SW1y`gGbcxy?(R{(}8==3w_<+9A zV|U5y8+LyoEZiOTfYdcK^Z@(zK?;B#P~RfEwjmFQZxMdmINtOTMTs)ZAvaXg`UT0V zGE6ZIGb8@d@fjv&8Ioox%O9l{PCYYM?Y3pgkl&ZC>5I`v-d-FR&B)59ho{03C0Oq!h_5?~^xb$~N=Q07xGFT9BnZ`QJX+QfSQl zZ-RlpE`;Vnhm;Y0{aY_6=Yn$cgkR9mw+Q|qb%|3xpcD5&urU2ZaUO6od-bQ#a>Si- zMNxCb<#NRsawV+vMFSyXB;ii_Tyn4I*CE0YCSau!svMYJT(MD_IcHr0)fz9UjW3xx8WQdk1Wfy?5bh53YKdSB zJ`NbP>wo_CK>d>FOV`z)Kge>DhC{a_nLy7SfxSb`rfQ8}*pJWl%NozPpPNmiopCVY z%&2|o%eU}sqk<*snSKVN#3iP0Q9?$MOAIBEK1P{KEG1E}Mxng4W|0y`sl1G4QItlp zyz~c=#zr~3%m-08MghEZ_>pQxwY-$6;q^vk?&^#<)e-wU0Ie^21PlWtMvc5I3*pWE zs5^mL49t-(JD-T;KvB0lHnl1`*d+a?6P$nYRh-VOl;pT1CRFO@1i92E)a&QHv!$et zz4?o-kMmY4n#w9I+g1wn79L%`kQPQA!nD>fmqz{7wX9yLyerq76K<3uol0?W(N$lZ zQ+Gz!RajKDD{G#Ybdl6mPMfE8QRi7uaKTyE89FgL*1Zkz)b}acP$NHvTi0uzCv_oN z7i*r)o>N#?yPxlH@mrU>pY^$cx@~MNKQHwO)l2Dzd;Ml=++Xe!`w}mJM^1ISxY5N88)`Rmr|QjY;15~ zLSW!wLtrHzzI1Lh-Ihmbsi@-O6n`gDQN$(Kmqb?4_=$xliJg9~iDM(_HLd8374cQZ z`4g$6`LrZe_^~(O4|C>{YeCZ)mO%g@Kqp`=Kro;^z&l`Alobo*`|>NdN(1(yphet@ z@m-6Mq0)-lU4xJ@2*I)YdzWBQ4^{f9<^@A9Zr3OITZTT)u1I}6vnQ^XOwLq%Ao?Fk zVkLjmKct6UdM9_$w1z~BT?yc~O`KPeJRb@o&!Ep#iJVkS!xZ$iKr(celb_SpoZo|p zTK%aq)ALaY6=-IVKbAKoNr#4>w7RHRWDZ&EHv~}+TAk@tdn6D1oe9EbouvveRAQXi zx00~a@eusV@3ZzI&1O#DIXqMJaG1)9XFWK`ScoGIYDDL-ncVd^;@l_z?r<{K&i{mr zD}Zt57t#b=k|dr9@>n5{_!~+0vXF@jck-dOYqA3NOPP46h_8=1R>HDoe}s@M67wO4 zQeLzAy{d6O%+PqIV)#3fCWVd7NZhm(M!Iar>P`a>w8NV>UfjFaxnB?Utjsd&er*VVIBPMG6!5A%)x&i|9_c-|JwWidDQ=3m;-J; z4z~ZBIpF5t<6sy5h=g)9HMBu-2mFUQNEw(H@jcK!&sk&QS)FjSvvo|0_o1PpN{bJ! zh{sBWLkjlMR~HqP>_%T3fOo4HC@%d{Fj#4%Z><~Is8Q*&`pc%W0I$4hg??}2JtM`eJoc0z}v7 z_E^e2v**)+<>^7a_^kyckg$q)-wW-IB4)2Tp-jll(a%@>MtQ3F&fXau6(*z=7 zy;_=RO)8{2(&yv@;+bw9!`neYJR#OSzS*$yr|U>Z3gfRNL`Xg~>a)(J$tA@M@y&HS zopYO{-i#03NJI^NjOpl3c_hW00 z#S6Xs{Rq6|%>FSC=A>6(3%Vz3Avt}htP7A?3szIN#u5U7MQ)bXB)4~6NHGs3bEurX z33CW(G)^-3G+5W3D0T(Uo?7R&qpTd^-+B#V)u2Nsew6@&-$c#a?E@I01smL_*Ft0) zuxhWj2JIF*+>x@siQ(2K#A|+7pT!0y8=MA$dX%=2-Eql+XfhZH3g%kA2kWG=5>-hM zS@)5hqgk<~ZI*~x=tlbheO4=PojDF5NM6Q;-=uyy(xmP+UO416>TrsGS~Mo-6`7;V zY0bFxqvg?|-%MlJb1+XgIX9pijTsjr@k3n;VXznRRV`CJ7vAG@!`$t=hLACOvBz?~(f>I@pMyi3A2cn1^G6d3|un3@H~;!}2i6!uogJUh7q6ZXoN_|Vx3mFYL_#S`Eqnw0zE57K-v_38CGTI}C~bAID#%^VDQ%mV=Iezl zZhp^L3w+QXXU4i;ZNPE7>xm#eU_IzKY1Xk$V8xC5qfFbiZy8JIrJKKA} za(yd5Q0XLnWrN$)(Mrxq&A6}VsdXiS3kPOieV2h@FeQ$OfoWuHVr23HSp1~=F_eGG zZ56z1!g=WO_p2OS-HG?DP>!CTQuWR~7w6togPpM1IOf>l(XD~T>jis94w=R{N4p7> z95ay$d$$|{g50Mp;3s;gIw2QfB}nbBTww0FCFJ{C<}dv;;62>#Z=&i4?XNI6dMusW zWl_2sCr8vmIjtK{F=MWaw{u25VdEtcoe@zAe9QL3CO;OUb?idz*1sgGND@+-&cz5& z1$+tgwW$VZ@c60N{q{1~tz11hGxA_fn4d4tS}Hep@kpKJ?jr-`pHj9}pEvge<}i+Z z-7I)?;`iEre3-HQXM2Cn;CZIqkGo+n=W4NL>Qf6#)w4DMJ21TSrR|ZU6fm`1ByVQvQD>kbR+?VYP#1E z1KKO9^vc47`M&0~l+4ns4kYy~dU@;d|8x;r*^C)8@@i(e*fS}-S>EJ)i2@0A)ODHn z^qMA5>bI|I{vO-suB_)#j*>U%Tv%+Cc=XWn^~H2G;=_~hGPldI)3r;k4khSD!z2yb zC(S;^J8Rr1jp&%FSN1CBsjflA>{OTE#xeLMu!DwhcP)H z6khe}`oBLic8+mg*pndY9L45lo~e+XlPeeI^Mb;?`dhQkIDN3`=I};uXACV9%52Wb zELx7@2$S3NbT68ckne*f{bc}pSQPb9_`*+5{s@xqY%lngaQmV2LCAbIMzB6qvzj#A zEgiagZ`VQ!j#f6Rv!3XNF3YUOH3~0YlPjEhncTFqYRxU&6#;$p>Wv7NjoJUQKc$4+ z4LpPfV3-HFQvtU}2LzGJ4Yi$djfFa<4vub^?pz-2G2mDDEjCLlmLIs?9x_+WUFxP6 z%tjgv>E65iYF%Ewet(_=*TY}})=!Q-Q)3EU9*7Cf?p&NTTD5-z zUG?@h#JfhJCyG3NDJXIu=VVFpJ{{weG zh`+o5%cDOyNU_UM#HeoTJd5b?Z0lOKyQhmTE{MjV#J{AM_^O~+@>LxolFrg=@-43E zHT!C4el^Xn=J`6Bug8f&q6rFz>ZIL0KGahm7e}kXj-TDnEOPR7pK}B z#H`L`A@b7tW{cfa#s|U42^P!Y-V)qCzav*lWZZj9Lv?qMp4& zrz5_!5XS}wkiEYh6TREJdb_gdmv%1iV$<2y3k|+;IPAPm)la`DuWPr%SIE{TE(KHC z6_gmUZP3xlbKIE3FYDq$(p#|Q3w`)#VUGu;t-!(#oa$Vcn%q2RF{(txznH<~)*b@w zmYimeF!dU9u|oVyd2=pZl4_d1tBW_DrLNEl;CI`4&DbJ;m?*UuP&)Xik+uc@UV%-e zMPH=VwD-eQ-)7V-w9%|4JwEP@NNb#r%Adb)#vI>R@v?#H2(wnlQJ$-(J-49T<+N%2 zp$B{ydc%HlvVGJz(1{7|r*UFK*Y1%Sy|c4&vJE41BUtwC-G+!Wzrf{Xh!~_a(^H35 z0}HAcv7INsi4m3zdMJlhm<+R@g9tJeqG23);t5x6tszJMV=@sJf`?240}wJXU{$1u zr{d$QeNUC>u95AH{Uqmbwf-7Og6e_y242q_c<+RxJnsa_`~3&+ePH|Yb$fYU(FbpR zP>@gTUOPinq@>qJ>3k`86Qg|~5r)oD}A zPMba2pm*3U8MSi;E_Wr+IucwiUe`b?isc}Have(00m-l_kXY>{&R831b=s`>5fys!^^VWYM3uI4Q{Qrtq?3ug` zdzK9w55=eV^H!&8txnfkovyVyU2AoEzhn;>jZTl#gN={xCx$@8c0W1dCvW(P->-}7 z$NogLrz1I}qMk6`6(iYdY{a1-sJw8Ri$ zP*wa0>PVjAt_OhU3=W=jMMd#jK@>j4FOH8O?OeC|f zyd$cgWFN|#LC1UGtrK=Gj&5xpcXuD<9A;~BK`~He;EGDZWnWrmr?Y^n046OIloF;C z6lJqwu;?vg=kA=f^5Koubz2@pZ#D)_d_SH zZ`!fAF5aZfbtXCu{#^g$-9KKt?Z~d$#6)soN-B!cV6Y`Pg3fq6>bNmK1^$ z7kBO<6{UhQsmAG%C04X11D#NNOh4MSgo}KbP`h?&LRoH#S+7d6!E7+Z`Erv}@;x>~ zls7Im#c3q`6Q{JPj`16PMN# zm(~-P))SZ36BkaA!(g;db@h`hZ3&aSW1{6@YgTJ5U!%>q4vhmxV=@g70~WV6m9!j5pC4Z=y5aL}$E->SPlg7ziB+#L-boEp0c)#zf@B7Ub%ZGuo4~yV#@7B*wMGNF)Fkd*#?4sf<QnuwPf|&7xK^l= zuootaE~@MNT~!6QEWGjUJ+)1H-`l(ASJyQ-Gpoilu4%}KG6WB%&%SNly4&Vu`e)s` zVg2oMGd9E|+jZXRS(QoIGYY1G0f6U2mmDLar=7;r|B zmcjt8{nWzLLW?_=zU!tF(QUKQ4}8T<JOOho-$GvFk48A9Sa0Zlq<8B*`q7b4m~l!t!8#a<0vpSbD|ohe8NtxY*tpc z?S$;2qmNpQ$odqN=fG$uBdhL!ik&qcTZS5vm(Zf3dRsw)V989Z1=>} z!l}#GESp+XvHXT{nE%GuX0ER6P~#HJ;q zx28|Y@FkVC6_rh{C@89$wtha#q^44dFY zzrVDMpunz zq{DzNN;NrC5|h*IiY3@v6&-I?QCS-$d0@b3(aWkeK3eLKs-xXjMb@L2l0ZH*L|}fT zJ9^+Hz?C`&=Ulb?DionR-y85)Ym#g7YGku9rr3frrkGBkVmfV#ZFE8v_mh7F&}~V# z0{u(pk-RtLe3vcvj*4*xL&37xrl{^iV?$B@E}ra>DH2wKY{&PN|gIB6}j&Maq?tm65BjR{D zd3n_46jD7&Fn024T2NDveDZRWb9Pb3@cHaYFXhklG7iv?^wneOu>*&a>(@`MUf7s# zrbb1Q_2$wU>jHIayXxY-Ei>j8j~JGwLJN8z+OPx`G#1(N?_aib@4TEJlElhc731er zd*$oR<~`b2ZIh&QZ}a9ExvqGZJ~GY`;j~)Jak0+m?5d3Ps+?#Xzd-95$1gggJf&$$ z?fG}ibWT{(ke=JJxGeZ?%A7HLNSkoI?86a#TU`%hU_4y*M0GVWc}q2ypj6Y;rP>Oh zX|@7N`^i58ZdVqyd08GTkwsTj7PW|3bPZ+oOQwJkT&B_zucGGn6ZJ`dle^y5QjY8) zm5pAtHc(nbHna-5g)^J(8gc=IU&JF^y*5gEdvr7%PWkPkh1YjyHP+XsV~pa8N^t1( zPETx{$6?55ZfM9@uxDn*qpso^0Z&z+F1>c!#H!A+IP&oY&+e+X`^ROjLF2C|X#CZ& z2JYw?2L7Bm)@Pf%v-g6!9gD_0#?%xB_jSyyT(}iA<}8$$M}7rL;HpCj%+hdY_I=HX zJ${(FMP~^9J|7DZsi98zUMDviphd zkg=8eyjdsM$6z3EbTI4-xBh5nbllA7TwzmQ_DHHeuClo^Z~iTdOD3$}*Ok>?TM}#3 zNsb6>dgb(Sn=bJNx+}|PRA*V}JigB!XOD#iztZI%6H_$=(d_ zQ;*Kv*_oA^gq24pl*#F>HyYF}&a$;avFkiQ26Dv!pB( z(i3#G6)W20Q?xn8(q=}v$x^8Nyx#1e4b!IydB5{F#ePavuN29(>cCsw;kyfbQT{%Q zPd3cfwocBhNe5le^in@pl>5@-2Ktif*R=;0HRf6LW}T)Q*9X=Y8ztD6KPIg|^hUs8L+3EfbfR=py#GXggQvyTK;4`Zg>=g(>uAxJ ze2o@|FZ|lCP3qhANm0^Wcqu%aJpQuChr6?D8a!%}H7&6;=0#{z*kpt*tMl?>|W& zRqLl98;GzvNXrzrV$GL}^m;mbY)oE90ujjM6zSb=eNm1=WgIA`Lhhtr(dn__Yn@}# z0%oLX)_lD@w&}ku9mid+p0V;Ll?`J&HNP3#H2XK6DcYu0HQ&Z~hpYaqqGMFJ%uM6V zvSX(0SQh@x5+B@Z`=X<{Dez;AgGM(dCP{XMZE}{OJuWU~pL}I|3!_P~f6zo%NzP(_ zI@) zRZE7roXiZ@go%myRa8S<7W$5~t9jr8FI@dpwQq`Vtxt~DywYfGQnR#^Q8sAN+6YH$ zlRf%bX+0!>>uehn+8S}8MSs$i9Kbh|`$^T|I9nrAtM8o1(zJl4QL~1{>tew>skxzp zfYK*b1_zJ9g$;IQSB%S|h#Q>ByTpzP$sac+vm8NJ^gfJ5wxKUl3^xZX)ukj8{lI{O zw4xz=gEg~2Q*;H?cv=``imu>UDIHS4qV>k2sQ`=CU5nOT3-w##qjPeAb`%11vne{o ztY$PO)Z1HFhgpb2^3bfJXU7Pwvu>z~(FT;mVUw+%^;jFF2tsr2h0BV7>w}83natoa2{V)uOGvdvOE{VQ;|7N05G07n3n(7^So9=hLtt$xJ;xGaW87 zwGw7JdS*IsW_Uv)_~Dt%K<)0nu_Mj?7?ABJ{xZNsGfeX<@dT9&;=t2mXv2ArYIUaMNx4@Q4cVRMpjW&W>L>bCc3-z zloeXn;V{MZ6VhjG@?ngi1yVE?&_pJJ0;;RD9};FK!$T+G?8@(rJMWPVYk#<=wEVn- z=OHb7)Lpe=O5^fcue*B1l!g_x9`ffkPhZ|#bMfH~NSlyuYuvG*ym;P@)}|fv%Zumk zpiyLSulzR3hDMRwX%y)#HEFZNq|FkO&^{)u_a-`BVq83cWT7P6+my3L zkY@@Y&oD>M9zfnWH=}k!AT?~VM7i7!eP&B*d(ML0G=MB(0c3r8?UspEU1jm)la0^q zoMcNW_64hiE>u3jIU~~@s|&|eWx85+J$6CerHd+^nG*|ww|8__F50GPOX&f&N44WHLsw^TGDe3F&q0aU;AQu;evYH&1E4r1`OCHEc8Z8*LMqt(XZktagsr zBxak#~S`IbX>I4m!FVclHo}i zQ#p-}g*Wt9X|?hQjDtCcGr{i5(PW;ZjYW<&7CD+0%h6_K4xNOSn24MczJ|nz6EO`1 z7=Itqb0u_)PP-y4>OOXqZ%HU;4vdC}PthWblvW#Tp3K~s`bB}ni>(fNAbpW=J3gkn zm=5d5Ws_o36QT^NQB`Iqrr08ly0qr=CQFe#GI~dEp@-KNWLWGJGEbaGR zx<4U5hZgR}Kr-4jb2^2wCA8>rX7{EBoNb0EmKPx6(PtVtzVlM2#*vb9MWDwbri zSd+CmnoKu7jn16Lbh8@Qh;D{Dv`InNr|>KWr_>oYDTW+Nah^N4U2&)0k?4v^wCh@L zVTMc}#RJNiyoUU$t#$gSWL&up#zw~~!s;-$g4(e7&SdT`ZZ>UhA2Q_)LzMrewT zgqPND!56(5jb3wnqnRr}lrNJiWOXw;n8p9HGYc+vOlM`W`!s{cjY9@9#u-!Q)GpF& zC6X)!k0}xH8A(YQagj>!bwwoxXL3xU&uLVGihNQsIlbH3Lgaty>?xjvRJ$(tgw3v6q9RGT z;xNf`T(Oa=Y_LWQ2%_^=d2gm^OQ&NO_JJ=x+k|6y#>|H%Lz(nc;=2)xC4Ja}- zsR63ro7?^ScaGuof9Hdt?G!C$*VA}i$9L3;6p>KelvmK07ZYF9oIhvrimZa{oXn!^ zoM5-|Y39U2H}zdJY764$b!L_qWoDO^1W!?!jtRa;&V%>C4W>SGOaj>6I>t}e^?E+g zWzNA%QRnF*W9(O{5zaWLJ;p?o%gnK<@o}j!=IfG+b93Te*PGCeW0Xpq+ua_UPG|E_ zAMn}GKgc!m&CCaMAA+cUY3oy_Bp-UPR_x%h>SI(17LM#)l4Ku|Cf69_GLk(Ru|{KT zh9@~A&NwnHdpy~0v)P^PNy(u!XCTANGrZm$oJ{dKw8yt%d)8o&%@A`4bo8_n7lzI# z<5Iw;WW7kc9~PRg$*Zc&rC2>FFSias>mb38Ws~}2j35n%Y%1jC739Zg;?iPT{ewzG zRH7@+>rix3w-Vt@a-mXI)Gs5f21Os?jL>b3uo_WXQ4v^Q9XTxJO5?!_k%vKVKA`}; zxT5XlczIjs{RS!5VGquA;19Xa5P=i#+w`PlzduQ5kH=ar3qC;prtSeBqy$_tRmw6o z?y}7iL0V*2viUNo&O>SQV^z!W*XT4^zn^j3?P?!Aj-QTZY|GVG3xm3~VWSz-I<+KG` z^f9)`3q?;`G+1ybDl-@p6IC8|Lusb;(7Rr0{E-ysjYVzimC zbvor(a0<@Nm1y6dS3OV+4Y2QNXhN4K#wxY;G?AzaSGO?NzSCiCgQr$023`AKH z5-eLv$chruTtdc`kgO6?f)Ad=FvEl0r7;V?opbyttOoKe1l_3b(5sSKE#pG@`KrI4 zK%cX#wx75Tsq@6WG`exx{T6E3?hjZ5;-E*Ij4Px{D$HS0Mmk9P5#G6oQJybebzpt_ zwmIX|Y>wP1n+~i=YYAjW>J^FT%|^4ow6&=F^68m!{DjsS1VFu-m|pQXmyw)tjU-0thIdc zRXyY8)ucwmBs(sBba_sG`(nDwIu&L647>kP29wB+r=gS@eLFo0Lxv7~Nxzm;B65mF zPO-=-qO%2^iXv^+G_vanI7E#kUzEug?nHPF+vLjcm*N6(Q5kFnWH4t%<30S^No*iK z$?8kOUeMr?ek4UDnZ~j*W2r%PC6d;$>_sh;zBu+7X(GDIZ#_;2d1%lF?@wr_TSv4V zr6X*Uy@uLe6Ro2rA1kd9jjj=muF)D@L#LYEL@k)9WW1U)5Z6^VFgRK0Ca`v3!%g$B zOBG0LVX+;ZJ&>jSge%xGzTAgyy~uL$;MrG9X{ocAo$_+yB0p2P{=mvbcdr?j(Y&Uv za!$ZouyEg!1=n?F)1$9RYn#*GO&r@%vTCind}ih1Rbx`>mey9!8=rjHuI)QX%e0-d za>q>F+&X^AjOLW&y7oDxwVOJN^4ix_7tNj4=5Q=1>RV0!w*%JSsmiwEw@ zZJsdRn_N}Xm_2{RN?fN6IG28gbIA!=@M$1!#3n+T*hI*oF-sbq9XTXydw}kVMNywM ziVj{>Eaje+=*?sgch^1IfOE-X>X)EtFkqJ`ik+3Kq$ZNo1mbg()OeB_ zr`>VMWU_a7Y;<+e(+(=`d;(Ookg3H#nVLW|wMZaS6KJLu0A$h|bCF50^rcuc{WROP zb%d0yE4#Li!t#&MDwvCG#5eA>6T8#VPpTjHP0h6R6Ft8rSXe!9j76XH=NLVRVq4N# zFKLT`4DR!EYnKQ$`FiboosJ)fl%;8VjdteK-lH=`=m+NLEoPn07-1lh-_bKCS!Xtq zF^a_z>%h=K_o*S$sMgZ>Qg4fQI^yj{`Q4jMN<>nOJ=SK?y&x+JQS@fr$?J@0tWnw< zP}+CmoUVesfr!jfl9fa<6Nx{G1p0*;3Xo_zi=&z5iKb$T#%cIuQ5yb%a!uCd&q$Ym znG45EPn*ni2hl#Z+~X<7$(Q?NQM4|%!&cr;GK7S9te(e}2`Z3dgBPNijJnB4;ZXN} z#61t)pzhGKo0oU0Myqk4B+_No%O)Qc^Nf42~oh2IY~iR9|e#oGo=# zTd#X;!#hR?+aFnqI^jl?UnfzYhFK`G1S+ywq`-heDWD6#fXS_Z%B`SZDhZe-claky zj>YIGK%*l+7V*>QD1f>CfE?*I*u;Jad(-V52^fWfTYSD@=VQk$BQ+H8-c z1LwqHh^(N8B^3c$NnQnEQ&*e9+yYeCE9}vw{lpwFHFjkG&EruU>Ge7D;Ptt@6XmwS z8+53;^SHj(bUi!%qZ{mw@*zDxbeXp_Y*$6{4c*icIcFL~7g~TDtImIT<%IQ}!5M4%yIk-I6j_Vsd08dcmm)o-|KN)%3!$&Oiz-50^8}s!s`Ym1Q)RCi^mc zs?{BBjj=~Mah!9fT`+0<^7eAGq%Ucsx}V;*`;{66V^A-h42(<5CH@?eo=sBINvfZu zB@n-x_?Uu9izR6>#2-!kF5-_OejC9dPE|>&LbBY1X)6cURyonJ$V7W=+9@DE{d@l@ zdODbpn``S2oeCsk2^(F0Haa70bmPNDjfjmp);9WE6X~FEU8SG_5SIu7CVGoN$WWSj7E&8Phfv%9q1StlF*>vYNBofCN9Th}lMLyqXyPMY z`Bzbn8^xX8fln>A2=vcQdh({~Ov+C379`njw7Y`)rQmGx09oht2fq-T9>k`zCB-_E z;$k9Xhk+ihV~la?r#|VEfpHKD32#}_$*%%UYaSk^e-1R#qv->Zz8pH2t0HMYH^uHh!vW+rGS(dG!`r`kh2O%0lmy>gvdw_tOJR=SKQ4y zOx>&sx*qY>3Tg)&^z{oWNNGiV#gYm+wSrXiOId-)yfl&)_{!tam;QZBM{GYa9MaDS zbGDhA%`O6UYo2zYP<>qTgm1(R+oCKRc3D^%ekxEZABf6t-}=zHto8}nQAV^426M*v zsYUbmbY@E>dwW*h)RkVi;{FZo7tINz+aF7*>8YMDry?P4?5vvRtEFeA9lU4HvI?`! z;Yf~;j*nEW4r}wp2j(Q_S1h@@W5yjD>oZ$d@7`a({jpW~c~cgZR4k}X%b^k<8~RdO zEZxFl>y&^a3X)8=I1=}m^^#;C@ja&Qz)r8nza;O)w_ZFw#2$IFAuUd+U)j*OtR~r8 zyRvc0%0T?pHg9Q~uh?sI`bttVijpJ9q}GeO3Ug;()YiCdR&nXFBg zl@Jx}Hp>Qimm2AEyIgLIY%my&df5=+w5UddStsiwqs(+JHiUksuEXz4SE9fsT|udA zruQwC46=>Q&bef)e3#q^5#WZz!w{)=nV%yjP{59#u|Tg_U~GJ>a754@Z6B7B?~1Vw zSgg@ecIoeqsBm7EGm?`tQd3fbGwIe>T1pE2w?XK?_o9BZFG`JA4gXxdJ z8V)rD9zItnyCFrN1mj0SNr!x_E9o@602LbI8T~uh&K2sM&>txj1@_CY!Rj+XSq%FT zE0j%8NFi^>vfHsOoO(25BQg;+TK;~y4d2@Vt4_ma%-;-GD|bPFd4@owsfhKMZ%4!`ZrCr;Yduw=tL0a)ZiMtc!xhp;8vg~h?QPhv(_#HL3}PQ*Gvk*a z9)>0V63=j8Jxk=5@VgMoA>>aW&r!$d6lmlKeU12LFx>?Sa#twGDbB}h;2y-C8od>9 zfySQ(c>uh+DkvKXw5p}@P8_q5SPr!s42-@Fb4DQP40#N?JPxH8_Tl=OrC~B+rbz48 z!YtkI5rhNF_9#~)Rv?ODzI+n8&jr$YNYmX8X@<8UP5BY>cW8X&2uv9PEnl~B1g;tZ zEq_>fBYwxku)22+JyT9_s7^@JXF!^K6iP;d_4Rb@Q;I{6C=)~9l6T-T@=jgkbQg?${MZ;`H%z0l5q^VS<05NY*M4yFXCE_UXEx*T&3}Q!y(@VDQZ8IjD)-i zb<#bMavCzBOM{-#v9Lj&4~yi@I9Crst@LMDB~50uLF$J|PUOEXT-!}ftZ55e+I-Gf*Blh#ol=>-!q>>Jdv> z{=JBc<#bFpB32C6agl7nv=w1zc?S^>NjKp8w;}Fjd7mKuD4~xb{RHs@mVFWN0s0)w z`#3cfQ9xctKja<6>m1mZRtoIfE~HzeOBmfq{sFt_@1JpBBy<(^LF5j!ue)Fz_aTBm zqc)BE3&FdoZRY+$un&EMsmu?!8B&G4M;Ui;8y<>g@1Wh5e}ujXx5H>JgKH?&Iq^Hu zp6URuSKDB&+7|p)*hFdzrIXCY_y%nm>Q>Ee?N<)N5^lG!Z=Z#xF?;j@*oEDZuYkFO zwq!H6AM$KyX7(UVcdIBVs@K&{xPsajhTZ6EQ3PSmir;jQH1m|>VOt} zMlk{P(Pq#|g|JsD41I>!g0M2Zav1x!1nI3f*Cl9`Wn4Q#C#-@D#Q_`f{Vp8;IdUAx z%5=D1!$pW_wG^hS6)+w9?NA?rz3Ppyh(fxGjim|YM8~>RQsLG?q~g2}!5RuSo~`f* z8_#tbHsTnigB<2N&etu&?|2oORq9`9(EPzR>gx^qTYmys_Z{Nb+;7y&Lw=m{Egxs< zrwZQ-`^nLddSNdgYc&DOe2v%u=j*=0I+Bq83s`lrNCSuk(5=je1$qPW>(Rdp;rm~q z?}ihb&5h6DKISJyX-LQM-VTvr{!q5M8Ex=(n5kTa&#r=75I1W+&UEU(U|;uB;Mfv2 zUp8w#$wP=0+FYT2m+&L*!Z~{v`rLWg4-=m+%C-2;axC{9tk(I^->Jj&JcvTYZ8%~1X#bUoC>J`n-bTHCRKwfU28BA&&x@gUBHV^! z{Z;4_G%FoA_d3w0>Oh;uZQurK*W@R0z9AGBMC&AQs>@-XG70TihGwe@5M!AAxqQ&) zpmrt>Ow`tCx;a*U6H=5Q=A-V$xwua$Vm7o!eGB%fK}S_lj&vT{5QKaR{Yiwr3T_Su=6x%lf)@wZ+-kTCu|+x``*c3cl6Jui#063S z^=bg~o1ho5^las@uDcPN5StMjm0nn>j7NXq6s$muCr4qATmpMkv`H%3CjCDUXgBng zoa!Ed$0#uV+^*ga)#^cL#r^`mTdh0}jhH_M`I$(m@6?I>(}*TacOYGjvdBWdSpFN@ z>U(hg{utxOd$8O+=%;z1(NKiC;uPxlKj9p=L!z=5=1H%iUj7UgAlh+$q{zQUS_&7- zedx25qCP0axg7}&h({3{5K9pr#A3us#6rYWhKVSfYamX(6!qYFs1FZK$aSdWIVgX_tVkv9$Jve{v!TE}1PHSJrIkzwWOzp$<(Lnx*K144x zOOL<=>9>$39l$yA2bhg^xlsB5^Z$T;%BRqdv|V}wW|HTi2hsUIc~`m%#*(k0K$;4b zQX|gGCWw;i@ttMGp1NAqkA3$xUZZYcOX)s%fh9+Edwbl zLtj#QPV??XSfZw)-$bE9ALl02sTA9={pd5%n2!1!G;R%#$;0Drj3W}HkKtNbhqk*J zu9Y4{T#e~kOwWdE$pOS`pi1v!KBkq``0N7In-`$Xd6)6C(Du%hwnIJY1_k}7nbMyi zUa3P}@dVbr2hk60=wl90C`&@2aL6xGpnb6*wG2{;#t;-F6bgj{>o8JC55o=(w^3kB zvMVf);}UE$g2hWWz%E>e1DGF$aI$!75cr)m&Z6-Xv&V?rdF%xc!k8`Cg5Pl|EWG@pI%%M|jL@UnO=;x){_S3X4B%HP+|flB=xO2Yxwx;H{03gl%C0rCgP zXZRQjDd3+_OCjmeCQulmg@W9LW$t2mg$!E0tbxqaFco4M{tKcRUcfnuK>4?a17-gs z`FWft@hAg|Xdn#nBS1h32mKBz2kbYK1+G0Si(LYc#A1TGFdhqmTfLa&x1c^w}WBkIO6_zd-`E)U=T5Z@L1#Tbt&)!M!=jXmj} zZ;iH(i?I-`7j6GC!;j#g!376(G}f7fbv}iN!gY)K9^<2NOlO~I|2S-4e{jD&1I`nA z9_u$4Zsv8ZWzbo_ueh-2R+6* z3o*utM?LOl{h?pz6~=lCY;G^b1`f|Tp13u z>kF_n1YgJRS&V+b3Hpxo zYUn)aRj?_=r~``O3N|;3(f2-v{iMEh3$|JFIY#c6QXfFN3T{*W!uL3JH$ji?M*Qvt zXxC|sO8bj*72mCt0-=9Uuzl1J%Ic5kM{HoR&IVeR{0-x~Ow=E@;vB(M4lxf$(-Pa2X8k8*9(C z{o&mR6UsF-v_H%sv-$9-wh#Ph=uHZ3Z*~G=tF~8rxqKIp@V!~KA1n4`Gw~VUg9Ta^ z>-Z@vL!Y6{$d}N;=HlCsiO+6P7GnQ?i>SqSegdT^#|r6lsFdSyo>stgobv{{M}vrx zUxNk~U(z$HU$OiS^v5gNd=?7QeLP(u+@Rcm&(JSZ zZ-h0trVQFyPY2t>+lgsL;bN2^M&6&NkjO#+DLzXhlcPoHF1u1 z4(B=Na)yIz_m-h^Gq&efA>T2i|ClX6sBVna`I%$0Qi7;J-8X^jScZ0db{8beZ=erR z#Ns-xr%*qjj{6(i17C`|=;P1>(zi4ZpI?qTZz1euV7f2#6ZT#Q-;Y$$2C{waQkaf< z&KCx*|E|S4uSH+xHn@y|>h@1VZ%Z|ye=@o|bQJa91gZm3ucRv*(FRP1>xI5!daNDW zOm*C^5YM6hcot?bJF*w%GKzgx^)Qd_!D63hp)WLx?wQiMQJ1Gv-Nxjky`x);cIhqX z(Z``o{)X~B3SM;!%G!cs_9&KJjxzo$WFyvLf8SPGLvPFPpiZ%d{)D!6f#SeAUxO9s z7wl8I(3hyj@@ru_jj<&f&)k6V(HD@<_HL=1&PTbwhQ1BmpMDT^Vg^L%evW-u7G|p- z#5#U~_LZU(iO(f8YizRSB|{)x6m>6)c;hS}gET$As@UU?%lV_#OXbJI15 zKEx)dp=YNE{owiOjTmD-%+66)Gyczry_nvL_C6JD?hGz-nf?Pk2}rSjRNita%AB65 zt`5CRK4fwS9J6eEwua#{?Ob&);s9a;`f7BXKH)NF@1gt=X_AcCgLTt0*~=lB9EE9e zF-#k_zmKsxi`VzTV#F%3r>HbTErrw$U$OIAdX9@c-49?q%`3-sRL=Jq&bZg`Fv^O? z;PX*7bNN{@I~%6oV}n~pLYX-nm^TsW*^oC9Sf0|fk-+?Tq^AM%YmlA^BinJday8%6 zjx*)^k)DSCX*<5BULVra@ICEoMtUZkYdg*+=TxMp;d|OK8R?lYtQ|PF7EmzXn(hnH zGpUDB$8)fKAG*gy=j-5}8kS>cUD_U*fD#COLm^9W8{6}uNM(COpm)Gq6ilyD{Ylpw zoyYXf#BV|&4qV>|dUhNRfKy8-hVQNMx*5)v|Mw9P`RRObi|zgWMoWjiKXN~GWIgCZ zJW0V~d4BJ4qM#UaM=CRdFH_3)6lj@lNK(^Qj%(hviC)MjHWnxq2q-RL|27R%AQYx_elDf@%F5etw{LHrc?Jz?qXh|b|>4&VQdZZY_D zi$gEz76*4D79fv#p`RkZN2KNB!7r5OgF6sg82@7Ma*e+qakFwN_=Wn*;10wVwJZ4W z8PfM7Zr1L8d~aFZ5sb5r1i#SV9NdA}qEDoG!_$%y{6hLoa0gsb2?o=q$mljQ=WliB1ikueJn#fjFq71iz3!3hq^7upQ3e#mYUwhcP{ggP(_} zZS1|Rx`^Orbzkt|!L&WNji<4$w%|d22j%Z4$;J}RWsKN;Mr|7CEqzMJN0>9>R1 zXnOGe$iK#{f1~WM_rzTk#1xHA)xh$#`zbdfu0!k%OWzolrsxXe@FPa+kDE=TM{ zETI3pg_fr1LD;0i;4d`(uZXRPOvG};vNPP78xFosH)?lbc4*jjhO`4=Lu?PbJ2L^X zp5CDm_htS!@bBF3{kQG{KmD(vop&{A7|Cx9i_8BN)}Bi_UFE+9KVu6m(6IRQbjU_N z3x0xhk#=Y6YHhEeL)#cz3vG$wjKim%I z(|c)%N7Qd1gM!|Jqxa*KdH8N5$b;t>SWd6!=Ro3|M?MZ+^h}CEyHn>HcCRk{oMHjA z4Bn5U*Z}M4olANTj`imEYWpCnJy8=EAcR1Zv~m26{#+mt$MK!ufi>dp*-20F!?X|d=koRJ?imy@%fiX*P^|keo!2kSj!w4GdI$FF8EDoeL$t09+ffH? z%B9$5y04#wZF`X2!%Js6BMRwfuwQ*cyBC6q7sl>U=9`ylL>nnJ%rq$!aY3n6fQUQf@x zX-qD6!j&RcSMEn_Bqn@zQaTS~^3P$4_|bE)-ODfLG~**~-n}!FN5h4toC#LFM(4dKY{|?-%I_Jco3FGL`&VnF>Ev z0AnK{&vOVq^bI}##lFv`>ly1>ue<@{)CF+9{0i8xO?#C*n5V?z8l43liW%411XwHk zalGjLSPdAP9ntVI!^5GgDU|o2OaFH;>Hi2(`rB|#uEMsW4pPmKs(S$G%P?JEjqBl6 z@Y4IuN2}@KfEYtPkT*UJ7ors^Y zxb(L;F4yoe4z|qhn2VCM(J(XNr8MH(seZdUjgIE20n)d zV#I73yc>}khOji={O~RFX0i5A_m+VibtSF!+9ET zwhhc1flD|}?MJ$n-@Q5YSHxG^?@3TRs(s%QEuE)@?hG!2Gq_uDch|uO*PsIo zHn_VF?hNkky8Q3GeQ$Sby>?SKsoPcEr*CdmPIWpb_sa?ACTMcX78qf_EDnd|W}3|( zF;^jhm__qP&LsFFlW)e?dt&KQw#X(I!bPOmq|mUL2S4PGY&phC2+mG>{ugMH9~rPY z{IIN_+Pi-m=?#;6z!z5eGKb0sA+zIsb?hIIY13%Nh(0ASaph=0@ zf-T$hi?oL0rO?iSmpk^n?Le9Nc#C@rcbWKLDGvQBTC=xyi4LaQ|3MVoSJ3u0E^7Nu z8sR*C#Mbd1fU@5v!ybJRu{ZZXNnrz*R0CX630N|nLXLm_>tyKV5ZQYFu^MT3e?e)n z@Zr;MC>Z*?jFa-6*fr|I%biO4%}2HZU)<%&$umBiu>I}hAo2RVg|hMeRJJ~DJ0NuR zGq!5%_J<|kz~3%{tHA)AquhGqkuLaKNDc?WffkCJ3!>$qkG5fcu*3*}&O29NuY6!( zo{gsr#Zz=X&m{&CadpM;nK{AB(K`?qD6}49#F2`V8z~9hMC9U>$}G>R=tF45?rpx(w*x zOx6sNo^i}^{T^3Di+E!TM2j@zR;XF}jLhIn@{FV4OqvXo9z|@$Hscj2PARrz=vjsg zoE|R3QaQG`U~9B-s-8K-QYE&=;7qy4cK^kzCvTtXTbG*g)+s7 zm1C3c!GkKoja6c+FvjW`4o-rSfPy5D5D$h&un=VfK>^j+HsSa6dS2DpKG4Q_gP(NS z0&vGSp>9%*JF&)bgP$sm0l4GvJ-##@Nm;$*?&h?lbqi%Ck8l?578NNw9UHjh_Vb z(`4i!?sNCFGh}E(-TX54|J+8{r|u!b9uI)urw#T}Ws`xr2{!g;$~b_&$u`c#9_I}9 z(q(%K_L65)M%ah%A;KBo2|mIcC-1pM*k|t%V#ttz0;;fyV2t+#d&#n)BJ6|r5TT8u zLIb;u{mC+3pz4j-9uW3Pde)dT+Ms|S<8!p}v*2agj8!OLrLhD0_(bqBV@4Jfu*lc} zZTv2{MTu<@VV|H!pE*M~c$qn)5(-#i{1a__FStdKt@n)l|Dq8>QNEH={C{n0BozPJ z+KDOt2aV&(c{G15}7|2Uuy<1!`wH|7QL3`(SZb9YdcaB5Vn60>5 z94?a8y@}|gHnCZ`Z6-vU+WvX=oyU{C?AX2H*km;&c~A3!Y^*C?1b+H0syywqAvu-+ z)`(;~rC)sbeR4Fk_}Rrb6>r(aR|4w%SI*yiEAL&l|6Wi7zxT^g(86c`=8w9F$&00m zPhRSLq?a|}i`vg*Q7ZH-O~!nz(+=@JWh5tfCW+k4MF`!|<~~!>k~&=j z0`6gz-t*2UIfZPB?B{0Pxd`GKcz#be@?=x;~BfR%Z*Ne^#*JLOkO=$2}tnKH$6>MV`vUIZ=d93Z{ za_px&susN^F7!Z+TDEZuHtWWzEY)+m1&#U_dZj)(6^)V?dR2|Gwgn3i)lbjEUaef( zw($#g)jGPIkOdv0oDc;aVzZ5<(Bdyar8ZySL!ZSmgH+%KJzdBeV647tg*yOLo3rw{ z0cbxU2tZj0^8`iQx{L;lUu;v(1vGg{%eg7gR$#gGJ$MvJ`88}K7mTaLbRpf^z;)m5 zGVf+5UTjJZDPOGa7LH5i)fsgd$mxB$Da#~q8?Yx%jG%9yovd*D->$M zR^RKML6FzwHw7iGDO+K}jdZkQLXgjjRzs5SiB;c{U+FSKk|*MB;k3@_S7!c}i3l#Dbg5$--`AINaa6qfMEOUdTzE zXDAY6tk(Rb5VbVO_#SFB#4CTFbE-Yy7YN=Q8R}P?v>K>ZQ|N{@B3NT1LEV92!$DC8 z^+Jk`OQ%K7_XDbYI`>lG3zNDQ8N1#&!v{8O>;E-2K*^XFl+s8gU(zP zW&&*QSl2i7difpTqrTQ<{Gz;ty5&*!f_&@`?GNq`;~)G?8kAmp0ZZ2fmJ7KFIhFrT ze@T4m1F`2AG}O)8y{HuMz-SrxLVXG63Fiy$hT5G6nFsz*@JjzkatVLQewie^^fvwT zbOSoT7}a^RS)tVvb}ruQCusS{~6{Z#B+$|v8ahh(eM z_Q7LHV$~zRt2qcLYB#C#H4j_~5;Q0KP%Q7JT&8;NoVrf}84n?o|IX$FcTXJZ2b<3V zb_)9|$ZvtqqlSB9-SmsAhaSE|8u!RL^eL1(*lLh{w^n8Q3yvq^B~8OJ(uNtbJ8$nj z%#tB$JNW8Jeof=_BEg|8_gpoE76fD9UN?s^eS;pv8*>``ImPmz0o_X*;;xoQ=i2#X zzUsLv!H!ZN`?*FyX@I;|X}~X$J`sAxK1Xw7#v&(a-@(w>s#qWVx~JdShol#IiQF3^ zlOls6JhrhPe3?oC>N@_#42|9wZ->3i#YM!rK0#w5a< z-4r3K?CLU7jP7#OitbWYbPTA;$bH|89A;rK#24Wv&Peh^g9tw_taEFJ<%8pel>3fkPe@4hp4k zqA6o@&?tAZ`wJUpgk@3Yrl%o^ZG-i$`S!yHN2OdlD6o2~DpDt655R0I~qtWv6< z&HF0-c<^_sPv?Eu?l?toPo)cB%!$2p{4_@|H@Xh(Uo=+d(h;MVilztnh&qmE=`Z z`gtDqN!u2jE8g0buT%>*(|jxo3`X=Qs;7N;f636WTP%Yl*7-6PVS8&~``&y5Od<{s zAB!Lw6Q*$N+st>CmsN>f7VFMhEpC>Vhc_Gb4&LxsPk)gn4@NDMU!AocU&lftsuUGo zElUZyPHt#4DEPY5*!Ax_>~_O2w2B~cA7kWjG5Be?y6R$7eU~ngI(QR@2OXpp0vUk; zG9_aC546HHv4tN)Qlv2QB+v>gBB|CNz{-zB+}8_0(8sH}NU%Z!u#bp?xFpfKTf-P3 z5nd671{}Xz(inn`1P?YAdJwEA$dwEk4>}e~8muqqTepfa92*`U6cQ8+*jtdls1X_T zA>=KLH@I++85#T`xGQWcxIqw$sAWFpH2NiElqhVz@HC1QVgoov;DRV48Hp8q1K1D9 zi9o)5`e`UDBrXU7h&oZoe1HmS8R8tc4P;{=UOtTqWEqMEWCes~;HW5dK7|T69cl_> zVbCv8{CqAI1Uh(0aO%KqQ7Sa>M=)GE7%b>$u&^L;QL%h5G?Y;A0#Ufc&m0K_DHe$9 z1V*S52rb(U1!W9IbO~1V4W)`+6by+hCAa{k3*sH-9pWA89n2rrAM)SQ5QIOJA%qAR zYv4imLpNv2%SJKQEAb`NBaSDMFQhNBFSIYb5CjQ?5g2OVRM1r5%O{Q|yoI@Cx<$Dq zvBh!;ehF}b{f)c^;qXt^>GtX7-ePcq{*9y$!2*FB=n-_&E!e%<&A-Lu1jC1%4WR@< z8@ThY@){o!5d;bZY#>-I=`hSaxKEI9w*wjC(tjkBtxs6tlBO1a3Ca$N2kaz>H_)wH z*BH5$;45?$lor@nka@S7F?=oFS12YZ9I%_ue<0`^qp%TXK#xONiU_7WU(NV6D3G;4v0wF^(8i7{3I%lJijkvmFgtN8kaaY2!A0j? zvearq82xr0-YT_T>R{WPygG2UfA7S(7INO1e-=y&J$ z#doALsWYr|@3UlYr<1eu1wA+N75y)IXXJ3?cXVuu3P~zSDjCIuub6d|-zBeQ9nCt2 zr-#Lp9LzF@4~O@YIw)RnXa_=~Wm51ow?TARQiU2CDjKpHL1h!FI;x9>4w8NbHZCpX zdxIKqn}$|C{duRFSK9pi{9dfU+9Xu_LhDb52!&Edws|_sjwcoh+Yg#@d6%L&@C2CE zW^a^0wLIa9+J?KfDs!Cr8RsfC?Ko-RkT6qzCo^M{g@H42NB#VjSsYcdbD)j#c_HEoUGSyJO+d>MTMYHhmJhCCcA-`LPDghgSJ=XzBqY( zxR+ACaY`k9hg_`?lYrdw!nPwlSJLro>i&Gf%P#_fje+%>l6I|O12db47mw7o%ISZi zikaT9Hwz(k__fkB3*d(#ReMDtGG)nG5oDxP*>?-!G~hL*HC4DZ@JwVG=Lo3tdCHh= z12JYqx!{ncC1xb&@M{LKI3Fv<>Vl}7oax;ERyskk#3ws(oJaZW6nYZnhSKgR3PCHI z*Ll(aViUTxcXl2*auL1K`XO|dBvG{JL{Oy3`A8A^$my^SWT2hDX~5x%CH;nCh%Zus z*T<0ht2P`#scOB3k{TrFsq;HorjNKoXzhxzJEx1m3~HWSunb&B9LWrko7}bxdRc;a znC|#*L82*Fpl=cRE=gUY4_CN;G5Rirb7EdYpl=C231X&#!m#m*SokanM-EEjjcX`T z8R4$9Fq!f(ikBM4?iXS5fMetq4c7K?%w2eCKk|cP)Tc$X;iqu1cV43FZuCUd_|SGX z?CU=EL|zL~C4P8w@icyHj!F7Q0mHrn&vVa>1E4VJrB`lSF6xu-luT}daNZ@fZ^1$C zf%=r+l%;Rd0dNYkbLl4Rpx^-AXf*w5FPBx==n6ZxW*NhM@CMoF$ZK5NtMg9iZ>8|& z6|8UE0lm?Q*Wf~JOzws7lrZa~z!d5$@|40Kks#fFML3M*RIi3jj)K1 z|KBX3E^q&xEa5Jn_bo^7_gy`qfU9PqfSc-EZQm@~2*-oSi>-`{-O!6|o$TxN>?`55 zz6Y1d2j>t=V9x_E8TR)s?C$~8YYBblEYf`xk=_oWNZ6aA;XxdPUX<${9U)SY!JC^M zq0qdof>$ck9gruV$OPggXFHk57~&PMoh={>_wU7J;j7RwB)^@=D-7%4<{nE|x8Ey8 z!dAK`Ro8gT4P!f0*J#lrx8e7$A%&}xcCN0TvCFJioUVzBEwNX>cW~CRq9+5x`1AwP z>jY2eclMfL#;ehGgpW||y$4Tj|2*x3yeooNpsHF5Mu`-@s+MGoOJRE%8AG&u;jc1^ zifEg{)-rONXuMAp4#!Q>Kvj7Tx2~|jii#kHrm#E`b3zh0Bi?{@RhVkUj3G%lqt$?U zCIy>B_gYwVNF@-%QdoIN(;7oUAx9OHTbO>x{2YljqkW6{HUP%1f`kz=fX1$YgxNO$ z$F5c!txEBwIA+Eq7LRc->dGWIgN~!0ie1Mnn%<-$gQheF-=uVT+|HqiP4CB*1!H|< zrtv3EwB$B%Zvo3WbDKpxp=4oMAG17vYT-WAwcRyN($AATZdMOk9y_(aXICHZv=FK9 z;_oK1Lg9c-_AQ2jr`>}i|H*36D&SBIAA|Ts<{uO=7=VTf?^5wyMeWDe_yjD9B)*~Y^Ep==ez$Dz z&#yNHvl+(9*`UnXxhUX|<+f}bB?a0I>%9hdjBFkyY*6*=B)xQ@oENBLwnxtORY?Yv zJiC0f?dsObzn(*qyOE2fEHVW~yL@zX>*6iwm))7Gq5Q+L0XXs$Tjh}}@Rk!aNoHBg z*^j>;>!g;Wnv3r=L~s`CHYkXh9i2Zl9lIKZo+(lN)v?Jq|WmZdXpt2v)P>a$a*OcS_ zg#W16i+Rc=j+0@i(zvMNWKI91^e0&q^IMDQCM{3}HIRS0fcR2_Nl{eEOqAtRasQ}* zUpaCW$?(lMGNntC@y%#5rS=oO%mAO>PNLK&wWFl?lSSqY>*#}NV!Dm(`b)yUz4;bIV5K7 z;*&q3uW2|~syuDExADpC7|eC7X{6=b6r9Q- z%_coV^7(#T%2RP}lYCsjlbpkqC$HvMVb&;hr{P#`Xj6Gy-Bb>+X_HqK)G%sPzf*71 z2uLk~DO@aci7u_i2bCYs1dLxv9iyL}*k9R7sapGJGM{JhEvJ zC><=@v8fU$7%We=X%{G=DVwz^6eyakKyp+1qUKmRa!j0BK3PeBY{0E)pfWor#cg1q z#yMxst#0s1?PzlA8K?v2d~ANU{@gDowCQXu$0^gZDQK<6DHpV<^EP5uB`qsC{;69E z&={MSu?c(rTCI^+rg@yJTMf{(o%3td@+(~^pPDB;4$`gZR3V&`U{@H&0ru;&E05zM z_KVlaGT|untJNto;WGBi*U4Am*!JtzDYcU?#2}j#Em85r@DAWyr7bZueKJH5OZ4th zOm%vv=sp90BU&>id zA4j`i)A>s~?avs(?b6@W4l#n;epf-i839n z+qPG6o=lzC%KaYO<(@R1xKjg&S0SGC{#YVWc>}E5+E-bR2CwArSOHO81B^+g6qs54 z#WbA4G`z^_8Kv}J#xzRp1xgo@I6@|TE&MtAk+}QDo$3R1rkTuecw#c7CQM3gpU2@i z|1deEu@#Mfx1g_Y&>3X1mCo85zqWv`FVq>SveRO({4qa%Y=K-~if_8WVVmVP-fAIS z-?`i!%T4$rZhX)Jv%Vbvz>MEV=pfrqsR`2wMIa)lXwvzh7&yw`XM64Fi4-tyd*$e5 z{~o&dck`(b&?dn5Z} zQ15cnOW_?h;UM5z>y>rm$HxSwCyJvf*~UV5dybf=PslY2%uZBz#0&DIAOXyU8QjJ) zn-{Lc_0RBuHvDK0Ub~GxC(rMH_eP}AF07$$45a}ZtHV<-c;rF@aC?4nY#UQ>2XAo@ zzJaQVs9XqFV$$CLOLV;vcv4R)MR=qZzkPrI-Jy(y1N@2HEthr%jMDYY#?s|Fr0f>O zsnHR4#YyjYOn-f4AvI0eH;v=)%~nlI;4OTDDk93WN;$xnh6aq3S!r-E5NgI&NOKnn zD406_a%~EWvv90qw__yy#PXc&1PQ0yYvJu^vxeR4kkSTd>elTzR{J=X4SB-OOx1OG zvIg(#kC?hGY9Z}t1cxo^BUQjc6KTKd z?eGPM((0R+5$;j+MkebuPYs%W7#Q0wQ#ZvMSUA=)+6lLf^3>W|L3H->{CZfja1XIJ zS!6bGj?w8YO#9}=q(1rmW|yc%bTaK`PrqfF!4A7VS{G0mxi?zv60yeOF=Fom(p+OO zm(nq>!qBp=EY{RIPkPw{c!{2;f@dNZwCYZ)*uRG4zt9_t|QnLp*WGdK=2UbnW> zIO;N2FK97V#$h{B52NnNTuVGfwKIw|VN_3Q$y8SEHMf4vsdBhRWE>zczN?4P@v9Ef z3b3tx)deIa?5RJ+y(;-L=INwSSIaW?GFVFKIcup`=FBe~TT|;vRFi-DYbJa3y;a7H zbEA%7V|y-*`t*yV*e-GU)QdXU)fHOim93g16&9LxHp4~N#X>B~W2V#ZYq&G=N}OGP^RQqf@p(*3$q^>^W`Z3efYKf}ey!L`qmIJfr1shYcW zTdcuCeAC$S-Wh_sRNHrh<)0?njg`x7XTn_bKb2+=tppZ+S`SVCY#=Op6>D-8w!;@3Zx@QO0tFld_@ij2j1MDssfoTX05r z>7XH6Jbm?VD_9?Pu;Z=ET5)#gY-?HXb+8_)&vMOLA9b*&U9vxIau;mtSnpA~O}kFJ zO}kGkh&YmND^ksqs+X@4svNKQbzB8e*~9S5riv!!oB5++5y{E0K)q5GqJqgt72(Nf zYyIa!q4OY29m0jt^L~P5gZ6&CZoO%}VZ9~2e!V%p@e^;q4Y-%Uoc`C-w4sKC<)Jgo zd%2eszZHOKUgMNk-;-Gp%+#*>qef9FdLIbC@zJ%2C-2)F`|=$ z$mDhq%A=7H^-tVb*$u{_nUlK@zn%|LAwN;CDdb*N_BoQ!d+j*X{Sp(xz{Cmrzak%*}qcmN<~#+ z$Cyrh1-2$MT2}x4SPL3Ag@2^!99a2AI8_YKmD^}+|46Zr(O3d`D7pfzx+@0JIP$iU z>rs7Rxb9R^|8;*zdxlEuheHv4gX4Js=Ff+y2Hvwi8%oHQ$Raq6+q>ihc`s#QG@I0i zc2lzRh=4Fh;rkOuQS&H;(Z6epXKFZcxCj1)mT@44SG^eP*(@uXpjk3QR4&t$TG+_l z{=#+skJk%5kIN?&Ch-~43ef9qx{OTVB>VJ-3 zvHrL26&L3}aFhY7l!K|GnYDu@tDKp+gR8Zx7qcv@x|@lsmy`Lw09S0R8jhL{)}K3> zlW}qVbIgC&z2g3l5&UQOii3|;LQYcM+?9-r_g{|t5BG|fjqTqC}2epw|U6e*}3`uEBA`$Uqb%7dnNfV4@Px!OGk4}x&M9l>L2mS zgzcYKu77)SuyFzafd6=t|4f{VwUeu(3jpxX)c-TjT>qT?f9YPa^YQZY{kwa`&Be{f z_g}hKzCOMhQcdS8Qz+!3kmwLSOJBw~P2kYukiwCu*o9)r$=uOuF(JYuGeU+XBn>Gr z3fV@ZkSHLLB*SG6$vDsxw-u)j? z3A?Ji&nh%4wW@VB+nS2eRMme-5Zn<7x69(@2tr8MB`cW`0-j`Aa#QN-iffb4Jbqru$BBB6Et*Wr|-JTp8I+E(zH z?6s(bcxE!nQpTAj{r(6MHMWih1N~XaLL2`LbpbE^qZkn8hNv&8s>bt@41zqWP30oS z*<930fa^tVu^}qX26x2alzu=rpOk%MEH>7`EAq!*^dysap?@3qRIT^>wAT zV(;PS!LJe%b9N^ex&Us8mad3JGruB`3Zt$}mKFO%iM5-il2S^jgUp!Owdr>N(kr2v zo~-t}Uub~HJ8m)ZEh7Gg=!y6dk$+~i#8rBkz6+1hv^m^-dgt>6;;Y`5p_KLL6}k)j zSITGMHr#y`ii7|kRpF3`n{L{|_K zZ*08fo)W{Su^7Z_#6@OhK7R}{kI2u3cMV)9tOgx^i?vj^v#=>ox?{HSgmd{=3{bFQ z(wxq1U1_bi>WD1;lYGp72&>B3(fbUG3}U*xWJ7lTQl?O9Dx{a4=O3y2qf*C`?F)|@ z@)xRz&Ziyxx-y5;5*d~+jr%$`amj-LM{RfNx=9=HG&6Rwr(?GjaBmQQR-DdO{GZ=$ zUydGTFF>2hT_30cH+AtWMdk|-mC@H_S@;T?UoGp8U!}nquOVIuskJt=t@(XpS*@{= z!(nUJ1!nToQoq)8h01Vb(D_--T(S1m_yu7;_+MeI5rgF+X);3<6 zQVd6glO4{S6-*u+4BA}JUF&O#7J=%$am3rJ%`vvC`{7Fq)w->ET#h)iT&;2`V_dFZ zG!Jp8S@3wf^K>xmc*)RWugo9Ua*Y)yGkv}X&oOMA*dEErjb4x|K*#lyHIfj;PTXm$ zd)xR=E5Hs{*Tot_f3SrubbYI*J!p{`&e|I4l4Lq7N@1`_U(gN%2#;pbF&-F-@gB{~UEy!tG0 zLUNi2&2?GPlLl%QF}Pt#-ES=S*EInzHVlSow67F&(W_WB7<7f)Ns5k;(9$=jb6Kha zdNP&Mzkesu0z5B;G6!-w`djS40{rlW4w3)FL=QoK{gM*M3hDI)<`LKXp&wQ+$O1-> z1}3ZEYdvlJfUS9^Go}y`Go#)i{7;y+Z!L!Jh}4mgC+3-ehVW4ih3A_@s<7}cRy*Ak z?0iQth(bO5Vbo3M*eQa~7tBCd(@8IO0X$M6dXkhvCWI8WF>2_wiE#|&DZ-IhYXA(EC|GPw!%p`lWQ8O)`P_zyixPbUXDohXUSU&W5w_V~yo`-DPs3&AD_%ErHi zgXX7)i^`Z-ZG^FKKMOvYjI{Ern8pr97!Xsi=e`0F{79N>ssw0CtGGC~&^e97o+k&c zdaABp^95C^ZhOe;JI(iyQJP4e!BCacK)<_raNc}3<~$w8d_jumslWDwLIx!+ymC`p zl!Kb_z7~5b<7A~1d<}cd7fc1w^R-MRc(G|6Ry~G1u_L7p-6+mn3z{q8(Lh7lry%sX zJC-R*Q5&*O6%246X++98O{#PeKd5|r<&jsG?_YxEvrI?^_W5OV4voMIlU#nQ+J-@G z3s;Ib0qQ6iF;3;kiNE)*;Xpqi$&gNgV2afCoSI3t7PmF=)pod1q-cA6?~t3mV4?m} zG^QR~15CYi<_of0AzfZXU1VK2H-~zutI!B0St3^*zI28ltSUvj!KQYKl^}yK?aJAX zbXPUZ!c&Hlb#M|_us4`nz_755IvweD?hAab+@I2_eb@R#;F4*W-9Sva3Ri}0{rRuP zz^0*|eNaE?CE@1>H|Va%j+g-=S5cWA)qNUs7|nuA1R&Hh_)L-5V}Z&9O!6#Wc(jda zD)?#86+RUdKE*u68tx7|+g1ddyJ4j@p7z(DzGTjmKjwFJ&{}Hv6FI$rIe~)5md)MwLkU74jdp&Lek>)8VQ| zT2Eos@_ap3(Or)7E6%5p-?|4S|8ajkF5UDD}TG&MsXV|5gVLC zG#|SqdUW-bwvH|LGX3!mg4Mfy%iS&tt!%z~>D0^gziqv~AGnSY)ScrV5pRGWWh?}H zm#*&6keS9c2KT3TS zyR;O_h^&g@^2rC01PbQVaGAr>g41l^7uZus!P)$vJ{PNnT>wvj2|eR{@0Y&EdvrF)RHh)aWvifA5XVmZW>6FcRZWG+kJ=li$(P=gf@icOmKJ z&3O4choR9PYuW=$4ol@g%@N&DHgy#L_91p&rj4vOb)UUr*Ht*uS7>N&4${+~2)j~{ z4&6(kv2SjTPtS=ACZOb_HpL^emwJ4aZNWt7S?l59(ul)||{!(FX2v z7d8MCh#C|^zCi)r*;{~q7iy@~|2PLmEkvR#P(91@$H|ZCImFq0y|}p5Ay|oO2CY$q zdCtW;jXhZ@Lx%o`7dNhIF5XlokBV}5C&HPfdi*$+hi#Z+27sRi5* zUX5c#Z~HK>j8f`w3UBTvXeL#SELpW%b_dAdVK!jjN&|^GL;6d(a3%u7_`6Gne4w0u zdS?{PuQjjJ)ZSLeGLQDwL}~V0wOHovH(YP8j91T%&Eja60nf{?vZI0TU$i?Gq_(=y z<8j~8w(4BETMKe!2v>(elf_(a7;b7>e z;r{R)&UDv04=opkdDE3>1FxKes-l%?4&xI zY_wj6vd?Ofq~ogW%A$?XDRj5Q&xa~9I4JY?t2YpEhei?}JhOE6Ec<7y<)VU0#BqD> z0SyUZ#93HwSiXU{XGF)({m*zif~Y$$+5Axqa|pvXY#sR|rB6tq%|YS2)3RFV98P7F z2ht7lqlF09db04-nGfu=&Bj8yA^tI*#D}2B1MxJES_9Kn3OmBnm_X(ODedpJluy#b z+-kW=DQ6U`G%{T&vsM!KM&q{Q%FEImCz0xa@8P*a*``nR$TO%jem}u(*}>McB^C4m zx|P(^Af%>`R5QVTyZQDqv2n|^N|f71f&5JAY6(4|E-3DfS@#p` zPo+05M}rhs=H=ooD3xXNW(00T=6J!;yd!aku=H#)DmP0Ro z^SAY@J`n3VPSm2^Q;8J#ilXVV?qt?|0zVdb;!FB%xQ8#qC8X?6Bhksh5L^w&!2)LV z+8a?1V;9&%J>|R4d;7K3M%0Uhr7pzt zzvzS9Aa5wTZOLv6)r`RS<042{WjtxA@$^KCB%hM@^+UhNV*>54eUX~0V7(_!eq_ox z01-x0$P*6WR;3z1XF=IQ8uT0ThN4}rHt;VQP^*tvPN<0!?)+RbG^TIXsG02{0Q-L5 zB_)M#Al`DqBPQRVE77ET^1Jj5xYzJAzQj?DL0$uvoH5>CAmlQ)?c=A#Qte+d-x~O> z##67^BeX1l$kEe6Ayhh~y*(|PwRI^*eFdPZK8aD?rs{tg&>{BJ`U zps&}vFOKyKQW~jE$RX#I+5?G+j*U=iny_xbe9w&1fKE#VusNrz&B6%AhW<4^m^w~ zz7|(@UxDIgwU}!KgBPv|y!eeLHlR;=nhMrX`K==()Sb3q zecsB_O&I&+Rujy~tXaq5415E^#*4X&L8~w)wX6%{PP;H5_0Wj=*>cF1xFuqjZZFt> zgT@y*!g9G&{UQNx$e?@mdndK^8TSx1zF#G2x~#YG+fU>Z531@8mW?(YkF!rwIOHlu#w zi&j|Cs-bQjz|VmiO+1=GYHh;)8>3(bsGsEgk-uPG;T~{pP#P3CDL zZ{BSAM$82SQ}hMICF6ROd(H@(IY1NXPXjDVp}Nxt(+f3I{RXYt&M07n@KQ)G?Bb?G z$J)8P^S(yp-8ZOvV&OUV1>Z&(Tk&YO;nF$i9Rn-7B7!%s$E!{@!FWF$HW;6HC}zaJ z`g2(d>J3(-3#}@_02p82FIj(K9Tynr!=_|^7*9C-39>0tGhPG))wJQBF<$zm_kcFP z2x*`B>2vLgO?`}K0HH_4wd&6m?-}yxQgufS--%m8=`8cXXZEQ|CMiQ63DXA zH=h2|9Cjxphy@s}2nST&AszpK@+P_UEV|d4wyD}{)t}YoI#$POX;eGqCCb}}vqHWn zh@o4>Pf#(c*)!rhVPB20qLw!*b47z=00?zy3=aS>t3pZQ%?z-t?^{erUZ?e$Vc)e; zT$AqV@ZJ;}_rphTrAS^O>>*uy0U*qG5P{6h^o;qj8bM*H4v1@BVBp35P2xr9PY__a zqMIaQ5woro54#(X-@#WD3olV3ihVDlxwQHdx+|9Iw3+2AYng*>>JL-R3VbaXSANaI zo3`oIjxFaOe4DXJ0GU`Gh@A-mVptuMwV;}L^*Qiqa10K<7=L1L<*>oLd0id&_ti@7iK!vgRY)(WP5)LlbI|C{ z2N%{@z@rbQ)rt_4586ZSuLxXrABZ!OFZ$v65WDZ-Jqp@R?6$D%df-{trI>VkALB33 zAG$PYK^rW?W;tQ`_n?)-B2FJ5wIR=Sr1`lncr0KdH-&%84V?S}g%M_dwBPH$S{C%a zr%G5IZ=!fW65 zQs2L47=2lib}M(p_2z@=gvm?35BxGR+bw8E&)1aeOiaycwnoTPMYykn`X)CXwK(I> zU&i7ICrbLd)rP(Ai94R-uw+S{!M()}%L76_MRpxuLft40%}Kv&dNA0BKDHPKBds6E z>~_JNB6K+3SJ|k!%7n>d%#T~&>e&DqE0eDHNS*rS)R#|h$eoXf`*4B)`#Dn#iG$CF zr|85hR!VuLCV-Hw8w~bl%CaTM>t*N!(mWlZg!}xO{Rj=HTnG#L=LXX2-0;q>7-z*I zJ|NOSYo|2nrc@iZ6)o~>d;}6|KG7=5kV7qU(3uKvbYnZ>R_cjx_=J2)WTUk~n0{r! zdK%#+eS|efh#w8N><+$se52knOlw>rz70X#qPOwpGOV)~PDQ%}bvIRbYm&b)U51Qk zLnr>7eW{wUki>eK=i_PKW@;o`-Zx;z?0GRU#4i+MH#mI<$_X&#Wd^%Y4k)1?`HZqK|5NkE6#K{JADl_jFS+X4K4_UH~5A(<0 zYAuXc&Ly{#Nuu5d9}*-#H9)KwvJVDf%{@t?&s(HDL#bxIK&nLl8_mbzD5yU`il^F5 zH{CvaeQ)fBr9gWP$c%)EGvRteTffo3c;-YtvTrBPF2@Yr7r^skW?7>Rv<3Vvrd~gl z53Hk?+%&)i7NMK%sq4LgJ)b4!3Q2UhJvos1bPin}^ByR4szC1=0CgP^+3|W~L%u)) zP`^?QaNf|ht~pyFU#hCVUqL|Xc}@bMihI@|&kfmn2ZP!D$_;aKM2{QN9qpWYoEDV# zAKH#E;(+6+M_qAQZI8N{w>R-aKCCyES*j6q$WecoRlHL_RKPhj3$hEHZJ_v3J`M(u zT<(*d%Fp!XnCK@kE5b83Vf*kPb7W17c;31(nQ*E^OzZ?}vJe?13hG#LjF{JfShj(k zMw!c-O(D9jq~7`nw!fIu-F1Vsp?2?^tb#&K<&rmudS)?Rn?(;G;c?>ZrL5(ZaV0YalOLxKha{f3_x1y&^7r);A@r1^I2D z)ZM!;GL-3I7V2#U-1ZZ!W^RI7a{~h)@hlFBod}0lD$n{>a_#)aER}jeHWI>5#DxtX zZK9Ck5y8VRlm5*^DVx}lH_vAaS-t5$Y;$-MbbM~Z!2ZlZil#Oi`sB2h395)JP5^AU zg$q6q+Si}~4kbL7tgzqmULkMmDQ`4T=B z&Q=AAJ$Sfk3PD7+1(B!)W_t#Dto<3V)w%_w7l^`g>Rn;yue8Twbtv+0Y?6ew?B}e} zb&eHHCHD}hVAWNmd?I#^6EAa9PdORWi#lqXzH`xT8l>`lL3p2K-4N~tBDo{T61?Oe znfIKF0;@fIuc&4P!G=)pFxeZ<5=SZ4J0;X zJT2GOnCagbdc|Qd7*-%Ih1h?@7N_IS%=1iZ3BruVn$l5zHEe))18&gV?wQX4LHQL{ z5sA&gJ-h{{H*BR8uW7{rFcZ6g;4YyT!{kPu_AYT0HUDwWA>vWrB4PIU@%nWU!Mt*- zygmBkd~R_7`VONN_NzH|r`*z`i^%Uc6+JZ`rH|t$gb`Pcg`5!-DMDeDi{zEKxx78B zpO{F+>>_1J_XSh-V(Y&?eEMFdjPb_=jUe&yHq)u@!y#X<9ub#|s%x^}Wk%0*8w^?B z8*4df;*|{Gzkr!uH1n&hAblYXUJ~3*}o+UjC@I6^bTi;yW>vNB@K!VRrShn zw@s;iMG0Fxgy9T2VNH8qR=i{{N=Pxx*D>rVhG<-bmT2niML%;;>zBd=$qnvq9@?oo zJsaZZ_5p9XEqj6~Ad7(Kk#Ov~=p{!c{l0M;%d;6Vl+raf=d@*zpUY$&^7iXn((~bx zMW5pd%t9(|&?ZC?~?HYjoaEkNaF!U%uIv7D{>7JesWSAwCf-AFWvZ{7>GV$U;Y^F^n!L{ zvrrHITgWFIjuXRa;e|S;vAcXPb_|x+6aw5B^I>)-h1OW3O?p7w@@Zb|g!3^PyTIMr zxn3vgBn@#6-jsdoEepBo=Gs!|%;AlnXOpXhS&kI3sgQ?8jvF;8$;H%4Th^OE;x zBzcaDQn^8&+Jfdn5_so?gg3LF>8f_ZWNb)JWid}KkMNG^Czx)j(;fP(Y%>L~u_1$g zzue1KtCXknw9)+A!~&+%RYbA@`r!e$9AE1|9$u9X6Rr5F5TS?T3{SCzFkOMtYA zLu%i9UQL(CUyA7ho^idhnz5M6bax>ep~HHf@%;jqH%U*NSLl5=l4~Y;+^J2Q^uJAi zvowwan)3!Ws&P-1SzN0|Pa%AGkd8Jf-wcaU2VQr*AHKb9pxNw*^%!xN^fjea5UQLz z9}|f4+?gG_0N0KWY`&mcZAP{GYU_;`6i|p{&c@ZxrT&fX(bs_&QhMXhDNrsAK&*TK zCzIPPVmdyjois*U#MLp^uX*>`P3Zpd#^)O`bS5wXxb}bKOuXXw!O}~}=72K@@vS~@ zT%jN?QLdU_aqXm`3w+C`rf&~;P&U1k(C~$*c{1wu;gYE=Q~V|t8&7*ThO5b5XtIXv zm5zDO6$tg`S+#09+2aWR8P#ehz4odj2-mJcTw-3NH-MjJQ}DWF`(hV=ivA@h$1@<> zP5J{h7O^WmJNiwkvAt`%y{4V?uSm#X&`(f_jb)_W{Z9c;GM)G#Mj+_>47cT z%n&_2`2hd!fEdIYuu))Ix1jVbzL~76Gc#D1{t|3^3{6B%OcQorO@Rfn1!h7EEA#_% zu8Umc6(cKGX2XhM0mrzeK3(gW_dl_A7qE>rUAm|P-C^i(hda#7++pSp$6;n>PKTM9 znVC5bGcz+YagdYB;1zGj(^|78zWr72umJEWa$6HhViW*ew#o0a2}$uc1>{ zO43l{PeSh$YFJkgo~et=ZhwTA&FpuddDTy>Aqt!^eW()nXS7=TZbmrBJXaR)B|dJ z4z6H;XTJ;fG4rTR9^9ff$q0Sb=JfdsQeVqOmBmM2oyN9c9xZs=&vUgAM+2HCIzikD3*KH=jMo14yon!| zG%nEX(Ck~+U1`d;T*&J4 zp}FSyFzj&AX5f7f-Ja!vkphL`nAm(^-@H7^QXM3`ykU2?OMk6-^Nh5IOguektxt<@ z6L{2mZ^y6m?ZC;x=Z1bEet1*dzQyULWcr`VahSe+@;XmH#AB9cpOMYI z2)&SX%6?e8y*;&Txb(rqjW@j7u(NHPoWpaLc-2wrsZ2HbLhf-%_xSR;Y_mx>!7@2o zH{m;xLN+=Z1KEVZiIR9&@1f;fU_XKH&1-|Exhr*Hr*F@rzn9)3OxRwvt!q5?VV%_! zkkuxb_efX;!4bpkaW!Ho}lc(0AMIGTRTe&clD&%N2Dn298O zj=Swza5~Vj>_ie@}B|Yt}4_XrV0LQk3>(9es(ypKuFnGE!h8OECbKa~S z*85GJs=3I4>lfOG!~#^B2PVOa-51X*Uwe63+6vHzla>+b?9XozVDAcrpAg0UA4a9< z`HUSP?titI(cF_lUgX`NdB+u=0E+k8vpD4yiV+)sK)nlwhKi1jnC>1$&aUZnE;}Gh zZR@AbeBL)S<6Tn|Pi3jTabmgrRE5|Buk&g05%OiOZ-e(Zn2viHoD=vY62``ydN;hY z-$j2i=n`skzaE2RM*JYm>m{y#C125iLIs7=M{ERC34OSw8eJ_f)A)$&8*%Tq!j#|) zv1p-hYZ1Pw3_TD^Gd%JuD-Kz2NJXW1XHOfsyGd>OJAOnl_p|dyY`HKkl`MVh;IrysR3ej6q z7AT8ga@wb+ig=D=Lh!KsPS0v@n%V;6@Z-)6^a%N^U~-20>B@L4CmI+BGO#f;@I+)m zSv`Cy7|18OiRixxG#M;>_X+ZqKe`mne(sjiVR+4bt6;j~^Uz+646B!1AvAA#pfXQt zTyw*AXnF{22rA(Ox=Ch0Q+5tlPb>gk7XrQHzwUvBJ9jf(Lb+6UUuD=_^9-7pmyJaU zr|wG>tk`rLdrx;~V!1IQZTlq3%>I6MpM?p3t$>PR-1PhC1o04#GB>=U(%TBlD{nzF z)i}1jl^!y{>L}(DM2Y^O2TB2NmG&6ifzJ4lpLp(YmMo4U?-{wRaw=zZDEDw@Q8F_> zvMH{9MT1*}e_#)IQ62OuAN3Qj6q?mbYsdoQyX12pu$26G31W&p!x>6)k+W63<=*%Z zX-_v{KRuvmSfZxwcUI5oF}u-kQYXxpv7lSAsv#3qAP0jIwV&2~Z&u->^E7deZu>k-?D_8~s<3CmYb0HQ%MkA8 z^BaK`PUKPh?37A;J(>Hz9_T%slzlue(&8(m3!Qefn6r4!8$WXRz*g^n6(m2(L*81t z#a(KM5=^E`>H`pU@fT)4@c3UrSmQ4ZRTROBu)UEkr%|fASi+g6(gfp;GPBVKAbHNqW)6^?r4z7Zzto1P00m z&@R7L2IVR%bl)>AHIfv+N~%Y;toU6TVxw)*L1o6CJIfDjmPmL9C9>Pzx}F%piMGB4 z&fkMRX%JojiS?w93_V1&8ODyM$j&em(6ZA^nX>TBqU0yZb9doBK2d)FBzkTgI=0?R zTqq1aZrDR*mCjN!n z4Bw2{jNyX)G?tKa1~!zy?+r%1#Z3=$LXRBnu5>@(~SE>vg7}| z&F-iES6d74bZ}lsux^koim%u+$1YPrN}0c1_q?wa#07{OsO+!}`VFTq0>p(thNzD= z_^U6Q@1q5|e0GPgif@q5qc0!9yOr99eF&V+_bUh+e+Galm_r|yBVIknk+tt}x6qbx zcU!l&u4M=IPiO+mWdjcU;FjS71yc^07)bB|A zzXXmbg@x#&*Ek@rPUkn@*Eip9c_)_-T|11O8ppjRF2;-x#x72(V-A`l1#wUGeU8Jk z$LXqCa8oZ?Wbdy>j;retZjY*4JBu7S5&CiK9!J<;MsfnsipCghNLl!wK^2?N#s_X z#Glbs`;p_>?H%A-ldDm0eRb0k*~QcJuGg7BD>w+5x!HK{nI)T~`|;u2UFp-fB5P5_ zJ7>5Y*KNPhQvVqnB{NkH8V?T{3Rv7O`*fZ3Yb+1ds$v-m3Ol|rV|M}nECqJkPjWPU zyx>0}xP+n3?;nc+HY{P^4soFxBAj*=IPr@d02z{(yUCS{_y=$Vw%ObiaHY8y+1Xum zur!PNE21k}_uAZ(LJMXWM=y*#hsGHgpSG*|*EedH^Dl2I=2?cW*@$D!7|6zocOOO3Nn z@Y~{Hx&%p3hJfmWd4|(%MxY%zd&pXIf$u#We$Kyoyy@Et--J27ZI{*bls9w+I;Mut zbZNNDg`v7IOq_pmOjp*pgDXrZ^cZpBHPmW#e>0{*9@_=^di0pDP%xA<&Z{-GVQn9Mn zX3GN5{_0wEcggvuN%CiBY+cYx7RpIs)i7gg#5VroCgNNmHmIPt54UgUzSS=XFTA7A zbI73YFFvmj8|&Y7YJL}=TJ0u&Mp%PnLN#3)n|_G=4=c7&q3qS7j)0^{V#o(1D%$=(N7r_DA6&G<3J04fcCbmZd% zXOic$08@OtxZ^%dxAF!*JgKfYv>uPZNPH|IB4m(Rw`q#WK}qPgSFE||QMM}>Xm-3V zD_Pa4?)DH8e6egi=CkGx5fpHEydo%>bfM1a3>$W_W~ukN)&e0CUYts0)LbZlEam}- ztFKNhda|+uM=kpKE3}D|BQv~X57F~1+{bPB{VScKFd}1T9EOxwPi4&4(!|2aTB5N3 z<3@}wOWJEzeWzdpcJFyXnTWZh5AP{46Z=$U$>Fq@UADk;%3HTZ*RWc^-;S}7hdjR6 z8D6omQ~qA~r^*z*@``%?Qy4vC6rTqX2_XT)@ql!CvR$26O@4gZG-pf6U|c^{L@>C#Mp`u3jPU6P-J#rJZwx$EbR$q z$Mr%Pi3Eg`9$-9;j$&Waz+h&*gWUE~p#!zhQZ3hk{z91kL71MjzXIsYB6%kviJ{Pu z7LMB$C`>$_7WRIPx=2JuczhT@Y+K1|?Cm#AL8+pUBx5rzaHkg#aQ!`QBndoZf5wjc z#cY!oi-fnW7}>cel-@OLBD=Go(Pbz0?!3;heuIp9IlhB0ske85cmS?08EW-%N2I z@O`5Byqop`l{~o~TLw(=z9I{X%qpc2OyeLWhE98C&Xm|zJyjyT@|M90P6{VO0$FiZ zE8S{8^5u-Pu&0kAbvQL7721^e+G%B$d5#AjUCe)azc+6rFsKXNDhb*-3Bfr5CW!>7 z7Gk5xje`nXl@4;+-TDiM+3)`Dej0IMT4n6C+OJb#1W2sYTU(7o?+ZHW50l!OO2;Bp z6hBX4$WH|ik#ptPfxGHw&z+#3?H9PA^}D3WIQsP5T_ESt%A_K@r0iMKUL~$T*DYkm zT|uW6LNF1z%sF1Jn_9K|U0totLCtwvs7@@Gi?s_gX)|L|-;Kwr8ZmJ$h^7P<82fRF z0z{_J*X`=X3_ubK%(11gecw^;Lvk+Oj~%gLpw!|)kO=)Q6J8F|xeOXBf;cYh1! z_l20O!L`^K!tGx)MBp^NwHWFoE;1e#re0ii^v-m(nBDfC-!SyB?QqTT#-7LU^sxN! z_OMeeml|oEK{{M4c7hem?0IXp)hy+4B&+E&@od?byqqJm}RFMHD3zH5H{4g?49tz%O*i2X^9UH(sV(vTcHI zdT#P;(sgom;(KCv9C{ji6nT(&l6eez4tj`s26;kyzIwWQta_w*rg<=V%zDmxD0-T9 zLU%HB{@FCq7S$%wrpY>f%~IdtQw;ljl&+tC&*B{Loy2Ve8TJ&IBt6AMh~ld`8 z%nUT%N?_)r`Mp_qeq_KIF;S>J#dU{7M4h^S#qidwO28Uyv8=M8=sC}mS7kdnzvxJp zU!^u|q5H8#i>l7$Abu1xMJFH|a3Hi1Ig)THuz`|kBdaCrDC;T9BnvA`C5t_2GRZor zJBc|dJjpXjB-_ZgkhVp$O|$E{t}(4KzB;kG$u-*6{hH#O^_cqT1s7qfN2sr-kE|Ek z&m3%uG0SFTHs+Vy&mgHMPaWnsxKguS)1uE)6QGBpKiZcVQGvEf&}e@KY)*elxW?XT z>hnhL;`*?88-A+V^$zZenIgzh6iE8p`F#NO7+@eZ{gnGX6rCnjk-AnjV4lMqrb4aV z=y^rW{ICkp#Yz@tAZ(N{E-_J>ti(~KzHm^UZj76y;`VX%TzB@iJYJV&c(}(sISl$y z)$ZB@!wptsdOF@)W&%bdt}%t+K$#(aI5T_43m#tc)evp7s^ZaiC?2o}KQFva-};kH z6QIc^&}wqZ!YJF;kwcMVFQD?R+?kV+h?05nxc!%PBiYFA{oD<+n`wtrW=-aKW?rU! zretPN=Ac$~h02u1NZOwC!h-qf`62FJ#x7uc{XOZPP)ohaODZo}i-Ab*;BDM3u~DmX z$-}!&>s3{|+l^{x@zbyaWyS5V;eN&Z)Nx?}TOr>w#!HyL+YNKYtw1?TYZnlgTS2xk zq1L8%9!DL$Z?Hd3Syhn~tHe?!vXEkInWX}!I$2i1rmS(PCL=R($+7rbXJleurH+|y z4E6?$yXR{Go^#tXD&G7@6yy*5Lez)&Y_a$TL5&MIZP>%78op6C9z8-b$6#%#fw3{) z_pRY4vI_jFYwIKqW_z21?y--Qcg9zvhvBn~Wu`h&qsOtz6ab@VN(RVK@}4%8ey-6{ z7|}ZS*5d2wW&DWP2sv1HLZ(Wfu|rqKPajBq(B}7zj<&}OO)U?u>l-)_+N%31cMZUW zH};#`lbw5p!j1C0Im%nfA{hYRp6C8W4ulq=b>mr2BO^&Ng>Pg0eTQ5{tF86qq}H5- z_D_da&MFWO7vv}b^!FqTPuOEKj86lc&y-dUkD&WsC+`|w&i#|i6H@!2W1?2gt$pKB zT~{ancNh@&-do=;QnbQcux(WqVM|;1n?ZoK@8|TM+q2baG`;=0cbCzT%y?GfOw0Up zjTF^ft)uEV)`J!u|3i}Dy1B4fJum?kj=m?6_x1}4hbB?A# zf9u;5R{FQ1Zj~+cBe2fzMLldfC`T|CzOGwb>$P04t-f4a#`I`+AnW|HQ`mQ4p4~80 z7)j<=yAfo<@JzPqm+Ns|4sQ5cEGo73*!p29KrlI9CUuP zukjW~Ikb7t%1X#61V{8MfF4Q*po+K<1g|5wG<2EDj{)av-8-;_c=e6dS9)9H%5n*g zHHbz>atWp}&{9WZ3866vS4Z>;HQir)h2ZR)XEoCqSepP!t1jCTZlD0{$`mM{Zy7yY z;3-!XS-4M3HQ3pAcOY*y-5KO7kUSYS-+!E*Ej$o>>vuBbLBMl1B!ZuRPu4G3P4JW{ zSbZJpF~p$m9Gjmw2%_CFHmEr8yE@!bKHqgHali+AZEd*Iv)2dws((FybCqsb6Vq*q-RWBndHy=a8@DvKZ-qauU)P z(QSLiH?YqMYebL3;5X54B6u;I!}>Rnt^%2{eB$V?BAIa(v4Wrhf7ouxbK)6CO0r`q z&EzoPb4VR$<;>*MIGMu(XQ8mf^Gs+{(?n*und!zwdFCYbO{m8a$$G_Y7}LRudQF)l z2Tt7zL1@Har+b-W&~t=M8PwAgryOM|)Ke34s-|n}acE@Yj!x^r+`yHD(&RX$3$3R@ z>yf3m(M%211C$HqO&rz1-~U$5q}HQXNS{u%*5g&kVowFuBUSY&x)?V4e)d(}NO@&! zZm~Vnc%^M_JJ0hrXyeL3&J#GQ;>wH8W1J*&R>~C9&5NJ&G3Sjt(k#U`=2nX0(wf!seEaN_vS&wlp{W{fIkAE%;Ipyo{1NV!C%5nIKD z))-Br7q-l00j&W;>l3ymy1v7XNL%N;?0)9i7*1nMwIuNXs?o1phq#Q@m{_5YUvqz^ zuN9$k$l@Zc6}EWb=mOA+T0Dez5j^K_AI82VZKo*pHH(eRN4buf$cH_aXw5Op=Q`$h zi`B|!I~IJ2^~&eN{0$MyZ$OVJ5E!d#z>di;5-V@OEKc%|!h)bgwmbYSR)jQG{0n0d zD2z2V;7s8+id8dUO%XhZ{l{eajUFp(P*Z7}6kV_fxWRMHq9z(0hN+G9_^li>zb9B5 zSWa>}Bx=C(oNTcr{zmh`YCVv4i{4IR-LG+*^-TAs;uW=dXz8M{5$Peo8Y^86`;`f= zxhRJ`9d@hPC`UXUeX04d@KFSo9M9RA)%jXPD-M>7LX;_pvY)H}(_QRvc*E+xR4B(FGg=1a%ajL@se_a-F%Kz>Mk5trD-bxg& zNYpIKoupfAX0G;hRfF*p{Ci?%j`6Yc9nO1ZW)a`jL%aVH9YV1G8>sh?_iogbkqgoX z6y2A{1oMJwlzo0h=7Of>pJGDQj$qxLc7@&!f7#u3W&8yDhPL7VxDEdF;{yV}XLDQs z3DN^BtCvp~-2);EXp!s>;s-?#4wV~B5cw@ui~zhv4vH}bJAe>&d5B+^97i}?3^l+m zXN??dpWjuUcaL8S)n8BE3pHp*9&E)ACfl$02W2?&G{T>6=R#i=*&m#Q->Y)se?qmg zF^;3|kUONRrr=&sIwY#5rQ9+$ve}Ml+%%eKFJ`ii>F!kAqBe>Uj|5+^J%zlBqK@(J zpgl`FI;da5d5e~rgFuN8{oz6^cN7C1)BZ9BO5`z`V7!Fel1?3(u*FH`@R-o81U)eM zx2QrfO@lf_x2TcVC6D8v5A>>i3p0>E`oS3kz)L*W!pxhR7KylT9}*o|v?+ zk9T0mK%Ka6Y)h?Wfx)E;M)k`n6j1()ikKHAt8BKMUeZ8VRbDw9&6q@0Tsbts$Z*NL zoSJHIq>lK3v#dLI8M~aYtWQDJ@Q#IZytKNs9II@?tU9$Ey=?rXy0sjyY|{HMN)3EL z&NHu5$#_ofK7(@*tAWKrYB6S9q2i+GT^eN)tby(u~fnVrKzQrX2q#8jC5?Fn z$3Fli*?ATCKZ5gpW0oRmu4C;+ya1Mm_^~?)+JJZkaluxDVCDjiK#8xLf-$;`nj%AD zm?ZYYYmDj#F|LNZUp=1JLrcvTm`_vT4a36|o(d<8w}wgr*#<0 zs>w&jg72$xXceY_?-O$f%OuPiP;m%O#Mt14dH;J)~>q!&$B!sf7j zZQz^M$1l1O$(zt}67Sf>Jx5o?jYQ`!{Ug)kmv`Lx-sMe=2O-`NpCS0NmQyV6v!>A~ z&Ue&5V!FZxxWZ^E5}1@iPKf|RXQCvW{58ry2+E1I33;ms+mr&rrQ`{c`^wXlQ6ptH z6j3*&KhexLl%ACo?;9=1v4`0V^_PWM6Rd}u44IZS`%Atl$)U*$E7`OBhF1`rqh}F_ zP$+4|W#pGuh;uYIC{3+2Dtk7oQ7_$4p_-#?5UEf+orh?UFDSD-QEXVp-JUp~IE-{0 zY=LcsZ3}CbY#wbMZPRupYolsaT$^8OSi8sBfg0TZ%c(09A2)c-X)EKWI7Om4Q=l0* zx({QULfb?&XV_wqv<>uq-mDuwS9I@ztXqprA|D?xXzJi>KUji%x!xbNNQ6?`=sCT} zjW$BbP#8#r3PK^8giXQdu0mQdvMzsB`hrzswEIF_Vt&B{`{hQHQzHu_$^St`=@*9n z4?XWXm7HoMRU-Y-rQ5 zMR&Q`=_bD77h(D~)Duy=Hrz7Z-);Ccp)dwQnxSYz!?t&tztM(BZqGEsdBG?3@7uVh z|J5EuvtjfCSLsWzfvF5H+bV6ws*E(>N^M53j5ytDZN{sN!rlsOM)E>o8<4h9d_(ub zW&@7f!mc4V^;Xzouc0>q&ut;skS2VXwyD{%Cw%Q~$mS^xi1z9n6%jv+;LQS4^U#Y)%mUQ&zO+oUpp`s?Vxp739>q*2 zeyzDy#at)8Zn;{;Y$yINxn9M5SUwQB{3i5Reu25VChSx4e5uzw^ zSgFCJIm0F}siB2A$0iV|Ax1gNCJ3ov2RY9spz6WsIsHet_1|cOFLNKKUQIC7BbBog zj=<|-Dn#&eSxjiuArE_=kBI8=Ji}SDWv4{`0sKra9c(x`Gp=m7!J_)|u1wNH2e)cm zaPXqYwwSq*WP9VTQMu7%fwR|e+$fU@%ans45X{lO@OpCoYH@H@P_|+?=%Rh{Kg?*5 z+IrBd(K?Kz1FL+`bs*W{Fnlbx6m5jgKzDoi>6eGB`!KIX{N%gIO;8z$*QX z9JQ=gslU`rsjgImr94%crmRtEu+)6M?nDE=JW!eFRJyEYzR%38!?44Wr7l&2rabY> zw`!OlFf(tcThYKRk5nczE@m3Tya=}u;mk{)mMPt@ziL1Vm;)t|2e^foO?3Q8lG-cz36h@`5*Uio&`MPs3x){(+4518;0IS-d2~5k4x_7o)2EHKAuy& z2KbFpOQPp=XZj*Md7abUCML|aQLAJEypXRwzR~Lh?%{B|Wcq;&v&n{ItOq~sBU}eO z^nGilR1K(EHy9FsN=7(GJcd6;7!KI!2h_|wb*ma$FtcJF4;bl()yyv$d6-$SIhi1u zr>7f6#@3BbGxp=qQKg~m5!%OUTF4|ajUm|;awyQ!&+ZA>=UNyd#8r%M*Cw^dJS2MV zH66HpEwAT479s5tH~{W?Usp`~)QA4k5wioS^O%Y_{FJ&q5qsaazH~aa6j(!S`^Ivs z;r;S#Cn>6w!T_Tc0sz#1W^1#T)0_`saw}iid_1b8B;- zvtL}HkUKfg9j;oh%7A+3d1)<1=i$)y-1chn(dc4)p5m0BQ@QF!iyW#OnM4YM`AOL857kA+DOr0uz%G+dRNg8SqD?tB5*M zk|I*va6nuzeF~2WS$5n7ldv2-#hh)7yhh`rJBr_xNz zo&ah9{}bOLk3vFPrLXC%%#X>wzX<0Yph%`wq{O6+y08O0^)P}OKd8FzLyi5!pcY#g zO6A8-%dk+}SOQ?B>qOzlO%hqgO|`!Mml7$d$*_gK*sPnC+6?4*Z3u+qw)=hRG9w~& zNW*O(>T^O}EaQvWuj z82$mc{=<;^uQL!cv~V(XFxA)nx67qsYQ=A5Z~9+O*MG;k82+b;S?K>g@qb!eEcE|r zak2dK!4>HKZE>+OG5j}+>+6aCgT?jbWBsCGqG$WXz{pJhi-Dez^}kzOEdTIh3UvRc z#U-d~VX9|m`v2bIqW==OjQ(YDG5*WqV*2Ot|7vml>)rpa78fH6BNP39SzN3P?5tli z+dJ49>RQ6Mq@Q&_e_31$yqb=e=RD%hMYGGC;?6V6&b2YW#Zsfc#s2v9_cz$L-px{%O(B4v`;V+3iO@_noM|V&6(x6=LB;p^bwKXhw`fMNxcoyTjvuq6r)B+wXNVtaa z?08&5+a--+&!fNgPWQBKKupPG<1mI+_qwi&4hh$hk%{>v?)vzHKOsMlGu?e@0b;)= z?!`DzCzo-$#y{1%+9%yAA6MO4?{fOeH?XNYBG`WGjLsi9S&UICE#ziTeQEae?#4(A z(kP)1I97)d>?qvM8SOY)c8nEO?j+iVtn5f$o+<6_Wk=j!@+*717h_L#5Z#C2_eAWN z&UTdTG`iN(=H4`haQ9@E;M$zP?JoY1INd|vnBUNINJU1A{sBz*Ei{Y#)jBBU)I`9w z5P)rhY-d>9Y}K-~#pI+xGi4M|vuifh4MLMo3Qc%?Q&SZ>`cqhHU# zo4rmRK$z_x+6C}h!A^};n1zbJ+j~oN#;ipmxqx(~D5p4J7|^bljQBn~-zBbB6?*eu zkUnP9T_}?Da35+7J;uB;G~yFO4imgND4Q6}X%%?s2#jV+>2~ zvF$0{P%BgwcV&s5u-tGyGVf&B$VKZ{Ic@>V#W z#1GSqOQT>7vV~)F6?!=UEXRvu=9LnNC<9+dMgtQNa1|7|n~UkmxTu&|I4Gvmwj$x` zR?B

xNCHPEplziR(r*)(=c&t;|(oJMu&fYwDdL7>&Le)Hk?CE3G$4wG~Oz92{%R z1jX9FCfr@ec5cMZ9Z--IsHJ35Ru#SPOH>tw$yk{LwinH;?wImbjNF%&6PJbP5bx~T z1{G1-c@-38G`g0qlr2tdD$Og0yl_$QI<(@rAA{`_={7TO2?s5*7CPQPz{w%@4-&~>Pg0+?#8vNvoWrEiwOW$#yT1z zlSb!<@RITK8@RQ4$;y?6(^@BY5%<)lS{_T_CgJoR$9c-eNa+I{9c3Bz<}1ibrDUxu zPg^H}i|ggF?CZhUl--?pqcs$Y8jAb;-%|ln7q)hS6%72z4MXRos4<~jPjf*gS@$6+ z6|(DngZg?&%d-(^+y3RgN((d%fJD(#SQO_^jzb5o#SX6BDb+Yg%i>TjeXTp{r_M z)v2ZOjokGGJl5Qd`tg|yv&oWDgUkJrm3if1?dbV^OB1i~z1Pi4@~h^^gE+96-ixHMQV1#H04dv+a&{Z*1{CDOH%-|*n1PVDi8s6O!b)B0S=~e5AZ3l9Y8$ zy?ClllFZMyF{+k&=NXa)Z19s>g1pC!Cws4(c^A_M0n^Mj@XE%`LQSQ|F=nl79HpE4ShmY`qem6yYyFguW@yNW zB0sPyWGho@+Suzs1naOi#x;0=r!}sn&f`Zn))_5jz{Ays_EO7_AId>ZMh~>s`t~J~ zo{QJxX#nh{JOOdsiZr>H1n5o0>Ft6fHixI9{fM|U{5k^CP7eY5BB25#OEenXkmiNA zH5z&id4kZn`ZPIz@iI`7(s}n}j-5ia zBW#Ckw@+o_m8Qn}CWGoARyq^;@#O^>5!ayXRo9GGgNN+&L_p34x1)s~d2$@BzCmwW z%z#nmt^hKk`L+b(x9xFbS6fr%G{_a@p>mOA&c^~HjL8)6q+;Spd0i({3~2MjbOUJC zrnFjlf)9q}*zv%8+Tp30UTrODKp|Rpo#i=tyS$wbWdn9Lu4bw3rJr}wwvyU(ks1p; zz@Xikha1-2w1E_ywh5AKe$40g@KGarmj;~;J0+^x#4&xZ1!Cdm*AYM_b!xwB(p}!8 zTUNO+Ng82{KID|)C#&i5JYSxtgotusjki^GYKM6e!_ym^jVGqRdy1i3DZP1iHUd?n z(#@%=?r`642{9f_!9s3+zhf6-5 z?EqobU<3NU#~ski3c3<@@?KtADSuR>&!nMZ=Gy&odkpQow9<)#Led#S3QFEyQigAq ztUBuZHFz1cF)%FQVHZyGlZU3tXNWa+UBfhIbLc9Aecq-53`G&e_w!vO+zS>Zl8vyXMa1SVI`<2U6d*=arIy!4Mkk7E_RlO!$`YfxdX%znsZkph=OUN#Is7kg9tiF z=T^mdONxszXzY_ZvT$ky7K})3%IWm!00y3luUK5$Cv$$w+`1wTlMDCTE!-!DL^Lfz zBIm6M9A;{cDT&}L%#&gv+Lf^s6=te3qZh@#KH!JAy%@K6VM<1%$i8i=42(GK_yxW6 z-{nr*RNsTEgVhaA4stdMY9V|bH@70A&@r(LU0Y;xHYRZru^CLD$1K}|h3oOj%K)~w zyX|%F@H}mvjB+@-zSzbRnm*Y@3Qx1m0*m5_3Ja<#yivznF%2U#F*Qv;%k~AGHJ{DF z(4Qi*b+nye1o$x9WCc5gQYYRH)7>}>MA-@Ph1Fg9r{n|xJQXa7RqX_Z2A(YLm>ten z>6+9Lp~5=}aoawlJ z@XBo$XMn_Rt1_mEKfv;j;KcR1KQb+1Sjs0S9nvQ@gJY#45(MOkY^duOQ&Gob~Xk7gSw_FH7BEx(%aqU|QCPHGIR+p_`K&iMFGUxdkM&?b9eV{~f znszL+YAp(ljGD@bSc{TC7nv@$7sjz-19$QWQF_BkjXi|^cqB8d{4|(4hT|96M-)Kr zkPm7KCC$SllKJ42Id@E7Ic0BHv^A^R6*9&lB0Y66#u%TPleX7FuR_VzKDTVjsD)>joEb|-nkVvKL4PXc~B$3?HY-vy215t&Wq1y59vKzO#WG#nkx-&$J>PRFr# zGB9vh{GR%?b=KU+&(Ckf>NRHZmYj0Pk|sYgHV~C!ijld9(f=nybxtQF9BLv2C%(eD zh_yLQAnlK5Qc{pe{L<~T+&16s*>j`TuofdV8Q*5+W)WLEvyhPj%>Bxw6H?myPCits zEW>`u%e3)N;F>(rHA3E89GymbjqXG5@vOxIOpXnLKJz(ek+d=8xGC3IMll zG-~ZI2JXNglz&Q7@~1tGt`v(dK%sMTkD%p**EPNHmR=G8kdOK*Mh z$?t`bh$=WU^b#r5vGC)cGU9d{cezs+yc=^NJgbr7`KKDQbvQW_#}{h=3R+|}WRCK; z@*TWTtltn#3o;G8!9~EL3ah9ZAx$KI~@@#x;FNLqx5!(cv3lMPcf)5 zGajy{#4A#qH#m*79CJP1!&eg%9}FHJuKY`&1A}i$Fr=~Cs(5fs!cFRy4RW|O}9M*W?hcID-&kR6j8gS8W`bq82X zHCwPgz&14}yEaEk`Y#P}oG5tChvdm)1=)J_nHA-U%RTHe2f&dUrJmI-@%SI>WRV0% zHs<+Ej^+0}^gLWCG+zV-wKwdai%sL%(FfpoI7FkM_NTm>Z$DO)C9E)=Jk$QB8$mTCil~RG^=&ha|KVA0wYE5DIpv?C zSOs#&2OTd1bGf;$d)`s9{NDvOqix1Rfh<%Ai*9V1bvrf{D!?d7R#tZ$!#YUCR%oT< zxWUzdwh6P;ak5m(%^~)(Ic2wrIlaZegJqok+|mkwIeLS2$=|lnNGY42OxTNaa}}mT za%4F!K_Q(@4>c8Nh8R4xG7m67p4<7o3{^EY>oj7~^o$r{Mzll?+Agu1ntano*K)Lr z&~%t(y-;!}fjpRGBba0b7>hA#q{EL<3;_j&^1l#Bg$%=>}5b5u_S}L zfMudglJAXRrbi)Q5-=wG{0~=mv?n)e+LGDO&=O~4%MHMfpMR>Nage0b8_O8_>y@{M zWQ?TDX{HIo+GF`!X18ANg9+l|U}5i#^@raC#>-4OLQ7e|+4`>D+H-sCT4)B)&Ji%( zEP+Ex@M?x-m)g}79#hG&x=dye-!c8ALVJ`LY^{32N6CmcJBsX_0@@7Mt_*>M{(f7o zexyk%WJ;5`yl$?R3XVO^0xIQf3npQ;BK;!n`nW058l>srLzJ>*R^`QiogVqytWT7q z9T>wkU+7!cakTT7C1Z^Egi||J)#p{@G2SN*?To5T34p*I%fe4MqY6w`7PzD_UKHEZ zFqmC5fEC6(Ppsx?X=4Pf?@~^g$~i1$I60CG!*2P68x3{8Yh}qbOHMK>+{2a4ymV&@ z-)ZVXc~(rQz5n6E%KM? z)IsMzt8qd3Gyx41Fy+!J;ks4THJk5`^?Fs7V-F83N~=bQu+mjSr^}1%B@PQB;WNWi z$)aAMVL?=!dVeklh#DUo#Lnj!&{@;TqTkD{K~6vaLN>usn($C~43HVn~6i5p$0B|nNX=qYZGm-lIg4)jHAGV6~m*h4F^2}*H z&46+m4gvj}J|T$&h@?i{hS*O4TGV6bcu(0JXNXA}eFIFp>Dprlh*Cgxf)v2$5Uoj}SOKA4v( zr0HbIeqmeceMD3-TFW4)5xxXk)T5E7UQ?t-l$eH%to2Yxd0$_rOj;^c%IBlNcuMDl zBFEhh?yU^Tt_UOsi4hda>iq#Xhu5UI=6^1Nv$8n(@ZtP@zwrc8XZOIuJDj4BPdWGN zQZnS^BD_p%V=E01hhk8DWGK$68cJGuMbl=zA3cNiYE09fLvyP2oa+cIOe7JDjYrmQ z^3rQ$S}}zRfq{2Y^{1APiL8YOU@P-Ei7V}wYNX+=J{U!1G9?Q$i+|wfXSCj{!jE0R zHPwI?BXB1jG-&o}v@km#%!EV7tvzP69E$@!7CmxUzY&Wc%nB3b#mOE<-h?+s=;gd0 zbEBsjrH_4O_iEnajENNI|D?CCzjR;0TWPsCD<}x z)AzCv{AJU!sAEvEuW_D>Rks$_iimfAP_Pi;En3>_2DQ0FLGdpI8nVFg6W5>)GGc&{(Gcz;A%*+gD z{oTFVxBIZt?x-~Cu4zrb)Qq~TOaGr9J0_{FwXvIF@TM0>1=kOC*)YfE-pX;^II`=IhgIz_#W^B9*pc^Q)>sE@=;VPY@3V7EZLg39K-VYL6C z_+p)$W?F7(0V%_00EsJ`X~4QwG&Oj^RiNc&#`#j~lC6fQdg95Cob1==@%+-p599y4_&-)gn5Mm)}=k2X8~m9dQK{$<#<)yvl?G#mYU= zLkTLCB4+TqjC6nG5(vp2BBIS1VFe*2h5j1Lh>8JOrgG1gU#`p;P`LhN<)8cHfPH-DP^CA1f`o zR_&`UP5($rHhZd;OFQeUaYVy6a0S|%D)}n@QrozyiGbml_T(Bm(0sy|qm4g;Shf!{ z`<1+qX&wpCfB!ks-<|9;LK>?aXVO+5_+cg9-iMaWOWtPcR>M5=Yfv0Cw2D+vQ%z@I zHr{B)IUg;b+0ZoTw^-pR_u`f8;<88VxLjvjhquHT9X*Mk%~#5{4(Y+Jbt&3Y5jo8A-3H`B;k!QzV`+oTEN9jRCI>keJz4crHh7IUMOfGFT zvp08^&cIaHnqR^uVckhy^4Nv8j#}NxH2u|9Uv*uGi4Bh0VB08e3~^)a ztQffS?oXB*wr)%n%+gQp1=|d56&62Tzw;)1sxNa*e>gR#^q&=uLaSPu>NquhdmRlxBbR-PA5(p&iBT?xosC+^j;+|c`fO3HvJK9+2GrBJ{91)nH#G*EiTePd8C5H zlS`Y!ww5N;+l=FM!PT|juNbR3Ng73_7}bH5nu}c$`r}vlEw6h`mD)G&hSgRw#Yhmh zD7A=J>XN6f_#|PaD5e2D*xTO9mMN=4<~C*4@8l(5@y~t;$4YtGaJkhWy_(sAtEE}_ zSL9_pBR-0mX~Tr6r0*HFZr;|Jwn$}@a#@{h-X`hYESJ80lIr35txB^QQG;R2;udQ5 z+h`78(L@ekX&GBbUt`-ia#b&L&&+jGHvGOiDbx#lVUJ~7=5qNzqmuP2Sowpl$RBgF z43VqFAB^5Io9_!{bL)Y!70%^Xi@Rq{Y~IY zXSK^6qeR3>IbjvYx|DHW49fqV>1yzseSy2PY$g>bR*qB9MUmf&u^wZY(X;Oq;vJcg zfT*&LfNzE=hiEZx3LC;_C%WjEAvkIdAN$qA)5S#PtEwQvaS^7LLI108Y6>lCJizqi z4~OxixfS2k>Pl6-z{e8 zRE*cH}@CH)tX7I*C8lq_P$&#@xZmlD>vmxT2J3q4uuV;jD$0eh01|r|+ z#(%1$eJR4zp3fb`rsLua%-t03mHMqa7oq!^=;3#>W!oG@rV-#Z%T3hv|32t(@u8Ne2W!(NBpDaF%1uczR?NabO9y!|=|wR3!pS*Yb|MKd9iO3I|)}3V!alz#Wt&3BNYmhyEqtz0MDw1ThC_UgtkPQo?`(_S@UbF0Ci z^Ge_xH>|^>Y{EE-;Yu$=Rp@d2qQ&GU6_I4hQU4*^o!N%)o-92mEcxSu**Aoxvxn*T zL4LgT6|qdBSOl;T3p3hW(NUTsnjRK4I{)G~ZGFMukCG@VH=Su+6bg`YX?ek7lxOw1qF%@~=6=2IODUe2iR`R$M&D9VW={pq-7 z6E@+*^V~#ozw>9TmnFq8>H^}Pg*h)%jH|WGW2EB|C*v8ltm?&OVc$pe*s3i^5bNrZ zWavBGvgobvfpPUBXtboo;b!Vx2;E`L6O8zo`*gC+V-dA_nI`BMCd{?i*l60m8eVUb zKgH6xkyI*5#rws^>!cZj)XNZF!Z*YaW6 z?U6}=;Cu{;l@?>bdM@FITFz!`J)J@fiN3JY^iLa|1XL+3g=*>YD*D(M<{XWS3=JH) zfEm|A3C^;{NRM^Fs_Pzp_bGYtl(+gs7ZpBYMsPnR%^hl?n<9o~ zrQgRPozpn3v+(F%WVJlwsyd5aD*B3lM}M`XrxHJa6TG>e#eQV^?KdYj)@-U7hM8suwNtUP2s^<~Rv(`6zzB9Uz^RQi4lu}w2;^p^y zdwDT(p;@H*TR~8aqh4(@Ic24#lPzoqj2_Zm8EL8*DD6*uB9=fLR6kUu0m9C`1hoi0r=m=OzS{FuEk5d zk)CmRXF4N6$=QZs8io1-j&HEibH;%jb|*?EzYYOT@n^--h_7~kYy^=seEwD6JBoZD zXoO_B)gpMDHLJE!G(@J5-p45Ql`nU9ag8us`fEq?uS|{E_Gzp~je2UF7T2@uR6MXo zGz~^J6W)0XB_b=%y5pand>_g3cB#$tDF+PaFJ=6U=GcR4;$}GgM*X`QCPXrn+;Td= z7>V_mUfa~FR)QZ`a8CW4MK~_&_yZ(3i&GdNtASOH$M`~8!?jY7ZlQ-@w;39*EBWy9 zb1uSCl1L%aY$m8ijz^jl*XHTbYVC&{Ho?w!Zxn8qw1=?{QqeL#6^nPFQ6o&Ru3%Ze8g;^X zW>5RM^vdFkUbK=0vWzNGVMQHf3<6c2Y7xO=4y`Rrx544OZ;_#jYjeMeg`5xTQj2^G z7V<014mV+Se|T#B5Ep0RRO+yQACHQ90U#ihAq-CQ(DYuTS>$uqIi7@;*F|fRYCosY zsp;WQ1Rb`0$Ax+qJV#W-msgFLuY%ED*rfR37tBeFY+kSlMTKI%d0PnVDjQZX%SbN+ znB1o9>t)g$W`R{>c2$Hjr|BjTvOKlc!LF`H!IU z`gEP&hph(q!t8>+uj&5A=FEJ|bB*cpU! z-aS)>d#d+J?}O0NvSnTLN#hq_`_YOg0Sjb%0iS=#0V$OGk&+c@c?X!~Q~dHwkK@$R zHCW;uiAD+8ZJTUf-!O>qbw=84M_^V#k%>Tlx6v)l`bf$&OJ*~%s$P;(l7^BZtRTnL z;L`DE96)_sfmR|>lZh1VCK2-XBpbItc^XXIBWq`!53Ny+8Xd&dh)?*b3Su;aF1Lv& z94ALVoG2uv!=@k zFEs|n4q)ti*$KyUgIARHneu_B)2Z@-pxDZ}P3UZX-h$X6N;2jjsN)1SUjVNR$H-&y zkO}PzUkAE|?HP+a5`6Z2t&E_#{Q`MOy9l91mDX`rAt{mPbh+R3d|RXjgUD`TdBIV zO@&WrdpfJ_5Vdg`D7HScJprh>95j)gh;kjOHT(G*Ab0;p{@HTa_pIlxE z5wp;sHe-FI5`WzOhZ^G{#JaBYf*j6P9zgQ;B+T{agUmrtzU%4w*Ts6VQnZBh?_BwE zoNfofW58XZ7dTe2lVC2Q~yemW!r6k!deod z;->T8F^*G;7ZXVyTp%!qoC}g`coVx3+lylglRrk?M>>Ia721i%a+X$kYur+EL|}o( zgNypYMJ|jY{k>YOMX;aj{8~7-aK~T7Ufn5NKs7Nn5zmZ(w=jUvtt1By){rCp-hnkm z49uHeM7gCMXivRoK1>uHHPabSkSSv7@FHf63}Olr@XS+okB1Q>!e1nn{W>C{=XAJ5 z$EGIMFlvCg76Rm60OP_+0O0mghZAl^)5TCA5R=^~V?{S2>U6J}G6f0Pf;e)4W5Q&y zeST}hJ1(^(&!;2Fqv!on!c=faB_HOFSe{sMP#loELD<91(D4zGL~)~A4WnKjPHKEesO$F~#vEs`Qi7r_>c zIwX|6qj_WtV80Nd6a}93J*8I~A+g95B99H5zUM%U2G!Y(CU*mK1IOb6D64RxtiH(1 zZUPgc91M@A{)z~_ z;lk{gJjfs9z4cXLgAo4NJt6m$Jmeiv6RgylNSPVTF`M6$>&&W6H$_&ze_y*GR;cpA zugw_!-79HcLUOi5Cf^nF<;bKiZIYb*2m{P7&0IXUHAS6&_KUuJ{?!rn9VhB<#7DB{ zir{Qf|F-}|wxVxoyY3jx9BFFOm|Ihe5I!rF>(Pc=<8)R#Enc3&Kc;Z{|q0-Meu9J+hsuXSFS z>w^8NCQQ)5*o?68gsfc`WMZC3Gq*$;bDr(fE^rwcUhUI!;md~Y1aSXEFa~^4iMelh z|B+U21bgWUg)M`o`qG$vQrH&xujI!|yn)p-(G+v8yWX9+F60@AMWhS7gKPY$J0^iA8qQV0Fm6IGfy)E!6b6#T_mfno&u;)FfZK^L+E$y}G}i5DtGP3NhJo4{ zuySClk7puSQ2$p8Gw%LHP&Cx+?Gdmk|-4vLv7P7Y7e;;p^8`l@p zR*(F?F6s}3Z?ADeIVhp=Z`YT1Vf7gDx>@n+nOc1KCG2?Wb>&%*`>zucG9S04_!XpC zY-@wxc{Gbl5(zQ?<|Dg zw|H<)?+0}QsOmo8w(Ll_3HIupGH(PgEpSRAmJ!eQj(!>}kG<;P2D5EKFpOBgYnD== z+z5xMlOjAovddJk1&?#!0cu2i4~O0j0Pev?6;0RpEppL`y&Q$PUCjw`fu6Lg%_J^Y z8f-ExP)pTh+)&(>tcsYC2GfhsNJqNmt7G2ZsJ$SxN>{!{wuuut@!LMQ_y6Oy{FS~I z>5P4$9q23^N9E1DlSN-xk_f- zuRb}-Iux58ez+H-dPW5-|DW&MKsE{wWty4}y&14>S zEBCOF9_nmke`Q8syG7u7?YW?5vw@g@I2J?n$K}eusE?SW)O1LIBR|c;L zQUA0bU*%P=No?DqGhzAmR!pG}ejeBEx|+{*?~Zr6@sC6gwY46=CINb<+bgx-?4nIN9zmK)5IX2k#=Odl_l-rVQxB8r{YR{ zhW=qV)76UVL~~V9Cl(Q#lGjS!^whk>8OW^$c?qkdxUEB0LN7wwB)iv1x5S+-8Y#{i*|WVNcDEbR?pFqc5gB#AxKv!&t5oxgpUmk0^chPoD1?1Jj-v_E zKAT5+X3fPpmb}6^B8PSkdQ%!-@GyS+z&IV^|9+v^Z*^Tbn1*WL1pDz`Q|rVZuKtj4 zJS%rKmXnY!0)0jy>QDGXylFTqY$aj~HNtQWV2b64r-d*MEznJ5h|YqDS7Qw0TK`LY zB()p0`lqmfc*6d(XRK6SFhK(JT4`bq+3vN_ZlK`jU}_eaw35!-tlAuYqZ(S5$=4$N ztJnSP%Xbzb$(c(uv4%vxV*uL^?^H^e$6m<~`C>!;$wVQMj~w(8Um!s@@~n+1hTqQy z$O&+=PtlqT>w73t!eF9G8LYduTyLTEzc~@-BI5)y1)$!2=__!|?djAMu54ZbjwSVk zoMRG}AP9FD5BqjldcB}quX~JAqB&hnw7U1-cek$|k8cK^^Q*wsKJ z!{5}+5O`uo4E`MmX~SpQkE|nVu7%2A{yGRhVKKhwr%NQ(I?vz%NZZNzsUYTTkz(5I zLL8F|oGr<%FW7*+FaQVz-Ihemeg@a%oGZ0(Rxb?hBX_b5EHSj*c*Fo z2EUG*vrc#|jF36~+>_|16EUg64obd@t4O8?ceU@gBbF@Q#J0KY6y)3;k9q&ji ztSThv6cr`nlzCwY*C^j&Ady^e3u0nJVKwItW?Q;M1-)@VAEc5wDl^O3BKL^A`v;II z{|XoNj3ksX zkLA>zvJ|+Liph44xv;=%I1J7EI5(tm6b$x7B%ZiDJU_UcSk#H~Az9d;g?)+sEEiT+&+yF&CE2U7*8I3{%a5h9oiYbJgQ}LIbxJxMXURDJdc}#Bmbg_ z|IV!4_gksH9HvDoBfAH3+YbO(w-s$1yUnn9x7J=pvNDFVtOsT@Eym`m;)UoRnEM~Y zL*yS4_0dK@qoxN_pHA#mA*MxOrT+N)ZS-e3eL*78Y<=UT^!zg|TBs9ZKE@(>dYXL+bnS-+wa<##Tqrf0+X zngird+2Q3q(?0WC@bi7bYIhM_+!Pz|oe%Igx`jRgcc>hLrR}Mhp2fx?eMW4}&|P)E zbQlrG9&P}L$zUtp%#ETkk%cc**7_!?c^_X-YG8?@9! zuLWjOLA*BJf`GZmX=95SP=}dRcQ!dZH1IqWT1%HvhI;|dLfw;)P*YIL1Wk`fTXZ{T z>yH+#zJmb_i!w|P0*%ZXTP*>i25QENKJeGmeZ#uaDh=<#5<=zY`v-L$+F*+pbYF+K zb7JW}nFwDbAybY;U9&=oi6K5@A-(lSvGzTc$MLkPNeLo>oG5k{zbuh6tY38w3E^6p ziYkW(P<4=h1<=x#Eb`^qgImD@t6*HWse4@0u(ZSP9$bq_cGexf%sBTiIXkS-qZNNd zrx-jZiTWW+KG$)PAL_G_{l!?_a9D9;YHN%lXYXvQBgV%wo)+H@j=Bs+jUkS|I@jNz zb`F(wWj7qx^wnD!>s#2UE$T1RUwljU{Rm(!eJ%ArI$;i@&?vXbO0Hylr&;u{x+3N7 z8LZtt)$}dp6AtiG#6SG+}#+$UhgZ(q3WOLidlg zegb|ZEgCI2OK7Rb)f959#Zjx;N(HUInu)F4i|2ZF*())h@y9vC9BODH_cCM>x3QhO zmqAWE=|tTG2XPC+3YIO&ku2gQ@pPTJc*bVW}YS27A92LnG_Vk?Ia{jBRL@Jshy`9!90xO8OZ|VcRxl6S*R-*m~Q7OC8ofOppb~A7?z& zU(-Xs=C7wE6(a>TkmaB!@$i{uo?zSM_~MX|;NX?le2QrwjF%F1Ay5-%He8#gs3+lq zzOCW|AXS3In+~bb93_Wmy4F`$47ytk@Ey-rT!gXr&eC%tq{(DY=OQ`uDJCDFrXJ_n z1)Zv!1CJV91RB4U_Eo0LxYnHb5iH_P{k&!xrJ%j#-AvW6^CNm2655OW>sJ`s?b7&h z{l2cPGkF}WKQuqd-c){Gtp8BTLWIadv~lx4&?>=#65D&Pvq|GOb>sM{G`PW7rx|kc`ZiVi$a(WF zO?)8YX3!SK(KF=*-Tl6)UV2O4&&Hi7r_QqZ5e`-;>jh_xw!P78gG1R#;ky#@D+h?C z#jDXj6z@%tJuMD){ow>4!Ph+LrIQpsBOJ*y=2=1V7CyC>x2FBT#FcEi3c^l-kY_G+ zrB%Tfv$WI=GP_XPqrIHzOBsxsH3&VpyeX7|GpM5{mjnbxDW6TXlXr(vhn^Cx_ke*P z{Hh2NSRyzx(4`8Qa)~S6NgKyc{7WR9vjH308$}7yxbi9!f5MDalhA zebO_KZ=nxPUcV1jq#59kq6ahfr)7$rxK73Mz1vpWY*3K+!QZi_s15UmNi7ro^iS&S z_4`{K1zmumhq4mGn^eb1z2n%sm_8fpY4B&VJtwLy(2d%_#y#P?ZAV!(XQNwj>!7KH z&#x!^tc1L*TPq7+`b8tXQAbP}i$mYDsG6zg4Lp^?I`_pTpUy`n(Fo>^vq z=I*L{0{v8wpTf{fNETt8i|DyPZ8Oq|Xu4Bnls~iWs}tC7H1Wu#9xU{3_tC{Lga-G4 zVG2~lP&>odvKUcbGgh~qRvokD~yX+kOUz`WQC%h*Z>L2{IxX00Hmzb=FihF#idbLA%EUQCz z-*80_&*a_0!Nf1z>X$HnDd9G9>Oab}DlWcUe9<7+FnPaYU25?ry+fsxVXGx{t7l)y z>Q_)oKyZdkpSB1fwQ+On=!_kQ#EBG?D2h>IlSbZSxNlF%+69`wLY9x;$g;%9J_+@C zJrayiNlqiS5EMmByaqKSmFBetUt?F-TA4~6KH>yt?DXou>KHrEP1HkXo zANORTo*+@ZvD-@$Dfq$P{Nm}$w*n?^N1HdgK%BoS_yk2zvrDlE=Uw{KSjT2c42%{Cv=82D2ySVQY@3=gH!0va!`CGT&r zYjYU9Q_4Q6L6UY~7xSI?pjxp1+_upUb?WhN1!(Y2*CVDO@IpRu?yW-}SBbc6vj58I zk`n&qdGGQzb#i!j24xPE%my#Tc|aqYT9i<4BLB z5(!SD*1@v>kqwObq||x&9^3iF|7=r?|FV2$+F%2(mI>^kql*lTR3au*aECKQzi%b!3}lBsuu5Vppidj8{}s8bWa8K^Tzr{G}N-w!JcRES>#-v zKuyt^C(Lwg4zX5JGAwhap7HZE3Z~w;z&89*-jszsH5p-{`i;b{6+S*0;i||cfg8)! zE=_$Z?^$!K)dfzkVPDyZ^rcz6p?PXHo-TP&NP9#5L5h%76-XKYx6}p0+~?b~lS^x? zv6P9FMTyKBr~LuLdW(Slnsg|G75wtPQXxH*=-4EcFF)Oh=mV0sXieCpT-lL_Je>>g z@R56K3za>+@h z#3Rx1ou^$28^7eoPY?6g7ktm=*M-X~6}236*pDQl>5gJQT)e(6Fto}Q3Hs>7U&-yE z#h%S|iG$KVdfguR5y4o;<Jkm!+YPI`xxQ0t8m*woM zm3Pwz5By4h*mFV&?RDtRpJ$qYPEk{*gUxvdl&KxKB-Fn8rrQTv^wrLNI+cpHPn-(F zzQ?faGV6lWHZjxCO9zJCvg`qa>a~8@-HHJpLP|`nuuvbK4RC|!3bR`X<4Q{5Ll{a% z7Jd;3F?h&%_AjGqWZ&GmMXQ3r1KOqxf5|^d!${!+AD`hvAA!~T>+4|D`Rnm)_|P=$ zD@l?6?eUCXLH2#iG7raw(oGWJgT#z5T$67EYae!}r-MWT?)3x7@a09>ga0|s&%iWy z58+DQOu$bUU&+4=tuh+kFF|bh!(ru_c~vMR+G4&D=oO7LGeKFgj~j-3*`PTBm`j$9 zni(nl{>QKqnPzOy^bYq`NCSG&oQ6Z7W=9m9j5PXMIB>3k<5f)l35<}o>$2TZIfdUkeS9V-PCM#mFm_FmviVzBM|Fd z;qJOQ{YVU@U%s<5t?PvP__CiKecCpRv0gJh6-|k?`v#G%8_o6SSy=Qvg*Sr|uanZn zO@qx~6Gd0t>zQkd*G=z*>BS87ayJ|Hq1oDYLMBHHT>svL5|HI3%v;KTG( z;^WfpW&V-<2t4;Hw9u~Tc5b)OIaRY*9X;!a`|BlUZsEoQ;*8+~M&TkjM=KIk3Lkaf zI!Vq#jTlyu0ub`bhBosj&koFH^=>N&a_iOt`kz7iiwMW%9OQ^S+HC!ZA$>pGiF#@S z{o;Ua)g$=&lBdu|k6hbyas2McI+PB*W%l-2*PH`wU=={qvT076wb6C`jcSH1z!yNqf6=i#+cJV&DIP-gY&v> z9Qd&QF{ef6H6u#o2i(6xqx?`Cr%Nv6G0wMK(W4V`WKA2bawF)WbW+|{(iz9D&}RaU zj=K{vL#Vh)fP$E;FxG>F;d?nrIcL;7*?ma)TFdI@6wFRwXwE3XQa5k$thrqW{~kp)ZZ)Z5R3*JeK_a{%<3NjKj%6$-}0^pb z4#j64HB8ITUmP19WBqN;vFc+A&1Ffu|Kc36`LK5@{4D_WdH{@%p_oOwF$sxe2my`P z4Z%bdZUkvb(hWwI<{ONc{dkOTN{YMdvxpB*qrt?jJAkr57!mrM|G$UBvix5+!8W;P zVC{78u;{l4yfBzafD8N|1F>fGkZHxlv*QGJB6f*c9r(47S_Sx2c%uq5CY)XzfGtih zHNY0D7Z&h?5_AvLq5(AmwJ1OfKrJc|Hc*QRWCqlt2T=jF7(o(1En3hQU>Y;X3z)_L z;svIC18D=(=s@^DOUzzZfIKF!0K$09ax8!0E(l9 zsX@a4p)te6qWuGgVFD}Bj6qu%zy`E@5E>l_9sok@p#!l1ctB|MFl}hIz}l|>arhn} zJjPE6cymA+$PnwND7-h|2$Tyiiu02ah8`v#VV)U80&qp#jzaUt>O}=U>BDa#Y#RWd zG~hSkw`~A#s_+H~+j0P30B9=YDjiJ`aa$GeCI+7azik4@#ORd*J}JWo;P!5Ut~3E} z67Zz(+kSvIZTL%sZ6)AS0on$3FDdBC2hdIdY7V(dLEFIUB?LZYq3PrHI)q&PM$^ac zWdM3=!*`+g$^kvU!!yHgdjSM!Kn950+JH=~-Wt$V6k0Y;F9JY-3S{tgTLvHi01bsW z{(z4~$dmi5W!y#w2rz<7fIRddMSv@Oo*uju$WaR(6=6FL4dv^$C_sP=^cv!rgLZ-4 zD+v6n3*Uv-s|VyE1HA+Ps=|A~Z@UBjQG;|t93#;l5Vp+$|0qF(A&$Xl4_~*v0Y7L! zHX)ADXw^8q>cC|R5JO0ej3 zP;}upp%-=WwB{<&&NO%a-BT7l=c+jO~DZyVw5TCHIkPZdbK9UgY7~vUKXYbsHqH86{?|%og-6hX|E$vTzTtcR7}rg<0*2H z-Ax*$3(?FPrwd<7A08Q_3(_Q>8tJ=qaf#T)8lwwMj8t{CxWiM%DjA+U`jZQARvh z)yd&}0Z!h!fLrKXY^ML#Qs-gWCC_{0TytjUaa1P6ODlUF`khktOT=7iX6M$D&|%pW zPu9U5UDpGuV$RigEN|A%9eN3>SA5s){HEtgjleywBB`Tyj=?K?$)o2%4$pi1TwCTx zcjQ}k$;S4byG1|cv~CtUrl^Cncd*4r%-m_F|JX67{+_yG&DD4+?|bN+T&Dh>g#Ol@ zz?5&yTn(?_*fC{^!F9sa)%;whqN8_&!7Eh>jn@HYCdr{|=A21ptLKQ6*9p2Jste!X zv89D4Z)rWd@7K~`RsIr+OcWRIusMJt9IxOqK1zTB=~m;vsbeAkbA81}$JUHFW}fwd zV~CRX%!|F`^&Xp+tU0^4)v8SU?S|O7iMP-U&l5>SZpYQJ8oeo-mYBK5w^h!}>H}Bo z62Z*sJt~jec_u|o$JWd_vrKn)K8rx6bJt0j^R0%CIbPg`=sBvaN$TlEMVd_3j=yu- zFsr>*>|K7CT@Dl_$XSysxsgxjr@U3W4JmU3iWR(7JL)xi%{5yM?Q^_{4N;m+NvS`2 ztyoJWGI5-Bx-T=PtVjatTizacGtF-k>Uk#iID%CPOO7*(uO*vrxHCqrzB=5Q>boHZ zD6^Nyyt&cbz;Ryd{CCufS)`$IRI^738rfRlv0t)g8Lf%lXBlbd&a_Jtm#j{*^nYmx zEs=C07-6sA?`sMy=j&sFR(B88Ln4^LZ^Bla!Ph}1m>d}@%T(32pgr*5iz<=jKmV5d z8PKk)=~hXO^cM}WFvu4T+`$oi=ZT^rZ#s}AZ;C$Dw6Uo~^HvtfW9JR^f71KtGB&dZ za^gnlf;Cen>7q4LK7(A9t)x(qe@=y*hPj44XbFFc;r2@BU*^t}-=9olo)#Vnmxz;w z#-HT;c5@==Sxv{ej*5zkMwvvJz-MH4|KMl~9o`VfuLoHDayp(e>{A?0Y|dK5tW2ZUKt=#s$LKvc1V7BC6h<~#RRO}9ZHYTTpH(^R)lcTU^~#gpC^ zYWHQ3$4}grO%#$nf5bQQE8|DPo{@`9$QrQ^JIByh&MoyV>>mO6UR?%G{G8wbKrH3{RJ$OUp=R- zA5BDXuV`;bAxQ=sP5!!Ld1Z;fO`{O~`LQQtyRzj!TsPu3T({ykT{rMfb_L-m>`3a! z>=;Wm=Ewbn+pYKa5W=&|EdEt-*h8fAlcsl5ReFju--znA9@U-^NEk%;nd!RkcKds& zGw%`Y74>R;tM#aU;B_H*;PX%O_oa&gJxz#fk@$lr^cRl+JGS+Vv2 zdxkkyGp7Qxz1?s3+D0qu>%C$+I2!IoEN*TE;B)u@=I`Gqik8-l_Lc+zwRu*y*J;J9 z!!^v+HUxgP)n!edYN``G-xVk#D>BNbJuyroult9&9-vH&uc-Sa7@YNlmR_rwe|&Xn zY*YNZr)<-}uOnM8IfHIea8y?sSx_C5P!(SzDcd>RFm7$rUx6MF^CSRtdLo~#tC0Eo z?H?u+zxG=1DUDji5B(7@<-FC!7e@!@#s!%w#b(uVXMVarT@K)0CjOkd4(r594eQ32 zs)VVCokcW{uzvq%F;j!#+@6I+Lyy|eUhux70gJO%G@a-!wVLiX`^b4UdwiRC-;^8l z1tB>Nd(QZ>Un)R5vX$YDpM2{JcuP`!WiiG=mub^!@!yxsD0fd+qj!rN=El|%3052o z`u5!4_sO)wFgOsmL0;WI3{hAJ)4q(um_yigyBZQqf0h4I43QX= z0-jsflaz6?^MMf7Xp7N6y>OlC&JAw+&$YI7ae>kUbo6KjZHZ#$Q$mu13<1(u8`(iV zn;kYCkI&VQ){8Z*6~9i7#g;biYi^5_Rp((iC8L$a<{=iO?v?e_;1(nsl@-*W)uk4c z)z!YJOC~Ezs6i%6?JH}m!6r)sDQ6?G< zcg{VdB^-^Y%hgK4Hv_WL5=bcHnTg{odnch3NmwacCw(mvr&D%LLZXt8QO5e32f=F^ zFG@g186OMbC7t9GZ4-l+9~KvbWl&Hvy?MlZNAm*x9^)^@JcD>gBq-LEUvh+d2O}sM zJ1v&rxLFu;XW>fv4!X!Aok2bFxg&0kyvSWU(!E1|Ay@|*F?$l4571Gb%L#td}>x}C`B4Bs7zPUMD!&9wEWAh0;YG`}UUPH0L# zl_jeV2q*uGrO@wR2l>2~T)#ui^YJbDeuoz4(^<0p22m%(><=ne21MK|{rR9<6drgf z(E?+g<`}=L{?E==iZe(oABX`Gkw0+C|4*C$XHKDk)e@~&ESntTbJa?}h(@J94|I#4 zPO#GBHh5RA9()J)B-~kOKldB0cT*O8U@kc_xP_ze4`+mLXP1{P6rwC*iU@z;^(x30 zD`(K+iu0{5ZI23Z)bwwhlhis@R#rSdcBaHjq^a>m+~(a{kK zBpH0}3TG+Ha(^_91BUtdsxAI){VU@tTt|B^@k~BmI9;m2bU4_O22~zh`{LyUQlt+o z-BcKVjRBc@hEQk|Q*Y02OaHb0d$AVZfDnstiJF73iM07u7`ZDfPFjUXCR_SL`j4~> zk<6*|rnIlAqN&@S;oi|&eBoPa{CtExnxK&Pq_9*1j8pse`Tp5}}0$ay+`AOAaf&V9#Skpwy-I^Y33gsdG^x7GY)6y4x{ zg_8(=+N#)UR-RRUQC?9VQ0`L}=v-?DZH64QYtz_FpuJUkDX?A%@gg+clf?=JkZ zG+}iP&3gghF9bxQ?U;uJs`#ptGYHgXD_=QqtvT#$_OnC@-k`I zgm}M{xIM83jmS}}qI`~RZ6#G@C|=>d7MJkwzwkLJO(pn8%6t(q#U@8ue2Pg$iAVn45X{l2bGNPp2aF0-tZMOrItfwKfDtQ| zgam4u&;L?FXfcJAl{bbMd!jr?ifOU7G~{p{Wu!SV1bM6?OXhJrGZu%WzO(N*^-^EP$@f_!)dqc4kZ)zN z`Ct;|+~isAS+?hg3g;l$l}yG(zOfKC_GZpwxTp}0E7-9LHx4l_#7sWsw^J5knpZQW zb$J^foJK#_pdNI0rop`$F`dOeJF*-M#;4Iq#j~cd4H@ka$uZ9ru`IN0`ytJ>D;c(W z%)Mfnd**oM_!xA{r|tV4`|YpabcU{MaBnoT47HuFUtjbJq@g0VU$$Qw$ zZNvb-3`=4Q7f8I-rx;g@h;;^w?9<(?zYK>%boL7ZMFc*d41tI;h5$nXdNxckw9pYg zp~pxXMT+bTQAm;_l1h)0G%^htawe$3@#}2QYaEGCAd=&6*KVY>^iF8}Z87*h{IG8A zuuvB?=tBVJkHB$9bmMId)1X)~h99A}j$i8f)c?XRZNr|UkoM8UK|A*)L*_5zUZ{bJ6A@J0Btjf3^GCXdCJqJFrNEn}R?WjK>f@gSkaj3iz2Ao1A@Qd=1( zA;b(_TMZ|1zzjiKB_*NAOj}zqB^BFLLR&^7Mcq_gTR|h0(^NrQZZXBpR8L!JF%{WV zY(<(YMZr{cMV>45o2lH2Y*UJzsqTtmQ!25k)QZd}U&B;$MZrB)&{Szf?o*K4)L=#F zAr;!Roro50q{lgoi2m2ewlj!`PHJSvIf{s3Xyn@YXC^I88GU(qd2x9K!RM{Wx`MA7 z#87rp0bBmbL?Wo5E-$k#{#}7nPUR;9R;fx;tx_jk&}dXWSBG2p?xvEZj=P}lrn;_< znv!H@UZzE4!dU5Du3z4KQt(p#i7p|?-B}Rfltx|z`VchK6lv(#6jZtMO#_HOzpFyN zbj51bqa?m$#j3e!nmgY2U@t~`+E1vE?Klj2O3J|Wl4xG8v@#;Xu{- zU+sMbSd?44wx~4Hp`wyf0@KWll$3yoAl;qP11Me6sZt6^ODlp(NGqWrf`WjAAV?@B zAszo3cYJR5{{FMSbG~!Ve_j8~1ruxDclEm0y`E>*x(^Fo*D~@h&FOzXd;Br^Ajy2t znp9Y3eE(?FItlS~P$~G`s8sjq8D=*cufyWkKS+&c`a>I2!>voCnr@irK*f&IMN^$8 zHV<-X+AxuZ^4~sj@kkyiB1pF>&BPHZsdH42I*Fv?I#W}TiSk1c9Ts~sk@q}L!xjp? z%TV^myu&8V&*++JMJHOk(=`-{owVH4CDOXVWQG^qNlP@jr+Z8*DbajVmsl%8(@IlE zQ!D13#S77tutWhA98p1H9+_GyI%2R$QOAs2T9P zdgG*(`~d7~&|QoC0n$07Xvgcal{wCuW(l+8_e@t44b9O5ZNs=W8L>#-X@1Y+=}Gw$ zpFNRX&E}rW=`qt|E@CWQ&sUa$YH1;_qL$5SF_0#uWwlx)q@!a=LktD!ivJ21LwCJ+ zF7^zuQc`;`?&C2JKcnWDtBCjcwb`r_R@G&W6|B=!RZET!QpcvMBOL2fKQTX?5EB)B zPFeNRF)MYPxw_D?EOjcW>a$~E>c^z&k4-saE?3ScrR(sb1GVpRO`>q+}ec>9H zXs1y{&peapbE7ASd6Z~}QRSogBcg47_S1tZb7I~-uPZYKiPsP{-F}WN?;&$$+xHC3 z8p@UCg=${;IV2A`&55q**F5`Bac2G`R`=3P^+_bw@bwMEq##!R^-b4FF|4uh4a!N* zY8~O5N|W$vgL*FQ7m2t;1EKyG*CzEg(?Lh-ID*R==0k!A4 z4)d;e99qs4azOgogD)|j)nx|m-@?gn=w0DKzHbq;pSbuAd!E<`X`m;`W;<~8#I$# zn>rFV8(!ouYj0kZNQQh;+DrxFHyry3{x2IQ&ur?^$uX%LkWccf*5A6oe?Pjsz3^O! zM#6>cTSXQE)fckViZleCT*!G_(86sjOjQZjBJckWn7SgZIDMk_vIs!lu`6JaBqwwAyi(K!3Xxb*+U)P(C}|&1q1A%5iw>}1Kj7s zJI!(jIL}AiH+ww5(;7Z+Rx-fR8gbXGcHlHm_=s8d02fb0omu$+Z(jJWSrLXkFXFn{ zQw+CN_;a&745w8@j#&kUXDWQcEFHr?6{T(#=wU3#$QCX){#Zv)f>EjYSlPuihK?4` zrPwNVt?($bdsVWbJ+5Yn8ww?K+Ray7-a`+aKWbaG6l0x`UXnjs=#`hAZ9QY_m8575 zPod&5dz<>uC?Eahh}R>dQuLjRJjD^H_sUlBofa_csz{yEVz>9|)jIvfUTnOK);%L`@R5rwJu>^5i?iNafqR$tOZ?x`#J~odZ`&|f<5Jr5ZWUU8 zNvX(-`E2Qx!WTcxjpA3PkAKFEC|o^QCkuJou_|4s0qN^dKBeCH2@!J9NWJ?LI^>&_ zuLfIR+cys1i)?RrFB&sAZKX=(WgN31x!ttIvTLwkWc($6E5t9)9}(!n6GMKdw>9Ol zS9*H!^vrv&dIy`-f%b~04knQ)!+GH^Eh9hQ_lo*Zy8ii4Mc#c=yE|9%iV98IQw8#} z_jE?P`@f;|+xjZpl}UR(JcbJ?`}Q zrL?Of`BueVQ@_R1{k_4}_i*B3oCTlu?3$~!j~WDhKHzyy$yaF`ZnZk7#t}7JnT8J+b~>fWm1-w3o;7=;EL-~~Yk}OaS#vp$DQc=EuVx3~ zzFfAS)#zbFN-HpDq(=Pl`mR z;7;>b^M^OWYRF$VR|eM!D^=9*wOunVmg1Ifr(gbpx}hDMzNTF+NGGj*`V=#1ZLq%T zd9rsGZ ztAwCcLeMH9Xq6DON(fpd1g#Q+RtZ6?grHSI&?+Hll@PQ_2wEistrCJ(2|=rbpjAT9 zDj{f<5VT4NS|tRn5`tCsGZtAwCcLeMH9Xq6DO zN(fpd1g#Q+RtZ6?grHSI&?+Hll@PQ_2wEistrCJ(2|=rbpjAT9Dj{f<5VT4NS|tRn z5`tCsGZtAwCcLeMH9Xq6DON(fpdcvTaERtZ6? zgrHSI&?+Hll@PQ_2wEistrCJ(2|=rbpjAT9Dj{f<5VT4NS|tRn5`tCv-UUA$Tzl2?@Mz3SL?Rj#obU@6amwp=2^n?v8HkaEOwvg{%H|)fDz$%kVuz zs;1VKuK&mp><85kMOS50XLc)7dsj<{Iz&@V@q5W2Qs!>9PLBA`d^T=w&aP)65Fo$p zW@<0&WMyS*ZYgZ;sj{>cfTM?7#jSLX=J2aJHZh zgpn{f0@RE!90nHwtt||NVG*F02_sNw6le)yGzRb7#4=V4u*jufv}A*6b*;r zzsI7WNJEIWjjfxds*|mw8;BAggkXoU!-Sz&LkS5($S+l@YKn8f*P--3$+jyy0-t|( z2S-Dk zXs@(18Ha6{o&p&M_q-}4IY)5r$txTj!8abu2yq9~-nv>4g1?FSzkbu{cx6p9o|w|w zy(}H{XP;u(lH51d3!yq75qN7`o8;C^4ke55&IH?jqyJ?7epYsNc5SWC$7dmliHT9n zLIOfUiT=S<~| zar^vsYiq08by`o7BX|bu@pWu$ER(U8;mZkrki2Y3NFTC}QHX-mt= zXCajL#>gZZ8XAl#D%G^K`i6%M^!3kJN{0sp1_lOvWPbvc?3MP9j*0oUx~kB`b^rc- zm+pX@H*bFUV8rhswJB(OYEYF;NjfrmOq%SrsLc4YrHQr{0lvK;>C?1&oib4cbVebJ zWE^X0SXZ7<%3xhp$_Uc#d|qDOd#dcJL>e*_9ACF)OQR-RKh$}*a0U+qkgmPcHcpg| z1`X|WLflKEZrJ3BjHxpIL51}dkP?#oVYsOlN#rE$43 zw`S{Wj2y7Tug#k3tWWn!j~}V>$hvg-?Df*v#5gPr#=*?N9mXXe8-6dRqN3vd{rP*z zMuXqjDx5|?3=TfMID*kI&Uu-lTj%AjnYe9w(XRi&WHllflX-&kik;nrb0g%*jScQ` zWRm30M;fybD(UCSsUld>BHCx57WiqU502GdJbDb&d^{h*rnR-zM=*4o1UK;Z?c2V- zB<~FYWO-fPU1^1I#FjbVR96N>OA55}zHE||>+&bXGS0&bAeGq%hBfSdV<9X?0Y_pi z>Zv#$TvSm3J!J0vBha&YrEg_KMpBf`Metld|42zuU4Qi4PJoOAnuGhZDy5>IW5e#% z?_W->e(o`Qudc4n#mo`x0ia^}kZ>i-!#>`dTO@4wR6|gjY)S5=g}R#Wmp`G$O{j=SUr!Ggv3E*J zN|KO%g?Sq_zbL3A=GM^n&%m`(#X@mvn!de>1H#R2XKE@i< zTTGSicV4O4F42Axf6Y3tkU9m9u+MY=0w+12trJv}yOkSDKLo(vRuq{UwT zI_HG-IdAWphO;)Uq$Wq`)E3EnGhuny{D|+pUk+#0E@#x#PoW!m+^(X62`1;QF z-B-IRn-kI=cd+K_Jbef4QMFP3{aqS~jdMLyx|wtbq%Ha) zUBdgbE)G>V#0Rki8Au#n{usvQHx@|x?CZ<&fPu8L?q5E=j5nH%Sd;xWW_DcLvG~ID z>$@x0xQWw^ZF2KgEQp*M9U~6=MK6aagAThp*k8{>49R?MBWB|EV$o+>^p{+ZmxO?k zJzD9MvGd~Q{7_}8xCU+A@VC=xM~Os5MNPT&DDM}VV5-kk8?*!rb$5@A*=(=nUGC~P z@>WJ{d0aW>BS_Nu@uMkMa-49Wesq&~G?(zw+zBxc)t-Z|P2Bcgs`$~@hK3(h$&m1! z>E>YjAY;_+7$>n+AaOyRnkOP)Hl#UXvLo5t`DPGTE5Zg@gE9yanmR`U9#5Y>B_SnM zzAF}c^KA4-a*2&DzqLnWaA^FiNm8Go*B8HHZ7%xoNa*^nMw}@S5JMnKZZZeBeLQq; zjMCt^Ps+lAV^vXJFl{66bJX|+4%~81?>QmvO9@Fy5<6d?t2sB@lN>ou(V2M&tyOY3 zsEq1McAC6^U=>SUU0vR%_Jhezg_#M;*HuC_bk2AZHBGXv9|{VHdUlVa3hgZqAG?qh z9v*PyEW;8QLIyh3xO+OWjaU?XLC5B;yxqi75er*%T-@S^I?>v)SAt}82E4`MH>-X4 z!$?wC=ubh<1#^!ro8$7GIjT-f_$eCE8Z|7ppQ+MS69!XsbI9^CZjz*)f%j54)6v66 z%-+X$ldEAyAPqaXgwheRgCwKIJL4>BP@)rg;Se?=&Q>>n9NEV}ZiTGwF?wf?%a^#v zL_S*GlcfeQdlzNb7d}ZAR2@*v&a6tQU0Io(%^4IeH8J*foWa5d<2)A7G&sJT6JcB4 zczZ#H`ts1mD-)R;U|P+&mgW3NjWZ0u)+GlAhlFFul>W6Vw{j;!PY0_h96U;NBvQ&0 zGFp6Md~m|`(U_U^4T?$W?}^HW;H2RmBu7qRV^;LFwRyFq71C&f@G}h4ZHxlt7Sl5W z{;H*^RHsNyo6J$~wK@I-T0I@yj8i(6AHX_=WvkU!05=<~Zx_nGLeu@|iAb)4sqd)b zJylg_xsFN=nwW64d!0)GoEMzEO#pI*1*vxmL;;Nw@F>BIfeJ4ib`T zg6yZFr4<;p?l$S3TUfa29_Sk96SZRGJt;SN53rKFys32Wg*%e|5}!tw4`ixsygH-( zU}tyHzj7#hK@~k|w9RheTT?#$DX3mUQ!~U4A|&+W^_7w}dlrDi1wR{FAoqZ3$s5dY&q?LI& zIb5#Bg1%4Y1T(8HFv+t@Ke{(Y%=dKaWsv^YPw^@jn$jWey&G6EVd@0O9kw#ZWq%ew z0j;+ii>3S9jAbuwr=|72k}-Q&vsk~K>l`n-P&Lt8sGi}sRhTf}#CPXgtUgP$e{uv{`$6<E7CHxm82c=RlphnyP}zG$Ib)xa?z338uBQ@@Fo1BMQnhm0>d0(5n(Oj4J`r`O zUtjQ~Y7m&}Y@Rg?pWpPcZ9j0%qI4tW&|^XSa>CBXPm$jW@I79An{7Q!Jnr@jHNp4HV1&;DJm5rfCScBrG4zRr)9u_107{SDqc`FupdE=2I6pS81z96ZaBph@A_Y`Wc` z3nmLhJIl@&FmXbv8r83GyM>b+KC!j)(#d~&$gG8Cw^o-sfwfw>D$GM-2r^PaahgI*^ofAj9$X=3j*mVt>R(Hs?4*e%33x$i?92SxD zGV_h#8J;tCBr`djb-bs*d%SAQgxSaMVIfZrw&)iN)2^xLmI(^Bv zC;_pxs}RKkVeUBLb-7a1g(nNYIDi`QWgOEk>=As*jgKzBdVGhuwY~j9nOL@pKw)7a zUB?MBtprl)fs;;o8J3;S-@a%n(b1J0PD8!3i+)e8M(G=tcKbGMF<&M#eFh(|bsEBE z>_W|{N-|z?U0uYvV@$`=bSSTr!M)&NDJhc=Y8L1_Vrt9pt8lzL7&$OHx<4KTp{AnR zcn#NlfDG&PT$R7BMoGSj_F%kj-ECu(d5BkB3Ph7p$hHz;JgQeP>OC#G>ShH{%53wQ z`gu#E(9lpXFE3htFHKqBi=NYWuSHrW0LmSe2nVe$`M{Hp`KoPpH&rKnR8z^4O}CwC zTHDD<;kRZjJr%y9-F0;W*?vLD+#7m}*Y;L2_8GsIW1o*64PYvW z=t$u(BDWpv9&36ZPF-pL-s~$%$JgkC5xuqag%A9Uz0&lIlazke>0J?Ys;>6RPsak3Lv;*gCt|LwtZeCH+IhzI)@_t|)+oQr zyq=oMArU!+4*~V2b&gA~__7z(JA}SsPGFqRQ6Rb@6v2$Tc_J%OdJ0sTK24=h&XcwE zxTEECb#)cmt~68ZUUqnyJ$7@6H^YZh3v-T6Ur!Ie;O_1HB>Kk9#)c0~*3%UdjI1l^ z+R0|7zjY*(M42Q8rCj7mS%72l7kd+S9AK3~b2_4QhHK(R5UVBb!a09C&{Q)8Cr_RX zXWX4knet^$8217b1N3Ycpx7-qt}LaaSHaLYCSpIpo+RFfEVPR9Z6q%U%3(Ve1xNPA z-7>Y%_;QG+lp)uXUq~n<&vB^YNaPIa{44Lxt@O!bginV032PtP5;BujA-yy8PP$Io zPK3wvpkwf|#aB``AJr1GKs|+50UuRX@>rEsZFaupzRB^irhO=yDg1fHxYc5qwY!$C zXS{xUfuV!egW@tAyLzi)+>pQLTFq_gPs z@OoKfRctw4^?d>RyaWvvmw7&>%;#5bTF-`k-BD1k=&Z;w&L)!+5Q9<=ZTW zleY>e`|SjfVy;usxFDn$nv?2*af5IYSFg0$t+Ad|#jcc>nPRbRW?(@em!uj=Waxhe zMlo%OU1(jIVY&^Go3IL|%Hq4}Btw3;F0SvW{aMZnSai@5#(-dhS+r z&}>usPK1@q>dfU%(XzXNnbKOoyd_ZKvx(#9KCIjiI@t5WcRmTm0??y+lCDPav5q8J zMr@_#+Tz0EVlqxD&^jlBul3x{atCBPN#-%Do(eYQx;&e&M#%{t9v*;F2VaYSd52uw zW3c4W3-#SzBPK5)B_Tm7IxvGFNR%kyg~UpMx;Q$z!g~~5TwHulmC@s^^Z_hjv-X+2IG`(tNR!DZQc4t zyk7T&NiuP{_|&OKD>NEP;y#5l&Vf$0RJTo`5*0@ros`5p8mIfgJl6G%LBeuQBd|5J zlurXYN{i0Q=5&UVZd)6hf3&yTm)^oIKtrP-^;NrTLryjGBAb<|V?433v3pB#{yQUp z>4M*%iY7eIuY8L%wvchs{0{}O*+Zwv)yjg`j9De%r>8hRK5OIS& zU=G<|&6nH-qqEnb<07CQX|4v32B&*eB<1J^MPRVNp^vmLi8QEof4E#CXj+k8fIx|bdNbEdNkQzRaH~-Tiv3s3WXzI+p&kZ z$@XFW5~8kwe>cDLM>sXnQqWw9y5a=~uED62-++H{?iu3VuBQb4m8b(2z}{u=|E339a7 z^AV%Hvjf`;Q@jz#M4%bAmrb>&Yg3}@Ls^~e)if* zsjRHrmY|{utZDGHcgtVWr+NSJ1V1@(DMPx{nt96!s+98|B4ekS<9u&?EKw~ns?%#Q zeNAm3O2!#{v=dJn`41m(Kc}3TjF1|e`POGM#mYh7BuXO?N*GcvA6r3t!qWV%3I_j*z89GK(mRBvaM zm6ZkDC8fdH$B~7)nzvsDv*JV5`Y{%d!K8H6k^#QXHypWSL7({V_mV9!N|%0nU+d{A zl`SJ1tZK&JOn-$VLX^@SvLUA(+x1Gb;2brWz@ke|@-UJ$0>fQ)nu81mzc6l7Cfj{- z013LkBE`o;Y}xnk3++K-nr2LM@iV?GQvd$9kLE7j-Q58kd8&^>h)Jo5n%PreCr5*v z1tij$9l@yerqwn9zJQHjGBZ03dI?!It)(}DO*t}GILPVx&6$=owlC#h5n;xv8c%G#PQ9J4ugIK2CiSR;wKELBxFEA)mUw$-FSPW-a|BfEk-%wNSJ zdfM8g=$0|%Kxobdsn}aD4rZ8R8LVb)3&$!PhQ=3_CR@Cs$`?sK8f_n?oj2NET3SMn z`lwT^i7mh2I)93My3~8>j&(E@t*@`I88*9`Z^C=t@J@QV1X!#CM?u$y_-;8^=p26t zCZiZgoYR{$ir|eySA;@t+(}ClyS6a=CP&_PF>s~#;03Ud5H_j}kK8O~BC*5-0ow(Y z`;)g{#H_p0zqut0UStnEqgApog>JB|NN;x`5nF0zAZa-6YYQyi9v%ZdS{kctS4^qG zwI~(so2o?4p7ok+XNrEN8)jw^D8CLkt=>bErk%|d79l+gfiXTzx}M4FG%Vn0bdL{Q z328sa0x_=l0ppG|nK-=jx8L1^LVEsfJ;6dv~5huJ{HX)gj@ux-R89 z{z6Rts$sa^(L;C-mYOQ?srd1a~dLT}yD+65O=}cP+tPOK{f`+_eOEEx}z&aMu#twFGx9!Cgym z*Am>d1a~dLT}yD+65O=}cP+tPOK{izr}jSnU){A}r|WO-+P~Ou`s-6XI$Ak_ zU9u`T3rif>!OZvDhH-wd5!u=nr0dBi1vZ0Q@`GKi&d&Ch4)~qFP_RK3#Gv6MuP6&P zVgJ6%93rdD4ue9W-#0GHTDqF!Y@P8tYT;lDFT2|B`(nYZ_>JcH4c_oyw#VW>LbTcS z5&B^NEZ824GK8V^Q6f-7I8t8(0X0Nm^pPlxArh?*7ZEWOf$F1>B8F&`z6e^x5R24D zqTz;c6u1V9FodIF`Y415I}(O5gk#|N_uy4T9|Z;9VMX*2FcCupLIm7{G(;e=`fw!5 z5Fvt(0}Eb30{G8pggyp_V8`E!z~JLU8zQjyR1x46@jXriJ}wl>5DA6qBd{QEFz^a; zg+zdygZq$3kRLR{5Q##4f5o6c;ZSHp@T+xwI2sfX3PplUiGVlY&R^evs6hzW#f)!S zydxj}!#47N-bIi|kstbqVSH!g1e7XZ(UXGBnnvamri_&@$TT$Y)LPj5!XfSowSz<4 zxu3I~8Y&)NGCi~#B&jd0m9xCN>XOo`oG97%KEqc1IVL30amDvruGiCO^{g{Y=NoqQ zVY^TCBjF?&Y?Uz&@@;QNO+c2%jEht_CeRW<)(~w ziU=`rHU+X-*0^R;|JrFy+`ue<^RA0`ZR_pn2;;aAjTSqhmJMvT!)LMkBW_vaWJ^sG z);UCx z$ykND9l1@OH2d~Al%eGC7ivUPUp5({Nu;F6jfq3&fe|{b_@==n zjmC=)eK;I?G=ojNFB>E$J{3FDZ^ARc-fqUDL*MfGZtnD@nL`<7&zx!Bnekv~TjZck zioALc(#bEKeS_n@J(kNL-rsVV_wLsdGpAM0emrRvG+~#@6GE1?oPEM7=(En{A_cNg zC6vq-X^2S)-|LpcJ^60tO2Z z1K_T${cN9Xw*wHW$hDRb4C@d1ZiXZ?I0YpM#e?cS> zG(c&5MS%D)pe8^W5Z}S+7nEWLxbz?WgGvIqLIN!O0l*MH{3Pk0cV7`G@<#wm=!{K; zGKQ>+5qEqxOQ00F$|=imF7rZMgq2kJ1L)y+zX1TT*o*JS)gH6mn`_(Jc*^;(r&rW%^EN(L{)3*^nf7~xTocHvIt9X6b9N(-;oN<4KmFe7`=scA? zYIg;f!>V+dub9<=NR#pDn*@R3;a?1;_D#QvB*byG6ib=ADSn2Nk-o^M4o zq|k%R?RviDIYp~4&%K+87uN`pr74g2lBrT2K@oAvf`Q3#KQjWKl_c50kj36`YSQ}4xv~8hrbatJapoT+IQ$g{wE0=fJ{KX{vL7r zFA_EYiAVsszY#V(4B|h4DdU$nSp1t`Lt(!#I2aV*H5%OXBNF~CDu;psa{W8qH%u6c z9D>q@F77fu9X1j?waMDQXM3?`hz{C|ka}lYn+cWy2{!XQ4xZt{U zX&;G_xc;LnJ`?Na%<5wwt|0sOX83uOLS@O7ZrGaP3N1ZuQI#+d_I4%f0%%rE( za%B6Eclg5Ub1uz5hQh~1UYBy|?r6mUe}^nQQUP`dAPf65c|-m!@`ghEk-T*Q@5RdQcW-t(-PiiyM&TXnI<&`o;g*k&b6 z#SBM3ZhKzxNR+O~xjS@Ph>Ki4w+MXWL<+tkP9%$jPm-!BIdk2(zHcOYYTrTLi zV0c@5LQj~RRaO@~$#K-huZs6ds9aEpmHimOjxHrIrx#eFlPL-pB}$QIGEKp<$R<(qDku-=keW znl=4Y?y?qGihke<$iKxE z;P5|ig&R#*oT;NGfV6M`ij#gKa(NI3LCG!fq zLJ?-o<}D_$+jU*=vkQQUPwuP2|acn=#sRG)33@f0UtxxHb) zAZ@t1iH?psHEnUS{2raAy5gNF0ZYbP`_`jbejXPk@6`2cn(hrB8I`D(ccpxkE0E0R zQo$bREU6V~n{zIGi-2Qt-$;Y>It&JuqQQ5lLzmgB%zt1(B7n!7 zu0o*CXmuWa$5ybUn`ZfQ^JsAFWZUNs+v8}>Jp*h1Jmn)3j9O1S*Wbl|`3%1l@<8M0 z?wY|b)>P&nt*H&;okJB8y+hN`_0>W?|B}Eym!}1ll5Epe5~InJ5JTHRM#gY~Zd&wK zpU`N{nZjEgA5=I#uvNBsmw1XVZI|6B^0wLYOUGF6Y(O3Zenq%`6nz@!=m zxzHPW%~K7hdx_f~6C9eLPo@D17au<1dv@_*xo_f!(}(SJQ3eBU zU3?`6t%4@wyG8CM(Aq;HU3Fi!gyk4szhiQpFY1noBBRryg|`I6*9OLLB77VTZ&Dl* zs_Z<>>rj~n%&UV7*uxBIkLP>XWXWDf;BJ%0dLxwu=%gp*PC^+3E;;c{G6%iE8+4sD z5V_aL&Fcy%5KTIlNx;E7mr+(h*zj?Ki%=mNrSwQ7+V<)Coz->sn28q|2hyJ-11Ds+ zwaxA%YupM z<^Nk;@xWPHQ9yxXlLB~0e|-Rp844KXtk{1ILyCw14uXHMJN^;|!@yv8*XfU8z-x$x z0!;a17#yrZe-4Af;jo{=;20>e6`ct}aBod(GAFji|!Fk7j z41Q(o}(g8AuQ{AKtPqJO*>T=)Hy z>7T+7c=q^H7z)p}{t$*j{l&eY`oIa~f4B|>KiC< zBKJ?%K~Weu=1=iqVVIxt0>ff~-|Xjfu^8x2?EwYRq5jm@5K!z-_rjr21p23X0b#($ z^~W^e!1D2P-GhD#|G69>42k(ud@vE{Pk90HiTpf{Kp3#X{4rh7?@^ea>I#Hm@TaQ( z*p_f8INbbCc?Q3P_$fYc9s1{f1;c~L&+Ussp?)4)a3}(dxS!KSAkcrRQ{evlAwVq*2@w=B>3^eX$ujwC0qyw&V-D(5Nw5B_O2;s5{u literal 0 HcmV?d00001 diff --git a/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Wolt+_2025-03-21_13_05_07.830_67dd64034768185bed93_7b1c77d4.pdf b/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Wolt+_2025-03-21_13_05_07.830_67dd64034768185bed93_7b1c77d4.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2697a59424074ff6ac87f623a01b3aedca6209f3 GIT binary patch literal 55797 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

T?7GVgEbENUGrauEF8k9_9g;UG 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 z≀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 literal 0 HcmV?d00001 diff --git a/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/invoice-F175490_0a1c36e0.pdf b/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/invoice-F175490_0a1c36e0.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cbf9a38064728680957c3f8ef830ce9ae6846f4e GIT binary patch literal 75817 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 h=I=~^fd&k6dj|tMhu +/// Shared context passed between Given/When/Then actions. +/// Stores references to Playwright and captured test data. +/// +public class ActionContext +{ + // Playwright + public required IPage Page { get; init; } + public required IBrowserContext BrowserContext { get; init; } + + // API access + public required HttpClient ApiClient { get; init; } + public required string BackendBaseUrl { get; init; } + public required string FrontendBaseUrl { get; init; } + + // Configuration (for credentials etc.) + public required E2EConfiguration Configuration { get; init; } + + // Test data captured during test execution + public string? CompanyId { get; set; } + public string? CompanyName { get; set; } + public string? Cvr { get; set; } + + // Order test data + public string? OrderId { get; set; } + public string? OrderNumber { get; set; } + public string? CustomerId { get; set; } + public string? CustomerName { get; set; } + + // Invoice test data + public string? InvoiceId { get; set; } + public string? InvoiceNumber { get; set; } + + // Authentication state + public string UserId { get; set; } = "e2e-test-user-001"; + public bool IsAuthenticated { get; set; } +} diff --git a/backend/Books.E2E.Tests/Actions/ActionsBuilder.cs b/backend/Books.E2E.Tests/Actions/ActionsBuilder.cs new file mode 100644 index 0000000..3a39e07 --- /dev/null +++ b/backend/Books.E2E.Tests/Actions/ActionsBuilder.cs @@ -0,0 +1,20 @@ +namespace Books.E2E.Tests.Actions; + +/// +/// Entry point for the fluent BDD-style test API. +/// Usage: Actions.Given.UserIsAuthenticated().When.UserNavigatesToCompanySetup()... +/// +public class ActionsBuilder +{ + private readonly ActionContext _context; + + public ActionsBuilder(ActionContext context) + { + _context = context; + } + + /// + /// Start the test with a Given clause (setup/preconditions). + /// + public GivenActions Given => new(_context); +} diff --git a/backend/Books.E2E.Tests/Actions/FluentExtensions.cs b/backend/Books.E2E.Tests/Actions/FluentExtensions.cs new file mode 100644 index 0000000..1c8e909 --- /dev/null +++ b/backend/Books.E2E.Tests/Actions/FluentExtensions.cs @@ -0,0 +1,153 @@ +namespace Books.E2E.Tests.Actions; + +/// +/// Extension methods to enable fluent async chaining. +/// Allows patterns like: await Actions.Given.UserIsAuthenticated().When.UserNavigatesToCompanySetup()... +/// +public static class FluentExtensions +{ + // GivenActions extensions + public static async Task When(this Task task) + { + var given = await task; + return given.When; + } + + // WhenActions extensions + public static async Task And(this Task task) + { + return await task; + } + + public static async Task UserNavigatesToCompanySetup(this Task task) + { + var when = await task; + return await when.UserNavigatesToCompanySetup(); + } + + public static async Task UserEntersCompanyName(this Task task, string name) + { + var when = await task; + return await when.UserEntersCompanyName(name); + } + + public static async Task UserEntersValidCvr(this Task task) + { + var when = await task; + return await when.UserEntersValidCvr(); + } + + public static async Task UserEntersCvr(this Task task, string cvr) + { + var when = await task; + return await when.UserEntersCvr(cvr); + } + + public static async Task UserSelectsCountry(this Task task, string country) + { + var when = await task; + return await when.UserSelectsCountry(country); + } + + public static async Task UserSelectsCurrency(this Task task, string currency) + { + var when = await task; + return await when.UserSelectsCurrency(currency); + } + + public static async Task UserSelectsFiscalYearStartMonth(this Task task, int month) + { + var when = await task; + return await when.UserSelectsFiscalYearStartMonth(month); + } + + public static async Task UserSelectsVatRegistered(this Task task, bool isRegistered) + { + var when = await task; + return await when.UserSelectsVatRegistered(isRegistered); + } + + public static async Task UserSelectsVatPeriod(this Task task, string period) + { + var when = await task; + return await when.UserSelectsVatPeriod(period); + } + + public static async Task UserClicksNext(this Task task) + { + var when = await task; + return await when.UserClicksNext(); + } + + public static async Task UserClicksBack(this Task task) + { + var when = await task; + return await when.UserClicksBack(); + } + + public static async Task UserSubmitsCompanyForm(this Task task) + { + var when = await task; + return await when.UserSubmitsCompanyForm(); + } + + public static async Task UserClicksGoToDashboard(this Task task) + { + var when = await task; + return await when.UserClicksGoToDashboard(); + } + + public static async Task Then(this Task task) + { + var when = await task; + return when.Then; + } + + // ThenAssertions extensions + public static async Task And(this Task task) + { + return await task; + } + + public static async Task UserIsRedirectedToDashboard(this Task task) + { + var then = await task; + return await then.UserIsRedirectedToDashboard(); + } + + public static async Task UserSeesSuccessMessage(this Task task) + { + var then = await task; + return await then.UserSeesSuccessMessage(); + } + + public static async Task CompanyIsCreated(this Task task) + { + var then = await task; + return await then.CompanyIsCreated(); + } + + public static async Task ValidationErrorIsShown(this Task task, string expectedMessage) + { + var then = await task; + return await then.ValidationErrorIsShown(expectedMessage); + } + + public static async Task ErrorMessageIsShown(this Task task, string expectedMessage) + { + var then = await task; + return await then.ErrorMessageIsShown(expectedMessage); + } + + public static async Task UserIsOnPage(this Task task, string expectedPath) + { + var then = await task; + return await then.UserIsOnPage(expectedPath); + } + + public static async Task TextIsVisible(this Task task, string text) + { + var then = await task; + return await then.TextIsVisible(text); + } +} diff --git a/backend/Books.E2E.Tests/Actions/GivenActions.cs b/backend/Books.E2E.Tests/Actions/GivenActions.cs new file mode 100644 index 0000000..adeaac2 --- /dev/null +++ b/backend/Books.E2E.Tests/Actions/GivenActions.cs @@ -0,0 +1,71 @@ +namespace Books.E2E.Tests.Actions; + +/// +/// BDD "Given" actions - setup and preconditions. +/// +public class GivenActions +{ + private readonly ActionContext _context; + + internal GivenActions(ActionContext context) + { + _context = context; + } + + /// + /// Performs real authentication through Keycloak OIDC login flow. + /// Navigates to the app, gets redirected to Keycloak, logs in, and returns. + /// + public async Task UserIsAuthenticated() + { + var config = _context.Configuration; + + Console.WriteLine($"[Given] Authenticating user {config.TestUserEmail} via Keycloak..."); + + // Navigate to the frontend - this will redirect to Keycloak + await _context.Page.GotoAsync(_context.FrontendBaseUrl); + + // Wait for Keycloak login page to load (look for the login form) + var usernameField = _context.Page.Locator("#username, input[name='username'], input[name='email']"); + await usernameField.WaitForAsync(new() { Timeout = 15000 }); + + // Fill in credentials + await usernameField.FillAsync(config.TestUserEmail); + + var passwordField = _context.Page.Locator("#password, input[name='password']"); + await passwordField.FillAsync(config.TestUserPassword); + + // Click the login button + var loginButton = _context.Page.Locator("#kc-login, button[type='submit'], input[type='submit']"); + await loginButton.ClickAsync(); + + // Wait for redirect back to the app (wait for the frontend URL) + await _context.Page.WaitForURLAsync( + url => url.StartsWith(_context.FrontendBaseUrl), + new() { Timeout = 15000 } + ); + + _context.IsAuthenticated = true; + _context.UserId = config.TestUserEmail; + + Console.WriteLine($"[Given] User authenticated successfully as {config.TestUserEmail}"); + + return new WhenActions(_context); + } + + /// + /// Ensures the user starts with no companies (clean slate). + /// Note: This assumes the target environment has appropriate test data setup. + /// + public async Task UserHasNoCompanies() + { + Console.WriteLine("[Given] User has no companies (assuming clean test environment)"); + await Task.CompletedTask; + return this; + } + + /// + /// Chain to When actions after setup. + /// + public WhenActions When => new(_context); +} diff --git a/backend/Books.E2E.Tests/Actions/ThenAssertions.cs b/backend/Books.E2E.Tests/Actions/ThenAssertions.cs new file mode 100644 index 0000000..9c72ae3 --- /dev/null +++ b/backend/Books.E2E.Tests/Actions/ThenAssertions.cs @@ -0,0 +1,195 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Actions; + +/// +/// BDD "Then" assertions - verification and expectations. +/// +public class ThenAssertions +{ + private readonly ActionContext _context; + + internal ThenAssertions(ActionContext context) + { + _context = context; + } + + /// + /// Asserts that the user is redirected to the dashboard. + /// + public async Task UserIsRedirectedToDashboard() + { + Console.WriteLine("[Then] Expecting redirect to dashboard"); + + // Wait for navigation to complete + await _context.Page.WaitForURLAsync( + url => url.EndsWith("/") || url.Contains("dashboard"), + new PageWaitForURLOptions { Timeout = 10000 }); + + var currentUrl = _context.Page.Url; + currentUrl.Should().Match(u => u.EndsWith("/") || u.Contains("dashboard"), + "User should be redirected to dashboard"); + + Console.WriteLine($"[Then] Successfully on dashboard: {currentUrl}"); + return this; + } + + /// + /// Asserts that a success message/result is shown. + /// + public async Task UserSeesSuccessMessage() + { + Console.WriteLine("[Then] Expecting success message"); + + // Wait for Ant Design success result + var successResult = _context.Page.Locator(".ant-result-success"); + await Assertions.Expect(successResult).ToBeVisibleAsync(new() { Timeout = 10000 }); + + Console.WriteLine("[Then] Success message visible"); + return this; + } + + /// + /// Asserts that the company was created by verifying the success screen shows the company name. + /// + public async Task CompanyIsCreated() + { + Console.WriteLine("[Then] Verifying company was created"); + + // Verify the success screen shows the company name + if (!string.IsNullOrEmpty(_context.CompanyName)) + { + var successText = _context.Page.Locator(".ant-result-title"); + await Assertions.Expect(successText).ToContainTextAsync(_context.CompanyName, new() { Timeout = 5000 }); + } + + Console.WriteLine("[Then] Company creation verified via UI"); + return this; + } + + /// + /// Asserts that a validation error message is shown. + /// + public async Task ValidationErrorIsShown(string expectedMessage) + { + Console.WriteLine($"[Then] Expecting validation error: {expectedMessage}"); + + // Wait for Ant Design form validation error + var errorLocator = _context.Page.Locator(".ant-form-item-explain-error"); + await Assertions.Expect(errorLocator.First).ToBeVisibleAsync(new() { Timeout = 5000 }); + + // Check if any error contains the expected message + var errors = await errorLocator.AllTextContentsAsync(); + errors.Should().Contain(e => e.Contains(expectedMessage, StringComparison.OrdinalIgnoreCase), + $"Expected validation error containing '{expectedMessage}'"); + + Console.WriteLine($"[Then] Validation error found: {string.Join(", ", errors)}"); + return this; + } + + /// + /// Asserts that an error toast/notification is shown. + /// + public async Task ErrorMessageIsShown(string expectedMessage) + { + Console.WriteLine($"[Then] Expecting error message: {expectedMessage}"); + + // Wait for Ant Design message error + var errorMessage = _context.Page.Locator(".ant-message-error"); + await Assertions.Expect(errorMessage).ToBeVisibleAsync(new() { Timeout = 5000 }); + + var text = await errorMessage.TextContentAsync(); + text.Should().Contain(expectedMessage); + + Console.WriteLine($"[Then] Error message found: {text}"); + return this; + } + + /// + /// Asserts that the user is on a specific page. + /// + public async Task UserIsOnPage(string expectedPath) + { + Console.WriteLine($"[Then] Expecting to be on page: {expectedPath}"); + + await _context.Page.WaitForURLAsync( + url => url.Contains(expectedPath), + new PageWaitForURLOptions { Timeout = 5000 }); + + var currentUrl = _context.Page.Url; + currentUrl.Should().Contain(expectedPath); + + Console.WriteLine($"[Then] On expected page: {currentUrl}"); + return this; + } + + /// + /// Asserts that specific text is visible on the page. + /// + public async Task TextIsVisible(string text) + { + Console.WriteLine($"[Then] Expecting text to be visible: {text}"); + + var locator = _context.Page.GetByText(text); + await Assertions.Expect(locator.First).ToBeVisibleAsync(new() { Timeout = 5000 }); + + Console.WriteLine($"[Then] Text is visible: {text}"); + return this; + } + + /// + /// Chain multiple Then assertions with "And". + /// + public ThenAssertions And => this; +} + +/// +/// Helper for handling eventual consistency in assertions. +/// +public static class Eventually +{ + public static async Task GetAsync( + Func> getter, + TimeSpan? timeout = null, + TimeSpan? pollInterval = null) where T : class + { + var actualTimeout = timeout ?? TimeSpan.FromSeconds(10); + var actualPollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200); + + var deadline = DateTime.UtcNow + actualTimeout; + + while (DateTime.UtcNow < deadline) + { + var result = await getter(); + if (result != null) + { + return result; + } + + await Task.Delay(actualPollInterval); + } + + return null; + } + + public static async Task AssertAsync( + Func> condition, + TimeSpan? timeout = null, + string? message = null) + { + var actualTimeout = timeout ?? TimeSpan.FromSeconds(5); + var deadline = DateTime.UtcNow + actualTimeout; + + while (DateTime.UtcNow < deadline) + { + if (await condition()) + { + return; + } + + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + throw new TimeoutException(message ?? $"Condition not met within {actualTimeout.TotalSeconds}s"); + } +} diff --git a/backend/Books.E2E.Tests/Actions/WhenActions.cs b/backend/Books.E2E.Tests/Actions/WhenActions.cs new file mode 100644 index 0000000..ae8ed5d --- /dev/null +++ b/backend/Books.E2E.Tests/Actions/WhenActions.cs @@ -0,0 +1,167 @@ +using Books.E2E.Tests.Pages; +using Books.E2E.Tests.TestData; + +namespace Books.E2E.Tests.Actions; + +/// +/// BDD "When" actions - user interactions and actions. +/// +public class WhenActions +{ + private readonly ActionContext _context; + private readonly CompanySetupWizardPage _wizardPage; + + internal WhenActions(ActionContext context) + { + _context = context; + _wizardPage = new CompanySetupWizardPage(context.Page); + } + + /// + /// Navigates to the company setup wizard. + /// + public async Task UserNavigatesToCompanySetup() + { + var url = $"{_context.FrontendBaseUrl}/opret-virksomhed"; + Console.WriteLine($"[When] Navigating to {url}"); + + await _context.Page.GotoAsync(url); + await _wizardPage.WaitForWizardToLoad(); + + return this; + } + + /// + /// Enters a company name in the wizard. + /// + public async Task UserEntersCompanyName(string name) + { + Console.WriteLine($"[When] Entering company name: {name}"); + _context.CompanyName = name; + await _wizardPage.EnterCompanyName(name); + return this; + } + + /// + /// Generates and enters a valid CVR number. + /// + public async Task UserEntersValidCvr() + { + var cvr = CvrGenerator.Generate(); + Console.WriteLine($"[When] Entering valid CVR: {cvr}"); + _context.Cvr = cvr; + await _wizardPage.EnterCvr(cvr); + return this; + } + + /// + /// Enters a specific CVR number (may be invalid). + /// + public async Task UserEntersCvr(string cvr) + { + Console.WriteLine($"[When] Entering CVR: {cvr}"); + _context.Cvr = cvr; + await _wizardPage.EnterCvr(cvr); + return this; + } + + /// + /// Selects the country in the wizard. + /// + public async Task UserSelectsCountry(string country) + { + Console.WriteLine($"[When] Selecting country: {country}"); + await _wizardPage.SelectCountry(country); + return this; + } + + /// + /// Selects the currency in the wizard. + /// + public async Task UserSelectsCurrency(string currency) + { + Console.WriteLine($"[When] Selecting currency: {currency}"); + await _wizardPage.SelectCurrency(currency); + return this; + } + + /// + /// Selects the fiscal year start month (1-12). + /// + public async Task UserSelectsFiscalYearStartMonth(int month) + { + Console.WriteLine($"[When] Selecting fiscal year start month: {month}"); + await _wizardPage.SelectFiscalYearStartMonth(month); + return this; + } + + /// + /// Sets whether the company is VAT registered. + /// + public async Task UserSelectsVatRegistered(bool isRegistered) + { + Console.WriteLine($"[When] Setting VAT registered: {isRegistered}"); + await _wizardPage.SelectVatRegistered(isRegistered); + return this; + } + + /// + /// Selects the VAT period frequency. + /// + public async Task UserSelectsVatPeriod(string period) + { + Console.WriteLine($"[When] Selecting VAT period: {period}"); + await _wizardPage.SelectVatPeriod(period); + return this; + } + + /// + /// Clicks the Next button to advance the wizard. + /// + public async Task UserClicksNext() + { + Console.WriteLine("[When] Clicking Next"); + await _wizardPage.ClickNext(); + return this; + } + + /// + /// Clicks the Back button to go back in the wizard. + /// + public async Task UserClicksBack() + { + Console.WriteLine("[When] Clicking Back"); + await _wizardPage.ClickBack(); + return this; + } + + /// + /// Submits the company creation form. + /// + public async Task UserSubmitsCompanyForm() + { + Console.WriteLine("[When] Submitting company form"); + await _wizardPage.SubmitForm(); + return new ThenAssertions(_context); + } + + /// + /// Clicks the "Go to Dashboard" button after successful creation. + /// + public async Task UserClicksGoToDashboard() + { + Console.WriteLine("[When] Clicking 'Go to Dashboard'"); + await _wizardPage.ClickGoToDashboard(); + return new ThenAssertions(_context); + } + + /// + /// Chain multiple When actions with "And". + /// + public WhenActions And => this; + + /// + /// Transition to Then assertions. + /// + public ThenAssertions Then => new(_context); +} diff --git a/backend/Books.E2E.Tests/Books.E2E.Tests.csproj b/backend/Books.E2E.Tests/Books.E2E.Tests.csproj new file mode 100644 index 0000000..3a970d7 --- /dev/null +++ b/backend/Books.E2E.Tests/Books.E2E.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + 14 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Books.E2E.Tests/Infrastructure/E2EConfiguration.cs b/backend/Books.E2E.Tests/Infrastructure/E2EConfiguration.cs new file mode 100644 index 0000000..2d51d9f --- /dev/null +++ b/backend/Books.E2E.Tests/Infrastructure/E2EConfiguration.cs @@ -0,0 +1,47 @@ +namespace Books.E2E.Tests.Infrastructure; + +/// +/// Configuration for E2E tests. +/// URLs can be configured via environment variables or appsettings. +/// +public class E2EConfiguration +{ + /// + /// Backend API URL. Default: https://localhost:5001 + /// Override with E2E_BACKEND_URL environment variable. + /// + public string BackendUrl { get; } + + /// + /// Frontend URL. Default: http://localhost:5173 + /// Override with E2E_FRONTEND_URL environment variable. + /// + public string FrontendUrl { get; } + + /// + /// GraphQL endpoint URL. Derived from BackendUrl. + /// + public string GraphQLUrl => $"{BackendUrl}/graphql"; + + /// + /// Test user email for authentication. + /// + public string TestUserEmail { get; } = "nhh@softwarehuset.com"; + + /// + /// Test user password for authentication. + /// + public string TestUserPassword { get; } = "nXfdFVlAr30GiK"; + + public E2EConfiguration() + { + BackendUrl = Environment.GetEnvironmentVariable("E2E_BACKEND_URL") + ?? "https://localhost:5001"; + + FrontendUrl = Environment.GetEnvironmentVariable("E2E_FRONTEND_URL") + ?? "http://localhost:3000"; + + Console.WriteLine($"[E2EConfiguration] Backend URL: {BackendUrl}"); + Console.WriteLine($"[E2EConfiguration] Frontend URL: {FrontendUrl}"); + } +} diff --git a/backend/Books.E2E.Tests/Infrastructure/E2ETestBase.cs b/backend/Books.E2E.Tests/Infrastructure/E2ETestBase.cs new file mode 100644 index 0000000..ac1168c --- /dev/null +++ b/backend/Books.E2E.Tests/Infrastructure/E2ETestBase.cs @@ -0,0 +1,67 @@ +using Books.E2E.Tests.Actions; +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Infrastructure; + +/// +/// Base class for all E2E tests. +/// Provides access to Playwright Page and Actions builder. +/// Tests run against pre-configured URLs (localhost or remote). +/// +[Collection(E2ETestCollection.Name)] +[Trait("Category", "E2E")] +public abstract class E2ETestBase : IAsyncLifetime +{ + protected readonly E2ETestFixture Fixture; + protected readonly PlaywrightFixture PlaywrightFixture; + + private IBrowserContext? _browserContext; + private IPage? _page; + + protected IPage Page => _page ?? throw new InvalidOperationException("Page not initialized. Did InitializeAsync complete?"); + protected ActionsBuilder Actions { get; private set; } = null!; + protected E2EConfiguration Config => Fixture.Configuration; + + protected E2ETestBase( + E2ETestFixture fixture, + PlaywrightFixture playwrightFixture) + { + Fixture = fixture; + PlaywrightFixture = playwrightFixture; + } + + public virtual async Task InitializeAsync() + { + // Create a fresh browser context for test isolation + _browserContext = await PlaywrightFixture.CreateContextAsync(); + _page = await _browserContext.NewPageAsync(); + + // Setup the actions builder with context + var context = new ActionContext + { + Page = Page, + BrowserContext = _browserContext, + ApiClient = Fixture.ApiClient, + BackendBaseUrl = Config.BackendUrl, + FrontendBaseUrl = Config.FrontendUrl, + Configuration = Config + }; + + Actions = new ActionsBuilder(context); + + Console.WriteLine($"[E2ETestBase] Test initialized - Backend: {Config.BackendUrl}, Frontend: {Config.FrontendUrl}"); + } + + public virtual async Task DisposeAsync() + { + if (_page != null) + { + await _page.CloseAsync(); + } + + if (_browserContext != null) + { + await _browserContext.CloseAsync(); + } + } +} diff --git a/backend/Books.E2E.Tests/Infrastructure/E2ETestCollection.cs b/backend/Books.E2E.Tests/Infrastructure/E2ETestCollection.cs new file mode 100644 index 0000000..1c0aaa8 --- /dev/null +++ b/backend/Books.E2E.Tests/Infrastructure/E2ETestCollection.cs @@ -0,0 +1,13 @@ +namespace Books.E2E.Tests.Infrastructure; + +/// +/// xUnit collection definition for E2E tests. +/// All E2E tests share these fixtures and run serially. +/// +[CollectionDefinition(Name)] +public class E2ETestCollection : + ICollectionFixture, + ICollectionFixture +{ + public const string Name = "E2E"; +} diff --git a/backend/Books.E2E.Tests/Infrastructure/E2ETestFixture.cs b/backend/Books.E2E.Tests/Infrastructure/E2ETestFixture.cs new file mode 100644 index 0000000..8ab9102 --- /dev/null +++ b/backend/Books.E2E.Tests/Infrastructure/E2ETestFixture.cs @@ -0,0 +1,60 @@ +namespace Books.E2E.Tests.Infrastructure; + +/// +/// Main fixture for E2E tests. +/// Provides configuration and shared resources. +/// Does NOT start any servers - tests run against pre-existing URLs. +/// +public class E2ETestFixture : IAsyncLifetime +{ + public E2EConfiguration Configuration { get; } = new(); + public HttpClient ApiClient { get; private set; } = null!; + + public async Task InitializeAsync() + { + // Create HTTP client for API calls + var handler = new HttpClientHandler + { + // Accept self-signed certs for localhost + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + + ApiClient = new HttpClient(handler) + { + BaseAddress = new Uri(Configuration.BackendUrl) + }; + + // Verify backend is reachable + try + { + var response = await ApiClient.GetAsync("/graphql?query={__typename}"); + Console.WriteLine($"[E2ETestFixture] Backend health check: {response.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($"[E2ETestFixture] WARNING: Backend not reachable at {Configuration.BackendUrl}: {ex.Message}"); + Console.WriteLine("[E2ETestFixture] Make sure the backend is running before executing E2E tests."); + } + + // Verify frontend is reachable + try + { + using var frontendClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var response = await frontendClient.GetAsync(Configuration.FrontendUrl); + Console.WriteLine($"[E2ETestFixture] Frontend health check: {response.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($"[E2ETestFixture] WARNING: Frontend not reachable at {Configuration.FrontendUrl}: {ex.Message}"); + Console.WriteLine("[E2ETestFixture] Make sure the frontend is running before executing E2E tests."); + } + + await Task.CompletedTask; + } + + public Task DisposeAsync() + { + ApiClient?.Dispose(); + return Task.CompletedTask; + } +} diff --git a/backend/Books.E2E.Tests/Infrastructure/PlaywrightFixture.cs b/backend/Books.E2E.Tests/Infrastructure/PlaywrightFixture.cs new file mode 100644 index 0000000..4d95afa --- /dev/null +++ b/backend/Books.E2E.Tests/Infrastructure/PlaywrightFixture.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Infrastructure; + +/// +/// Shared Playwright browser instance for E2E tests. +/// Implements IAsyncLifetime for xUnit async fixture pattern. +/// +public class PlaywrightFixture : IAsyncLifetime +{ + private IPlaywright? _playwright; + private IBrowser? _browser; + + public IBrowser Browser => _browser ?? throw new InvalidOperationException("Browser not initialized. Did you forget to call InitializeAsync?"); + + public async Task InitializeAsync() + { + Console.WriteLine("[PlaywrightFixture] Initializing Playwright..."); + + _playwright = await Playwright.CreateAsync(); + + var launchOptions = new BrowserTypeLaunchOptions + { + Headless = !Debugger.IsAttached, + SlowMo = Debugger.IsAttached ? 100 : 0 + }; + + _browser = await _playwright.Chromium.LaunchAsync(launchOptions); + + Console.WriteLine($"[PlaywrightFixture] Browser launched (headless: {launchOptions.Headless})"); + } + + public async Task DisposeAsync() + { + if (_browser != null) + { + await _browser.CloseAsync(); + Console.WriteLine("[PlaywrightFixture] Browser closed"); + } + + _playwright?.Dispose(); + } + + /// + /// Creates a new browser context with E2E test settings. + /// Each test should get its own context for isolation. + /// + public async Task CreateContextAsync() + { + return await Browser.NewContextAsync(new BrowserNewContextOptions + { + IgnoreHTTPSErrors = true, + ViewportSize = new ViewportSize { Width = 1920, Height = 1080 }, + Locale = "da-DK", + TimezoneId = "Europe/Copenhagen" + }); + } +} diff --git a/backend/Books.E2E.Tests/Pages/BasePage.cs b/backend/Books.E2E.Tests/Pages/BasePage.cs new file mode 100644 index 0000000..904c7bc --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/BasePage.cs @@ -0,0 +1,81 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Base class for all page objects with common utilities. +/// +public abstract class BasePage +{ + protected readonly IPage Page; + + protected BasePage(IPage page) + { + Page = page; + } + + protected ILocator Locator(string selector) => Page.Locator(selector); + + /// + /// Waits for network activity to settle. + /// + protected async Task WaitForNetworkIdleAsync() + { + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + /// + /// Clicks a button by its visible text. + /// + protected async Task ClickButtonAsync(string text) + { + await Page.GetByRole(AriaRole.Button, new() { Name = text }).ClickAsync(); + } + + /// + /// Fills an input field by its label. + /// + protected async Task FillInputAsync(string label, string value) + { + await Page.GetByLabel(label).FillAsync(value); + } + + /// + /// Selects an option in an Ant Design Select component by label. + /// + protected async Task SelectAntOptionAsync(string formItemLabel, string optionText) + { + // Find the form item containing the label + var formItem = Page.Locator($".ant-form-item:has(.ant-form-item-label:has-text('{formItemLabel}'))"); + + // Click the select to open dropdown + var select = formItem.Locator(".ant-select"); + await select.ClickAsync(); + + // Wait for dropdown and click option + var dropdown = Page.Locator(".ant-select-dropdown:visible"); + await dropdown.WaitForAsync(); + + var option = dropdown.Locator($".ant-select-item-option:has-text('{optionText}')"); + await option.ClickAsync(); + + // Wait for dropdown to close + await Task.Delay(100); + } + + /// + /// Waits for an element to be visible. + /// + protected async Task WaitForVisibleAsync(string selector, int timeoutMs = 5000) + { + await Page.Locator(selector).WaitForAsync(new() { Timeout = timeoutMs }); + } + + /// + /// Checks if an element is visible. + /// + protected async Task IsVisibleAsync(string selector) + { + return await Page.Locator(selector).IsVisibleAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/CompanySetupWizardPage.cs b/backend/Books.E2E.Tests/Pages/CompanySetupWizardPage.cs new file mode 100644 index 0000000..aa19096 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/CompanySetupWizardPage.cs @@ -0,0 +1,203 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the Company Setup Wizard at /opret-virksomhed. +/// +public class CompanySetupWizardPage : BasePage +{ + // Selectors + private readonly ILocator _companyNameInput; + private readonly ILocator _cvrInput; + private readonly ILocator _nextButton; + private readonly ILocator _backButton; + private readonly ILocator _submitButton; + private readonly ILocator _successResult; + private readonly ILocator _goToDashboardButton; + + public CompanySetupWizardPage(IPage page) : base(page) + { + _companyNameInput = page.GetByLabel("Virksomhedsnavn"); + _cvrInput = page.GetByLabel("CVR-nummer"); + _nextButton = page.Locator("button:has-text('Næste')"); + _backButton = page.Locator("button:has-text('Tilbage')"); + _submitButton = page.GetByRole(AriaRole.Button, new() { Name = "Opret virksomhed" }); + _successResult = page.Locator(".ant-result-success"); + _goToDashboardButton = page.GetByRole(AriaRole.Button, new() { Name = "Gå til dashboard" }); + } + + /// + /// Waits for the wizard to be fully loaded. + /// + public async Task WaitForWizardToLoad() + { + // Wait for the welcome text + await Page.GetByText("Velkommen til Books").WaitForAsync(new() { Timeout = 10000 }); + await WaitForNetworkIdleAsync(); + } + + /// + /// Enters the company name. + /// + public async Task EnterCompanyName(string name) + { + await _companyNameInput.ClearAsync(); + await _companyNameInput.FillAsync(name); + } + + /// + /// Enters the CVR number. + /// + public async Task EnterCvr(string cvr) + { + await _cvrInput.ClearAsync(); + await _cvrInput.FillAsync(cvr); + } + + /// + /// Selects the country. + /// + public async Task SelectCountry(string country) + { + var countryLabel = country switch + { + "DK" => "Danmark", + _ => country + }; + await SelectAntOptionAsync("Land", countryLabel); + } + + /// + /// Selects the currency. + /// + public async Task SelectCurrency(string currency) + { + var currencyLabel = currency switch + { + "DKK" => "DKK - Danske kroner", + "EUR" => "EUR - Euro", + _ => currency + }; + await SelectAntOptionAsync("Valuta", currencyLabel); + } + + /// + /// Selects the fiscal year start month. + /// + public async Task SelectFiscalYearStartMonth(int month) + { + var monthName = GetDanishMonthName(month); + await SelectAntOptionAsync("Regnskabsår starter", monthName); + } + + /// + /// Sets whether the company is VAT registered. + /// + public async Task SelectVatRegistered(bool isRegistered) + { + var label = isRegistered + ? "Ja, virksomheden er momsregistreret" + : "Nej, ikke momsregistreret"; + await SelectAntOptionAsync("Momsregistreret", label); + } + + /// + /// Selects the VAT period frequency. + /// + public async Task SelectVatPeriod(string period) + { + var label = period switch + { + "MONTHLY" => "Månedlig", + "QUARTERLY" => "Kvartalsvis", + "HALFYEARLY" => "Halvårlig", + _ => period + }; + await SelectAntOptionAsync("Momsperiode", label); + } + + /// + /// Clicks the Next button to advance the wizard. + /// + public async Task ClickNext() + { + await _nextButton.ClickAsync(); + await WaitForNetworkIdleAsync(); + // Small delay for step transition animation + await Task.Delay(300); + } + + /// + /// Clicks the Back button. + /// + public async Task ClickBack() + { + await _backButton.ClickAsync(); + await Task.Delay(300); + } + + /// + /// Submits the company creation form. + /// + public async Task SubmitForm() + { + await _submitButton.ClickAsync(); + + // Wait for either success or error + await Page.WaitForSelectorAsync( + ".ant-result-success, .ant-message-error", + new() { Timeout = 15000 }); + } + + /// + /// Waits for the success result to appear. + /// + public async Task WaitForSuccessResult() + { + await _successResult.WaitForAsync(new() { Timeout = 10000 }); + } + + /// + /// Clicks "Go to Dashboard" button. + /// + public async Task ClickGoToDashboard() + { + await _goToDashboardButton.ClickAsync(); + await WaitForNetworkIdleAsync(); + } + + /// + /// Checks if validation error is visible. + /// + public async Task HasValidationError() + { + return await Page.Locator(".ant-form-item-explain-error").IsVisibleAsync(); + } + + /// + /// Gets all visible validation error messages. + /// + public async Task> GetValidationErrors() + { + var errors = Page.Locator(".ant-form-item-explain-error"); + return await errors.AllTextContentsAsync(); + } + + private static string GetDanishMonthName(int month) => month switch + { + 1 => "Januar", + 2 => "Februar", + 3 => "Marts", + 4 => "April", + 5 => "Maj", + 6 => "Juni", + 7 => "Juli", + 8 => "August", + 9 => "September", + 10 => "Oktober", + 11 => "November", + 12 => "December", + _ => throw new ArgumentOutOfRangeException(nameof(month), month, "Month must be 1-12") + }; +} diff --git a/backend/Books.E2E.Tests/Pages/CustomersPage.cs b/backend/Books.E2E.Tests/Pages/CustomersPage.cs new file mode 100644 index 0000000..f10111c --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/CustomersPage.cs @@ -0,0 +1,109 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the Customers page (Kunder). +/// +public class CustomersPage : BasePage +{ + private readonly ILocator _pageTitle; + private readonly ILocator _newCustomerButton; + private readonly ILocator _searchInput; + private readonly ILocator _customersTable; + private readonly ILocator _loadingSpinner; + private readonly ILocator _emptyState; + + public CustomersPage(IPage page) : base(page) + { + _pageTitle = page.Locator("h4:has-text('Kunder')"); + _newCustomerButton = page.GetByRole(AriaRole.Button, new() { Name = "Ny kunde" }); + _searchInput = page.GetByPlaceholder("Soeg kunde..."); + _customersTable = page.Locator(".ant-table"); + _loadingSpinner = page.Locator(".ant-spin"); + _emptyState = page.Locator("[class*='empty-state'], .ant-empty"); + } + + /// + /// Waits for the Customers page to load completely. + /// + public async Task WaitForPageToLoad() + { + await _pageTitle.WaitForAsync(new() { Timeout = 10000 }); + await WaitForNetworkIdleAsync(); + await _loadingSpinner.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 }); + } + + /// + /// Checks if the page title is visible. + /// + public async Task IsTitleVisible() + { + return await _pageTitle.IsVisibleAsync(); + } + + /// + /// Clicks the "Ny kunde" button to create a new customer. + /// + public async Task ClickNewCustomer() + { + await _newCustomerButton.ClickAsync(); + await Page.Locator(".ant-modal, .ant-drawer").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Searches for customers by text. + /// + public async Task SearchCustomers(string searchText) + { + await _searchInput.ClearAsync(); + await _searchInput.FillAsync(searchText); + await Task.Delay(300); + } + + /// + /// Gets the number of customers displayed in the table. + /// + public async Task GetCustomerCount() + { + var rows = _customersTable.Locator("tbody tr.ant-table-row"); + return await rows.CountAsync(); + } + + /// + /// Checks if the empty state is visible. + /// + public async Task IsEmptyStateVisible() + { + return await _emptyState.IsVisibleAsync(); + } + + /// + /// Checks if the customers table is visible. + /// + public async Task IsTableVisible() + { + return await _customersTable.IsVisibleAsync(); + } + + /// + /// Gets all customer names from the table. + /// + public async Task> GetCustomerNames() + { + // Customer name is typically in the second column + var cells = _customersTable.Locator("tbody tr.ant-table-row td:nth-child(2)"); + return await cells.AllTextContentsAsync(); + } + + /// + /// Clicks the view/edit button for a customer by name. + /// + public async Task ViewCustomer(string customerName) + { + var row = _customersTable.Locator($"tr:has-text('{customerName}')"); + var viewButton = row.Locator("button").First; + await viewButton.ClickAsync(); + await Page.Locator(".ant-drawer, .ant-modal").WaitForAsync(new() { Timeout = 5000 }); + } +} diff --git a/backend/Books.E2E.Tests/Pages/DashboardPage.cs b/backend/Books.E2E.Tests/Pages/DashboardPage.cs new file mode 100644 index 0000000..3e51bc8 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/DashboardPage.cs @@ -0,0 +1,44 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the Dashboard page at /. +/// +public class DashboardPage : BasePage +{ + public DashboardPage(IPage page) : base(page) + { + } + + /// + /// Waits for the dashboard to be fully loaded. + /// + public async Task WaitForDashboardToLoad() + { + // Wait for dashboard header or main content + await Page.WaitForSelectorAsync(".ant-layout-content", new() { Timeout = 10000 }); + await WaitForNetworkIdleAsync(); + } + + /// + /// Checks if the dashboard shows any KPI cards. + /// + public async Task HasKpiCards() + { + return await Page.Locator(".ant-statistic").CountAsync() > 0; + } + + /// + /// Gets the company name shown in the header. + /// + public async Task GetCurrentCompanyName() + { + var companySelector = Page.Locator("[data-testid='company-name'], .company-name"); + if (await companySelector.IsVisibleAsync()) + { + return await companySelector.TextContentAsync(); + } + return null; + } +} diff --git a/backend/Books.E2E.Tests/Pages/InvoicesPage.cs b/backend/Books.E2E.Tests/Pages/InvoicesPage.cs new file mode 100644 index 0000000..10c2dcd --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/InvoicesPage.cs @@ -0,0 +1,149 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the Invoices page (Fakturaer). +/// +public class InvoicesPage : BasePage +{ + private readonly ILocator _pageTitle; + private readonly ILocator _newInvoiceButton; + private readonly ILocator _searchInput; + private readonly ILocator _statusFilter; + private readonly ILocator _invoicesTable; + private readonly ILocator _loadingSpinner; + private readonly ILocator _emptyState; + + // Statistics + private readonly ILocator _totalInvoicesStat; + private readonly ILocator _draftsStat; + private readonly ILocator _sentStat; + private readonly ILocator _paidStat; + + public InvoicesPage(IPage page) : base(page) + { + _pageTitle = page.Locator("h4:has-text('Fakturaer')"); + _newInvoiceButton = page.GetByRole(AriaRole.Button, new() { Name = "Ny faktura" }); + _searchInput = page.GetByPlaceholder("Soeg faktura..."); + _statusFilter = page.Locator(".ant-select").First; + _invoicesTable = page.Locator(".ant-table"); + _loadingSpinner = page.Locator(".ant-spin"); + _emptyState = page.Locator("[class*='empty-state'], .ant-empty"); + + _totalInvoicesStat = page.Locator(".ant-statistic:has-text('Fakturaer i alt')"); + _draftsStat = page.Locator(".ant-statistic:has-text('Kladder')"); + _sentStat = page.Locator(".ant-statistic:has-text('Sendt')"); + _paidStat = page.Locator(".ant-statistic:has-text('Betalt')"); + } + + /// + /// Waits for the Invoices page to load completely. + /// + public async Task WaitForPageToLoad() + { + await _pageTitle.WaitForAsync(new() { Timeout = 10000 }); + await WaitForNetworkIdleAsync(); + await _loadingSpinner.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 }); + } + + /// + /// Checks if the page title is visible. + /// + public async Task IsTitleVisible() + { + return await _pageTitle.IsVisibleAsync(); + } + + /// + /// Clicks the "Ny faktura" button to create a new invoice. + /// + public async Task ClickNewInvoice() + { + await _newInvoiceButton.ClickAsync(); + await Page.Locator(".ant-modal, .ant-drawer").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Searches for invoices by text. + /// + public async Task SearchInvoices(string searchText) + { + await _searchInput.ClearAsync(); + await _searchInput.FillAsync(searchText); + await Task.Delay(300); + } + + /// + /// Filters invoices by status. + /// + public async Task FilterByStatus(string status) + { + await _statusFilter.ClickAsync(); + await Page.Locator($".ant-select-item-option:has-text('{status}')").ClickAsync(); + await Task.Delay(300); + } + + /// + /// Gets the number of invoices displayed in the table. + /// + public async Task GetInvoiceCount() + { + var rows = _invoicesTable.Locator("tbody tr.ant-table-row"); + return await rows.CountAsync(); + } + + /// + /// Checks if the empty state is visible. + /// + public async Task IsEmptyStateVisible() + { + return await _emptyState.IsVisibleAsync(); + } + + /// + /// Checks if the invoices table is visible. + /// + public async Task IsTableVisible() + { + return await _invoicesTable.IsVisibleAsync(); + } + + /// + /// Gets all invoice numbers from the table. + /// + public async Task> GetInvoiceNumbers() + { + var cells = _invoicesTable.Locator("tbody tr.ant-table-row td:nth-child(1) code"); + return await cells.AllTextContentsAsync(); + } + + /// + /// Views an invoice by its invoice number. + /// + public async Task ViewInvoice(string invoiceNumber) + { + var row = _invoicesTable.Locator($"tr:has-text('{invoiceNumber}')"); + var viewButton = row.Locator("button").First; + await viewButton.ClickAsync(); + await Page.Locator(".ant-drawer").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Gets the total invoices statistic value. + /// + public async Task GetTotalInvoicesStat() + { + var value = _totalInvoicesStat.Locator(".ant-statistic-content-value"); + return await value.TextContentAsync(); + } + + /// + /// Gets the drafts statistic value. + /// + public async Task GetDraftsStat() + { + var value = _draftsStat.Locator(".ant-statistic-content-value"); + return await value.TextContentAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/Modals/AddOrderLineModalPage.cs b/backend/Books.E2E.Tests/Pages/Modals/AddOrderLineModalPage.cs new file mode 100644 index 0000000..0c93e26 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/Modals/AddOrderLineModalPage.cs @@ -0,0 +1,178 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages.Modals; + +/// +/// Page object for the Add Order Line modal. +/// +public class AddOrderLineModalPage : BasePage +{ + private readonly ILocator _modal; + private readonly ILocator _descriptionInput; + private readonly ILocator _quantityInput; + private readonly ILocator _unitInput; + private readonly ILocator _unitPriceInput; + private readonly ILocator _discountPercentInput; + private readonly ILocator _vatCodeSelect; + private readonly ILocator _submitButton; + private readonly ILocator _cancelButton; + + public AddOrderLineModalPage(IPage page) : base(page) + { + _modal = page.Locator(".ant-modal:has-text('Tilfoej linje')"); + _descriptionInput = _modal.Locator("input").First; // Description is first input + _quantityInput = _modal.Locator(".ant-form-item:has-text('Antal') input"); + _unitInput = _modal.Locator(".ant-form-item:has-text('Enhed') input"); + _unitPriceInput = _modal.Locator(".ant-form-item:has-text('Enhedspris') input"); + _discountPercentInput = _modal.Locator(".ant-form-item:has-text('Rabat') input"); + _vatCodeSelect = _modal.Locator(".ant-form-item:has-text('Momskode') .ant-select"); + _submitButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Tilfoej" }); + _cancelButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Annuller" }); + } + + /// + /// Waits for the modal to be visible. + /// + public async Task WaitForModalToOpen() + { + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 }); + } + + /// + /// Enters the line description. + /// + public async Task EnterDescription(string description) + { + await _descriptionInput.ClearAsync(); + await _descriptionInput.FillAsync(description); + } + + /// + /// Enters the quantity. + /// + public async Task EnterQuantity(int quantity) + { + await _quantityInput.ClearAsync(); + await _quantityInput.FillAsync(quantity.ToString()); + } + + /// + /// Enters the unit (e.g., "stk", "timer"). + /// + public async Task EnterUnit(string unit) + { + await _unitInput.ClearAsync(); + await _unitInput.FillAsync(unit); + } + + /// + /// Enters the unit price. + /// + public async Task EnterUnitPrice(decimal price) + { + await _unitPriceInput.ClearAsync(); + await _unitPriceInput.FillAsync(price.ToString("F2")); + } + + /// + /// Enters a discount percentage. + /// + public async Task EnterDiscount(decimal discountPercent) + { + await _discountPercentInput.ClearAsync(); + await _discountPercentInput.FillAsync(discountPercent.ToString("F0")); + } + + /// + /// Selects a VAT code (e.g., "S25", "S0"). + /// + public async Task SelectVatCode(string vatCode) + { + await _vatCodeSelect.ClickAsync(); + var dropdown = Page.Locator(".ant-select-dropdown:visible"); + await dropdown.WaitForAsync(new() { Timeout = 5000 }); + + var option = dropdown.Locator($".ant-select-item-option:has-text('{vatCode}')"); + await option.ClickAsync(); + } + + /// + /// Fills all required fields for a basic order line. + /// + public async Task FillBasicLine(string description, int quantity, decimal unitPrice) + { + await EnterDescription(description); + await EnterQuantity(quantity); + await EnterUnitPrice(unitPrice); + } + + /// + /// Fills all fields for a complete order line. + /// + public async Task FillCompleteLine( + string description, + int quantity, + string unit, + decimal unitPrice, + decimal? discountPercent = null, + string vatCode = "S25") + { + await EnterDescription(description); + await EnterQuantity(quantity); + await EnterUnit(unit); + await EnterUnitPrice(unitPrice); + + if (discountPercent.HasValue) + { + await EnterDiscount(discountPercent.Value); + } + + await SelectVatCode(vatCode); + } + + /// + /// Submits the form to add the line. + /// + public async Task Submit() + { + await _submitButton.ClickAsync(); + // Wait for modal to close or show error + await Task.WhenAny( + _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 }), + Page.Locator(".ant-form-item-explain-error").WaitForAsync(new() { Timeout = 10000 }) + ); + } + + /// + /// Cancels and closes the modal. + /// + public async Task Cancel() + { + await _cancelButton.ClickAsync(); + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 5000 }); + } + + /// + /// Checks if there are validation errors. + /// + public async Task HasValidationErrors() + { + return await _modal.Locator(".ant-form-item-explain-error").IsVisibleAsync(); + } + + /// + /// Gets all validation error messages. + /// + public async Task> GetValidationErrors() + { + return await _modal.Locator(".ant-form-item-explain-error").AllTextContentsAsync(); + } + + /// + /// Checks if the modal is visible. + /// + public async Task IsVisible() + { + return await _modal.IsVisibleAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/Modals/CancelOrderModalPage.cs b/backend/Books.E2E.Tests/Pages/Modals/CancelOrderModalPage.cs new file mode 100644 index 0000000..b028296 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/Modals/CancelOrderModalPage.cs @@ -0,0 +1,87 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages.Modals; + +/// +/// Page object for the Cancel Order modal. +/// +public class CancelOrderModalPage : BasePage +{ + private readonly ILocator _modal; + private readonly ILocator _warningAlert; + private readonly ILocator _reasonInput; + private readonly ILocator _submitButton; + private readonly ILocator _cancelButton; + + public CancelOrderModalPage(IPage page) : base(page) + { + _modal = page.Locator(".ant-modal:has-text('Annuller ordre')"); + _warningAlert = _modal.Locator(".ant-alert-warning"); + _reasonInput = _modal.Locator("textarea"); + _submitButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Annuller ordre" }); + _cancelButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Fortryd" }); + } + + /// + /// Waits for the modal to be visible. + /// + public async Task WaitForModalToOpen() + { + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 }); + } + + /// + /// Checks if the warning alert is visible. + /// + public async Task IsWarningAlertVisible() + { + return await _warningAlert.IsVisibleAsync(); + } + + /// + /// Enters the cancellation reason. + /// + public async Task EnterReason(string reason) + { + await _reasonInput.ClearAsync(); + await _reasonInput.FillAsync(reason); + } + + /// + /// Submits the form to cancel the order. + /// + public async Task Submit() + { + await _submitButton.ClickAsync(); + // Wait for modal to close or show error + await Task.WhenAny( + _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 }), + Page.Locator(".ant-form-item-explain-error").WaitForAsync(new() { Timeout = 10000 }) + ); + } + + /// + /// Cancels and closes the modal (does not cancel the order). + /// + public async Task Cancel() + { + await _cancelButton.ClickAsync(); + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 5000 }); + } + + /// + /// Checks if there are validation errors. + /// + public async Task HasValidationErrors() + { + return await _modal.Locator(".ant-form-item-explain-error").IsVisibleAsync(); + } + + /// + /// Checks if the modal is visible. + /// + public async Task IsVisible() + { + return await _modal.IsVisibleAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/Modals/ConvertToInvoiceModalPage.cs b/backend/Books.E2E.Tests/Pages/Modals/ConvertToInvoiceModalPage.cs new file mode 100644 index 0000000..4991ee8 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/Modals/ConvertToInvoiceModalPage.cs @@ -0,0 +1,166 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages.Modals; + +/// +/// Page object for the Convert Order to Invoice modal. +/// +public class ConvertToInvoiceModalPage : BasePage +{ + private readonly ILocator _modal; + private readonly ILocator _infoAlert; + private readonly ILocator _linesList; + private readonly ILocator _submitButton; + private readonly ILocator _cancelButton; + + public ConvertToInvoiceModalPage(IPage page) : base(page) + { + _modal = page.Locator(".ant-modal:has-text('Opret faktura fra ordre')"); + _infoAlert = _modal.Locator(".ant-alert-info"); + _linesList = _modal.Locator(".ant-list"); + _submitButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Opret faktura" }); + _cancelButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Annuller" }); + } + + /// + /// Waits for the modal to be visible. + /// + public async Task WaitForModalToOpen() + { + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 }); + } + + /// + /// Checks if the info alert is visible. + /// + public async Task IsInfoAlertVisible() + { + return await _infoAlert.IsVisibleAsync(); + } + + /// + /// Gets the number of available lines to invoice. + /// + public async Task GetAvailableLineCount() + { + return await _linesList.Locator(".ant-list-item").CountAsync(); + } + + /// + /// Selects a line by its description text. + /// + public async Task SelectLine(string description) + { + var line = _linesList.Locator($".ant-list-item:has-text('{description}')"); + var checkbox = line.Locator(".ant-checkbox-input"); + if (!await checkbox.IsCheckedAsync()) + { + await checkbox.ClickAsync(); + } + } + + /// + /// Deselects a line by its description text. + /// + public async Task DeselectLine(string description) + { + var line = _linesList.Locator($".ant-list-item:has-text('{description}')"); + var checkbox = line.Locator(".ant-checkbox-input"); + if (await checkbox.IsCheckedAsync()) + { + await checkbox.ClickAsync(); + } + } + + /// + /// Selects all available lines. + /// + public async Task SelectAllLines() + { + var checkboxes = _linesList.Locator(".ant-checkbox-input"); + var count = await checkboxes.CountAsync(); + for (var i = 0; i < count; i++) + { + var checkbox = checkboxes.Nth(i); + if (!await checkbox.IsCheckedAsync()) + { + await checkbox.ClickAsync(); + } + } + } + + /// + /// Deselects all lines. + /// + public async Task DeselectAllLines() + { + var checkboxes = _linesList.Locator(".ant-checkbox-input"); + var count = await checkboxes.CountAsync(); + for (var i = 0; i < count; i++) + { + var checkbox = checkboxes.Nth(i); + if (await checkbox.IsCheckedAsync()) + { + await checkbox.ClickAsync(); + } + } + } + + /// + /// Gets the count of selected lines. + /// + public async Task GetSelectedLineCount() + { + var checkboxes = _linesList.Locator(".ant-checkbox-input"); + var count = await checkboxes.CountAsync(); + var selectedCount = 0; + for (var i = 0; i < count; i++) + { + if (await checkboxes.Nth(i).IsCheckedAsync()) + { + selectedCount++; + } + } + return selectedCount; + } + + /// + /// Checks if a specific line is selected. + /// + public async Task IsLineSelected(string description) + { + var line = _linesList.Locator($".ant-list-item:has-text('{description}')"); + var checkbox = line.Locator(".ant-checkbox-input"); + return await checkbox.IsCheckedAsync(); + } + + /// + /// Submits the form to create the invoice. + /// + public async Task Submit() + { + await _submitButton.ClickAsync(); + // Wait for modal to close or show error/warning + await Task.WhenAny( + _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 15000 }), + Page.Locator(".ant-message-warning, .ant-message-error").WaitForAsync(new() { Timeout = 15000 }) + ); + } + + /// + /// Cancels and closes the modal. + /// + public async Task Cancel() + { + await _cancelButton.ClickAsync(); + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 5000 }); + } + + /// + /// Checks if the modal is visible. + /// + public async Task IsVisible() + { + return await _modal.IsVisibleAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/Modals/CreateOrderModalPage.cs b/backend/Books.E2E.Tests/Pages/Modals/CreateOrderModalPage.cs new file mode 100644 index 0000000..a5cd180 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/Modals/CreateOrderModalPage.cs @@ -0,0 +1,145 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages.Modals; + +/// +/// Page object for the Create Order modal. +/// +public class CreateOrderModalPage : BasePage +{ + private readonly ILocator _modal; + private readonly ILocator _customerSelect; + private readonly ILocator _orderDatePicker; + private readonly ILocator _expectedDeliveryDatePicker; + private readonly ILocator _referenceInput; + private readonly ILocator _notesInput; + private readonly ILocator _submitButton; + private readonly ILocator _cancelButton; + + public CreateOrderModalPage(IPage page) : base(page) + { + _modal = page.Locator(".ant-modal:has-text('Opret ordre')"); + _customerSelect = _modal.Locator(".ant-form-item:has-text('Kunde') .ant-select"); + _orderDatePicker = _modal.Locator(".ant-form-item:has-text('Ordredato') .ant-picker"); + _expectedDeliveryDatePicker = _modal.Locator(".ant-form-item:has-text('Forventet levering') .ant-picker"); + _referenceInput = _modal.Locator("input#reference, input[id*='reference']").First; + _notesInput = _modal.Locator("textarea"); + _submitButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Opret" }); + _cancelButton = _modal.GetByRole(AriaRole.Button, new() { Name = "Annuller" }); + } + + /// + /// Waits for the modal to be visible. + /// + public async Task WaitForModalToOpen() + { + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 }); + } + + /// + /// Selects a customer by searching for their name or number. + /// + public async Task SelectCustomer(string customerSearchText) + { + await _customerSelect.ClickAsync(); + + // Wait for dropdown to appear + var dropdown = Page.Locator(".ant-select-dropdown:visible"); + await dropdown.WaitForAsync(new() { Timeout = 5000 }); + + // Type to search + await Page.Keyboard.TypeAsync(customerSearchText); + await Task.Delay(300); // Wait for search filtering + + // Click the first matching option + var option = dropdown.Locator($".ant-select-item-option").First; + await option.ClickAsync(); + } + + /// + /// Sets the order date. + /// + public async Task SetOrderDate(DateTime date) + { + await _orderDatePicker.ClickAsync(); + // Clear and type the date + await Page.Keyboard.PressAsync("Control+a"); + await Page.Keyboard.TypeAsync(date.ToString("dd-MM-yyyy")); + await Page.Keyboard.PressAsync("Enter"); + } + + /// + /// Sets the expected delivery date. + /// + public async Task SetExpectedDeliveryDate(DateTime date) + { + await _expectedDeliveryDatePicker.ClickAsync(); + await Page.Keyboard.PressAsync("Control+a"); + await Page.Keyboard.TypeAsync(date.ToString("dd-MM-yyyy")); + await Page.Keyboard.PressAsync("Enter"); + } + + /// + /// Enters a reference. + /// + public async Task EnterReference(string reference) + { + await _referenceInput.ClearAsync(); + await _referenceInput.FillAsync(reference); + } + + /// + /// Enters notes. + /// + public async Task EnterNotes(string notes) + { + await _notesInput.ClearAsync(); + await _notesInput.FillAsync(notes); + } + + /// + /// Submits the form to create the order. + /// + public async Task Submit() + { + await _submitButton.ClickAsync(); + // Wait for modal to close or show error + await Task.WhenAny( + _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 }), + Page.Locator(".ant-form-item-explain-error").WaitForAsync(new() { Timeout = 10000 }) + ); + } + + /// + /// Cancels and closes the modal. + /// + public async Task Cancel() + { + await _cancelButton.ClickAsync(); + await _modal.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 5000 }); + } + + /// + /// Checks if there are validation errors. + /// + public async Task HasValidationErrors() + { + return await _modal.Locator(".ant-form-item-explain-error").IsVisibleAsync(); + } + + /// + /// Gets all validation error messages. + /// + public async Task> GetValidationErrors() + { + return await _modal.Locator(".ant-form-item-explain-error").AllTextContentsAsync(); + } + + /// + /// Checks if the modal is visible. + /// + public async Task IsVisible() + { + return await _modal.IsVisibleAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/OrderDrawerPage.cs b/backend/Books.E2E.Tests/Pages/OrderDrawerPage.cs new file mode 100644 index 0000000..9d8b78e --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/OrderDrawerPage.cs @@ -0,0 +1,203 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the Order detail drawer. +/// +public class OrderDrawerPage : BasePage +{ + private readonly ILocator _drawer; + private readonly ILocator _drawerTitle; + private readonly ILocator _closeButton; + + // Action buttons + private readonly ILocator _addLineButton; + private readonly ILocator _confirmButton; + private readonly ILocator _createInvoiceButton; + private readonly ILocator _cancelButton; + + // Content sections + private readonly ILocator _customerDescription; + private readonly ILocator _linesList; + private readonly ILocator _noLinesAlert; + + // Totals + private readonly ILocator _totalAmount; + + public OrderDrawerPage(IPage page) : base(page) + { + _drawer = page.Locator(".ant-drawer"); + _drawerTitle = _drawer.Locator(".ant-drawer-title"); + _closeButton = _drawer.Locator(".ant-drawer-close"); + + _addLineButton = _drawer.GetByRole(AriaRole.Button, new() { Name = "Tilfoej linje" }); + _confirmButton = _drawer.GetByRole(AriaRole.Button, new() { Name = "Bekraeft" }); + _createInvoiceButton = _drawer.GetByRole(AriaRole.Button, new() { Name = "Opret faktura" }); + _cancelButton = _drawer.GetByRole(AriaRole.Button, new() { Name = "Annuller" }); + + _customerDescription = _drawer.Locator(".ant-descriptions-item:has-text('Kunde')"); + _linesList = _drawer.Locator(".ant-list"); + _noLinesAlert = _drawer.Locator(".ant-alert:has-text('Ingen linjer')"); + + _totalAmount = _drawer.Locator("text=Total:").Locator(".."); + } + + /// + /// Waits for the drawer to be visible. + /// + public async Task WaitForDrawerToOpen() + { + await _drawer.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 }); + await WaitForNetworkIdleAsync(); + } + + /// + /// Closes the drawer. + /// + public async Task CloseDrawer() + { + await _closeButton.ClickAsync(); + await _drawer.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 5000 }); + } + + /// + /// Gets the order number from the drawer title. + /// + public async Task GetOrderNumber() + { + var title = await _drawerTitle.TextContentAsync(); + // Extract order number from "Ordre ORD-XXXXX" + return title?.Replace("Ordre ", "").Trim(); + } + + /// + /// Gets the order status tag text. + /// + public async Task GetOrderStatus() + { + var statusTag = _drawerTitle.Locator(".ant-tag"); + return await statusTag.TextContentAsync(); + } + + /// + /// Gets the customer name from the description. + /// + public async Task GetCustomerName() + { + var customerValue = _drawer.Locator(".ant-descriptions-item:has(.ant-descriptions-item-label:text('Kunde')) .ant-descriptions-item-content"); + return await customerValue.TextContentAsync(); + } + + /// + /// Checks if the "Add Line" button is visible. + /// + public async Task IsAddLineButtonVisible() + { + return await _addLineButton.IsVisibleAsync(); + } + + /// + /// Checks if the "Confirm" button is visible. + /// + public async Task IsConfirmButtonVisible() + { + return await _confirmButton.IsVisibleAsync(); + } + + /// + /// Checks if the "Create Invoice" button is visible. + /// + public async Task IsCreateInvoiceButtonVisible() + { + return await _createInvoiceButton.IsVisibleAsync(); + } + + /// + /// Checks if the "Cancel" button is visible. + /// + public async Task IsCancelButtonVisible() + { + return await _cancelButton.IsVisibleAsync(); + } + + /// + /// Clicks the "Add Line" button to open the add line modal. + /// + public async Task ClickAddLine() + { + await _addLineButton.ClickAsync(); + await Page.Locator(".ant-modal:has-text('Tilfoej linje')").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Clicks the "Confirm" button to confirm the order. + /// + public async Task ClickConfirm() + { + await _confirmButton.ClickAsync(); + await WaitForNetworkIdleAsync(); + } + + /// + /// Clicks the "Create Invoice" button. + /// + public async Task ClickCreateInvoice() + { + await _createInvoiceButton.ClickAsync(); + await Page.Locator(".ant-modal:has-text('Opret faktura')").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Clicks the "Cancel" button to open the cancel modal. + /// + public async Task ClickCancel() + { + await _cancelButton.ClickAsync(); + await Page.Locator(".ant-modal:has-text('Annuller ordre')").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Gets the number of order lines. + /// + public async Task GetLineCount() + { + var lines = _linesList.Locator(".ant-list-item"); + return await lines.CountAsync(); + } + + /// + /// Checks if the "no lines" alert is visible. + /// + public async Task IsNoLinesAlertVisible() + { + return await _noLinesAlert.IsVisibleAsync(); + } + + /// + /// Gets all line descriptions. + /// + public async Task> GetLineDescriptions() + { + var titles = _linesList.Locator(".ant-list-item-meta-title"); + return await titles.AllTextContentsAsync(); + } + + /// + /// Checks if a line is marked as invoiced. + /// + public async Task IsLineInvoiced(string description) + { + var line = _linesList.Locator($".ant-list-item:has-text('{description}')"); + var invoicedTag = line.Locator(".ant-tag:has-text('Faktureret')"); + return await invoicedTag.IsVisibleAsync(); + } + + /// + /// Gets the total amount from the drawer. + /// + public async Task GetTotalAmount() + { + return await _totalAmount.TextContentAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/OrdersPage.cs b/backend/Books.E2E.Tests/Pages/OrdersPage.cs new file mode 100644 index 0000000..8b3c3ef --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/OrdersPage.cs @@ -0,0 +1,189 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the Orders page (Ordrer). +/// +public class OrdersPage : BasePage +{ + // Locators + private readonly ILocator _pageTitle; + private readonly ILocator _newOrderButton; + private readonly ILocator _searchInput; + private readonly ILocator _statusFilter; + private readonly ILocator _ordersTable; + private readonly ILocator _loadingSpinner; + private readonly ILocator _emptyState; + + // Statistics locators + private readonly ILocator _totalOrdersStat; + private readonly ILocator _draftsStat; + private readonly ILocator _confirmedStat; + private readonly ILocator _totalValueStat; + + public OrdersPage(IPage page) : base(page) + { + _pageTitle = page.Locator("h4:has-text('Ordrer')"); + _newOrderButton = page.GetByRole(AriaRole.Button, new() { Name = "Ny ordre" }); + _searchInput = page.GetByPlaceholder("Soeg ordre..."); + _statusFilter = page.Locator(".ant-select").First; + _ordersTable = page.Locator(".ant-table"); + _loadingSpinner = page.Locator(".ant-spin"); + _emptyState = page.Locator("[class*='empty-state'], .ant-empty"); + + // Stats cards - by their title text + _totalOrdersStat = page.Locator(".ant-statistic:has-text('Ordrer i alt')"); + _draftsStat = page.Locator(".ant-statistic:has-text('Kladder')"); + _confirmedStat = page.Locator(".ant-statistic:has-text('Bekraeftede')"); + _totalValueStat = page.Locator(".ant-statistic:has-text('Samlet vaerdi')"); + } + + /// + /// Waits for the Orders page to load completely. + /// + public async Task WaitForPageToLoad() + { + await _pageTitle.WaitForAsync(new() { Timeout = 10000 }); + await WaitForNetworkIdleAsync(); + // Wait for loading spinner to disappear + await _loadingSpinner.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 }); + } + + /// + /// Checks if the page title is visible. + /// + public async Task IsTitleVisible() + { + return await _pageTitle.IsVisibleAsync(); + } + + /// + /// Clicks the "Ny ordre" button to create a new order. + /// + public async Task ClickNewOrder() + { + await _newOrderButton.ClickAsync(); + // Wait for modal to appear + await Page.Locator(".ant-modal").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Searches for orders by text. + /// + public async Task SearchOrders(string searchText) + { + await _searchInput.ClearAsync(); + await _searchInput.FillAsync(searchText); + // Wait for filtering to complete + await Task.Delay(300); + } + + /// + /// Clears the search input. + /// + public async Task ClearSearch() + { + var clearButton = _searchInput.Locator("..").Locator(".ant-input-clear-icon"); + if (await clearButton.IsVisibleAsync()) + { + await clearButton.ClickAsync(); + } + else + { + await _searchInput.ClearAsync(); + } + } + + /// + /// Filters orders by status. + /// + public async Task FilterByStatus(string status) + { + await _statusFilter.ClickAsync(); + await Page.Locator($".ant-select-item-option:has-text('{status}')").ClickAsync(); + await Task.Delay(300); + } + + /// + /// Gets the number of orders displayed in the table. + /// + public async Task GetOrderCount() + { + var rows = _ordersTable.Locator("tbody tr.ant-table-row"); + return await rows.CountAsync(); + } + + /// + /// Checks if the empty state is visible. + /// + public async Task IsEmptyStateVisible() + { + return await _emptyState.IsVisibleAsync(); + } + + /// + /// Checks if the orders table is visible. + /// + public async Task IsTableVisible() + { + return await _ordersTable.IsVisibleAsync(); + } + + /// + /// Clicks the view button for an order by its order number. + /// + public async Task ViewOrder(string orderNumber) + { + var row = _ordersTable.Locator($"tr:has-text('{orderNumber}')"); + var viewButton = row.Locator("button").First; + await viewButton.ClickAsync(); + // Wait for drawer to open + await Page.Locator(".ant-drawer").WaitForAsync(new() { Timeout = 5000 }); + } + + /// + /// Gets all order numbers from the table. + /// + public async Task> GetOrderNumbers() + { + var cells = _ordersTable.Locator("tbody tr.ant-table-row td:nth-child(1) code"); + return await cells.AllTextContentsAsync(); + } + + /// + /// Gets the total orders statistic value. + /// + public async Task GetTotalOrdersStat() + { + var value = _totalOrdersStat.Locator(".ant-statistic-content-value"); + return await value.TextContentAsync(); + } + + /// + /// Gets the drafts statistic value. + /// + public async Task GetDraftsStat() + { + var value = _draftsStat.Locator(".ant-statistic-content-value"); + return await value.TextContentAsync(); + } + + /// + /// Gets the confirmed orders statistic value. + /// + public async Task GetConfirmedStat() + { + var value = _confirmedStat.Locator(".ant-statistic-content-value"); + return await value.TextContentAsync(); + } + + /// + /// Gets the total value statistic. + /// + public async Task GetTotalValueStat() + { + var value = _totalValueStat.Locator(".ant-statistic-content-value"); + return await value.TextContentAsync(); + } +} diff --git a/backend/Books.E2E.Tests/Pages/SidebarPage.cs b/backend/Books.E2E.Tests/Pages/SidebarPage.cs new file mode 100644 index 0000000..e1da147 --- /dev/null +++ b/backend/Books.E2E.Tests/Pages/SidebarPage.cs @@ -0,0 +1,154 @@ +using Microsoft.Playwright; + +namespace Books.E2E.Tests.Pages; + +/// +/// Page object for the sidebar navigation. +/// Handles menu navigation across all pages. +/// +public class SidebarPage : BasePage +{ + public SidebarPage(IPage page) : base(page) + { + } + + /// + /// Navigates to Dashboard. + /// + public async Task NavigateToDashboard() + { + await ClickMenuItem("Dashboard"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Orders page (Ordrer). + /// + public async Task NavigateToOrders() + { + await ClickMenuItem("Ordrer"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Invoices page (Fakturaer). + /// + public async Task NavigateToInvoices() + { + await ClickMenuItem("Fakturaer"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Customers page (Kunder). + /// + public async Task NavigateToCustomers() + { + await ClickMenuItem("Kunder"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Products page (Produkter). + /// + public async Task NavigateToProducts() + { + await ClickMenuItem("Produkter"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Chart of Accounts page (Kontooversigt). + /// + public async Task NavigateToChartOfAccounts() + { + await ClickMenuItem("Kontooversigt"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Journal page (Kassekladde). + /// + public async Task NavigateToJournal() + { + await ClickMenuItem("Kassekladde"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Quick Booking page (Hurtig bogfoering). + /// + public async Task NavigateToQuickBooking() + { + await ClickMenuItem("Hurtig bogfoering"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Bank Reconciliation page (Bankafstemning). + /// + public async Task NavigateToBankReconciliation() + { + await ClickMenuItem("Bankafstemning"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to VAT Reporting page (Momsindberetning). + /// + public async Task NavigateToVatReporting() + { + await ClickMenuItem("Momsindberetning"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Export page (Eksport). + /// + public async Task NavigateToExport() + { + await ClickMenuItem("Eksport"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Navigates to Settings page (Indstillinger). + /// + public async Task NavigateToSettings() + { + await ClickMenuItem("Indstillinger"); + await WaitForNetworkIdleAsync(); + } + + /// + /// Clicks a menu item in the sidebar by its label text. + /// + private async Task ClickMenuItem(string label) + { + // Ant Design menu item selector + var menuItem = Page.Locator($".ant-menu-item:has-text('{label}'), .ant-menu-submenu-title:has-text('{label}')"); + await menuItem.First.ClickAsync(); + } + + /// + /// Checks if a menu item is currently selected/active. + /// + public async Task IsMenuItemActive(string label) + { + var menuItem = Page.Locator($".ant-menu-item-selected:has-text('{label}')"); + return await menuItem.IsVisibleAsync(); + } + + /// + /// Gets the currently active menu item label. + /// + public async Task GetActiveMenuItemLabel() + { + var activeItem = Page.Locator(".ant-menu-item-selected"); + if (await activeItem.IsVisibleAsync()) + { + return await activeItem.TextContentAsync(); + } + return null; + } +} diff --git a/backend/Books.E2E.Tests/TestData/CvrGenerator.cs b/backend/Books.E2E.Tests/TestData/CvrGenerator.cs new file mode 100644 index 0000000..85db52f --- /dev/null +++ b/backend/Books.E2E.Tests/TestData/CvrGenerator.cs @@ -0,0 +1,47 @@ +namespace Books.E2E.Tests.TestData; + +/// +/// Generates valid Danish CVR numbers for testing. +/// +public static class CvrGenerator +{ + private static readonly int[] Weights = [2, 7, 6, 5, 4, 3, 2, 1]; + private static readonly Random Random = new(); + + /// + /// Generates a random valid CVR number with correct modulus 11 checksum. + /// + public static string Generate() + { + // Generate 7 random digits + var digits = new int[8]; + for (var i = 0; i < 7; i++) + { + digits[i] = Random.Next(0, 10); + } + + // Ensure first digit is not 0 (CVR numbers don't start with 0) + if (digits[0] == 0) digits[0] = Random.Next(1, 10); + + // Calculate checksum digit using modulus 11 + var sum = 0; + for (var i = 0; i < 7; i++) + { + sum += digits[i] * Weights[i]; + } + + // Find the last digit that makes sum % 11 == 0 + var remainder = sum % 11; + var checkDigit = (11 - remainder) % 11; + + // If checkDigit is 10, regenerate (single digit only) + if (checkDigit == 10) + { + return Generate(); + } + + digits[7] = checkDigit; + + return string.Join("", digits); + } +} diff --git a/backend/Books.E2E.Tests/TestData/TestDataGenerator.cs b/backend/Books.E2E.Tests/TestData/TestDataGenerator.cs new file mode 100644 index 0000000..c3eaa33 --- /dev/null +++ b/backend/Books.E2E.Tests/TestData/TestDataGenerator.cs @@ -0,0 +1,103 @@ +namespace Books.E2E.Tests.TestData; + +/// +/// Generates unique test data for E2E tests. +/// +public static class TestDataGenerator +{ + private static readonly Random Random = new(); + private static int _counter = 0; + + /// + /// Generates a unique company name for testing. + /// + public static string GenerateCompanyName() + { + var timestamp = DateTime.Now.ToString("HHmmss"); + return $"E2E Test Virksomhed {timestamp}-{Interlocked.Increment(ref _counter)}"; + } + + /// + /// Generates a unique customer name for testing. + /// + public static string GenerateCustomerName() + { + var timestamp = DateTime.Now.ToString("HHmmss"); + return $"Test Kunde {timestamp}-{Interlocked.Increment(ref _counter)}"; + } + + /// + /// Generates a unique product name for testing. + /// + public static string GenerateProductName() + { + var products = new[] { "Konsulenttime", "Support", "Udvikling", "Analyse", "Design", "Test" }; + var product = products[Random.Next(products.Length)]; + return $"{product} E2E-{Interlocked.Increment(ref _counter)}"; + } + + /// + /// Generates a unique order line description. + /// + public static string GenerateOrderLineDescription() + { + var descriptions = new[] + { + "Konsulentbistand", + "Systemudvikling", + "Support og vedligeholdelse", + "Projektledelse", + "Implementering", + "Analyse og design" + }; + return $"{descriptions[Random.Next(descriptions.Length)]} E2E-{Interlocked.Increment(ref _counter)}"; + } + + /// + /// Generates a random unit price. + /// + public static decimal GenerateUnitPrice(decimal min = 100m, decimal max = 5000m) + { + var range = max - min; + return Math.Round(min + (decimal)Random.NextDouble() * range, 2); + } + + /// + /// Generates a random quantity. + /// + public static int GenerateQuantity(int min = 1, int max = 100) + { + return Random.Next(min, max + 1); + } + + /// + /// Generates a unique reference string. + /// + public static string GenerateReference() + { + return $"REF-E2E-{DateTime.Now:yyyyMMdd}-{Interlocked.Increment(ref _counter)}"; + } + + /// + /// Generates test notes/description text. + /// + public static string GenerateNotes() + { + var notes = new[] + { + "Automatiseret E2E test ordre", + "Test ordre til systemvalidering", + "Genereret af E2E test suite" + }; + return $"{notes[Random.Next(notes.Length)]} #{Interlocked.Increment(ref _counter)}"; + } + + /// + /// Generates a future date for delivery. + /// + public static DateTime GenerateFutureDeliveryDate(int minDays = 7, int maxDays = 30) + { + var days = Random.Next(minDays, maxDays + 1); + return DateTime.Today.AddDays(days); + } +} diff --git a/backend/Books.E2E.Tests/Tests/CompanySetup/CompanySetupWizardTests.cs b/backend/Books.E2E.Tests/Tests/CompanySetup/CompanySetupWizardTests.cs new file mode 100644 index 0000000..bdf86e4 --- /dev/null +++ b/backend/Books.E2E.Tests/Tests/CompanySetup/CompanySetupWizardTests.cs @@ -0,0 +1,195 @@ +using Books.E2E.Tests.Actions; +using Books.E2E.Tests.Infrastructure; + +namespace Books.E2E.Tests.Tests.CompanySetup; + +/// +/// E2E tests for the Company Setup Wizard flow. +/// Tests the complete user journey of creating a new company. +/// +/// Configure target environment via environment variables: +/// - E2E_BACKEND_URL (default: https://localhost:5001) +/// - E2E_FRONTEND_URL (default: http://localhost:5173) +/// +[Trait("Category", "E2E")] +public class CompanySetupWizardTests : E2ETestBase +{ + public CompanySetupWizardTests( + E2ETestFixture fixture, + PlaywrightFixture playwright) + : base(fixture, playwright) + { + } + + [Fact] + public async Task CompanySetup_WithValidData_CreatesCompanySuccessfully() + { + // Complete happy path through the wizard + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + // Step 1: Company info + .UserEntersCompanyName("Test Virksomhed ApS") + .UserEntersValidCvr() + .UserClicksNext() + // Step 2: Accounting settings + .UserSelectsCountry("DK") + .UserSelectsCurrency("DKK") + .UserSelectsFiscalYearStartMonth(1) + .UserClicksNext() + // Step 3: VAT settings + .UserSelectsVatRegistered(false) + .UserClicksNext() + // Step 4: Confirmation - Submit + .UserSubmitsCompanyForm() + .UserSeesSuccessMessage() + .CompanyIsCreated(); + } + + [Fact] + public async Task CompanySetup_WithVatRegistration_CreatesCompanySuccessfully() + { + // Test with VAT registration enabled + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + // Step 1: Company info (CVR required for VAT) + .UserEntersCompanyName("Moms Virksomhed A/S") + .UserEntersValidCvr() + .UserClicksNext() + // Step 2: Accounting settings + .UserSelectsCountry("DK") + .UserSelectsCurrency("DKK") + .UserSelectsFiscalYearStartMonth(1) + .UserClicksNext() + // Step 3: VAT settings + .UserSelectsVatRegistered(true) + .UserSelectsVatPeriod("QUARTERLY") + .UserClicksNext() + // Step 4: Confirmation - Submit + .UserSubmitsCompanyForm() + .UserSeesSuccessMessage() + .CompanyIsCreated(); + } + + [Fact] + public async Task CompanySetup_InvalidCvr_ShowsValidationError() + { + // Test CVR validation (modulus 11 check) + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + .UserEntersCompanyName("Test Virksomhed ApS") + .UserEntersCvr("12345678") // Invalid checksum + .UserClicksNext() + .Then() + .ValidationErrorIsShown("modulus 11"); + } + + [Fact] + public async Task CompanySetup_CvrTooShort_ShowsValidationError() + { + // Test CVR length validation + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + .UserEntersCompanyName("Test Virksomhed ApS") + .UserEntersCvr("1234") // Too short + .UserClicksNext() + .Then() + .ValidationErrorIsShown("8 cifre"); + } + + [Fact] + public async Task CompanySetup_EmptyCompanyName_ShowsValidationError() + { + // Test required company name validation + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + // Don't enter company name + .UserEntersValidCvr() + .UserClicksNext() + .Then() + .ValidationErrorIsShown("virksomhedsnavn"); + } + + [Fact] + public async Task CompanySetup_VatRegisteredWithoutCvr_ShowsError() + { + // VAT registration requires CVR + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + .UserEntersCompanyName("Test Virksomhed ApS") + // No CVR entered + .UserClicksNext() + // Step 2: Accounting + .UserSelectsCountry("DK") + .UserSelectsCurrency("DKK") + .UserSelectsFiscalYearStartMonth(1) + .UserClicksNext() + // Step 3: VAT - select registered + .UserSelectsVatRegistered(true) + .UserSelectsVatPeriod("MONTHLY") + .UserClicksNext() + .Then() + .ErrorMessageIsShown("CVR er påkrævet"); + } + + [Fact] + public async Task CompanySetup_NavigateBackAndForth_PreservesData() + { + // Test that form data is preserved when navigating back + var companyName = "Navigation Test ApS"; + + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + // Step 1: Enter data + .UserEntersCompanyName(companyName) + .UserEntersValidCvr() + .UserClicksNext() + // Step 2: Enter more data + .UserSelectsCountry("DK") + .UserSelectsCurrency("DKK") + .UserSelectsFiscalYearStartMonth(6) // June + .UserClicksNext() + // Step 3: Enter VAT settings + .UserSelectsVatRegistered(false) + .UserClicksNext() + // Now go back and verify data + .UserClicksBack() + .UserClicksBack() + .UserClicksBack() + // We're back at step 1 - the name should still be there + .Then() + .TextIsVisible(companyName); + } + + [Fact] + public async Task CompanySetup_AfterSuccess_CanNavigateToDashboard() + { + // Test the full flow including navigation to dashboard + await Actions + .Given.UserIsAuthenticated() + .UserNavigatesToCompanySetup() + .UserEntersCompanyName("Dashboard Test ApS") + .UserEntersValidCvr() + .UserClicksNext() + .UserSelectsCountry("DK") + .UserSelectsCurrency("DKK") + .UserSelectsFiscalYearStartMonth(1) + .UserClicksNext() + .UserSelectsVatRegistered(false) + .UserClicksNext() + .UserSubmitsCompanyForm() + .UserSeesSuccessMessage(); + + // Click go to dashboard + await Actions + .Given.UserIsAuthenticated() + .UserClicksGoToDashboard() + .UserIsRedirectedToDashboard(); + } +} diff --git a/backend/Books.E2E.Tests/Tests/Navigation/SidebarNavigationTests.cs b/backend/Books.E2E.Tests/Tests/Navigation/SidebarNavigationTests.cs new file mode 100644 index 0000000..6c3ab42 --- /dev/null +++ b/backend/Books.E2E.Tests/Tests/Navigation/SidebarNavigationTests.cs @@ -0,0 +1,175 @@ +using Books.E2E.Tests.Infrastructure; +using Books.E2E.Tests.Pages; + +namespace Books.E2E.Tests.Tests.Navigation; + +/// +/// E2E tests for sidebar navigation. +/// Verifies that all main navigation links work correctly. +/// +/// Prerequisites: +/// - Backend and frontend running +/// - User has access to at least one company +/// +[Trait("Category", "E2E")] +public class SidebarNavigationTests : E2ETestBase +{ + public SidebarNavigationTests( + E2ETestFixture fixture, + PlaywrightFixture playwright) + : base(fixture, playwright) + { + } + + [Fact] + public async Task NavigateToDashboard_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToDashboard(); + + // Assert + var dashboard = new DashboardPage(Page); + await dashboard.WaitForDashboardToLoad(); + } + + [Fact] + public async Task NavigateToOrders_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToOrders(); + + // Assert + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + (await ordersPage.IsTitleVisible()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateToInvoices_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToInvoices(); + + // Assert + var invoicesPage = new InvoicesPage(Page); + await invoicesPage.WaitForPageToLoad(); + (await invoicesPage.IsTitleVisible()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateToCustomers_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToCustomers(); + + // Assert + var customersPage = new CustomersPage(Page); + await customersPage.WaitForPageToLoad(); + (await customersPage.IsTitleVisible()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateToChartOfAccounts_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToChartOfAccounts(); + + // Assert + var pageTitle = Page.Locator("h4:has-text('Kontooversigt')"); + await pageTitle.WaitForAsync(new() { Timeout = 10000 }); + (await pageTitle.IsVisibleAsync()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateToJournal_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToJournal(); + + // Assert + var pageTitle = Page.Locator("h4:has-text('Kassekladde')"); + await pageTitle.WaitForAsync(new() { Timeout = 10000 }); + (await pageTitle.IsVisibleAsync()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateToVatReporting_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToVatReporting(); + + // Assert + var pageTitle = Page.Locator("h4:has-text('Momsindberetning')"); + await pageTitle.WaitForAsync(new() { Timeout = 10000 }); + (await pageTitle.IsVisibleAsync()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateToSettings_LoadsSuccessfully() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act + await sidebar.NavigateToSettings(); + + // Assert + var pageTitle = Page.Locator("h4:has-text('Indstillinger')"); + await pageTitle.WaitForAsync(new() { Timeout = 10000 }); + (await pageTitle.IsVisibleAsync()).Should().BeTrue(); + } + + [Fact] + public async Task NavigateBetweenPages_MaintainsState() + { + // Arrange + await Page.GotoAsync(Config.FrontendUrl); + var sidebar = new SidebarPage(Page); + + // Act - Navigate through multiple pages + await sidebar.NavigateToOrders(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await sidebar.NavigateToInvoices(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await sidebar.NavigateToCustomers(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await sidebar.NavigateToDashboard(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Assert - Should end up on dashboard + var dashboard = new DashboardPage(Page); + await dashboard.WaitForDashboardToLoad(); + } +} diff --git a/backend/Books.E2E.Tests/Tests/Orders/CreateOrderTests.cs b/backend/Books.E2E.Tests/Tests/Orders/CreateOrderTests.cs new file mode 100644 index 0000000..b9aa358 --- /dev/null +++ b/backend/Books.E2E.Tests/Tests/Orders/CreateOrderTests.cs @@ -0,0 +1,108 @@ +using Books.E2E.Tests.Infrastructure; +using Books.E2E.Tests.Pages; +using Books.E2E.Tests.Pages.Modals; +using Books.E2E.Tests.TestData; + +namespace Books.E2E.Tests.Tests.Orders; + +/// +/// E2E tests for creating orders. +/// Tests the complete order creation workflow. +/// +/// Prerequisites: +/// - Backend and frontend running +/// - At least one company exists +/// - At least one customer exists in the company +/// +[Trait("Category", "E2E")] +public class CreateOrderTests : E2ETestBase +{ + public CreateOrderTests( + E2ETestFixture fixture, + PlaywrightFixture playwright) + : base(fixture, playwright) + { + } + + [Fact] + public async Task CreateOrder_WithCustomer_CreatesOrderSuccessfully() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + var initialOrderCount = await ordersPage.GetOrderCount(); + + // Act - Open create modal + await ordersPage.ClickNewOrder(); + + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + + // Select first available customer (type to search) + await createModal.SelectCustomer(""); + + // Set dates + await createModal.SetOrderDate(DateTime.Today); + await createModal.SetExpectedDeliveryDate(TestDataGenerator.GenerateFutureDeliveryDate()); + + // Add reference and notes + await createModal.EnterReference(TestDataGenerator.GenerateReference()); + await createModal.EnterNotes(TestDataGenerator.GenerateNotes()); + + // Submit + await createModal.Submit(); + + // Assert - Order drawer should open + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + var orderNumber = await drawer.GetOrderNumber(); + orderNumber.Should().NotBeNullOrEmpty("Order number should be assigned"); + + var status = await drawer.GetOrderStatus(); + status.Should().Contain("Kladde", "New order should be in draft status"); + } + + [Fact] + public async Task CreateOrder_WithoutCustomer_ShowsValidationError() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act + await ordersPage.ClickNewOrder(); + + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + + // Don't select a customer, just try to submit + await createModal.Submit(); + + // Assert + (await createModal.HasValidationErrors()).Should().BeTrue("Should show validation error for missing customer"); + } + + [Fact] + public async Task CreateOrder_CanCancelModal() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act + await ordersPage.ClickNewOrder(); + + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + + await createModal.Cancel(); + + // Assert + (await createModal.IsVisible()).Should().BeFalse("Modal should be closed after cancel"); + } +} diff --git a/backend/Books.E2E.Tests/Tests/Orders/OrderLineTests.cs b/backend/Books.E2E.Tests/Tests/Orders/OrderLineTests.cs new file mode 100644 index 0000000..65c4241 --- /dev/null +++ b/backend/Books.E2E.Tests/Tests/Orders/OrderLineTests.cs @@ -0,0 +1,213 @@ +using Books.E2E.Tests.Infrastructure; +using Books.E2E.Tests.Pages; +using Books.E2E.Tests.Pages.Modals; +using Books.E2E.Tests.TestData; + +namespace Books.E2E.Tests.Tests.Orders; + +/// +/// E2E tests for order line management. +/// Tests adding, viewing, and managing order lines. +/// +/// Prerequisites: +/// - Backend and frontend running +/// - At least one company and customer exist +/// - Ability to create orders +/// +[Trait("Category", "E2E")] +public class OrderLineTests : E2ETestBase +{ + public OrderLineTests( + E2ETestFixture fixture, + PlaywrightFixture playwright) + : base(fixture, playwright) + { + } + + [Fact] + public async Task AddOrderLine_WithValidData_AddsLineSuccessfully() + { + // Arrange - Create a new order first + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Create order + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + // Wait for drawer + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Verify no lines initially + (await drawer.IsNoLinesAlertVisible()).Should().BeTrue("New order should have no lines"); + + // Act - Add a line + await drawer.ClickAddLine(); + + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + + var description = TestDataGenerator.GenerateOrderLineDescription(); + var quantity = TestDataGenerator.GenerateQuantity(1, 10); + var unitPrice = TestDataGenerator.GenerateUnitPrice(500m, 2000m); + + await addLineModal.FillBasicLine(description, quantity, unitPrice); + await addLineModal.Submit(); + + // Assert + await Task.Delay(500); // Wait for drawer to update + var lineCount = await drawer.GetLineCount(); + lineCount.Should().BeGreaterThan(0, "Order should have at least one line after adding"); + + var lineDescriptions = await drawer.GetLineDescriptions(); + lineDescriptions.Should().Contain(d => d.Contains(description), "Added line description should be visible"); + } + + [Fact] + public async Task AddOrderLine_WithAllFields_AddsLineCorrectly() + { + // Arrange - Create a new order first + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Act - Add a line with all fields + await drawer.ClickAddLine(); + + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + + var description = "Konsulentbistand premium"; + await addLineModal.FillCompleteLine( + description: description, + quantity: 8, + unit: "timer", + unitPrice: 1250.00m, + discountPercent: 10m, + vatCode: "S25" + ); + await addLineModal.Submit(); + + // Assert + await Task.Delay(500); + var lineDescriptions = await drawer.GetLineDescriptions(); + lineDescriptions.Should().Contain(d => d.Contains(description)); + } + + [Fact] + public async Task AddOrderLine_WithoutDescription_ShowsValidationError() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Act - Try to add line without description + await drawer.ClickAddLine(); + + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + + // Only fill quantity and price, not description + await addLineModal.EnterQuantity(1); + await addLineModal.EnterUnitPrice(100m); + await addLineModal.Submit(); + + // Assert + (await addLineModal.HasValidationErrors()).Should().BeTrue("Should show validation error for missing description"); + } + + [Fact] + public async Task AddOrderLine_CanCancelModal() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Act + await drawer.ClickAddLine(); + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + + await addLineModal.EnterDescription("This should not be saved"); + await addLineModal.Cancel(); + + // Assert + (await addLineModal.IsVisible()).Should().BeFalse("Modal should be closed after cancel"); + (await drawer.IsNoLinesAlertVisible()).Should().BeTrue("No lines should be added after cancel"); + } + + [Fact] + public async Task AddMultipleOrderLines_DisplaysAllLines() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Act - Add multiple lines + var lineDescriptions = new[] { "Linje 1 - Analyse", "Linje 2 - Design", "Linje 3 - Udvikling" }; + + foreach (var desc in lineDescriptions) + { + await drawer.ClickAddLine(); + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + await addLineModal.FillBasicLine(desc, 1, 1000m); + await addLineModal.Submit(); + await Task.Delay(300); // Wait for modal to close and drawer to update + } + + // Assert + var lineCount = await drawer.GetLineCount(); + lineCount.Should().Be(3, "Order should have 3 lines"); + + var displayedDescriptions = await drawer.GetLineDescriptions(); + foreach (var desc in lineDescriptions) + { + displayedDescriptions.Should().Contain(d => d.Contains(desc), $"Line '{desc}' should be visible"); + } + } +} diff --git a/backend/Books.E2E.Tests/Tests/Orders/OrderWorkflowTests.cs b/backend/Books.E2E.Tests/Tests/Orders/OrderWorkflowTests.cs new file mode 100644 index 0000000..7eb0e64 --- /dev/null +++ b/backend/Books.E2E.Tests/Tests/Orders/OrderWorkflowTests.cs @@ -0,0 +1,315 @@ +using Books.E2E.Tests.Infrastructure; +using Books.E2E.Tests.Pages; +using Books.E2E.Tests.Pages.Modals; +using Books.E2E.Tests.TestData; + +namespace Books.E2E.Tests.Tests.Orders; + +/// +/// E2E tests for order workflow: confirm, cancel, convert to invoice. +/// Tests the complete order lifecycle. +/// +/// Prerequisites: +/// - Backend and frontend running +/// - At least one company and customer exist +/// +[Trait("Category", "E2E")] +public class OrderWorkflowTests : E2ETestBase +{ + public OrderWorkflowTests( + E2ETestFixture fixture, + PlaywrightFixture playwright) + : base(fixture, playwright) + { + } + + [Fact] + public async Task ConfirmOrder_WithLines_ChangesStatusToConfirmed() + { + // Arrange - Create order with a line + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Add a line (required for confirmation) + await drawer.ClickAddLine(); + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + await addLineModal.FillBasicLine("Test produkt til bekraeftelse", 2, 750m); + await addLineModal.Submit(); + await Task.Delay(500); + + // Act - Confirm the order + await drawer.ClickConfirm(); + await Task.Delay(1000); // Wait for confirmation to process + + // Assert + var status = await drawer.GetOrderStatus(); + status.Should().Contain("Bekraeftet", "Order status should change to confirmed"); + + // Confirm button should no longer be visible + (await drawer.IsConfirmButtonVisible()).Should().BeFalse("Confirm button should be hidden after confirmation"); + + // Create Invoice button should now be visible + (await drawer.IsCreateInvoiceButtonVisible()).Should().BeTrue("Create Invoice button should be visible for confirmed orders"); + } + + [Fact] + public async Task ConfirmOrder_WithoutLines_ShowsWarning() + { + // Arrange - Create order without lines + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Verify confirm button is disabled (no lines) + var confirmButton = Page.Locator(".ant-drawer").GetByRole(AriaRole.Button, new() { Name = "Bekraeft" }); + var isDisabled = await confirmButton.IsDisabledAsync(); + + // Assert + isDisabled.Should().BeTrue("Confirm button should be disabled when order has no lines"); + } + + [Fact] + public async Task CancelOrder_FromDraft_CancelsSuccessfully() + { + // Arrange - Create a draft order + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Act - Cancel the order + await drawer.ClickCancel(); + + var cancelModal = new CancelOrderModalPage(Page); + await cancelModal.WaitForModalToOpen(); + + // Verify warning is shown + (await cancelModal.IsWarningAlertVisible()).Should().BeTrue("Warning about cancellation should be visible"); + + // Enter reason and submit + await cancelModal.EnterReason("E2E Test - automatisk annullering"); + await cancelModal.Submit(); + + await Task.Delay(1000); // Wait for cancellation to process + + // Assert + var status = await drawer.GetOrderStatus(); + status.Should().Contain("Annulleret", "Order status should be cancelled"); + + // Cancel button should no longer be visible + (await drawer.IsCancelButtonVisible()).Should().BeFalse("Cancel button should be hidden after cancellation"); + } + + [Fact] + public async Task CancelOrder_WithoutReason_ShowsValidationError() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Act - Try to cancel without reason + await drawer.ClickCancel(); + + var cancelModal = new CancelOrderModalPage(Page); + await cancelModal.WaitForModalToOpen(); + await cancelModal.Submit(); // Don't enter reason + + // Assert + (await cancelModal.HasValidationErrors()).Should().BeTrue("Should show validation error for missing reason"); + } + + [Fact] + public async Task ConvertToInvoice_FullOrder_CreatesInvoice() + { + // Arrange - Create and confirm order + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Add lines + await drawer.ClickAddLine(); + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + await addLineModal.FillBasicLine("Produkt A", 2, 1000m); + await addLineModal.Submit(); + await Task.Delay(500); + + await drawer.ClickAddLine(); + addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + await addLineModal.FillBasicLine("Produkt B", 1, 500m); + await addLineModal.Submit(); + await Task.Delay(500); + + // Confirm order + await drawer.ClickConfirm(); + await Task.Delay(1000); + + // Act - Convert to invoice + await drawer.ClickCreateInvoice(); + + var convertModal = new ConvertToInvoiceModalPage(Page); + await convertModal.WaitForModalToOpen(); + + // Verify all lines are available + var availableLines = await convertModal.GetAvailableLineCount(); + availableLines.Should().Be(2, "Both lines should be available for invoicing"); + + // Select all lines (should be pre-selected) + await convertModal.SelectAllLines(); + await convertModal.Submit(); + + // Assert - Success message should appear + var successMessage = Page.Locator(".ant-message-success"); + await successMessage.WaitForAsync(new() { Timeout = 10000 }); + (await successMessage.IsVisibleAsync()).Should().BeTrue("Success message should appear after invoice creation"); + } + + [Fact] + public async Task ConvertToInvoice_PartialOrder_CreatesPartialInvoice() + { + // Arrange - Create and confirm order with multiple lines + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + // Add multiple lines + var lines = new[] { "Fase 1 - Analyse", "Fase 2 - Design", "Fase 3 - Implementering" }; + foreach (var line in lines) + { + await drawer.ClickAddLine(); + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + await addLineModal.FillBasicLine(line, 1, 5000m); + await addLineModal.Submit(); + await Task.Delay(300); + } + + // Confirm order + await drawer.ClickConfirm(); + await Task.Delay(1000); + + // Act - Convert only first line to invoice + await drawer.ClickCreateInvoice(); + + var convertModal = new ConvertToInvoiceModalPage(Page); + await convertModal.WaitForModalToOpen(); + + // Deselect all, then select only first line + await convertModal.DeselectAllLines(); + await convertModal.SelectLine("Fase 1"); + + var selectedCount = await convertModal.GetSelectedLineCount(); + selectedCount.Should().Be(1, "Only one line should be selected"); + + await convertModal.Submit(); + + // Assert + var successMessage = Page.Locator(".ant-message-success"); + await successMessage.WaitForAsync(new() { Timeout = 10000 }); + + // Refresh drawer to see updated state + await Task.Delay(1000); + + // Order should still have uninvoiced lines + (await drawer.IsCreateInvoiceButtonVisible()).Should().BeTrue("Create Invoice button should still be visible for partial invoicing"); + } + + [Fact] + public async Task ConvertToInvoice_NoLinesSelected_ShowsWarning() + { + // Arrange - Create and confirm order + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + await ordersPage.ClickNewOrder(); + var createModal = new CreateOrderModalPage(Page); + await createModal.WaitForModalToOpen(); + await createModal.SelectCustomer(""); + await createModal.Submit(); + + var drawer = new OrderDrawerPage(Page); + await drawer.WaitForDrawerToOpen(); + + await drawer.ClickAddLine(); + var addLineModal = new AddOrderLineModalPage(Page); + await addLineModal.WaitForModalToOpen(); + await addLineModal.FillBasicLine("Test produkt", 1, 1000m); + await addLineModal.Submit(); + await Task.Delay(500); + + await drawer.ClickConfirm(); + await Task.Delay(1000); + + // Act - Try to convert with no lines selected + await drawer.ClickCreateInvoice(); + + var convertModal = new ConvertToInvoiceModalPage(Page); + await convertModal.WaitForModalToOpen(); + + await convertModal.DeselectAllLines(); + await convertModal.Submit(); + + // Assert - Warning should appear + var warningMessage = Page.Locator(".ant-message-warning"); + await warningMessage.WaitForAsync(new() { Timeout = 5000 }); + (await warningMessage.IsVisibleAsync()).Should().BeTrue("Warning should appear when no lines selected"); + } +} diff --git a/backend/Books.E2E.Tests/Tests/Orders/OrdersPageTests.cs b/backend/Books.E2E.Tests/Tests/Orders/OrdersPageTests.cs new file mode 100644 index 0000000..4801158 --- /dev/null +++ b/backend/Books.E2E.Tests/Tests/Orders/OrdersPageTests.cs @@ -0,0 +1,142 @@ +using Books.E2E.Tests.Infrastructure; +using Books.E2E.Tests.Pages; + +namespace Books.E2E.Tests.Tests.Orders; + +/// +/// E2E tests for the Orders page (Ordrer). +/// Tests the order list view, filtering, and navigation. +/// +/// Prerequisites: +/// - Backend running at E2E_BACKEND_URL (default: https://localhost:5001) +/// - Frontend running at E2E_FRONTEND_URL (default: http://localhost:3000) +/// - At least one company and customer must exist +/// +[Trait("Category", "E2E")] +public class OrdersPageTests : E2ETestBase +{ + public OrdersPageTests( + E2ETestFixture fixture, + PlaywrightFixture playwright) + : base(fixture, playwright) + { + } + + [Fact] + public async Task OrdersPage_LoadsSuccessfully() + { + // Arrange - Navigate to orders page + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + + // Act + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Assert + (await ordersPage.IsTitleVisible()).Should().BeTrue("Orders page title should be visible"); + } + + [Fact] + public async Task OrdersPage_DisplaysStatisticsCards() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act & Assert - Statistics should be visible + var totalStat = await ordersPage.GetTotalOrdersStat(); + var draftsStat = await ordersPage.GetDraftsStat(); + var confirmedStat = await ordersPage.GetConfirmedStat(); + + totalStat.Should().NotBeNullOrEmpty("Total orders stat should be visible"); + draftsStat.Should().NotBeNullOrEmpty("Drafts stat should be visible"); + confirmedStat.Should().NotBeNullOrEmpty("Confirmed stat should be visible"); + } + + [Fact] + public async Task OrdersPage_CanOpenNewOrderModal() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act + await ordersPage.ClickNewOrder(); + + // Assert + var modal = Page.Locator(".ant-modal:has-text('Opret ordre')"); + (await modal.IsVisibleAsync()).Should().BeTrue("New order modal should be visible"); + } + + [Fact] + public async Task OrdersPage_CanSearchOrders() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act + await ordersPage.SearchOrders("TEST-SEARCH-STRING-UNLIKELY-TO-EXIST"); + + // Assert - Should show empty state or no matching orders + var hasOrders = await ordersPage.GetOrderCount() > 0; + var hasEmptyState = await ordersPage.IsEmptyStateVisible(); + + // Either no orders match or empty state is shown + (hasOrders || hasEmptyState).Should().BeTrue("Search should filter orders or show empty state"); + } + + [Fact] + public async Task OrdersPage_CanFilterByStatus() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act - Filter by draft status + await ordersPage.FilterByStatus("Kladde"); + + // Assert - Page should still function (filter applied) + // We can't assert exact results without knowing test data + (await ordersPage.IsTitleVisible()).Should().BeTrue("Page should remain functional after filtering"); + } + + [Fact] + public async Task OrdersPage_CanClearSearch() + { + // Arrange + await Page.GotoAsync($"{Config.FrontendUrl}/ordrer"); + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + + // Act + await ordersPage.SearchOrders("test search"); + await ordersPage.ClearSearch(); + + // Assert - Search should be cleared + var searchInput = Page.GetByPlaceholder("Soeg ordre..."); + var value = await searchInput.InputValueAsync(); + value.Should().BeEmpty("Search input should be cleared"); + } + + [Fact] + public async Task OrdersPage_NavigationViaSidebar() + { + // Arrange - Start at dashboard + await Page.GotoAsync(Config.FrontendUrl); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Act - Navigate via sidebar + var sidebar = new SidebarPage(Page); + await sidebar.NavigateToOrders(); + + // Assert + var ordersPage = new OrdersPage(Page); + await ordersPage.WaitForPageToLoad(); + (await ordersPage.IsTitleVisible()).Should().BeTrue("Should navigate to orders page via sidebar"); + } +} diff --git a/backend/Books.E2E.Tests/xunit.runner.json b/backend/Books.E2E.Tests/xunit.runner.json new file mode 100644 index 0000000..dd80f43 --- /dev/null +++ b/backend/Books.E2E.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +}

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 aPJ%C2f|(29AY?H}03K+0c@;DjY5xz0*Zf8R literal 0 HcmV?d00001 diff --git a/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Faktura5216698_fd933f8a.pdf b/backend/Books.Api/uploads/company-9b8a6d2e-4788-46d5-884b-bceab0843761/Faktura5216698_fd933f8a.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4ec015171ee73c40262509ff4a509aa72ff3caaf GIT binary patch literal 97584 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�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≻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