diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9bcc488 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,67 @@ +name: Publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + automatic_release: + description: 'Automatically release the Central Portal deployment' + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + publish: + name: Publish to Maven Central + runs-on: macos-latest + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: oracle + java-version: '26' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Check release version + run: | + version="$(sed -n 's/^val releaseVersion = "\(.*\)"/\1/p' build.gradle.kts | head -n 1)" + test -n "$version" + + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + tag_version="${GITHUB_REF#refs/tags/v}" + if [[ "$tag_version" != "$version" ]]; then + echo "Tag version $tag_version does not match Gradle version $version" >&2 + exit 1 + fi + fi + + if [[ "$version" == *-SNAPSHOT ]]; then + echo "Release publishing requires a non-SNAPSHOT version, found $version" >&2 + exit 1 + fi + + echo "Publishing version $version" + + - name: Publish + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.automatic_release }}" == "true" ]]; then + ./gradlew publishAndReleaseToMavenCentral + else + ./gradlew publishAndReleaseToMavenCentral + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d14ea9..e6305db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,6 @@ Before submitting a change: ./gradlew :benchmark:jmhJar ``` -Changes to generated XML require independent current-release validation evidence and focused fixtures. Performance changes require JMH measurements with JDK, hardware, command, throughput, and allocation results. Dependency additions require an architectural rationale and must not affect `brev-core` or `brev-billing` runtime classpaths. +Changes to generated XML require independent current-release validation evidence and focused fixtures. Performance changes require JMH measurements with JDK, hardware, command, throughput, and allocation results. Dependency additions require an architectural rationale and must not affect published module runtime classpaths. Do not include real customer invoices, personal data, certificates, private keys, or production endpoint credentials in issues or fixtures. diff --git a/DESIGN.md b/DESIGN.md index 3330be9..f43d9ae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,20 +31,22 @@ Brev can therefore coexist with and contribute to those projects. Its initial tr application domain │ ▼ -brev-core ── brev-billing ── brev-validation - │ │ - ├── brev-reader └── reference PHIVE adapter - │ - └── brev-sbdh - │ - ▼ - brev-transport-api - │ - phase4 adapter first - │ - Peppol network +brev-core + │ + ├── brev-documents (Billing write + read) + │ │ + │ └── conformance (test-only PHIVE/Saxon) + │ + ├── brev-smp (types now, JDK client later) + │ + └── brev-ap (types now, Phase4 adapter later) + │ + Peppol network ``` +All of the above live in this repository. Consumers depend on one artifact. +There is no `ph-commons` equivalent that every module must drag in. + The document layers must not depend on transport. The transport SPI must not leak Phase4 types. ## Model strategy diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..ba2a1ee --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,45 @@ +# Publishing + +Brev publishes `brev-core`, `brev-documents`, `brev-smp`, and `brev-ap` to +Maven Central under `no.beint.brev`. + +## Secrets + +Do not put tokens or the private signing key in the repository. Export them as +environment variables (see `~/.config/skald/maven-central.env` on a release +machine; the same Central namespace and signing key are used): + +```text +MAVEN_CENTRAL_USERNAME +MAVEN_CENTRAL_PASSWORD +SIGNING_IN_MEMORY_KEY +SIGNING_IN_MEMORY_KEY_ID +SIGNING_IN_MEMORY_KEY_PASSWORD +``` + +Gradle also accepts the same values as `ORG_GRADLE_PROJECT_mavenCentralUsername`, +`ORG_GRADLE_PROJECT_mavenCentralPassword`, `ORG_GRADLE_PROJECT_signingInMemoryKey`, +`ORG_GRADLE_PROJECT_signingInMemoryKeyId`, and +`ORG_GRADLE_PROJECT_signingInMemoryKeyPassword`. + +Generate a Central Portal user token at +. + +## Release + +Set `releaseVersion` in `build.gradle.kts`, then: + +```shell +./gradlew clean build +./gradlew publishAndReleaseToMavenCentral +``` + +The Central Portal typically takes 10–30 minutes after a successful deployment +before the artifacts are downloadable from Maven Central. + +For a CI release, push a matching tag: + +```shell +git tag v0.1.0 +git push origin v0.1.0 +``` diff --git a/README.md b/README.md index 83e8830..e5f4549 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,93 @@ # Brev -**Current Peppol documents, compiled into small and explicit Java.** +**Current Peppol, as one modular JDK 26 library.** -Brev is an experimental, dependency-free JVM library for constructing, writing, reading, and validating current Peppol business documents. It deliberately does not preserve compatibility with obsolete Peppol releases or expose the complete UBL schema as an object model. +Brev is a dependency-free JVM library for constructing, writing, reading, and +later transporting current Peppol business documents. It lives in **one +repository**. Applications take only the module they need. The name is Norwegian for “letter.” > [!WARNING] -> Brev is an early vertical slice, not yet a conformant replacement for a production Peppol Billing generator or validator. The current code writes a deliberately narrow positive-invoice subset. Authoritative validation remains required before transmission. +> `0.1.0` replaces ReAI's Digipost + `ph-ubl` document layer. `brev-smp` and +> `brev-ap` publish types only. Keep official PHIVE validation on the send path +> until generated-rule parity exists. -## Why Brev +## Why one repo, not ten -Existing JVM libraries solve important but different problems: +The Helger stack is the interoperability reference. It is also split across +many repositories because it preserves every UBL version, every Peppol +profile, and years of API compatibility. -- `ph-ubl` provides complete JAXB bindings for several UBL versions; -- the Digipost generator offers a convenient mutable Billing-domain API; -- PHIVE executes authoritative XSD and Schematron validation artefacts; -- Phase4 and Oxalis implement full AS4 access-point transport. +Brev occupies the opposite corner: -Brev targets the unoccupied space between an application's domain model and transport: +- one repo, independently published modules; +- current Peppol only; +- JDK 26 baseline, no older-Java tax; +- typed documents instead of the full UBL schema; +- zero third-party runtime dependencies in every published module. -- current Peppol profile semantics rather than every UBL element; -- distinct immutable Java types instead of interchangeable strings; -- required data at construction time; -- calculated totals rather than duplicated mutable values; -- direct buffered UTF-8 output without DOM, JAXB, reflection, or an intermediate XML string; -- validation rules compiled from exact official artefacts into ordinary Java; -- zero third-party runtime dependencies in the document core. +| Module | Take it when you need | Status | +|---|---|---| +| `brev-core` | identifiers, codes, release metadata | shipping | +| `brev-documents` | Billing invoice/credit note model, writer, reader | shipping | +| `brev-smp` | typed SMP lookup results | types only | +| `brev-ap` | typed send/receive messages | types only | -See [DESIGN.md](DESIGN.md) for the architectural rationale, [ROADMAP.md](ROADMAP.md) for the complete implementation plan, and [docs/performance.md](docs/performance.md) for the measured starting point. +See [docs/modules.md](docs/modules.md), [DESIGN.md](DESIGN.md), and +[ROADMAP.md](ROADMAP.md). -## Current target - -The repository tracks one release target: +## Current document target | Component | Target | |---|---| | Peppol BIS Billing | 3.0.21 | | EN 16931 validation artefacts | 1.3.16 | -| Publication date | 2026-05-20 | | Mandatory from | 2026-08-17 | -| UBL syntax | UBL 2.1 Invoice | +| UBL syntax | Invoice and CreditNote 2.1 | | Process | Billing profile 01 | -Brev does not bundle historical rule sets. See [docs/version-policy.md](docs/version-policy.md). - -## Modules - -- `brev-core`: participant, endpoint, document, process, currency, country, and unit value types plus release metadata. -- `brev-billing`: immutable Billing model, derived totals, and direct UBL writer. -- `conformance`: test-only PHIVE/Saxon reference validation; never a production dependency. -- `benchmark`: JMH performance harness; never a production dependency. - -Planned modules are added only after their conformance gates pass. They include validation, streaming input, SBDH, discovery, reporting, and a Phase4 transport adapter. +Historical rule sets are not bundled. When Billing 4 is mandatory, Brev will +replace this writer rather than keep a compatibility flag. ## Example ```java -var nok = new CurrencyCode("NOK"); -var organization = new SchemeId("0192"); -var address = new PostalAddress( - "Dokumentveien 1", "Oslo", "0150", new CountryCode("NO")); - -var seller = Party.withVat( - new EndpointId(organization, "913341464"), - "Seller AS", "913341464", "NO913341464MVA", address); -var buyer = Party.withoutVat( - new EndpointId(organization, "987654321"), - "Buyer AS", "987654321", address); - -var line = new InvoiceLine( - "1", - "Consulting", - new Quantity(new BigDecimal("10"), new UnitCode("HUR")), - new UnitPrice(nok, new BigDecimal("1250")), - new VatCategory(TaxCategoryCode.STANDARD_RATE, new BigDecimal("25"))); - -var invoice = new Invoice( - "INV-1", - LocalDate.of(2026, 8, 17), - LocalDate.of(2026, 9, 1), - nok, - "buyer-reference", - seller, - buyer, - new PaymentInstruction("NO9386011117947", "payment-reference"), - List.of(line)); - -PeppolBillingWriter.write(invoice, outputStream); +var invoice = BillingDocument.invoice() + .id("INV-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(new CurrencyCode("NOK")) + .buyerReference("buyer-reference") + .seller(seller) + .buyer(buyer) + .payment(new PaymentInstruction("NO9386011117947", "payment-reference")) + .line(new BillingLine( + "1", + "Consulting", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(new CurrencyCode("NOK"), new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")))) + .build(); + +Documents.write(invoice, outputStream); +BillingDocument parsed = Documents.read(Documents.toByteArray(invoice)); ``` -The writer emits compact XML straight to the supplied stream and does not close it. +The writer emits compact XML straight to the supplied stream and does not close +it. Attachments are Base64-encoded incrementally. -## Deliberately unsupported in the first slice +## What ReAI can generate today -- Credit notes and negative invoices. -- Allowances, charges, rounding adjustments, prepayments, and multiple payment means. -- Exempt, reverse-charge, intra-community, export, and out-of-scope VAT categories. -- Attachments and document references. -- Full current-profile validation. -- XML parsing, SBDH, SMP, reporting, and AS4. -- Older Peppol Billing releases. +- invoices and credit notes +- VAT categories S, Z, E, G, AE, O +- Norwegian supplier identity (0192, VAT, Foretaksregisteret) +- order reference, billing reference, IBAN/BIC +- embedded PDF and other attachments +- price-level allowances +- optional payment, due date, and address parts -Unsupported features fail through the absence of an API rather than silently producing approximate XML. +Unsupported Peppol Billing features fail through the absence of an API. ## Building @@ -113,12 +98,11 @@ Brev requires JDK 26. ./gradlew :benchmark:jmh ``` -The build also runs the PHIVE/Saxon Billing 3.0.21 conformance fixture and fails if either published module acquires a third-party runtime dependency. - -## Status - -Version `0.1.0-SNAPSHOT` establishes the API direction and provides an executable performance baseline. It must not be used to transmit invoices until the Phase 1 conformance gate in [ROADMAP.md](ROADMAP.md) is complete. +The build fails if a published module acquires a third-party runtime +dependency. Conformance runs official PHIVE/Saxon Billing 3.0.21 artefacts as +a test-only oracle. ## License -Apache License 2.0. Imported standards and validation artefacts retain their own licenses and provenance. +Apache License 2.0. Imported standards and validation artefacts retain their +own licenses and provenance. diff --git a/ROADMAP.md b/ROADMAP.md index 091c6da..58fbdd1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,9 +10,11 @@ Status: in progress in `0.1.0-SNAPSHOT`. - [x] Zero-runtime-dependency enforcement. - [x] Typed participant, endpoint, currency, country, and unit values. - [x] Exact release metadata for Billing 3.0.21 / validation artefacts 1.3.16. -- [x] Immutable positive-invoice vertical slice. +- [x] Immutable Billing invoice and credit-note model covering ReAI output. - [x] Derived line, tax, and payable totals. -- [x] Direct buffered UTF-8 UBL writer. +- [x] Direct buffered UTF-8 UBL writer for Invoice and CreditNote. +- [x] Bounded StAX reader for inbound Invoice and CreditNote. +- [x] One-repo modules for documents, SMP types, and AP types. - [x] XML escaping, Unicode, immutability, and invariant tests. - [x] JMH benchmark entry point. - [x] Independent XSD and Schematron validation of the emitted fixture through PHIVE/Saxon. @@ -20,20 +22,19 @@ Status: in progress in `0.1.0-SNAPSHOT`. Exit gate: the fixture passes independent current-release validation and benchmark results are recorded with hardware, JDK, commands, throughput, and allocation. -## Phase 1 — complete current Billing model and writer +## Phase 1 — ReAI Billing surface -- Generate code-list types from the pinned 3.0.21 artefacts. -- Add credit notes and negative invoices as separate semantic variants. -- Add all current VAT categories with category-specific required fields. -- Add document and line allowances/charges. -- Add payment means, prepayments, rounding, tax-currency totals, periods, delivery, references, attachments, and notes. -- Add profile 02 without weakening profile 01 construction rules. -- Express mutually exclusive and conditional groups as sealed types. -- Stream Base64 attachments without whole-attachment copies. -- Add deterministic output snapshots for every model branch. -- Run every generated fixture through XSD, official Peppol/EN16931 Schematron, and at least one independent service. +Status: the ReAI-used subset is implemented in `brev-documents`. Remaining Billing terms that ReAI does not emit stay out of the API. -Exit gate: every business term supported by Peppol Billing 3.0.21 is either represented and tested or listed as an intentional, specification-cited exclusion. Every fixture passes the authoritative validator. +- [x] Credit notes as a document type, not a flag on a mutable invoice. +- [x] VAT categories S, Z, E, G, AE, O with category-specific fields. +- [x] Price allowances, order/billing references, IBAN/BIC, attachments. +- [x] Incremental Base64 for embedded documents. +- [ ] Generate remaining code-list types from the pinned 3.0.21 artefacts. +- [ ] Document-level allowances, prepayments, rounding, delivery, notes. +- [ ] Profile 02 without weakening profile 01 construction rules. + +Exit gate: every business term ReAI emits is represented, tested, and independently validated. Unused Billing terms remain absent. ## Phase 2 — validation compiler diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts index 5786144..33866ba 100644 --- a/benchmark/build.gradle.kts +++ b/benchmark/build.gradle.kts @@ -4,5 +4,5 @@ plugins { } dependencies { - implementation(project(":brev-billing")) + implementation(project(":brev-documents")) } diff --git a/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java b/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java index b6cecca..a160f16 100644 --- a/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java +++ b/benchmark/src/jmh/java/no/beint/brev/benchmark/BillingWriterBenchmark.java @@ -5,16 +5,15 @@ import no.beint.brev.EndpointId; import no.beint.brev.SchemeId; import no.beint.brev.UnitCode; -import no.beint.brev.billing.Invoice; -import no.beint.brev.billing.InvoiceLine; -import no.beint.brev.billing.Party; -import no.beint.brev.billing.PaymentInstruction; -import no.beint.brev.billing.PeppolBillingWriter; -import no.beint.brev.billing.PostalAddress; -import no.beint.brev.billing.Quantity; -import no.beint.brev.billing.TaxCategoryCode; -import no.beint.brev.billing.UnitPrice; -import no.beint.brev.billing.VatCategory; +import no.beint.brev.documents.BillingDocument; +import no.beint.brev.documents.BillingLine; +import no.beint.brev.documents.Documents; +import no.beint.brev.documents.Party; +import no.beint.brev.documents.PaymentInstruction; +import no.beint.brev.documents.PostalAddress; +import no.beint.brev.documents.Quantity; +import no.beint.brev.documents.UnitPrice; +import no.beint.brev.documents.VatCategory; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -27,7 +26,6 @@ import java.math.BigDecimal; import java.time.LocalDate; -import java.util.List; import java.util.concurrent.TimeUnit; @State(Scope.Benchmark) @@ -38,42 +36,41 @@ @Fork(2) public class BillingWriterBenchmark { private static final CurrencyCode NOK = new CurrencyCode("NOK"); - private final Invoice invoice = createInvoice(); + private final BillingDocument invoice = createInvoice(); @Benchmark public byte[] writeInvoice() { - return PeppolBillingWriter.toByteArray(invoice); + return Documents.toByteArray(invoice); } - private static Invoice createInvoice() { - SchemeId norwegianOrganization = new SchemeId("0192"); + private static BillingDocument createInvoice() { PostalAddress address = new PostalAddress("Testveien 1", "Oslo", "0150", new CountryCode("NO")); Party seller = Party.withVat( - new EndpointId(norwegianOrganization, "913341464"), + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "913341464"), "Seller AS", "913341464", "NO913341464MVA", address); Party buyer = Party.withoutVat( - new EndpointId(norwegianOrganization, "987654321"), + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "987654321"), "Buyer AS", "987654321", address); - InvoiceLine line = new InvoiceLine( - "1", - "Consulting", - new Quantity(new BigDecimal("10"), new UnitCode("HUR")), - new UnitPrice(NOK, new BigDecimal("1250")), - new VatCategory(TaxCategoryCode.STANDARD_RATE, new BigDecimal("25"))); - return new Invoice( - "BENCH-1", - LocalDate.of(2026, 8, 17), - LocalDate.of(2026, 9, 1), - NOK, - "benchmark", - seller, - buyer, - new PaymentInstruction("NO9386011117947", "1234567890123456789012345"), - List.of(line)); + return BillingDocument.invoice() + .id("BENCH-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(NOK) + .buyerReference("benchmark") + .seller(seller) + .buyer(buyer) + .payment(new PaymentInstruction("NO9386011117947", "1234567890123456789012345")) + .line(new BillingLine( + "1", + "Consulting", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")))) + .build(); } } diff --git a/brev-ap/README.md b/brev-ap/README.md new file mode 100644 index 0000000..4ff2c28 --- /dev/null +++ b/brev-ap/README.md @@ -0,0 +1,10 @@ +# brev-ap + +Typed Peppol access-point model for the Brev modular library. + +The document module does not depend on this artifact. A later release of this +same module will add a Phase4 adapter behind these types. Native AS4 is not +part of the first document slice. + +Do not take this artifact if you only generate or parse Billing documents. +Use `brev-documents`. diff --git a/brev-billing/build.gradle.kts b/brev-ap/build.gradle.kts similarity index 100% rename from brev-billing/build.gradle.kts rename to brev-ap/build.gradle.kts diff --git a/brev-ap/src/main/java/module-info.java b/brev-ap/src/main/java/module-info.java new file mode 100644 index 0000000..a190874 --- /dev/null +++ b/brev-ap/src/main/java/module-info.java @@ -0,0 +1,5 @@ +module no.beint.brev.ap { + requires transitive no.beint.brev.core; + + exports no.beint.brev.ap; +} diff --git a/brev-ap/src/main/java/no/beint/brev/ap/OutboundMessage.java b/brev-ap/src/main/java/no/beint/brev/ap/OutboundMessage.java new file mode 100644 index 0000000..1a0279f --- /dev/null +++ b/brev-ap/src/main/java/no/beint/brev/ap/OutboundMessage.java @@ -0,0 +1,36 @@ +package no.beint.brev.ap; + +import no.beint.brev.CountryCode; +import no.beint.brev.DocumentTypeId; +import no.beint.brev.ParticipantId; +import no.beint.brev.ProcessId; + +import java.util.Objects; + +/** A payload ready for Peppol transport. The first release does not send it. */ +public record OutboundMessage( + ParticipantId sender, + ParticipantId receiver, + DocumentTypeId documentType, + ProcessId process, + CountryCode senderCountry, + byte[] payload) { + + public OutboundMessage { + Objects.requireNonNull(sender, "sender"); + Objects.requireNonNull(receiver, "receiver"); + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(process, "process"); + Objects.requireNonNull(senderCountry, "senderCountry"); + Objects.requireNonNull(payload, "payload"); + if (payload.length == 0) { + throw new IllegalArgumentException("payload must not be empty"); + } + payload = payload.clone(); + } + + @Override + public byte[] payload() { + return payload.clone(); + } +} diff --git a/brev-ap/src/main/java/no/beint/brev/ap/SendReceipt.java b/brev-ap/src/main/java/no/beint/brev/ap/SendReceipt.java new file mode 100644 index 0000000..8d0dc59 --- /dev/null +++ b/brev-ap/src/main/java/no/beint/brev/ap/SendReceipt.java @@ -0,0 +1,20 @@ +package no.beint.brev.ap; + +import java.util.Objects; +import java.util.Optional; + +/** Transport outcome types for a future Phase4 adapter. */ +public record SendReceipt(boolean success, Optional messageId, Optional error) { + public SendReceipt { + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(error, "error"); + } + + public static SendReceipt ok(String messageId) { + return new SendReceipt(true, Optional.of(messageId), Optional.empty()); + } + + public static SendReceipt failed(String error) { + return new SendReceipt(false, Optional.empty(), Optional.of(error)); + } +} diff --git a/brev-ap/src/test/java/no/beint/brev/ap/ApTypesTest.java b/brev-ap/src/test/java/no/beint/brev/ap/ApTypesTest.java new file mode 100644 index 0000000..88e7e3e --- /dev/null +++ b/brev-ap/src/test/java/no/beint/brev/ap/ApTypesTest.java @@ -0,0 +1,25 @@ +package no.beint.brev.ap; + +import no.beint.brev.CountryCode; +import no.beint.brev.DocumentTypeId; +import no.beint.brev.ParticipantId; +import no.beint.brev.ProcessId; +import no.beint.brev.SchemeId; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ApTypesTest { + @Test + void outboundMessageRejectsEmptyPayloadByConstruction() { + OutboundMessage message = new OutboundMessage( + new ParticipantId(SchemeId.NORWEGIAN_ORGANIZATION, "1"), + new ParticipantId(SchemeId.NORWEGIAN_ORGANIZATION, "2"), + new DocumentTypeId("invoice"), + new ProcessId("billing"), + new CountryCode("NO"), + new byte[] {1}); + assertTrue(SendReceipt.ok("mid-1").success()); + assertTrue(message.payload().length == 1); + } +} diff --git a/brev-billing/src/main/java/module-info.java b/brev-billing/src/main/java/module-info.java deleted file mode 100644 index 3946e70..0000000 --- a/brev-billing/src/main/java/module-info.java +++ /dev/null @@ -1,5 +0,0 @@ -module no.beint.brev.billing { - requires transitive no.beint.brev.core; - - exports no.beint.brev.billing; -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/Invoice.java b/brev-billing/src/main/java/no/beint/brev/billing/Invoice.java deleted file mode 100644 index eaa53d1..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/Invoice.java +++ /dev/null @@ -1,160 +0,0 @@ -package no.beint.brev.billing; - -import no.beint.brev.CurrencyCode; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * An immutable positive Peppol Billing invoice for the initial Brev profile. - * Totals and tax breakdowns are derived once during construction. - */ -public final class Invoice { - private final String id; - private final LocalDate issueDate; - private final LocalDate dueDate; - private final CurrencyCode currency; - private final String buyerReference; - private final Party seller; - private final Party buyer; - private final PaymentInstruction payment; - private final List lines; - private final Money lineExtensionTotal; - private final List taxSubtotals; - private final Money taxTotal; - private final Money payableAmount; - - public Invoice( - String id, - LocalDate issueDate, - LocalDate dueDate, - CurrencyCode currency, - String buyerReference, - Party seller, - Party buyer, - PaymentInstruction payment, - List lines) { - this.id = BillingValues.nonBlank(id, "invoice ID", 100); - this.issueDate = Objects.requireNonNull(issueDate, "issueDate"); - this.dueDate = Objects.requireNonNull(dueDate, "dueDate"); - if (dueDate.isBefore(issueDate)) { - throw new IllegalArgumentException("due date must not precede issue date"); - } - this.currency = Objects.requireNonNull(currency, "currency"); - this.buyerReference = BillingValues.nonBlank(buyerReference, "buyer reference", 200); - this.seller = Objects.requireNonNull(seller, "seller"); - this.buyer = Objects.requireNonNull(buyer, "buyer"); - this.payment = Objects.requireNonNull(payment, "payment"); - Objects.requireNonNull(lines, "lines"); - if (lines.isEmpty()) { - throw new IllegalArgumentException("invoice must contain at least one line"); - } - this.lines = List.copyOf(lines); - ensureUniqueLineIds(this.lines); - ensureDocumentCurrency(this.lines, currency); - - this.lineExtensionTotal = calculateLineExtensionTotal(this.lines, currency); - this.taxSubtotals = calculateTaxSubtotals(this.lines, currency); - this.taxTotal = taxSubtotals.stream() - .map(TaxSubtotal::taxAmount) - .reduce(Money.zero(currency), Money::add); - this.payableAmount = lineExtensionTotal.add(taxTotal); - } - - private static void ensureUniqueLineIds(List lines) { - long distinctIds = lines.stream().map(InvoiceLine::id).distinct().count(); - if (distinctIds != lines.size()) { - throw new IllegalArgumentException("invoice line IDs must be unique"); - } - } - - private static void ensureDocumentCurrency(List lines, CurrencyCode currency) { - for (InvoiceLine line : lines) { - if (!line.unitPrice().currency().equals(currency)) { - throw new IllegalArgumentException("line " + line.id() + " does not use document currency " + currency); - } - } - } - - private static Money calculateLineExtensionTotal(List lines, CurrencyCode currency) { - Money total = Money.zero(currency); - for (InvoiceLine line : lines) { - total = total.add(line.netAmount()); - } - return total; - } - - private static List calculateTaxSubtotals(List lines, CurrencyCode currency) { - Map taxableByCategory = new LinkedHashMap<>(); - for (InvoiceLine line : lines) { - taxableByCategory.merge(line.vatCategory(), line.netAmount(), Money::add); - } - List subtotals = new ArrayList<>(taxableByCategory.size()); - taxableByCategory.forEach((category, taxable) -> { - BigDecimal tax = taxable.amount() - .multiply(category.rate()) - .movePointLeft(2) - .setScale(2, RoundingMode.HALF_UP); - subtotals.add(new TaxSubtotal(category, taxable, new Money(currency, tax))); - }); - return List.copyOf(subtotals); - } - - public String id() { - return id; - } - - public LocalDate issueDate() { - return issueDate; - } - - public LocalDate dueDate() { - return dueDate; - } - - public CurrencyCode currency() { - return currency; - } - - public String buyerReference() { - return buyerReference; - } - - public Party seller() { - return seller; - } - - public Party buyer() { - return buyer; - } - - public PaymentInstruction payment() { - return payment; - } - - public List lines() { - return lines; - } - - public Money lineExtensionTotal() { - return lineExtensionTotal; - } - - public List taxSubtotals() { - return taxSubtotals; - } - - public Money taxTotal() { - return taxTotal; - } - - public Money payableAmount() { - return payableAmount; - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/InvoiceLine.java b/brev-billing/src/main/java/no/beint/brev/billing/InvoiceLine.java deleted file mode 100644 index 9585000..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/InvoiceLine.java +++ /dev/null @@ -1,29 +0,0 @@ -package no.beint.brev.billing; - -import java.math.RoundingMode; -import java.util.Objects; - -/** An invoice line whose net amount is derived deterministically from quantity and unit price. */ -public record InvoiceLine( - String id, - String itemName, - Quantity quantity, - UnitPrice unitPrice, - VatCategory vatCategory) { - - public InvoiceLine { - BillingValues.nonBlank(id, "line ID", 64); - BillingValues.nonBlank(itemName, "item name", 200); - Objects.requireNonNull(quantity, "quantity"); - Objects.requireNonNull(unitPrice, "unitPrice"); - Objects.requireNonNull(vatCategory, "vatCategory"); - } - - public Money netAmount() { - return new Money( - unitPrice.currency(), - unitPrice.amount() - .multiply(quantity.value()) - .divide(unitPrice.baseQuantity(), 2, RoundingMode.HALF_UP)); - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/Party.java b/brev-billing/src/main/java/no/beint/brev/billing/Party.java deleted file mode 100644 index 54e3118..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/Party.java +++ /dev/null @@ -1,41 +0,0 @@ -package no.beint.brev.billing; - -import no.beint.brev.EndpointId; - -import java.util.Objects; -import java.util.Optional; - -/** A seller or buyer with typed endpoint identity and immutable legal details. */ -public record Party( - EndpointId endpoint, - String legalName, - String registrationId, - Optional vatIdentifier, - PostalAddress address) { - - public Party { - Objects.requireNonNull(endpoint, "endpoint"); - BillingValues.nonBlank(legalName, "legal name", 200); - BillingValues.nonBlank(registrationId, "registration ID", 100); - Objects.requireNonNull(vatIdentifier, "vatIdentifier"); - vatIdentifier = vatIdentifier.map(value -> BillingValues.nonBlank(value, "VAT identifier", 100)); - Objects.requireNonNull(address, "address"); - } - - public static Party withVat( - EndpointId endpoint, - String legalName, - String registrationId, - String vatIdentifier, - PostalAddress address) { - return new Party(endpoint, legalName, registrationId, Optional.of(vatIdentifier), address); - } - - public static Party withoutVat( - EndpointId endpoint, - String legalName, - String registrationId, - PostalAddress address) { - return new Party(endpoint, legalName, registrationId, Optional.empty(), address); - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/PaymentInstruction.java b/brev-billing/src/main/java/no/beint/brev/billing/PaymentInstruction.java deleted file mode 100644 index f682f95..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/PaymentInstruction.java +++ /dev/null @@ -1,9 +0,0 @@ -package no.beint.brev.billing; - -/** A Peppol credit-transfer instruction (payment means code 30). */ -public record PaymentInstruction(String accountId, String paymentReference) { - public PaymentInstruction { - BillingValues.nonBlank(accountId, "payment account ID", 100); - BillingValues.nonBlank(paymentReference, "payment reference", 100); - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/PeppolBillingWriter.java b/brev-billing/src/main/java/no/beint/brev/billing/PeppolBillingWriter.java deleted file mode 100644 index bf7d05c..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/PeppolBillingWriter.java +++ /dev/null @@ -1,172 +0,0 @@ -package no.beint.brev.billing; - -import no.beint.brev.billing.internal.Utf8XmlOutput; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.UncheckedIOException; -import java.util.Objects; - -/** Writes the supported Billing model directly as compact UTF-8 UBL 2.1 XML. */ -public final class PeppolBillingWriter { - public static final String CUSTOMIZATION_ID = - "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"; - public static final String PROFILE_ID = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"; - - private PeppolBillingWriter() { - } - - public static void write(Invoice invoice, OutputStream destination) throws IOException { - Objects.requireNonNull(invoice, "invoice"); - Utf8XmlOutput xml = new Utf8XmlOutput(destination); - xml.declaration(); - xml.raw(""); - - xml.element("cbc:CustomizationID", CUSTOMIZATION_ID); - xml.element("cbc:ProfileID", PROFILE_ID); - xml.element("cbc:ID", invoice.id()); - xml.element("cbc:IssueDate", invoice.issueDate().toString()); - xml.element("cbc:DueDate", invoice.dueDate().toString()); - xml.element("cbc:InvoiceTypeCode", "380"); - xml.element("cbc:DocumentCurrencyCode", invoice.currency().value()); - xml.element("cbc:BuyerReference", invoice.buyerReference()); - - writeParty(xml, "cac:AccountingSupplierParty", invoice.seller()); - writeParty(xml, "cac:AccountingCustomerParty", invoice.buyer()); - writePayment(xml, invoice.payment()); - writeTaxes(xml, invoice); - writeTotals(xml, invoice); - for (InvoiceLine line : invoice.lines()) { - writeLine(xml, line); - } - - xml.end("Invoice"); - xml.finish(); - } - - public static byte[] toByteArray(Invoice invoice) { - ByteArrayOutputStream destination = new ByteArrayOutputStream(4096); - try { - write(invoice, destination); - } catch (IOException exception) { - throw new UncheckedIOException("unexpected in-memory write failure", exception); - } - return destination.toByteArray(); - } - - private static void writeParty(Utf8XmlOutput xml, String wrapper, Party party) throws IOException { - xml.start(wrapper); - xml.start("cac:Party"); - xml.element("cbc:EndpointID", "schemeID", party.endpoint().scheme().value(), party.endpoint().value()); - xml.start("cac:PartyName"); - xml.element("cbc:Name", party.legalName()); - xml.end("cac:PartyName"); - writeAddress(xml, party.address()); - if (party.vatIdentifier().isPresent()) { - xml.start("cac:PartyTaxScheme"); - xml.element("cbc:CompanyID", party.vatIdentifier().orElseThrow()); - xml.start("cac:TaxScheme"); - xml.element("cbc:ID", "VAT"); - xml.end("cac:TaxScheme"); - xml.end("cac:PartyTaxScheme"); - } - xml.start("cac:PartyLegalEntity"); - xml.element("cbc:RegistrationName", party.legalName()); - xml.element("cbc:CompanyID", party.registrationId()); - xml.end("cac:PartyLegalEntity"); - xml.end("cac:Party"); - xml.end(wrapper); - } - - private static void writeAddress(Utf8XmlOutput xml, PostalAddress address) throws IOException { - xml.start("cac:PostalAddress"); - xml.element("cbc:StreetName", address.streetName()); - xml.element("cbc:CityName", address.cityName()); - xml.element("cbc:PostalZone", address.postalZone()); - xml.start("cac:Country"); - xml.element("cbc:IdentificationCode", address.country().value()); - xml.end("cac:Country"); - xml.end("cac:PostalAddress"); - } - - private static void writePayment(Utf8XmlOutput xml, PaymentInstruction payment) throws IOException { - xml.start("cac:PaymentMeans"); - xml.element("cbc:PaymentMeansCode", "name", "Credit transfer", "30"); - xml.element("cbc:PaymentID", payment.paymentReference()); - xml.start("cac:PayeeFinancialAccount"); - xml.element("cbc:ID", payment.accountId()); - xml.end("cac:PayeeFinancialAccount"); - xml.end("cac:PaymentMeans"); - } - - private static void writeTaxes(Utf8XmlOutput xml, Invoice invoice) throws IOException { - xml.start("cac:TaxTotal"); - amount(xml, "cbc:TaxAmount", invoice.taxTotal()); - for (TaxSubtotal subtotal : invoice.taxSubtotals()) { - xml.start("cac:TaxSubtotal"); - amount(xml, "cbc:TaxableAmount", subtotal.taxableAmount()); - amount(xml, "cbc:TaxAmount", subtotal.taxAmount()); - xml.start("cac:TaxCategory"); - xml.element("cbc:ID", subtotal.category().code().xmlValue()); - xml.element("cbc:Percent", subtotal.category().rateXmlValue()); - taxScheme(xml); - xml.end("cac:TaxCategory"); - xml.end("cac:TaxSubtotal"); - } - xml.end("cac:TaxTotal"); - } - - private static void writeTotals(Utf8XmlOutput xml, Invoice invoice) throws IOException { - xml.start("cac:LegalMonetaryTotal"); - amount(xml, "cbc:LineExtensionAmount", invoice.lineExtensionTotal()); - amount(xml, "cbc:TaxExclusiveAmount", invoice.lineExtensionTotal()); - amount(xml, "cbc:TaxInclusiveAmount", invoice.payableAmount()); - amount(xml, "cbc:PayableAmount", invoice.payableAmount()); - xml.end("cac:LegalMonetaryTotal"); - } - - private static void writeLine(Utf8XmlOutput xml, InvoiceLine line) throws IOException { - xml.start("cac:InvoiceLine"); - xml.element("cbc:ID", line.id()); - xml.element( - "cbc:InvoicedQuantity", - "unitCode", - line.quantity().unit().value(), - line.quantity().xmlValue()); - amount(xml, "cbc:LineExtensionAmount", line.netAmount()); - xml.start("cac:Item"); - xml.element("cbc:Name", line.itemName()); - xml.start("cac:ClassifiedTaxCategory"); - xml.element("cbc:ID", line.vatCategory().code().xmlValue()); - xml.element("cbc:Percent", line.vatCategory().rateXmlValue()); - taxScheme(xml); - xml.end("cac:ClassifiedTaxCategory"); - xml.end("cac:Item"); - xml.start("cac:Price"); - xml.element( - "cbc:PriceAmount", - "currencyID", - line.unitPrice().currency().value(), - line.unitPrice().amountXmlValue()); - xml.element( - "cbc:BaseQuantity", - "unitCode", - line.quantity().unit().value(), - line.unitPrice().baseQuantityXmlValue()); - xml.end("cac:Price"); - xml.end("cac:InvoiceLine"); - } - - private static void amount(Utf8XmlOutput xml, String element, Money money) throws IOException { - xml.element(element, "currencyID", money.currency().value(), money.xmlValue()); - } - - private static void taxScheme(Utf8XmlOutput xml) throws IOException { - xml.start("cac:TaxScheme"); - xml.element("cbc:ID", "VAT"); - xml.end("cac:TaxScheme"); - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/PostalAddress.java b/brev-billing/src/main/java/no/beint/brev/billing/PostalAddress.java deleted file mode 100644 index bf3022c..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/PostalAddress.java +++ /dev/null @@ -1,15 +0,0 @@ -package no.beint.brev.billing; - -import no.beint.brev.CountryCode; - -import java.util.Objects; - -/** The required address fields in Brev's initial Billing profile. */ -public record PostalAddress(String streetName, String cityName, String postalZone, CountryCode country) { - public PostalAddress { - BillingValues.nonBlank(streetName, "street name", 200); - BillingValues.nonBlank(cityName, "city name", 100); - BillingValues.nonBlank(postalZone, "postal zone", 32); - Objects.requireNonNull(country, "country"); - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/TaxCategoryCode.java b/brev-billing/src/main/java/no/beint/brev/billing/TaxCategoryCode.java deleted file mode 100644 index 15fe878..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/TaxCategoryCode.java +++ /dev/null @@ -1,17 +0,0 @@ -package no.beint.brev.billing; - -/** Tax categories supported by Brev's first positive-invoice vertical slice. */ -public enum TaxCategoryCode { - STANDARD_RATE("S"), - ZERO_RATE("Z"); - - private final String xmlValue; - - TaxCategoryCode(String xmlValue) { - this.xmlValue = xmlValue; - } - - public String xmlValue() { - return xmlValue; - } -} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/VatCategory.java b/brev-billing/src/main/java/no/beint/brev/billing/VatCategory.java deleted file mode 100644 index 471c0f7..0000000 --- a/brev-billing/src/main/java/no/beint/brev/billing/VatCategory.java +++ /dev/null @@ -1,25 +0,0 @@ -package no.beint.brev.billing; - -import java.math.BigDecimal; -import java.util.Objects; - -/** A current-profile VAT category and percentage. */ -public record VatCategory(TaxCategoryCode code, BigDecimal rate) { - public VatCategory { - Objects.requireNonNull(code, "code"); - Objects.requireNonNull(rate, "rate"); - if (rate.signum() < 0 || rate.compareTo(BigDecimal.valueOf(100)) > 0) { - throw new IllegalArgumentException("VAT rate must be between 0 and 100"); - } - if (code == TaxCategoryCode.STANDARD_RATE && rate.signum() == 0) { - throw new IllegalArgumentException("standard-rate VAT must have a positive rate"); - } - if (code == TaxCategoryCode.ZERO_RATE && rate.signum() != 0) { - throw new IllegalArgumentException("zero-rate VAT must have a zero rate"); - } - } - - public String rateXmlValue() { - return BillingValues.decimal(rate); - } -} diff --git a/brev-billing/src/test/java/no/beint/brev/billing/InvoiceFixtures.java b/brev-billing/src/test/java/no/beint/brev/billing/InvoiceFixtures.java deleted file mode 100644 index f91a74f..0000000 --- a/brev-billing/src/test/java/no/beint/brev/billing/InvoiceFixtures.java +++ /dev/null @@ -1,55 +0,0 @@ -package no.beint.brev.billing; - -import no.beint.brev.CountryCode; -import no.beint.brev.CurrencyCode; -import no.beint.brev.EndpointId; -import no.beint.brev.SchemeId; -import no.beint.brev.UnitCode; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.List; - -final class InvoiceFixtures { - static final CurrencyCode NOK = new CurrencyCode("NOK"); - - private InvoiceFixtures() { - } - - static Invoice invoice() { - Party seller = Party.withVat( - new EndpointId(new SchemeId("0192"), "913341464"), - "Brev & Sønner AS", - "913341464", - "NO913341464MVA", - new PostalAddress("Dokumentveien 1", "Oslo", "0150", new CountryCode("NO"))); - Party buyer = Party.withoutVat( - new EndpointId(new SchemeId("0192"), "987654321"), - "Kjøper AS", - "987654321", - new PostalAddress("Testgata 2", "Bergen", "5003", new CountryCode("NO"))); - - return new Invoice( - "INV-2026-1", - LocalDate.of(2026, 8, 17), - LocalDate.of(2026, 9, 1), - NOK, - "buyer@example.no", - seller, - buyer, - new PaymentInstruction("NO9386011117947", "1234567890123456789012345"), - List.of( - new InvoiceLine( - "1", - "Rådgivning ", - new Quantity(new BigDecimal("2.5"), new UnitCode("HUR")), - new UnitPrice(NOK, new BigDecimal("1000")), - new VatCategory(TaxCategoryCode.STANDARD_RATE, new BigDecimal("25"))), - new InvoiceLine( - "2", - "Rapport 📄", - new Quantity(BigDecimal.ONE, UnitCode.EACH), - new UnitPrice(NOK, new BigDecimal("400.50")), - new VatCategory(TaxCategoryCode.ZERO_RATE, BigDecimal.ZERO)))); - } -} diff --git a/brev-billing/src/test/java/no/beint/brev/billing/InvoiceTest.java b/brev-billing/src/test/java/no/beint/brev/billing/InvoiceTest.java deleted file mode 100644 index 6ad07ed..0000000 --- a/brev-billing/src/test/java/no/beint/brev/billing/InvoiceTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package no.beint.brev.billing; - -import no.beint.brev.CurrencyCode; -import no.beint.brev.UnitCode; -import org.junit.jupiter.api.Test; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -final class InvoiceTest { - @Test - void derivesTotalsAndTaxBreakdownsOnce() { - Invoice invoice = InvoiceFixtures.invoice(); - - assertEquals("2900.50", invoice.lineExtensionTotal().xmlValue()); - assertEquals("625.00", invoice.taxTotal().xmlValue()); - assertEquals("3525.50", invoice.payableAmount().xmlValue()); - assertEquals(2, invoice.taxSubtotals().size()); - } - - @Test - void defensivelyCopiesLines() { - Invoice original = InvoiceFixtures.invoice(); - List mutable = new ArrayList<>(original.lines()); - Invoice copy = new Invoice( - original.id(), - original.issueDate(), - original.dueDate(), - original.currency(), - original.buyerReference(), - original.seller(), - original.buyer(), - original.payment(), - mutable); - - mutable.clear(); - - assertEquals(2, copy.lines().size()); - assertThrows(UnsupportedOperationException.class, () -> copy.lines().clear()); - } - - @Test - void rejectsCurrencyMismatchesAndInvalidDates() { - Invoice valid = InvoiceFixtures.invoice(); - InvoiceLine eurLine = new InvoiceLine( - "EUR-1", - "Wrong currency", - new Quantity(BigDecimal.ONE, UnitCode.EACH), - new UnitPrice(new CurrencyCode("EUR"), BigDecimal.TEN), - new VatCategory(TaxCategoryCode.STANDARD_RATE, new BigDecimal("25"))); - - assertThrows(IllegalArgumentException.class, () -> new Invoice( - "INV-X", - LocalDate.of(2026, 8, 17), - LocalDate.of(2026, 8, 16), - valid.currency(), - valid.buyerReference(), - valid.seller(), - valid.buyer(), - valid.payment(), - List.of(eurLine))); - - assertThrows(IllegalArgumentException.class, () -> new Invoice( - "INV-X", - valid.issueDate(), - valid.dueDate(), - valid.currency(), - valid.buyerReference(), - valid.seller(), - valid.buyer(), - valid.payment(), - List.of(eurLine))); - } -} diff --git a/brev-billing/src/test/java/no/beint/brev/billing/PeppolBillingWriterTest.java b/brev-billing/src/test/java/no/beint/brev/billing/PeppolBillingWriterTest.java deleted file mode 100644 index 28dacdc..0000000 --- a/brev-billing/src/test/java/no/beint/brev/billing/PeppolBillingWriterTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package no.beint.brev.billing; - -import org.junit.jupiter.api.Test; -import org.w3c.dom.Document; - -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilderFactory; -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -final class PeppolBillingWriterTest { - @Test - void writesCompactUtf8UblWithoutAnIntermediateDocumentTree() throws Exception { - byte[] bytes = PeppolBillingWriter.toByteArray(InvoiceFixtures.invoice()); - String xml = new String(bytes, StandardCharsets.UTF_8); - - assertTrue(xml.startsWith("Brev & Sønner AS")); - assertTrue(xml.contains("Rådgivning <senior>")); - assertTrue(xml.contains("Rapport 📄")); - assertTrue(xml.contains("625.00")); - assertTrue(xml.contains("3525.50")); - assertFalse(xml.contains("\n")); - - Document document = parseSecurely(bytes); - assertEquals("Invoice", document.getDocumentElement().getLocalName()); - assertEquals("urn:oasis:names:specification:ubl:schema:xsd:Invoice-2", - document.getDocumentElement().getNamespaceURI()); - assertEquals(2, document.getElementsByTagNameNS( - "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", - "InvoiceLine").getLength()); - } - - @Test - void rejectsCharactersForbiddenByXml10() { - Invoice invoice = InvoiceFixtures.invoice(); - Party invalidSeller = Party.withVat( - invoice.seller().endpoint(), - "Bad\u0000Name", - invoice.seller().registrationId(), - invoice.seller().vatIdentifier().orElseThrow(), - invoice.seller().address()); - Invoice invalid = new Invoice( - invoice.id(), - invoice.issueDate(), - invoice.dueDate(), - invoice.currency(), - invoice.buyerReference(), - invalidSeller, - invoice.buyer(), - invoice.payment(), - invoice.lines()); - - assertThrows(IllegalArgumentException.class, () -> PeppolBillingWriter.toByteArray(invalid)); - } - - private static Document parseSecurely(byte[] bytes) throws Exception { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); - } -} diff --git a/brev-core/src/main/java/no/beint/brev/SchemeId.java b/brev-core/src/main/java/no/beint/brev/SchemeId.java index e18fa98..bb94313 100644 --- a/brev-core/src/main/java/no/beint/brev/SchemeId.java +++ b/brev-core/src/main/java/no/beint/brev/SchemeId.java @@ -2,6 +2,8 @@ /** A four-digit ISO 6523/EAS identifier scheme code. */ public record SchemeId(String value) { + public static final SchemeId NORWEGIAN_ORGANIZATION = new SchemeId("0192"); + public SchemeId { Values.nonBlank(value, "scheme ID", 4); if (value.length() != 4 || !isAsciiDigits(value)) { diff --git a/brev-core/src/main/java/no/beint/brev/UnitCode.java b/brev-core/src/main/java/no/beint/brev/UnitCode.java index ae5e758..543a133 100644 --- a/brev-core/src/main/java/no/beint/brev/UnitCode.java +++ b/brev-core/src/main/java/no/beint/brev/UnitCode.java @@ -3,6 +3,8 @@ /** A UN/ECE Recommendation 20 unit code. */ public record UnitCode(String value) { public static final UnitCode EACH = new UnitCode("C62"); + public static final UnitCode EA = new UnitCode("EA"); + public static final UnitCode HOUR = new UnitCode("HUR"); public UnitCode { Values.upperAsciiCode(value, "unit code", 1, 3); diff --git a/brev-documents/build.gradle.kts b/brev-documents/build.gradle.kts new file mode 100644 index 0000000..b6b7287 --- /dev/null +++ b/brev-documents/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":brev-core")) +} diff --git a/brev-documents/src/main/java/module-info.java b/brev-documents/src/main/java/module-info.java new file mode 100644 index 0000000..e08e761 --- /dev/null +++ b/brev-documents/src/main/java/module-info.java @@ -0,0 +1,6 @@ +module no.beint.brev.documents { + requires transitive no.beint.brev.core; + requires java.xml; + + exports no.beint.brev.documents; +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/AdditionalDocument.java b/brev-documents/src/main/java/no/beint/brev/documents/AdditionalDocument.java new file mode 100644 index 0000000..d43291c --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/AdditionalDocument.java @@ -0,0 +1,46 @@ +package no.beint.brev.documents; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +/** An embedded supporting document. The writer Base64-encodes {@code content} incrementally. */ +public record AdditionalDocument( + String id, String mimeType, String fileName, byte[] content, Optional uri) { + public AdditionalDocument { + BillingValues.nonBlank(id, "additional document ID", 200); + BillingValues.nonBlank(mimeType, "mime type", 100); + BillingValues.nonBlank(fileName, "file name", 200); + Objects.requireNonNull(content, "content"); + Objects.requireNonNull(uri, "uri"); + uri = uri.flatMap(value -> BillingValues.optionalText(value, "document URI", 500)); + if (content.length == 0) { + throw new IllegalArgumentException("additional document content must not be empty"); + } + content = content.clone(); + } + + public AdditionalDocument(String id, String mimeType, String fileName, byte[] content) { + this(id, mimeType, fileName, content, Optional.empty()); + } + + @Override + public byte[] content() { + return content.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof AdditionalDocument document + && id.equals(document.id) + && mimeType.equals(document.mimeType) + && fileName.equals(document.fileName) + && Arrays.equals(content, document.content) + && uri.equals(document.uri); + } + + @Override + public int hashCode() { + return Objects.hash(id, mimeType, fileName, Arrays.hashCode(content), uri); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/BillingDocument.java b/brev-documents/src/main/java/no/beint/brev/documents/BillingDocument.java new file mode 100644 index 0000000..ba75773 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/BillingDocument.java @@ -0,0 +1,413 @@ +package no.beint.brev.documents; + +import no.beint.brev.CurrencyCode; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** An immutable current-profile Peppol Billing invoice or credit note. */ +public final class BillingDocument { + private final BillingDocumentType type; + private final String id; + private final LocalDate issueDate; + private final Optional dueDate; + private final CurrencyCode currency; + private final Optional buyerReference; + private final Optional orderReference; + private final Optional referencedInvoiceId; + private final Party seller; + private final Party buyer; + private final Optional payment; + private final List additionalDocuments; + private final List allowanceCharges; + private final List lines; + private final Money lineExtensionTotal; + private final Money taxExclusiveAmount; + private final Money taxInclusiveAmount; + private final Optional chargeTotal; + private final Optional payableRounding; + private final Money payableAmount; + private final List taxSubtotals; + private final Money taxTotal; + private final Optional invoiceTypeCode; + + BillingDocument( + BillingDocumentType type, + String id, + LocalDate issueDate, + Optional dueDate, + CurrencyCode currency, + Optional buyerReference, + Optional orderReference, + Optional referencedInvoiceId, + Party seller, + Party buyer, + Optional payment, + List additionalDocuments, + List allowanceCharges, + List lines, + Money lineExtensionTotal, + Money taxExclusiveAmount, + Money taxInclusiveAmount, + Optional chargeTotal, + Optional payableRounding, + Money payableAmount, + List taxSubtotals, + Money taxTotal, + Optional invoiceTypeCode) { + this.type = type; + this.id = id; + this.issueDate = issueDate; + this.dueDate = dueDate; + this.currency = currency; + this.buyerReference = buyerReference; + this.orderReference = orderReference; + this.referencedInvoiceId = referencedInvoiceId; + this.seller = seller; + this.buyer = buyer; + this.payment = payment; + this.additionalDocuments = additionalDocuments; + this.allowanceCharges = allowanceCharges; + this.lines = lines; + this.lineExtensionTotal = lineExtensionTotal; + this.taxExclusiveAmount = taxExclusiveAmount; + this.taxInclusiveAmount = taxInclusiveAmount; + this.chargeTotal = chargeTotal; + this.payableRounding = payableRounding; + this.payableAmount = payableAmount; + this.taxSubtotals = taxSubtotals; + this.taxTotal = taxTotal; + this.invoiceTypeCode = invoiceTypeCode; + } + + static BillingDocument parsed( + BillingDocumentType type, + String id, + LocalDate issueDate, + Optional dueDate, + CurrencyCode currency, + Optional buyerReference, + Optional orderReference, + Optional referencedInvoiceId, + Party seller, + Party buyer, + Optional payment, + List additionalDocuments, + List allowanceCharges, + List lines, + Money lineExtensionTotal, + Money taxExclusiveAmount, + Money taxInclusiveAmount, + Optional chargeTotal, + Optional payableRounding, + Money payableAmount, + List taxSubtotals, + Money taxTotal, + Optional invoiceTypeCode) { + return new BillingDocument( + type, + BillingValues.nonBlank(id, "document ID", 100), + Objects.requireNonNull(issueDate, "issueDate"), + Objects.requireNonNull(dueDate, "dueDate"), + Objects.requireNonNull(currency, "currency"), + Objects.requireNonNull(buyerReference, "buyerReference"), + Objects.requireNonNull(orderReference, "orderReference"), + Objects.requireNonNull(referencedInvoiceId, "referencedInvoiceId"), + Objects.requireNonNull(seller, "seller"), + Objects.requireNonNull(buyer, "buyer"), + Objects.requireNonNull(payment, "payment"), + List.copyOf(additionalDocuments), + List.copyOf(allowanceCharges), + List.copyOf(lines), + Objects.requireNonNull(lineExtensionTotal, "lineExtensionTotal"), + Objects.requireNonNull(taxExclusiveAmount, "taxExclusiveAmount"), + Objects.requireNonNull(taxInclusiveAmount, "taxInclusiveAmount"), + Objects.requireNonNull(chargeTotal, "chargeTotal"), + Objects.requireNonNull(payableRounding, "payableRounding"), + Objects.requireNonNull(payableAmount, "payableAmount"), + List.copyOf(taxSubtotals), + Objects.requireNonNull(taxTotal, "taxTotal"), + Objects.requireNonNull(invoiceTypeCode, "invoiceTypeCode")); + } + + public static Builder invoice() { + return new Builder(BillingDocumentType.INVOICE); + } + + public static Builder creditNote() { + return new Builder(BillingDocumentType.CREDIT_NOTE); + } + + public BillingDocumentType type() { + return type; + } + + public String id() { + return id; + } + + public LocalDate issueDate() { + return issueDate; + } + + public Optional dueDate() { + return dueDate; + } + + public CurrencyCode currency() { + return currency; + } + + public Optional buyerReference() { + return buyerReference; + } + + public Optional orderReference() { + return orderReference; + } + + public Optional referencedInvoiceId() { + return referencedInvoiceId; + } + + public Party seller() { + return seller; + } + + public Party buyer() { + return buyer; + } + + public Optional payment() { + return payment; + } + + public List additionalDocuments() { + return additionalDocuments; + } + + public List allowanceCharges() { + return allowanceCharges; + } + + public List lines() { + return lines; + } + + public Money lineExtensionTotal() { + return lineExtensionTotal; + } + + public Money taxExclusiveAmount() { + return taxExclusiveAmount; + } + + public Money taxInclusiveAmount() { + return taxInclusiveAmount; + } + + public Optional chargeTotal() { + return chargeTotal; + } + + public Optional payableRounding() { + return payableRounding; + } + + public Money payableAmount() { + return payableAmount; + } + + public List taxSubtotals() { + return taxSubtotals; + } + + public Money taxTotal() { + return taxTotal; + } + + public Optional invoiceTypeCode() { + return invoiceTypeCode; + } + + public boolean isCreditNote() { + return type.isCreditNote(); + } + + static List deriveTaxSubtotals(List lines, CurrencyCode currency) { + record Key(TaxCategoryCode code, Optional rate, Optional reason) { + } + Map taxableByCategory = new LinkedHashMap<>(); + Map categories = new LinkedHashMap<>(); + for (BillingLine line : lines) { + VatCategory category = line.vatCategory(); + Key key = new Key(category.code(), category.rate(), category.exemptionReason()); + taxableByCategory.merge(key, line.netAmount(), Money::add); + categories.putIfAbsent(key, category); + } + List subtotals = new ArrayList<>(taxableByCategory.size()); + taxableByCategory.forEach((key, taxable) -> { + VatCategory category = categories.get(key); + BigDecimal tax = category.rate() + .map(rate -> taxable.amount() + .multiply(rate) + .movePointLeft(2) + .setScale(2, RoundingMode.HALF_UP)) + .orElse(BigDecimal.ZERO.setScale(2)); + subtotals.add(new TaxSubtotal(category, taxable, new Money(currency, tax))); + }); + return List.copyOf(subtotals); + } + + public static final class Builder { + private final BillingDocumentType type; + private String id; + private LocalDate issueDate; + private Optional dueDate = Optional.empty(); + private CurrencyCode currency; + private Optional buyerReference = Optional.empty(); + private Optional orderReference = Optional.empty(); + private Optional referencedInvoiceId = Optional.empty(); + private Party seller; + private Party buyer; + private Optional payment = Optional.empty(); + private final List additionalDocuments = new ArrayList<>(); + private final List lines = new ArrayList<>(); + + private Builder(BillingDocumentType type) { + this.type = type; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder issueDate(LocalDate issueDate) { + this.issueDate = issueDate; + return this; + } + + public Builder dueDate(LocalDate dueDate) { + this.dueDate = Optional.ofNullable(dueDate); + return this; + } + + public Builder currency(CurrencyCode currency) { + this.currency = currency; + return this; + } + + public Builder buyerReference(String buyerReference) { + this.buyerReference = BillingValues.optionalText(buyerReference, "buyer reference", 200); + return this; + } + + public Builder orderReference(OrderReference orderReference) { + this.orderReference = Optional.ofNullable(orderReference); + return this; + } + + public Builder referencedInvoiceId(String referencedInvoiceId) { + this.referencedInvoiceId = BillingValues.optionalText(referencedInvoiceId, "referenced invoice ID", 100); + return this; + } + + public Builder seller(Party seller) { + this.seller = seller; + return this; + } + + public Builder buyer(Party buyer) { + this.buyer = buyer; + return this; + } + + public Builder payment(PaymentInstruction payment) { + this.payment = Optional.ofNullable(payment); + return this; + } + + public Builder additionalDocument(AdditionalDocument document) { + this.additionalDocuments.add(Objects.requireNonNull(document, "additional document")); + return this; + } + + public Builder line(BillingLine line) { + this.lines.add(Objects.requireNonNull(line, "line")); + return this; + } + + public BillingDocument build() { + String documentId = BillingValues.nonBlank(id, "document ID", 100); + LocalDate issued = Objects.requireNonNull(issueDate, "issueDate"); + CurrencyCode documentCurrency = Objects.requireNonNull(currency, "currency"); + Party sellingParty = Objects.requireNonNull(seller, "seller"); + Party buyingParty = Objects.requireNonNull(buyer, "buyer"); + if (sellingParty.endpoint().isEmpty() || buyingParty.endpoint().isEmpty()) { + throw new IllegalArgumentException("seller and buyer must have endpoint identifiers"); + } + if (buyerReference.isEmpty() && orderReference.isEmpty()) { + throw new IllegalArgumentException("document must have a buyer reference or an order reference"); + } + dueDate.ifPresent(date -> { + if (date.isBefore(issued)) { + throw new IllegalArgumentException("due date must not precede issue date"); + } + }); + if (lines.isEmpty()) { + throw new IllegalArgumentException("document must contain at least one line"); + } + List copiedLines = List.copyOf(lines); + long distinctIds = copiedLines.stream().map(BillingLine::id).distinct().count(); + if (distinctIds != copiedLines.size()) { + throw new IllegalArgumentException("line IDs must be unique"); + } + for (BillingLine line : copiedLines) { + if (!line.unitPrice().currency().equals(documentCurrency)) { + throw new IllegalArgumentException( + "line " + line.id() + " does not use document currency " + documentCurrency); + } + } + Money lineExtension = copiedLines.stream() + .map(BillingLine::netAmount) + .reduce(Money.zero(documentCurrency), Money::add); + List subtotals = deriveTaxSubtotals(copiedLines, documentCurrency); + Money tax = subtotals.stream() + .map(TaxSubtotal::taxAmount) + .reduce(Money.zero(documentCurrency), Money::add); + Money payable = lineExtension.add(tax); + return new BillingDocument( + type, + documentId, + issued, + dueDate, + documentCurrency, + buyerReference, + orderReference, + referencedInvoiceId, + sellingParty, + buyingParty, + payment, + List.copyOf(additionalDocuments), + List.of(), + copiedLines, + lineExtension, + lineExtension, + payable, + Optional.empty(), + Optional.empty(), + payable, + subtotals, + tax, + Optional.of(type.typeCode())); + } + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/BillingDocumentType.java b/brev-documents/src/main/java/no/beint/brev/documents/BillingDocumentType.java new file mode 100644 index 0000000..c0ccfd4 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/BillingDocumentType.java @@ -0,0 +1,79 @@ +package no.beint.brev.documents; + +import no.beint.brev.DocumentTypeId; +import no.beint.brev.ProcessId; + +/** The two current Peppol BIS Billing 3 document variants ReAI sends and receives. */ +public enum BillingDocumentType { + INVOICE( + "Invoice", + "380", + "InvoiceLine", + "InvoicedQuantity", + "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2", + new DocumentTypeId( + "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1")), + CREDIT_NOTE( + "CreditNote", + "381", + "CreditNoteLine", + "CreditedQuantity", + "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2", + new DocumentTypeId( + "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1")); + + public static final String CUSTOMIZATION_ID = + "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"; + public static final ProcessId PROCESS = + new ProcessId("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"); + + private final String rootElement; + private final String typeCode; + private final String lineElement; + private final String quantityElement; + private final String namespace; + private final DocumentTypeId documentTypeId; + + BillingDocumentType( + String rootElement, + String typeCode, + String lineElement, + String quantityElement, + String namespace, + DocumentTypeId documentTypeId) { + this.rootElement = rootElement; + this.typeCode = typeCode; + this.lineElement = lineElement; + this.quantityElement = quantityElement; + this.namespace = namespace; + this.documentTypeId = documentTypeId; + } + + public String rootElement() { + return rootElement; + } + + public String typeCode() { + return typeCode; + } + + public String lineElement() { + return lineElement; + } + + public String quantityElement() { + return quantityElement; + } + + public String namespace() { + return namespace; + } + + public DocumentTypeId documentTypeId() { + return documentTypeId; + } + + public boolean isCreditNote() { + return this == CREDIT_NOTE; + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/BillingLine.java b/brev-documents/src/main/java/no/beint/brev/documents/BillingLine.java new file mode 100644 index 0000000..820e025 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/BillingLine.java @@ -0,0 +1,84 @@ +package no.beint.brev.documents; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; +import java.util.Optional; + +/** One invoice or credit-note line. The net amount is explicit so ledger rounding is preserved. */ +public record BillingLine( + String id, + String itemName, + Quantity quantity, + UnitPrice unitPrice, + VatCategory vatCategory, + Money netAmount, + Optional priceAllowance, + Optional itemDescription) { + + public BillingLine { + BillingValues.nonBlank(id, "line ID", 64); + BillingValues.nonBlank(itemName, "item name", 200); + Objects.requireNonNull(quantity, "quantity"); + Objects.requireNonNull(unitPrice, "unitPrice"); + Objects.requireNonNull(vatCategory, "vatCategory"); + Objects.requireNonNull(netAmount, "netAmount"); + Objects.requireNonNull(priceAllowance, "priceAllowance"); + Objects.requireNonNull(itemDescription, "itemDescription"); + itemDescription = itemDescription.flatMap(value -> BillingValues.optionalText(value, "item description", 500)); + if (!unitPrice.currency().equals(netAmount.currency())) { + throw new IllegalArgumentException("line " + id + " net amount currency does not match unit price"); + } + priceAllowance.ifPresent(allowance -> { + allowance.requireSameCurrency(netAmount); + if (allowance.amount().signum() < 0) { + throw new IllegalArgumentException("price allowance must not be negative"); + } + }); + } + + public BillingLine( + String id, + String itemName, + Quantity quantity, + UnitPrice unitPrice, + VatCategory vatCategory) { + this( + id, + itemName, + quantity, + unitPrice, + vatCategory, + derivedNet(unitPrice, quantity), + Optional.empty(), + Optional.empty()); + } + + public BillingLine( + String id, + String itemName, + Quantity quantity, + UnitPrice unitPrice, + VatCategory vatCategory, + Money netAmount, + Optional priceAllowance) { + this(id, itemName, quantity, unitPrice, vatCategory, netAmount, priceAllowance, Optional.empty()); + } + + public BillingLine withPriceAllowance(Money allowance) { + return new BillingLine( + id, itemName, quantity, unitPrice, vatCategory, netAmount, Optional.of(allowance), itemDescription); + } + + public BillingLine withItemDescription(String description) { + return new BillingLine( + id, itemName, quantity, unitPrice, vatCategory, netAmount, priceAllowance, Optional.of(description)); + } + + private static Money derivedNet(UnitPrice unitPrice, Quantity quantity) { + BigDecimal net = unitPrice.amount() + .multiply(quantity.value()) + .divide(unitPrice.baseQuantity(), 2, RoundingMode.HALF_UP); + return new Money(unitPrice.currency(), net); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/BillingReader.java b/brev-documents/src/main/java/no/beint/brev/documents/BillingReader.java new file mode 100644 index 0000000..5946af6 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/BillingReader.java @@ -0,0 +1,910 @@ +package no.beint.brev.documents; + +import no.beint.brev.CountryCode; +import no.beint.brev.CurrencyCode; +import no.beint.brev.EndpointId; +import no.beint.brev.SchemeId; +import no.beint.brev.UnitCode; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Bounded StAX reader for current Peppol Billing Invoice and CreditNote documents. */ +public final class BillingReader { + private static final int MAX_DEPTH = 64; + private static final int MAX_ELEMENTS = 200_000; + private static final int MAX_TEXT = 2_000_000; + private static final int MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; + + private BillingReader() { + } + + public static BillingDocument read(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes"); + return read(new ByteArrayInputStream(bytes)); + } + + public static Optional tryRead(byte[] bytes) { + try { + return Optional.of(read(bytes)); + } catch (RuntimeException exception) { + return Optional.empty(); + } + } + + public static BillingDocument read(InputStream input) { + Objects.requireNonNull(input, "input"); + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false); + try { + XMLStreamReader reader = factory.createXMLStreamReader(input); + try { + return new Parser(reader).parse(); + } finally { + reader.close(); + } + } catch (XMLStreamException exception) { + throw new IllegalArgumentException("invalid Billing XML", exception); + } + } + + private static final class Parser { + private final XMLStreamReader reader; + private int depth; + private int elements; + + private Parser(XMLStreamReader reader) { + this.reader = reader; + } + + private BillingDocument parse() throws XMLStreamException { + while (reader.hasNext()) { + if (reader.next() == XMLStreamConstants.START_ELEMENT) { + countedStart(); + if ("Invoice".equals(reader.getLocalName())) { + return parseDocument(BillingDocumentType.INVOICE); + } + if ("CreditNote".equals(reader.getLocalName())) { + return parseDocument(BillingDocumentType.CREDIT_NOTE); + } + skipSubtree(); + } + } + throw new IllegalArgumentException("document is not a Peppol Billing Invoice or CreditNote"); + } + + private BillingDocument parseDocument(BillingDocumentType type) throws XMLStreamException { + String id = null; + LocalDate issueDate = null; + LocalDate dueDate = null; + CurrencyCode currency = null; + String buyerReference = null; + OrderReference orderReference = null; + String referencedInvoiceId = null; + Party seller = null; + Party buyer = null; + PaymentInstruction payment = null; + LocalDate paymentDueDate = null; + List attachments = new ArrayList<>(); + List allowances = new ArrayList<>(); + List lines = new ArrayList<>(); + List taxSubtotals = new ArrayList<>(); + Money taxTotal = null; + Money lineExtension = null; + Money taxExclusive = null; + Money taxInclusive = null; + Money chargeTotal = null; + Money payableRounding = null; + Money payable = null; + String typeCode = null; + + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> id = text(); + case "IssueDate" -> issueDate = LocalDate.parse(text()); + case "DueDate" -> dueDate = LocalDate.parse(text()); + case "DocumentCurrencyCode" -> currency = new CurrencyCode(text()); + case "BuyerReference" -> buyerReference = text(); + case "InvoiceTypeCode", "CreditNoteTypeCode" -> typeCode = text(); + case "OrderReference" -> orderReference = parseOrderReference(); + case "BillingReference" -> + referencedInvoiceId = firstPresent(referencedInvoiceId, parseBillingReference()); + case "AdditionalDocumentReference" -> parseAttachment().ifPresent(attachments::add); + case "AccountingSupplierParty" -> seller = parseParty(); + case "AccountingCustomerParty" -> buyer = parseParty(); + case "PaymentMeans" -> { + ParsedPayment parsed = parsePayment(); + if (payment == null) { + payment = parsed.instruction; + paymentDueDate = parsed.dueDate; + } + } + case "AllowanceCharge" -> parseAllowance().ifPresent(allowances::add); + case "TaxTotal" -> { + ParsedTaxTotal parsed = parseTaxTotal(); + if (taxTotal == null) { + taxTotal = parsed.taxAmount; + taxSubtotals.addAll(parsed.subtotals); + } + } + case "LegalMonetaryTotal" -> { + ParsedTotals totals = parseTotals(); + lineExtension = totals.lineExtension; + taxExclusive = totals.taxExclusive; + taxInclusive = totals.taxInclusive; + chargeTotal = totals.chargeTotal; + payableRounding = totals.payableRounding; + payable = totals.payable; + } + case "InvoiceLine", "CreditNoteLine" -> lines.add(parseLine(currency)); + default -> skipSubtree(); + } + } + if (id == null || issueDate == null || seller == null || buyer == null || lines.isEmpty()) { + throw new IllegalArgumentException("Billing document is missing required fields"); + } + CurrencyCode documentCurrency = currency != null ? currency : firstCurrency(lines, taxTotal, payable); + Money zero = Money.zero(documentCurrency); + if (dueDate == null) { + dueDate = paymentDueDate; + } + return BillingDocument.parsed( + type, + id, + issueDate, + Optional.ofNullable(dueDate), + documentCurrency, + Optional.ofNullable(buyerReference), + Optional.ofNullable(orderReference), + Optional.ofNullable(referencedInvoiceId), + seller, + buyer, + Optional.ofNullable(payment), + attachments, + allowances, + lines, + firstNonNull(lineExtension, sumNets(lines, documentCurrency)), + firstNonNull(taxExclusive, firstNonNull(lineExtension, sumNets(lines, documentCurrency))), + firstNonNull(taxInclusive, firstNonNull(payable, zero)), + Optional.ofNullable(chargeTotal), + Optional.ofNullable(payableRounding), + firstNonNull(payable, zero), + taxSubtotals, + firstNonNull(taxTotal, zero), + Optional.ofNullable(typeCode)); + } + + private OrderReference parseOrderReference() throws XMLStreamException { + String id = null; + String salesOrderId = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> id = text(); + case "SalesOrderID" -> salesOrderId = text(); + default -> skipSubtree(); + } + } + if (id == null) { + throw new IllegalArgumentException("OrderReference is missing ID"); + } + return new OrderReference(id, Optional.ofNullable(salesOrderId)); + } + + private String parseBillingReference() throws XMLStreamException { + String invoiceId = null; + while (nextChild()) { + if ("InvoiceDocumentReference".equals(reader.getLocalName())) { + invoiceId = firstPresent(invoiceId, parseNestedId()); + } else { + skipSubtree(); + } + } + return invoiceId; + } + + private String parseNestedId() throws XMLStreamException { + String id = null; + while (nextChild()) { + if ("ID".equals(reader.getLocalName())) { + id = text(); + } else { + skipSubtree(); + } + } + return id; + } + + private Optional parseAttachment() throws XMLStreamException { + String id = "attachment"; + String mimeType = null; + String fileName = null; + String uri = null; + byte[] content = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> id = text(); + case "Attachment" -> { + ParsedBinary binary = parseBinary(); + mimeType = binary.mimeType; + fileName = binary.fileName; + uri = binary.uri; + content = binary.content; + } + default -> skipSubtree(); + } + } + if (content == null || content.length == 0) { + return Optional.empty(); + } + String resolvedName = fileName != null && !fileName.isBlank() + ? fileName + : (uri != null && !uri.isBlank() ? uri : id); + return Optional.of(new AdditionalDocument( + id, + mimeType == null || mimeType.isBlank() ? "application/octet-stream" : mimeType, + resolvedName, + content, + Optional.ofNullable(uri))); + } + + private ParsedBinary parseBinary() throws XMLStreamException { + ParsedBinary binary = new ParsedBinary(); + while (nextChild()) { + if ("EmbeddedDocumentBinaryObject".equals(reader.getLocalName())) { + binary.mimeType = attribute("mimeCode"); + binary.fileName = attribute("filename"); + binary.uri = attribute("uri"); + String encoded = text(); + if (encoded != null && !encoded.isBlank()) { + byte[] decoded = Base64.getMimeDecoder().decode(encoded.replace(" ", "")); + if (decoded.length > MAX_ATTACHMENT_BYTES) { + throw new IllegalArgumentException("embedded document exceeds size limit"); + } + binary.content = decoded; + } + } else { + skipSubtree(); + } + } + return binary; + } + + private Party parseParty() throws XMLStreamException { + Party party = null; + while (nextChild()) { + if ("Party".equals(reader.getLocalName())) { + party = parsePartyContents(); + } else { + skipSubtree(); + } + } + if (party == null) { + throw new IllegalArgumentException("party wrapper is missing cac:Party"); + } + return party; + } + + private Party parsePartyContents() throws XMLStreamException { + EndpointId endpoint = null; + String legalName = null; + String partyName = null; + String registrationId = null; + SchemeId registrationScheme = null; + String vatIdentifier = null; + boolean foretaksregisteret = false; + PostalAddress address = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "EndpointID" -> endpoint = parseEndpoint(); + case "PartyName" -> partyName = firstPresent(partyName, parseNestedName()); + case "PostalAddress" -> address = parseAddress(); + case "PartyTaxScheme" -> { + ParsedTaxScheme scheme = parseTaxScheme(); + if ("TAX".equals(scheme.scheme) && "Foretaksregisteret".equals(scheme.companyId)) { + foretaksregisteret = true; + } else if (scheme.companyId != null && vatIdentifier == null) { + vatIdentifier = scheme.companyId; + } + } + case "PartyLegalEntity" -> { + ParsedLegalEntity entity = parseLegalEntity(); + legalName = firstPresent(legalName, entity.name); + registrationId = firstPresent(registrationId, entity.companyId); + if (entity.scheme != null) { + registrationScheme = entity.scheme; + } + } + default -> skipSubtree(); + } + } + String name = firstPresent(legalName, partyName); + if (name == null) { + throw new IllegalArgumentException("party is missing a name"); + } + if (address == null) { + address = PostalAddress.ofCountry(new CountryCode("NO")); + } + return new Party( + Optional.ofNullable(endpoint), + name, + Optional.ofNullable(registrationId), + Optional.ofNullable(registrationScheme), + Optional.ofNullable(vatIdentifier), + foretaksregisteret, + address); + } + + private EndpointId parseEndpoint() throws XMLStreamException { + String scheme = attribute("schemeID"); + String value = text(); + if (value == null || value.isBlank()) { + return null; + } + SchemeId schemeId = scheme == null || scheme.isBlank() + ? SchemeId.NORWEGIAN_ORGANIZATION + : new SchemeId(scheme); + return new EndpointId(schemeId, value); + } + + private String parseNestedName() throws XMLStreamException { + String name = null; + while (nextChild()) { + if ("Name".equals(reader.getLocalName()) || "RegistrationName".equals(reader.getLocalName())) { + name = text(); + } else { + skipSubtree(); + } + } + return name; + } + + private PostalAddress parseAddress() throws XMLStreamException { + String street = null; + String additional = null; + String city = null; + String postal = null; + CountryCode country = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "StreetName" -> street = text(); + case "AdditionalStreetName" -> additional = text(); + case "CityName" -> city = text(); + case "PostalZone" -> postal = text(); + case "Country" -> country = parseCountry(); + default -> skipSubtree(); + } + } + if (country == null) { + country = new CountryCode("NO"); + } + return new PostalAddress( + Optional.ofNullable(street), + Optional.ofNullable(additional), + Optional.ofNullable(city), + Optional.ofNullable(postal), + country); + } + + private CountryCode parseCountry() throws XMLStreamException { + CountryCode country = null; + while (nextChild()) { + if ("IdentificationCode".equals(reader.getLocalName())) { + country = new CountryCode(text()); + } else { + skipSubtree(); + } + } + return country; + } + + private ParsedTaxScheme parseTaxScheme() throws XMLStreamException { + ParsedTaxScheme scheme = new ParsedTaxScheme(); + while (nextChild()) { + switch (reader.getLocalName()) { + case "CompanyID" -> scheme.companyId = text(); + case "TaxScheme" -> scheme.scheme = parseNestedId(); + default -> skipSubtree(); + } + } + return scheme; + } + + private ParsedLegalEntity parseLegalEntity() throws XMLStreamException { + ParsedLegalEntity entity = new ParsedLegalEntity(); + while (nextChild()) { + switch (reader.getLocalName()) { + case "RegistrationName" -> entity.name = text(); + case "CompanyID" -> { + String scheme = attribute("schemeID"); + entity.companyId = text(); + if (scheme != null && !scheme.isBlank()) { + entity.scheme = new SchemeId(scheme); + } + } + default -> skipSubtree(); + } + } + return entity; + } + + private ParsedPayment parsePayment() throws XMLStreamException { + String account = null; + String paymentId = null; + String bic = null; + String bankName = null; + String bankCountry = null; + LocalDate dueDate = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "PaymentID" -> paymentId = firstPresent(paymentId, text()); + case "PaymentDueDate" -> dueDate = LocalDate.parse(text()); + case "PayeeFinancialAccount" -> { + ParsedAccount parsed = parseAccount(); + account = firstPresent(account, parsed.id); + bic = firstPresent(bic, parsed.bic); + bankName = firstPresent(bankName, parsed.bankName); + bankCountry = firstPresent(bankCountry, parsed.bankCountry); + } + default -> skipSubtree(); + } + } + if (account == null) { + return new ParsedPayment(null, dueDate); + } + return new ParsedPayment( + new PaymentInstruction( + account, + Optional.ofNullable(paymentId), + Optional.ofNullable(bic), + Optional.ofNullable(bankName), + Optional.ofNullable(bankCountry)), + dueDate); + } + + private ParsedAccount parseAccount() throws XMLStreamException { + ParsedAccount account = new ParsedAccount(); + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> account.id = text(); + case "FinancialInstitutionBranch" -> parseBranch(account); + default -> skipSubtree(); + } + } + return account; + } + + private void parseBranch(ParsedAccount account) throws XMLStreamException { + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> account.bic = firstPresent(account.bic, text()); + case "Name" -> account.bankName = firstPresent(account.bankName, text()); + case "FinancialInstitution" -> parseFinancialInstitution(account); + default -> skipSubtree(); + } + } + } + + private void parseFinancialInstitution(ParsedAccount account) throws XMLStreamException { + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> account.bic = firstPresent(account.bic, text()); + case "Name" -> account.bankName = firstPresent(account.bankName, text()); + case "Address" -> { + PostalAddress address = parseAddress(); + account.bankCountry = firstPresent(account.bankCountry, address.country().value()); + } + default -> skipSubtree(); + } + } + } + + private Optional parseAllowance() throws XMLStreamException { + Boolean charge = null; + String reason = null; + Money amount = null; + VatCategory vat = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "ChargeIndicator" -> charge = Boolean.parseBoolean(text()); + case "AllowanceChargeReason" -> reason = firstPresent(reason, text()); + case "Amount" -> amount = parseAmount(); + case "TaxCategory" -> vat = parseVatCategory(); + default -> skipSubtree(); + } + } + if (amount == null) { + return Optional.empty(); + } + return Optional.of(new DocumentAllowanceCharge( + Boolean.TRUE.equals(charge), + Optional.ofNullable(reason), + amount, + Optional.ofNullable(vat))); + } + + private ParsedTaxTotal parseTaxTotal() throws XMLStreamException { + Money taxAmount = null; + List subtotals = new ArrayList<>(); + while (nextChild()) { + switch (reader.getLocalName()) { + case "TaxAmount" -> taxAmount = parseAmount(); + case "TaxSubtotal" -> parseTaxSubtotal().ifPresent(subtotals::add); + default -> skipSubtree(); + } + } + return new ParsedTaxTotal(taxAmount, subtotals); + } + + private Optional parseTaxSubtotal() throws XMLStreamException { + Money taxable = null; + Money tax = null; + VatCategory category = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "TaxableAmount" -> taxable = parseAmount(); + case "TaxAmount" -> tax = parseAmount(); + case "TaxCategory" -> category = parseVatCategory(); + default -> skipSubtree(); + } + } + if (taxable == null || tax == null || category == null) { + return Optional.empty(); + } + return Optional.of(new TaxSubtotal(category, taxable, tax)); + } + + private ParsedTotals parseTotals() throws XMLStreamException { + ParsedTotals totals = new ParsedTotals(); + while (nextChild()) { + switch (reader.getLocalName()) { + case "LineExtensionAmount" -> totals.lineExtension = parseAmount(); + case "TaxExclusiveAmount" -> totals.taxExclusive = parseAmount(); + case "TaxInclusiveAmount" -> totals.taxInclusive = parseAmount(); + case "ChargeTotalAmount" -> totals.chargeTotal = parseAmount(); + case "PayableRoundingAmount" -> totals.payableRounding = parseAmount(); + case "PayableAmount" -> totals.payable = parseAmount(); + default -> skipSubtree(); + } + } + return totals; + } + + private BillingLine parseLine(CurrencyCode documentCurrency) throws XMLStreamException { + String id = "1"; + Quantity quantity = null; + Money net = null; + String itemName = "Item"; + String itemDescription = null; + VatCategory vat = null; + UnitPrice unitPrice = null; + Money priceAllowance = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> id = text(); + case "InvoicedQuantity", "CreditedQuantity" -> quantity = parseQuantity(); + case "LineExtensionAmount" -> net = parseAmount(); + case "Item" -> { + ParsedItem item = parseItem(); + itemName = firstPresent(item.name, firstPresent(item.description, itemName)); + itemDescription = firstPresent(itemDescription, item.description); + vat = item.vat; + } + case "Price" -> { + ParsedPrice price = parsePrice(); + unitPrice = price.unitPrice; + priceAllowance = price.allowance; + } + default -> skipSubtree(); + } + } + CurrencyCode currency = net != null + ? net.currency() + : (documentCurrency != null ? documentCurrency : new CurrencyCode("NOK")); + if (quantity == null) { + quantity = new Quantity(BigDecimal.ONE, UnitCode.EA); + } + if (net == null) { + net = Money.zero(currency); + } + if (unitPrice == null) { + unitPrice = new UnitPrice(currency, BigDecimal.ZERO); + } + if (vat == null) { + vat = VatCategory.zero(); + } + return new BillingLine( + id, + itemName, + quantity, + unitPrice, + vat, + net, + Optional.ofNullable(priceAllowance), + Optional.ofNullable(itemDescription)); + } + + private ParsedItem parseItem() throws XMLStreamException { + ParsedItem item = new ParsedItem(); + while (nextChild()) { + switch (reader.getLocalName()) { + case "Name" -> item.name = text(); + case "Description" -> item.description = firstPresent(item.description, text()); + case "ClassifiedTaxCategory" -> item.vat = parseVatCategory(); + default -> skipSubtree(); + } + } + return item; + } + + private ParsedPrice parsePrice() throws XMLStreamException { + ParsedPrice price = new ParsedPrice(); + BigDecimal amount = null; + CurrencyCode currency = null; + BigDecimal base = BigDecimal.ONE; + while (nextChild()) { + switch (reader.getLocalName()) { + case "PriceAmount" -> { + currency = currencyAttribute(); + amount = new BigDecimal(text()); + } + case "BaseQuantity" -> { + String value = text(); + if (value != null && !value.isBlank()) { + base = new BigDecimal(value); + } + } + case "AllowanceCharge" -> price.allowance = parsePriceAllowance(); + default -> skipSubtree(); + } + } + if (amount != null && currency != null) { + price.unitPrice = new UnitPrice(currency, amount, base.signum() > 0 ? base : BigDecimal.ONE); + } + return price; + } + + private Money parsePriceAllowance() throws XMLStreamException { + Money amount = null; + while (nextChild()) { + if ("Amount".equals(reader.getLocalName())) { + amount = parseAmount(); + } else { + skipSubtree(); + } + } + return amount; + } + + private VatCategory parseVatCategory() throws XMLStreamException { + String code = null; + BigDecimal rate = null; + String reason = null; + while (nextChild()) { + switch (reader.getLocalName()) { + case "ID" -> code = text(); + case "Percent" -> { + String value = text(); + if (value != null && !value.isBlank()) { + rate = new BigDecimal(value); + } + } + case "TaxExemptionReason" -> reason = text(); + default -> skipSubtree(); + } + } + Optional typed = TaxCategoryCode.fromXmlValue(code); + if (typed.isPresent()) { + return switch (typed.orElseThrow()) { + case STANDARD_RATE -> rate == null || rate.signum() == 0 + ? VatCategory.zero() + : VatCategory.standard(rate); + case ZERO_RATE -> VatCategory.zero(); + case EXEMPT -> VatCategory.exempt(firstPresent(reason, "Exempt from VAT")); + case EXPORT -> VatCategory.export(firstPresent(reason, "Export outside the EU")); + case REVERSE_CHARGE -> VatCategory.reverseCharge(firstPresent(reason, "Reverse charge")); + case OUTSIDE_SCOPE -> VatCategory.outsideScope(firstPresent(reason, "Not subject to VAT")); + }; + } + if (rate != null && rate.signum() > 0) { + return VatCategory.standard(rate); + } + return VatCategory.exempt(firstPresent(reason, "Exempt from VAT")); + } + + private Quantity parseQuantity() throws XMLStreamException { + String unit = attribute("unitCode"); + String value = text(); + UnitCode unitCode = unit == null || unit.isBlank() ? UnitCode.EA : new UnitCode(unit); + return new Quantity(value == null || value.isBlank() ? BigDecimal.ONE : new BigDecimal(value), unitCode); + } + + private Money parseAmount() throws XMLStreamException { + CurrencyCode currency = currencyAttribute(); + return new Money(currency, new BigDecimal(text())); + } + + private CurrencyCode currencyAttribute() { + String currency = attribute("currencyID"); + return new CurrencyCode(currency == null || currency.isBlank() ? "NOK" : currency); + } + + /** + * Advance to the next child START_ELEMENT of the current element. + * Returns false when the current element's END_ELEMENT is reached. + */ + private boolean nextChild() throws XMLStreamException { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + countedStart(); + return true; + } + if (event == XMLStreamConstants.END_ELEMENT) { + return false; + } + } + return false; + } + + private void countedStart() { + elements++; + depth++; + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("XML exceeds depth limit"); + } + if (elements > MAX_ELEMENTS) { + throw new IllegalArgumentException("XML exceeds element limit"); + } + } + + private void skipSubtree() throws XMLStreamException { + int remaining = 1; + while (remaining > 0 && reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + countedStart(); + remaining++; + } else if (event == XMLStreamConstants.END_ELEMENT) { + remaining--; + depth--; + } + } + } + + private String text() throws XMLStreamException { + StringBuilder builder = new StringBuilder(); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) { + builder.append(reader.getText()); + if (builder.length() > MAX_TEXT) { + throw new IllegalArgumentException("XML text exceeds size limit"); + } + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + return builder.toString(); + } else if (event == XMLStreamConstants.START_ELEMENT) { + countedStart(); + skipSubtree(); + } + } + return builder.toString(); + } + + private String attribute(String name) { + String value = reader.getAttributeValue(null, name); + if (value != null) { + return value; + } + for (int index = 0; index < reader.getAttributeCount(); index++) { + if (name.equals(reader.getAttributeLocalName(index))) { + return reader.getAttributeValue(index); + } + } + return null; + } + } + + private static String firstPresent(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } + + private static Money firstNonNull(Money first, Money second) { + return first != null ? first : second; + } + + private static Money sumNets(List lines, CurrencyCode currency) { + return lines.stream().map(BillingLine::netAmount).reduce(Money.zero(currency), Money::add); + } + + private static CurrencyCode firstCurrency(List lines, Money taxTotal, Money payable) { + if (taxTotal != null) { + return taxTotal.currency(); + } + if (payable != null) { + return payable.currency(); + } + if (!lines.isEmpty()) { + return lines.getFirst().netAmount().currency(); + } + return new CurrencyCode("NOK"); + } + + private static final class ParsedBinary { + private String mimeType; + private String fileName; + private String uri; + private byte[] content; + } + + private static final class ParsedTaxScheme { + private String companyId; + private String scheme; + } + + private static final class ParsedLegalEntity { + private String name; + private String companyId; + private SchemeId scheme; + } + + private static final class ParsedPayment { + private final PaymentInstruction instruction; + private final LocalDate dueDate; + + private ParsedPayment(PaymentInstruction instruction, LocalDate dueDate) { + this.instruction = instruction; + this.dueDate = dueDate; + } + } + + private static final class ParsedAccount { + private String id; + private String bic; + private String bankName; + private String bankCountry; + } + + private static final class ParsedTaxTotal { + private final Money taxAmount; + private final List subtotals; + + private ParsedTaxTotal(Money taxAmount, List subtotals) { + this.taxAmount = taxAmount; + this.subtotals = subtotals; + } + } + + private static final class ParsedTotals { + private Money lineExtension; + private Money taxExclusive; + private Money taxInclusive; + private Money chargeTotal; + private Money payableRounding; + private Money payable; + } + + private static final class ParsedItem { + private String name; + private String description; + private VatCategory vat; + } + + private static final class ParsedPrice { + private UnitPrice unitPrice; + private Money allowance; + } +} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/BillingValues.java b/brev-documents/src/main/java/no/beint/brev/documents/BillingValues.java similarity index 75% rename from brev-billing/src/main/java/no/beint/brev/billing/BillingValues.java rename to brev-documents/src/main/java/no/beint/brev/documents/BillingValues.java index f8bcb93..1a5fbd2 100644 --- a/brev-billing/src/main/java/no/beint/brev/billing/BillingValues.java +++ b/brev-documents/src/main/java/no/beint/brev/documents/BillingValues.java @@ -1,7 +1,8 @@ -package no.beint.brev.billing; +package no.beint.brev.documents; import java.math.BigDecimal; import java.util.Objects; +import java.util.Optional; final class BillingValues { private BillingValues() { @@ -21,12 +22,11 @@ static String nonBlank(String value, String name, int maximumLength) { return value; } - static BigDecimal positive(BigDecimal value, String name) { - Objects.requireNonNull(value, name); - if (value.signum() <= 0) { - throw new IllegalArgumentException(name + " must be greater than zero"); + static Optional optionalText(String value, String name, int maximumLength) { + if (value == null || value.isBlank()) { + return Optional.empty(); } - return value; + return Optional.of(nonBlank(value, name, maximumLength)); } static String decimal(BigDecimal value) { diff --git a/brev-documents/src/main/java/no/beint/brev/documents/BillingWriter.java b/brev-documents/src/main/java/no/beint/brev/documents/BillingWriter.java new file mode 100644 index 0000000..4a993de --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/BillingWriter.java @@ -0,0 +1,275 @@ +package no.beint.brev.documents; + +import no.beint.brev.documents.internal.Utf8XmlOutput; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.util.Objects; + +/** Writes a Billing document directly as compact UTF-8 UBL 2.1 XML. */ +public final class BillingWriter { + private static final String CAC = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"; + private static final String CBC = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"; + + private BillingWriter() { + } + + public static void write(BillingDocument document, OutputStream destination) throws IOException { + Objects.requireNonNull(document, "document"); + Objects.requireNonNull(destination, "destination"); + Utf8XmlOutput xml = new Utf8XmlOutput(destination); + xml.declaration(); + xml.startOpen(document.type().rootElement()); + xml.raw(" xmlns=\""); + xml.raw(document.type().namespace()); + xml.raw("\" xmlns:cac=\""); + xml.raw(CAC); + xml.raw("\" xmlns:cbc=\""); + xml.raw(CBC); + xml.raw("\""); + xml.closeStart(); + + xml.element("cbc:CustomizationID", BillingDocumentType.CUSTOMIZATION_ID); + xml.element("cbc:ProfileID", BillingDocumentType.PROCESS.value()); + xml.element("cbc:ID", document.id()); + xml.element("cbc:IssueDate", document.issueDate().toString()); + if (!document.isCreditNote()) { + xml.optionalElement("cbc:DueDate", document.dueDate().map(Object::toString)); + xml.element("cbc:InvoiceTypeCode", document.type().typeCode()); + } else { + xml.element("cbc:CreditNoteTypeCode", document.type().typeCode()); + } + xml.element("cbc:DocumentCurrencyCode", document.currency().value()); + xml.optionalElement("cbc:BuyerReference", document.buyerReference()); + writeOrderReference(xml, document); + writeBillingReference(xml, document); + writeAdditionalDocuments(xml, document); + writeParty(xml, "cac:AccountingSupplierParty", document.seller()); + writeParty(xml, "cac:AccountingCustomerParty", document.buyer()); + writePayment(xml, document); + writeTaxes(xml, document); + writeTotals(xml, document); + for (BillingLine line : document.lines()) { + writeLine(xml, document, line); + } + xml.end(document.type().rootElement()); + xml.finish(); + } + + public static byte[] toByteArray(BillingDocument document) { + ByteArrayOutputStream destination = new ByteArrayOutputStream(4096); + try { + write(document, destination); + } catch (IOException exception) { + throw new UncheckedIOException("unexpected in-memory write failure", exception); + } + return destination.toByteArray(); + } + + private static void writeOrderReference(Utf8XmlOutput xml, BillingDocument document) throws IOException { + if (document.orderReference().isEmpty()) { + return; + } + OrderReference reference = document.orderReference().orElseThrow(); + xml.start("cac:OrderReference"); + xml.element("cbc:ID", reference.id()); + xml.optionalElement("cbc:SalesOrderID", reference.salesOrderId()); + xml.end("cac:OrderReference"); + } + + private static void writeBillingReference(Utf8XmlOutput xml, BillingDocument document) throws IOException { + if (document.referencedInvoiceId().isEmpty()) { + return; + } + xml.start("cac:BillingReference"); + xml.start("cac:InvoiceDocumentReference"); + xml.element("cbc:ID", document.referencedInvoiceId().orElseThrow()); + xml.end("cac:InvoiceDocumentReference"); + xml.end("cac:BillingReference"); + } + + private static void writeAdditionalDocuments(Utf8XmlOutput xml, BillingDocument document) throws IOException { + for (AdditionalDocument attachment : document.additionalDocuments()) { + xml.start("cac:AdditionalDocumentReference"); + xml.element("cbc:ID", attachment.id()); + xml.start("cac:Attachment"); + xml.startOpen("cbc:EmbeddedDocumentBinaryObject"); + xml.attribute("mimeCode", attachment.mimeType()); + xml.attribute("filename", attachment.fileName()); + xml.closeStart(); + xml.base64(attachment.content()); + xml.end("cbc:EmbeddedDocumentBinaryObject"); + xml.end("cac:Attachment"); + xml.end("cac:AdditionalDocumentReference"); + } + } + + private static void writeParty(Utf8XmlOutput xml, String wrapper, Party party) throws IOException { + xml.start(wrapper); + xml.start("cac:Party"); + if (party.endpoint().isPresent()) { + xml.element( + "cbc:EndpointID", + "schemeID", + party.endpoint().orElseThrow().scheme().value(), + party.endpoint().orElseThrow().value()); + } + xml.start("cac:PartyName"); + xml.element("cbc:Name", party.legalName()); + xml.end("cac:PartyName"); + writeAddress(xml, party.address()); + if (party.vatIdentifier().isPresent()) { + xml.start("cac:PartyTaxScheme"); + xml.element("cbc:CompanyID", party.vatIdentifier().orElseThrow()); + taxScheme(xml, "VAT"); + xml.end("cac:PartyTaxScheme"); + } + if (party.registeredInForetaksregisteret()) { + xml.start("cac:PartyTaxScheme"); + xml.element("cbc:CompanyID", "Foretaksregisteret"); + taxScheme(xml, "TAX"); + xml.end("cac:PartyTaxScheme"); + } + xml.start("cac:PartyLegalEntity"); + xml.element("cbc:RegistrationName", party.legalName()); + if (party.registrationId().isPresent()) { + if (party.registrationScheme().isPresent()) { + xml.element( + "cbc:CompanyID", + "schemeID", + party.registrationScheme().orElseThrow().value(), + party.registrationId().orElseThrow()); + } else { + xml.element("cbc:CompanyID", party.registrationId().orElseThrow()); + } + } + xml.end("cac:PartyLegalEntity"); + xml.end("cac:Party"); + xml.end(wrapper); + } + + private static void writeAddress(Utf8XmlOutput xml, PostalAddress address) throws IOException { + xml.start("cac:PostalAddress"); + xml.optionalElement("cbc:StreetName", address.streetName()); + xml.optionalElement("cbc:AdditionalStreetName", address.additionalStreetName()); + xml.optionalElement("cbc:CityName", address.cityName()); + xml.optionalElement("cbc:PostalZone", address.postalZone()); + xml.start("cac:Country"); + xml.element("cbc:IdentificationCode", address.country().value()); + xml.end("cac:Country"); + xml.end("cac:PostalAddress"); + } + + private static void writePayment(Utf8XmlOutput xml, BillingDocument document) throws IOException { + if (document.payment().isEmpty()) { + return; + } + PaymentInstruction payment = document.payment().orElseThrow(); + xml.start("cac:PaymentMeans"); + xml.element("cbc:PaymentMeansCode", "name", "Credit transfer", "30"); + xml.optionalElement("cbc:PaymentID", payment.paymentReference()); + xml.start("cac:PayeeFinancialAccount"); + xml.element("cbc:ID", payment.accountId()); + if (payment.bic().isPresent()) { + xml.start("cac:FinancialInstitutionBranch"); + xml.element("cbc:ID", payment.bic().orElseThrow()); + xml.end("cac:FinancialInstitutionBranch"); + } + xml.end("cac:PayeeFinancialAccount"); + xml.end("cac:PaymentMeans"); + } + + private static void writeTaxes(Utf8XmlOutput xml, BillingDocument document) throws IOException { + xml.start("cac:TaxTotal"); + amount(xml, "cbc:TaxAmount", document.taxTotal()); + for (TaxSubtotal subtotal : document.taxSubtotals()) { + xml.start("cac:TaxSubtotal"); + amount(xml, "cbc:TaxableAmount", subtotal.taxableAmount()); + amount(xml, "cbc:TaxAmount", subtotal.taxAmount()); + writeTaxCategory(xml, "cac:TaxCategory", subtotal.category()); + xml.end("cac:TaxSubtotal"); + } + xml.end("cac:TaxTotal"); + } + + private static void writeTaxCategory(Utf8XmlOutput xml, String name, VatCategory category) throws IOException { + xml.start(name); + xml.element("cbc:ID", category.code().xmlValue()); + if (category.rate().isPresent()) { + xml.element("cbc:Percent", category.rateXmlValue()); + } + xml.optionalElement("cbc:TaxExemptionReason", category.exemptionReason()); + taxScheme(xml, "VAT"); + xml.end(name); + } + + private static void writeTotals(Utf8XmlOutput xml, BillingDocument document) throws IOException { + xml.start("cac:LegalMonetaryTotal"); + amount(xml, "cbc:LineExtensionAmount", document.lineExtensionTotal()); + amount(xml, "cbc:TaxExclusiveAmount", document.taxExclusiveAmount()); + amount(xml, "cbc:TaxInclusiveAmount", document.taxInclusiveAmount()); + document.chargeTotal().ifPresent(charge -> { + try { + amount(xml, "cbc:ChargeTotalAmount", charge); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + }); + document.payableRounding().ifPresent(rounding -> { + try { + amount(xml, "cbc:PayableRoundingAmount", rounding); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + }); + amount(xml, "cbc:PayableAmount", document.payableAmount()); + xml.end("cac:LegalMonetaryTotal"); + } + + private static void writeLine(Utf8XmlOutput xml, BillingDocument document, BillingLine line) throws IOException { + xml.start("cac:" + document.type().lineElement()); + xml.element("cbc:ID", line.id()); + xml.element( + "cbc:" + document.type().quantityElement(), + "unitCode", + line.quantity().unit().value(), + line.quantity().xmlValue()); + amount(xml, "cbc:LineExtensionAmount", line.netAmount()); + xml.start("cac:Item"); + xml.optionalElement("cbc:Description", line.itemDescription()); + xml.element("cbc:Name", line.itemName()); + writeTaxCategory(xml, "cac:ClassifiedTaxCategory", line.vatCategory()); + xml.end("cac:Item"); + xml.start("cac:Price"); + xml.element( + "cbc:PriceAmount", + "currencyID", + line.unitPrice().currency().value(), + line.unitPrice().amountXmlValue()); + xml.element( + "cbc:BaseQuantity", + "unitCode", + line.quantity().unit().value(), + line.unitPrice().baseQuantityXmlValue()); + if (line.priceAllowance().isPresent()) { + xml.start("cac:AllowanceCharge"); + xml.element("cbc:ChargeIndicator", "false"); + amount(xml, "cbc:Amount", line.priceAllowance().orElseThrow()); + xml.end("cac:AllowanceCharge"); + } + xml.end("cac:Price"); + xml.end("cac:" + document.type().lineElement()); + } + + private static void amount(Utf8XmlOutput xml, String element, Money money) throws IOException { + xml.element(element, "currencyID", money.currency().value(), money.xmlValue()); + } + + private static void taxScheme(Utf8XmlOutput xml, String id) throws IOException { + xml.start("cac:TaxScheme"); + xml.element("cbc:ID", id); + xml.end("cac:TaxScheme"); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/DocumentAllowanceCharge.java b/brev-documents/src/main/java/no/beint/brev/documents/DocumentAllowanceCharge.java new file mode 100644 index 0000000..e5c2fde --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/DocumentAllowanceCharge.java @@ -0,0 +1,19 @@ +package no.beint.brev.documents; + +import java.util.Objects; +import java.util.Optional; + +/** A document-level allowance or charge preserved from inbound UBL. */ +public record DocumentAllowanceCharge( + boolean charge, + Optional reason, + Money amount, + Optional vat) { + + public DocumentAllowanceCharge { + Objects.requireNonNull(reason, "reason"); + reason = reason.flatMap(value -> BillingValues.optionalText(value, "allowance reason", 200)); + Objects.requireNonNull(amount, "amount"); + Objects.requireNonNull(vat, "vat"); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/Documents.java b/brev-documents/src/main/java/no/beint/brev/documents/Documents.java new file mode 100644 index 0000000..7cf720c --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/Documents.java @@ -0,0 +1,27 @@ +package no.beint.brev.documents; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Optional; + +/** Facade for the current Peppol Billing document module. */ +public final class Documents { + private Documents() { + } + + public static void write(BillingDocument document, OutputStream destination) throws IOException { + BillingWriter.write(document, destination); + } + + public static byte[] toByteArray(BillingDocument document) { + return BillingWriter.toByteArray(document); + } + + public static BillingDocument read(byte[] xml) { + return BillingReader.read(xml); + } + + public static Optional tryRead(byte[] xml) { + return BillingReader.tryRead(xml); + } +} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/Money.java b/brev-documents/src/main/java/no/beint/brev/documents/Money.java similarity index 73% rename from brev-billing/src/main/java/no/beint/brev/billing/Money.java rename to brev-documents/src/main/java/no/beint/brev/documents/Money.java index e6453c7..ec022b9 100644 --- a/brev-billing/src/main/java/no/beint/brev/billing/Money.java +++ b/brev-documents/src/main/java/no/beint/brev/documents/Money.java @@ -1,18 +1,16 @@ -package no.beint.brev.billing; +package no.beint.brev.documents; import no.beint.brev.CurrencyCode; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.Objects; -/** A non-negative document amount with at most two fractional digits. */ +/** A document amount with at most two fractional digits. Negative values are allowed for credit notes. */ public record Money(CurrencyCode currency, BigDecimal amount) { public Money { Objects.requireNonNull(currency, "currency"); Objects.requireNonNull(amount, "amount"); - if (amount.signum() < 0) { - throw new IllegalArgumentException("money amount must not be negative in the initial positive-invoice profile"); - } if (fractionalDigits(amount) > 2) { throw new IllegalArgumentException("money amount must have at most two fractional digits"); } @@ -22,6 +20,10 @@ public static Money zero(CurrencyCode currency) { return new Money(currency, BigDecimal.ZERO); } + public static Money of(CurrencyCode currency, BigDecimal amount) { + return new Money(currency, amount.setScale(2, RoundingMode.HALF_UP)); + } + public Money add(Money other) { requireSameCurrency(other); return new Money(currency, amount.add(other.amount)); @@ -35,7 +37,7 @@ public void requireSameCurrency(Money other) { } public String xmlValue() { - return amount.setScale(2).toPlainString(); + return amount.setScale(2, RoundingMode.UNNECESSARY).toPlainString(); } private static int fractionalDigits(BigDecimal value) { diff --git a/brev-documents/src/main/java/no/beint/brev/documents/OrderReference.java b/brev-documents/src/main/java/no/beint/brev/documents/OrderReference.java new file mode 100644 index 0000000..4e90373 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/OrderReference.java @@ -0,0 +1,19 @@ +package no.beint.brev.documents; + +import java.util.Optional; + +/** A purchase-order / sales-order reference (BT-13 / BT-14). */ +public record OrderReference(String id, Optional salesOrderId) { + public OrderReference { + BillingValues.nonBlank(id, "order reference", 100); + salesOrderId = salesOrderId.flatMap(value -> BillingValues.optionalText(value, "sales order ID", 100)); + } + + public OrderReference(String id) { + this(id, Optional.empty()); + } + + public static OrderReference salesOrder(String salesOrderId) { + return new OrderReference("NA", Optional.of(salesOrderId)); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/Party.java b/brev-documents/src/main/java/no/beint/brev/documents/Party.java new file mode 100644 index 0000000..0d217f9 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/Party.java @@ -0,0 +1,85 @@ +package no.beint.brev.documents; + +import no.beint.brev.EndpointId; +import no.beint.brev.SchemeId; + +import java.util.Objects; +import java.util.Optional; + +/** A seller or buyer. Endpoints are required when constructing a document for writing. */ +public record Party( + Optional endpoint, + String legalName, + Optional registrationId, + Optional registrationScheme, + Optional vatIdentifier, + boolean registeredInForetaksregisteret, + PostalAddress address) { + + public Party { + Objects.requireNonNull(endpoint, "endpoint"); + BillingValues.nonBlank(legalName, "legal name", 200); + Objects.requireNonNull(registrationId, "registrationId"); + Objects.requireNonNull(registrationScheme, "registrationScheme"); + Objects.requireNonNull(vatIdentifier, "vatIdentifier"); + registrationId = registrationId.flatMap(value -> BillingValues.optionalText(value, "registration ID", 100)); + vatIdentifier = vatIdentifier.flatMap(value -> BillingValues.optionalText(value, "VAT identifier", 100)); + Objects.requireNonNull(address, "address"); + if (registrationScheme.isPresent() && registrationId.isEmpty()) { + throw new IllegalArgumentException("registration scheme requires a registration ID"); + } + } + + public static Party withVat( + EndpointId endpoint, + String legalName, + String registrationId, + String vatIdentifier, + PostalAddress address) { + return new Party( + Optional.of(endpoint), + legalName, + Optional.of(registrationId), + Optional.empty(), + Optional.of(vatIdentifier), + false, + address); + } + + public static Party withoutVat( + EndpointId endpoint, + String legalName, + String registrationId, + PostalAddress address) { + return new Party( + Optional.of(endpoint), + legalName, + Optional.of(registrationId), + Optional.empty(), + Optional.empty(), + false, + address); + } + + public Party withRegistrationScheme(SchemeId scheme) { + return new Party( + endpoint, + legalName, + registrationId, + Optional.of(scheme), + vatIdentifier, + registeredInForetaksregisteret, + address); + } + + public Party withForetaksregisteret() { + return new Party( + endpoint, + legalName, + registrationId, + registrationScheme, + vatIdentifier, + true, + address); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/PaymentInstruction.java b/brev-documents/src/main/java/no/beint/brev/documents/PaymentInstruction.java new file mode 100644 index 0000000..68b95ad --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/PaymentInstruction.java @@ -0,0 +1,28 @@ +package no.beint.brev.documents; + +import java.util.Optional; + +/** A Peppol credit-transfer instruction (payment means code 30). */ +public record PaymentInstruction( + String accountId, + Optional paymentReference, + Optional bic, + Optional bankName, + Optional bankCountryCode) { + + public PaymentInstruction { + BillingValues.nonBlank(accountId, "payment account ID", 100); + paymentReference = paymentReference.flatMap(value -> BillingValues.optionalText(value, "payment reference", 100)); + bic = bic.flatMap(value -> BillingValues.optionalText(value, "BIC", 20)); + bankName = bankName.flatMap(value -> BillingValues.optionalText(value, "bank name", 200)); + bankCountryCode = bankCountryCode.flatMap(value -> BillingValues.optionalText(value, "bank country", 2)); + } + + public PaymentInstruction(String accountId, Optional paymentReference, Optional bic) { + this(accountId, paymentReference, bic, Optional.empty(), Optional.empty()); + } + + public PaymentInstruction(String accountId, String paymentReference) { + this(accountId, Optional.of(paymentReference), Optional.empty()); + } +} diff --git a/brev-documents/src/main/java/no/beint/brev/documents/PostalAddress.java b/brev-documents/src/main/java/no/beint/brev/documents/PostalAddress.java new file mode 100644 index 0000000..5353d1d --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/PostalAddress.java @@ -0,0 +1,36 @@ +package no.beint.brev.documents; + +import no.beint.brev.CountryCode; + +import java.util.Objects; +import java.util.Optional; + +/** A postal address. Only the country is mandatory; empty optional parts are omitted from XML. */ +public record PostalAddress( + Optional streetName, + Optional additionalStreetName, + Optional cityName, + Optional postalZone, + CountryCode country) { + + public PostalAddress { + Objects.requireNonNull(streetName, "streetName"); + Objects.requireNonNull(additionalStreetName, "additionalStreetName"); + Objects.requireNonNull(cityName, "cityName"); + Objects.requireNonNull(postalZone, "postalZone"); + Objects.requireNonNull(country, "country"); + streetName = streetName.flatMap(value -> BillingValues.optionalText(value, "street name", 200)); + additionalStreetName = + additionalStreetName.flatMap(value -> BillingValues.optionalText(value, "additional street name", 200)); + cityName = cityName.flatMap(value -> BillingValues.optionalText(value, "city name", 100)); + postalZone = postalZone.flatMap(value -> BillingValues.optionalText(value, "postal zone", 32)); + } + + public PostalAddress(String streetName, String cityName, String postalZone, CountryCode country) { + this(Optional.of(streetName), Optional.empty(), Optional.of(cityName), Optional.of(postalZone), country); + } + + public static PostalAddress ofCountry(CountryCode country) { + return new PostalAddress(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), country); + } +} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/Quantity.java b/brev-documents/src/main/java/no/beint/brev/documents/Quantity.java similarity index 65% rename from brev-billing/src/main/java/no/beint/brev/billing/Quantity.java rename to brev-documents/src/main/java/no/beint/brev/documents/Quantity.java index ee03565..f01a127 100644 --- a/brev-billing/src/main/java/no/beint/brev/billing/Quantity.java +++ b/brev-documents/src/main/java/no/beint/brev/documents/Quantity.java @@ -1,14 +1,14 @@ -package no.beint.brev.billing; +package no.beint.brev.documents; import no.beint.brev.UnitCode; import java.math.BigDecimal; import java.util.Objects; -/** A strictly positive quantity for the initial positive-invoice profile. */ +/** A quantity that may be negative on credit notes. */ public record Quantity(BigDecimal value, UnitCode unit) { public Quantity { - BillingValues.positive(value, "quantity"); + Objects.requireNonNull(value, "quantity"); Objects.requireNonNull(unit, "unit"); } diff --git a/brev-documents/src/main/java/no/beint/brev/documents/TaxCategoryCode.java b/brev-documents/src/main/java/no/beint/brev/documents/TaxCategoryCode.java new file mode 100644 index 0000000..029ff67 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/TaxCategoryCode.java @@ -0,0 +1,49 @@ +package no.beint.brev.documents; + +import java.util.Locale; +import java.util.Optional; + +/** VAT category codes used by the current Peppol Billing profile. */ +public enum TaxCategoryCode { + STANDARD_RATE("S", true, false), + ZERO_RATE("Z", true, false), + EXEMPT("E", true, true), + EXPORT("G", true, true), + REVERSE_CHARGE("AE", true, true), + OUTSIDE_SCOPE("O", false, true); + + private final String xmlValue; + private final boolean rateRequired; + private final boolean exemptionRequired; + + TaxCategoryCode(String xmlValue, boolean rateRequired, boolean exemptionRequired) { + this.xmlValue = xmlValue; + this.rateRequired = rateRequired; + this.exemptionRequired = exemptionRequired; + } + + public String xmlValue() { + return xmlValue; + } + + public boolean rateRequired() { + return rateRequired; + } + + public boolean exemptionRequired() { + return exemptionRequired; + } + + public static Optional fromXmlValue(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + String normalized = value.strip().toUpperCase(Locale.ROOT); + for (TaxCategoryCode code : values()) { + if (code.xmlValue.equals(normalized)) { + return Optional.of(code); + } + } + return Optional.empty(); + } +} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/TaxSubtotal.java b/brev-documents/src/main/java/no/beint/brev/documents/TaxSubtotal.java similarity index 77% rename from brev-billing/src/main/java/no/beint/brev/billing/TaxSubtotal.java rename to brev-documents/src/main/java/no/beint/brev/documents/TaxSubtotal.java index 35014bc..cbba177 100644 --- a/brev-billing/src/main/java/no/beint/brev/billing/TaxSubtotal.java +++ b/brev-documents/src/main/java/no/beint/brev/documents/TaxSubtotal.java @@ -1,8 +1,8 @@ -package no.beint.brev.billing; +package no.beint.brev.documents; import java.util.Objects; -/** A computed taxable amount and tax amount for one VAT category and rate. */ +/** A taxable amount and tax amount for one VAT category. */ public record TaxSubtotal(VatCategory category, Money taxableAmount, Money taxAmount) { public TaxSubtotal { Objects.requireNonNull(category, "category"); diff --git a/brev-billing/src/main/java/no/beint/brev/billing/UnitPrice.java b/brev-documents/src/main/java/no/beint/brev/documents/UnitPrice.java similarity index 71% rename from brev-billing/src/main/java/no/beint/brev/billing/UnitPrice.java rename to brev-documents/src/main/java/no/beint/brev/documents/UnitPrice.java index 0e8ff0a..13b05ef 100644 --- a/brev-billing/src/main/java/no/beint/brev/billing/UnitPrice.java +++ b/brev-documents/src/main/java/no/beint/brev/documents/UnitPrice.java @@ -1,11 +1,11 @@ -package no.beint.brev.billing; +package no.beint.brev.documents; import no.beint.brev.CurrencyCode; import java.math.BigDecimal; import java.util.Objects; -/** A price per an explicit base quantity. Price precision is not limited to two decimals. */ +/** A non-negative price per an explicit base quantity. */ public record UnitPrice(CurrencyCode currency, BigDecimal amount, BigDecimal baseQuantity) { public UnitPrice { Objects.requireNonNull(currency, "currency"); @@ -13,7 +13,10 @@ public record UnitPrice(CurrencyCode currency, BigDecimal amount, BigDecimal bas if (amount.signum() < 0) { throw new IllegalArgumentException("unit price must not be negative"); } - BillingValues.positive(baseQuantity, "base quantity"); + Objects.requireNonNull(baseQuantity, "base quantity"); + if (baseQuantity.signum() <= 0) { + throw new IllegalArgumentException("base quantity must be greater than zero"); + } } public UnitPrice(CurrencyCode currency, BigDecimal amount) { diff --git a/brev-documents/src/main/java/no/beint/brev/documents/VatCategory.java b/brev-documents/src/main/java/no/beint/brev/documents/VatCategory.java new file mode 100644 index 0000000..3c512d0 --- /dev/null +++ b/brev-documents/src/main/java/no/beint/brev/documents/VatCategory.java @@ -0,0 +1,77 @@ +package no.beint.brev.documents; + +import java.math.BigDecimal; +import java.util.Objects; +import java.util.Optional; + +/** A current-profile VAT category with the fields that category requires. */ +public record VatCategory(TaxCategoryCode code, Optional rate, Optional exemptionReason) { + public VatCategory { + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(rate, "rate"); + Objects.requireNonNull(exemptionReason, "exemptionReason"); + rate = rate.map(value -> { + if (value.signum() < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new IllegalArgumentException("VAT rate must be between 0 and 100"); + } + return value; + }); + exemptionReason = exemptionReason.map(value -> BillingValues.nonBlank(value, "exemption reason", 200)); + if (code.rateRequired() && rate.isEmpty()) { + throw new IllegalArgumentException(code + " requires a VAT rate"); + } + if (!code.rateRequired() && rate.isPresent()) { + throw new IllegalArgumentException(code + " must not include a VAT rate"); + } + if (code == TaxCategoryCode.STANDARD_RATE && rate.orElseThrow().signum() == 0) { + throw new IllegalArgumentException("standard-rate VAT must have a positive rate"); + } + if (code == TaxCategoryCode.ZERO_RATE && rate.orElseThrow().signum() != 0) { + throw new IllegalArgumentException("zero-rate VAT must have a zero rate"); + } + if ((code == TaxCategoryCode.EXEMPT + || code == TaxCategoryCode.EXPORT + || code == TaxCategoryCode.REVERSE_CHARGE) + && rate.orElseThrow().signum() != 0) { + throw new IllegalArgumentException(code + " must have a zero rate"); + } + if (code.exemptionRequired() && exemptionReason.isEmpty()) { + throw new IllegalArgumentException(code + " requires an exemption reason"); + } + if (!code.exemptionRequired() && exemptionReason.isPresent()) { + throw new IllegalArgumentException(code + " must not include an exemption reason"); + } + } + + public VatCategory(TaxCategoryCode code, BigDecimal rate) { + this(code, Optional.of(rate), Optional.empty()); + } + + public static VatCategory standard(BigDecimal rate) { + return new VatCategory(TaxCategoryCode.STANDARD_RATE, Optional.of(rate), Optional.empty()); + } + + public static VatCategory zero() { + return new VatCategory(TaxCategoryCode.ZERO_RATE, Optional.of(BigDecimal.ZERO), Optional.empty()); + } + + public static VatCategory exempt(String reason) { + return new VatCategory(TaxCategoryCode.EXEMPT, Optional.of(BigDecimal.ZERO), Optional.of(reason)); + } + + public static VatCategory export(String reason) { + return new VatCategory(TaxCategoryCode.EXPORT, Optional.of(BigDecimal.ZERO), Optional.of(reason)); + } + + public static VatCategory reverseCharge(String reason) { + return new VatCategory(TaxCategoryCode.REVERSE_CHARGE, Optional.of(BigDecimal.ZERO), Optional.of(reason)); + } + + public static VatCategory outsideScope(String reason) { + return new VatCategory(TaxCategoryCode.OUTSIDE_SCOPE, Optional.empty(), Optional.of(reason)); + } + + public String rateXmlValue() { + return BillingValues.decimal(rate.orElseThrow(() -> new IllegalStateException("VAT category has no rate"))); + } +} diff --git a/brev-billing/src/main/java/no/beint/brev/billing/internal/Utf8XmlOutput.java b/brev-documents/src/main/java/no/beint/brev/documents/internal/Utf8XmlOutput.java similarity index 72% rename from brev-billing/src/main/java/no/beint/brev/billing/internal/Utf8XmlOutput.java rename to brev-documents/src/main/java/no/beint/brev/documents/internal/Utf8XmlOutput.java index b65323f..aa36f83 100644 --- a/brev-billing/src/main/java/no/beint/brev/billing/internal/Utf8XmlOutput.java +++ b/brev-documents/src/main/java/no/beint/brev/documents/internal/Utf8XmlOutput.java @@ -1,10 +1,12 @@ -package no.beint.brev.billing.internal; +package no.beint.brev.documents.internal; import java.io.IOException; import java.io.OutputStream; +import java.util.Base64; import java.util.Objects; +import java.util.Optional; -/** Small buffered UTF-8 encoder specialized for XML 1.0 output. */ +/** Small buffered UTF-8 encoder specialized for compact XML 1.0 output. */ public final class Utf8XmlOutput { private static final int BUFFER_SIZE = 8192; @@ -36,6 +38,23 @@ public void start(String name, String attributeName, String attributeValue) thro raw("\">"); } + public void startOpen(String name) throws IOException { + raw("<"); + raw(name); + } + + public void attribute(String name, String value) throws IOException { + raw(" "); + raw(name); + raw("=\""); + escaped(value, true); + raw("\""); + } + + public void closeStart() throws IOException { + raw(">"); + } + public void end(String name) throws IOException { raw(" value) throws IOException { + if (value.isPresent()) { + element(name, value.get()); + } + } + public void raw(String ascii) throws IOException { Objects.requireNonNull(ascii, "ascii"); for (int index = 0; index < ascii.length(); index++) { @@ -65,6 +90,25 @@ public void raw(String ascii) throws IOException { } } + public void base64(byte[] data) throws IOException { + Objects.requireNonNull(data, "data"); + Base64.Encoder encoder = Base64.getEncoder(); + int offset = 0; + while (offset < data.length) { + int remaining = data.length - offset; + int chunk = Math.min(remaining, 3072); + if (remaining > chunk) { + chunk -= chunk % 3; + } + byte[] source = offset == 0 && chunk == data.length + ? data + : java.util.Arrays.copyOfRange(data, offset, offset + chunk); + byte[] encoded = encoder.encode(source); + writeBytes(encoded, encoded.length); + offset += chunk; + } + } + public void finish() throws IOException { drain(); } @@ -133,6 +177,19 @@ private void writeCodePoint(int codePoint) throws IOException { } } + private void writeBytes(byte[] bytes, int length) throws IOException { + int offset = 0; + while (offset < length) { + if (position == buffer.length) { + drain(); + } + int copy = Math.min(length - offset, buffer.length - position); + System.arraycopy(bytes, offset, buffer, position, copy); + position += copy; + offset += copy; + } + } + private void writeByte(int value) throws IOException { if (position == buffer.length) { drain(); diff --git a/brev-documents/src/test/java/no/beint/brev/documents/BillingDocumentTest.java b/brev-documents/src/test/java/no/beint/brev/documents/BillingDocumentTest.java new file mode 100644 index 0000000..4347eb0 --- /dev/null +++ b/brev-documents/src/test/java/no/beint/brev/documents/BillingDocumentTest.java @@ -0,0 +1,72 @@ +package no.beint.brev.documents; + +import no.beint.brev.CurrencyCode; +import no.beint.brev.UnitCode; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class BillingDocumentTest { + @Test + void derivesTotalsAndTaxBreakdownsOnce() { + BillingDocument invoice = DocumentFixtures.invoice(); + + assertEquals("2900.50", invoice.lineExtensionTotal().xmlValue()); + assertEquals("625.00", invoice.taxTotal().xmlValue()); + assertEquals("3525.50", invoice.payableAmount().xmlValue()); + assertEquals(2, invoice.taxSubtotals().size()); + assertEquals("380", invoice.invoiceTypeCode().orElseThrow()); + } + + @Test + void creditNoteUsesType381AndOptionalDueDate() { + BillingDocument credit = DocumentFixtures.creditNote(); + + assertTrue(credit.isCreditNote()); + assertEquals("381", credit.invoiceTypeCode().orElseThrow()); + assertTrue(credit.dueDate().isEmpty()); + assertEquals("INV-2026-1", credit.referencedInvoiceId().orElseThrow()); + assertEquals("3125.00", credit.payableAmount().xmlValue()); + } + + @Test + void rejectsMissingBuyerAndOrderReference() { + assertThrows(IllegalArgumentException.class, () -> BillingDocument.invoice() + .id("INV-X") + .issueDate(LocalDate.of(2026, 8, 17)) + .currency(DocumentFixtures.NOK) + .seller(DocumentFixtures.seller()) + .buyer(DocumentFixtures.buyer()) + .line(new BillingLine( + "1", + "X", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(DocumentFixtures.NOK, BigDecimal.TEN), + VatCategory.standard(new BigDecimal("25")))) + .build()); + } + + @Test + void rejectsCurrencyMismatch() { + assertThrows(IllegalArgumentException.class, () -> BillingDocument.invoice() + .id("INV-X") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(DocumentFixtures.NOK) + .buyerReference("ref") + .seller(DocumentFixtures.seller()) + .buyer(DocumentFixtures.buyer()) + .line(new BillingLine( + "1", + "EUR", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(new CurrencyCode("EUR"), BigDecimal.TEN), + VatCategory.standard(new BigDecimal("25")))) + .build()); + } +} diff --git a/brev-documents/src/test/java/no/beint/brev/documents/BillingWriterReaderTest.java b/brev-documents/src/test/java/no/beint/brev/documents/BillingWriterReaderTest.java new file mode 100644 index 0000000..c11da61 --- /dev/null +++ b/brev-documents/src/test/java/no/beint/brev/documents/BillingWriterReaderTest.java @@ -0,0 +1,231 @@ +package no.beint.brev.documents; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class BillingWriterReaderTest { + @Test + void writesCompactUtf8Invoice() throws Exception { + byte[] bytes = Documents.toByteArray(DocumentFixtures.invoice()); + String xml = new String(bytes, StandardCharsets.UTF_8); + + assertTrue(xml.startsWith("Brev & Sønner AS")); + assertTrue(xml.contains("Rådgivning <senior>")); + assertTrue(xml.contains("Rapport 📄")); + assertTrue(xml.contains("Foretaksregisteret")); + assertTrue(xml.contains("schemeID=\"0192\"")); + assertTrue(xml.contains("2026-100")); + assertFalse(xml.contains("\n")); + + Document document = parseSecurely(bytes); + assertEquals("Invoice", document.getDocumentElement().getLocalName()); + assertEquals(2, document.getElementsByTagNameNS( + "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", + "InvoiceLine").getLength()); + } + + @Test + void writesCreditNoteRootAndTypeCode() { + String xml = new String(Documents.toByteArray(DocumentFixtures.creditNote()), StandardCharsets.UTF_8); + assertTrue(xml.contains("381")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("INV-2026-1")); + } + + @Test + void roundTripsNorwegianInvoiceWithAttachmentAndPriceAllowance() { + BillingDocument original = DocumentFixtures.norwegianInvoiceWithAttachment(); + BillingDocument parsed = Documents.read(Documents.toByteArray(original)); + + assertEquals(original.id(), parsed.id()); + assertEquals(original.payableAmount().xmlValue(), parsed.payableAmount().xmlValue()); + assertEquals("DNBANOKK", parsed.payment().orElseThrow().bic().orElseThrow()); + assertEquals(1, parsed.additionalDocuments().size()); + assertArrayEquals( + original.additionalDocuments().getFirst().content(), + parsed.additionalDocuments().getFirst().content()); + assertEquals("50.00", parsed.lines().getFirst().priceAllowance().orElseThrow().xmlValue()); + assertTrue(parsed.seller().registeredInForetaksregisteret()); + assertEquals("0192", parsed.seller().registrationScheme().orElseThrow().value()); + } + + @Test + void writesEveryVatCategoryReAiEmits() { + String xml = new String(Documents.toByteArray(DocumentFixtures.mixedVatInvoice()), StandardCharsets.UTF_8); + assertTrue(xml.contains("S")); + assertTrue(xml.contains("E")); + assertTrue(xml.contains("G")); + assertTrue(xml.contains("AE")); + assertTrue(xml.contains("O")); + assertTrue(xml.contains("Reverse charge")); + assertFalse(xml.contains("")); + } + + @Test + void rejectsCharactersForbiddenByXml10() { + assertThrows(IllegalArgumentException.class, () -> Documents.toByteArray( + BillingDocument.invoice() + .id("INV-X") + .issueDate(java.time.LocalDate.of(2026, 8, 17)) + .dueDate(java.time.LocalDate.of(2026, 9, 1)) + .currency(DocumentFixtures.NOK) + .buyerReference("ref") + .seller(Party.withVat( + DocumentFixtures.seller().endpoint().orElseThrow(), + "Bad\u0000Name", + "913341464", + "NO913341464MVA", + DocumentFixtures.seller().address())) + .buyer(DocumentFixtures.buyer()) + .line(DocumentFixtures.invoice().lines().getFirst()) + .build())); + } + + @Test + void readsInboundBookkeepingFieldsUsedByReAi() { + String xml = """ + + + EXT-9 + 2026-08-01 + 2026-08-15 + 380 + NOK + ref + + 922989451 + Ekstern AS + NO + Ekstern AS + + + 987654321 + Kjøper + NO + Kjøper + + + 30 + KID99 + + NO9386011117947 + + DNBANOKK + + DNB Bank ASA + NO + + + + + 0.00 + + 10.00 + 10.00 + 10.00 + 10.00 + + + 1 + 1 + 10.00 + + Monthly retainer + Advisory + Z0 + VAT + + + 10 + + + """; + BillingDocument parsed = Documents.read(xml.getBytes(StandardCharsets.UTF_8)); + assertEquals("Monthly retainer", parsed.lines().getFirst().itemDescription().orElseThrow()); + assertEquals("Advisory", parsed.lines().getFirst().itemName()); + assertEquals("DNB Bank ASA", parsed.payment().orElseThrow().bankName().orElseThrow()); + assertEquals("NO", parsed.payment().orElseThrow().bankCountryCode().orElseThrow()); + assertEquals("KID99", parsed.payment().orElseThrow().paymentReference().orElseThrow()); + } + + @Test + void skipsUnknownInboundElements() { + String xml = """ + + + x + y + IN-1 + 2026-08-17 + 380 + NOK + ref + ignore + + 111111111 + S + NO + S + + + 222222222 + B + NO + B + + 0.00 + + 10.00 + 10.00 + 10.00 + 10.00 + + + 1 + 1 + 10.00 + Widget + Z0 + VAT + + + 10 + + + """; + BillingDocument parsed = Documents.read(xml.getBytes(StandardCharsets.UTF_8)); + assertEquals("IN-1", parsed.id()); + assertEquals("Widget", parsed.lines().getFirst().itemName()); + assertEquals("10.00", parsed.payableAmount().xmlValue()); + } + + private static Document parseSecurely(byte[] bytes) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); + } +} diff --git a/brev-documents/src/test/java/no/beint/brev/documents/DocumentFixtures.java b/brev-documents/src/test/java/no/beint/brev/documents/DocumentFixtures.java new file mode 100644 index 0000000..b7bf6b3 --- /dev/null +++ b/brev-documents/src/test/java/no/beint/brev/documents/DocumentFixtures.java @@ -0,0 +1,159 @@ +package no.beint.brev.documents; + +import no.beint.brev.CountryCode; +import no.beint.brev.CurrencyCode; +import no.beint.brev.EndpointId; +import no.beint.brev.SchemeId; +import no.beint.brev.UnitCode; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +final class DocumentFixtures { + static final CurrencyCode NOK = new CurrencyCode("NOK"); + + private DocumentFixtures() { + } + + static Party seller() { + return Party.withVat( + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "913341464"), + "Brev & Sønner AS", + "913341464", + "NO913341464MVA", + new PostalAddress("Dokumentveien 1", "Oslo", "0150", new CountryCode("NO"))) + .withRegistrationScheme(SchemeId.NORWEGIAN_ORGANIZATION) + .withForetaksregisteret(); + } + + static Party buyer() { + return Party.withoutVat( + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "987654321"), + "Kjøper AS", + "987654321", + new PostalAddress( + java.util.Optional.of("Testgata 2"), + java.util.Optional.of("C/O Post"), + java.util.Optional.of("Bergen"), + java.util.Optional.of("5003"), + new CountryCode("NO"))) + .withRegistrationScheme(SchemeId.NORWEGIAN_ORGANIZATION); + } + + static BillingDocument invoice() { + return BillingDocument.invoice() + .id("INV-2026-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(NOK) + .buyerReference("buyer@example.no") + .orderReference(OrderReference.salesOrder("2026-100")) + .seller(seller()) + .buyer(buyer()) + .payment(new PaymentInstruction("NO9386011117947", "1234567890123456789012345")) + .line(new BillingLine( + "1", + "Rådgivning ", + new Quantity(new BigDecimal("2.5"), new UnitCode("HUR")), + new UnitPrice(NOK, new BigDecimal("1000")), + VatCategory.standard(new BigDecimal("25")))) + .line(new BillingLine( + "2", + "Rapport 📄", + new Quantity(BigDecimal.ONE, UnitCode.EACH), + new UnitPrice(NOK, new BigDecimal("400.50")), + VatCategory.zero())) + .build(); + } + + static BillingDocument creditNote() { + return BillingDocument.creditNote() + .id("CN-2026-1") + .issueDate(LocalDate.of(2026, 8, 18)) + .currency(NOK) + .orderReference(OrderReference.salesOrder("2026-100")) + .referencedInvoiceId("INV-2026-1") + .seller(seller()) + .buyer(buyer()) + .payment(new PaymentInstruction("NO9386011117947", java.util.Optional.of("CN-2026-1"), java.util.Optional.of("DNBANOKK"))) + .line(new BillingLine( + "1", + "Kreditert rådgivning", + new Quantity(new BigDecimal("2.5"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1000")), + VatCategory.standard(new BigDecimal("25")))) + .build(); + } + + static BillingDocument norwegianInvoiceWithAttachment() { + return BillingDocument.invoice() + .id("INV-NO-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 8, 31)) + .currency(NOK) + .orderReference(OrderReference.salesOrder("SO-1")) + .seller(seller()) + .buyer(buyer()) + .payment(new PaymentInstruction( + "NO9386011117947", + java.util.Optional.of("INV-NO-1"), + java.util.Optional.of("DNBANOKK"))) + .additionalDocument(new AdditionalDocument( + "invoice-pdf.pdf", + "application/pdf", + "Invoice-INV-NO-1.pdf", + "%PDF-1.4 test".getBytes(StandardCharsets.US_ASCII))) + .line(new BillingLine( + "1", + "Konsulenttime", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")), + Money.of(NOK, new BigDecimal("12500.00")), + java.util.Optional.of(Money.of(NOK, new BigDecimal("50.00"))))) + .build(); + } + + static BillingDocument mixedVatInvoice() { + return BillingDocument.invoice() + .id("INV-VAT-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(NOK) + .buyerReference("vat-routing") + .seller(seller()) + .buyer(buyer()) + .line(new BillingLine( + "1", + "Standard", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("100")), + VatCategory.standard(new BigDecimal("25")))) + .line(new BillingLine( + "2", + "Exempt", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("40")), + VatCategory.exempt("Exempt from VAT"))) + .line(new BillingLine( + "3", + "Export", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("30")), + VatCategory.export("Export outside the EU"))) + .line(new BillingLine( + "4", + "Reverse", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("20")), + VatCategory.reverseCharge("Reverse charge"))) + .line(new BillingLine( + "5", + "Outside", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("10")), + VatCategory.outsideScope("Not subject to VAT"))) + .build(); + } +} diff --git a/brev-smp/README.md b/brev-smp/README.md new file mode 100644 index 0000000..d3813eb --- /dev/null +++ b/brev-smp/README.md @@ -0,0 +1,10 @@ +# brev-smp + +Typed Peppol SMP model for the Brev modular library. + +This module publishes identifier and metadata types so applications can depend on +a stable API without pulling Phase4, JAXB, or an HTTP stack. A JDK-only SMP +client belongs in a later release of this same module, not a second repository. + +Do not take this artifact if you only generate or parse Billing documents. +Use `brev-documents`. diff --git a/brev-smp/build.gradle.kts b/brev-smp/build.gradle.kts new file mode 100644 index 0000000..b6b7287 --- /dev/null +++ b/brev-smp/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":brev-core")) +} diff --git a/brev-smp/src/main/java/module-info.java b/brev-smp/src/main/java/module-info.java new file mode 100644 index 0000000..77e64ae --- /dev/null +++ b/brev-smp/src/main/java/module-info.java @@ -0,0 +1,5 @@ +module no.beint.brev.smp { + requires transitive no.beint.brev.core; + + exports no.beint.brev.smp; +} diff --git a/brev-smp/src/main/java/no/beint/brev/smp/ServiceMetadata.java b/brev-smp/src/main/java/no/beint/brev/smp/ServiceMetadata.java new file mode 100644 index 0000000..726a383 --- /dev/null +++ b/brev-smp/src/main/java/no/beint/brev/smp/ServiceMetadata.java @@ -0,0 +1,24 @@ +package no.beint.brev.smp; + +import no.beint.brev.DocumentTypeId; +import no.beint.brev.ParticipantId; +import no.beint.brev.ProcessId; + +import java.util.List; +import java.util.Objects; + +/** SMP service metadata for one participant and document type. */ +public record ServiceMetadata( + ParticipantId participant, + DocumentTypeId documentType, + ProcessId process, + List endpoints) { + + public ServiceMetadata { + Objects.requireNonNull(participant, "participant"); + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(process, "process"); + Objects.requireNonNull(endpoints, "endpoints"); + endpoints = List.copyOf(endpoints); + } +} diff --git a/brev-smp/src/main/java/no/beint/brev/smp/SmpEndpoint.java b/brev-smp/src/main/java/no/beint/brev/smp/SmpEndpoint.java new file mode 100644 index 0000000..0ade760 --- /dev/null +++ b/brev-smp/src/main/java/no/beint/brev/smp/SmpEndpoint.java @@ -0,0 +1,21 @@ +package no.beint.brev.smp; + +import java.net.URI; +import java.util.Objects; +import java.util.Optional; + +/** The typed result of an SMP endpoint lookup. No HTTP client is included in this release. */ +public record SmpEndpoint( + URI url, + TransportProfile transportProfile, + Optional certificate, + Optional technicalContact) { + + public SmpEndpoint { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(transportProfile, "transportProfile"); + Objects.requireNonNull(certificate, "certificate"); + certificate = certificate.map(byte[]::clone); + Objects.requireNonNull(technicalContact, "technicalContact"); + } +} diff --git a/brev-smp/src/main/java/no/beint/brev/smp/TransportProfile.java b/brev-smp/src/main/java/no/beint/brev/smp/TransportProfile.java new file mode 100644 index 0000000..c6b8982 --- /dev/null +++ b/brev-smp/src/main/java/no/beint/brev/smp/TransportProfile.java @@ -0,0 +1,13 @@ +package no.beint.brev.smp; + +/** Peppol transport profile identifiers. The first release only names AS4 v2. */ +public record TransportProfile(String value) { + public static final TransportProfile PEPPOL_AS4_V2 = + new TransportProfile("peppol-transport-as4-v2_0"); + + public TransportProfile { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("transport profile must not be blank"); + } + } +} diff --git a/brev-smp/src/test/java/no/beint/brev/smp/SmpTypesTest.java b/brev-smp/src/test/java/no/beint/brev/smp/SmpTypesTest.java new file mode 100644 index 0000000..1451898 --- /dev/null +++ b/brev-smp/src/test/java/no/beint/brev/smp/SmpTypesTest.java @@ -0,0 +1,29 @@ +package no.beint.brev.smp; + +import no.beint.brev.DocumentTypeId; +import no.beint.brev.ParticipantId; +import no.beint.brev.ProcessId; +import no.beint.brev.SchemeId; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class SmpTypesTest { + @Test + void serviceMetadataKeepsADefensiveEndpointList() { + ServiceMetadata metadata = new ServiceMetadata( + new ParticipantId(SchemeId.NORWEGIAN_ORGANIZATION, "922989451"), + new DocumentTypeId("invoice"), + new ProcessId("billing"), + List.of(new SmpEndpoint( + URI.create("https://ap.example/as4"), + TransportProfile.PEPPOL_AS4_V2, + Optional.empty(), + Optional.empty()))); + assertEquals(1, metadata.endpoints().size()); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e061a36..18407b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,11 +16,13 @@ plugins { id("me.champeau.jmh") version "0.7.3" apply false } -val releaseVersion = "0.1.0-SNAPSHOT" -val publishedModules = setOf("brev-core", "brev-billing") +val releaseVersion = "0.1.0" +val publishedModules = setOf("brev-core", "brev-documents", "brev-smp", "brev-ap") val moduleDescriptions = mapOf( - "brev-core" to "Dependency-free identifiers and value types for current Peppol profiles", - "brev-billing" to "Immutable Peppol Billing model and direct UTF-8 UBL writer" + "brev-core" to "Dependency-free identifiers, codes, and release metadata for current Peppol profiles", + "brev-documents" to "Current Peppol Billing documents: immutable model, direct UTF-8 writer, and bounded reader", + "brev-smp" to "Typed Peppol SMP lookup model. Network client is added in a later module release", + "brev-ap" to "Typed Peppol access-point send/receive model. Transport adapter is added in a later module release" ) allprojects { diff --git a/conformance/build.gradle.kts b/conformance/build.gradle.kts index c9a335b..1e82987 100644 --- a/conformance/build.gradle.kts +++ b/conformance/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - testImplementation(project(":brev-billing")) - testImplementation("com.helger.phive.rules:phive-rules-peppol:4.4.1") + testImplementation(project(":brev-documents")) + testImplementation("com.helger.phive.rules:phive-rules-peppol:4.5.3") testRuntimeOnly("org.glassfish.jaxb:jaxb-runtime:4.0.9") } diff --git a/conformance/src/test/java/no/beint/brev/conformance/CurrentBillingConformanceTest.java b/conformance/src/test/java/no/beint/brev/conformance/CurrentBillingConformanceTest.java index 7e5c583..b6db225 100644 --- a/conformance/src/test/java/no/beint/brev/conformance/CurrentBillingConformanceTest.java +++ b/conformance/src/test/java/no/beint/brev/conformance/CurrentBillingConformanceTest.java @@ -1,6 +1,9 @@ package no.beint.brev.conformance; +import com.helger.diver.api.coord.DVRCoordinate; import com.helger.phive.api.execute.ValidationExecutionManager; +import com.helger.phive.api.executorset.IValidationExecutorSet; +import com.helger.phive.api.executorset.IValidationExecutorSetRegistry; import com.helger.phive.api.executorset.ValidationExecutorSetRegistry; import com.helger.phive.api.validity.IValidityDeterminator; import com.helger.phive.peppol.PeppolValidation2026_05; @@ -11,16 +14,19 @@ import no.beint.brev.EndpointId; import no.beint.brev.SchemeId; import no.beint.brev.UnitCode; -import no.beint.brev.billing.Invoice; -import no.beint.brev.billing.InvoiceLine; -import no.beint.brev.billing.Party; -import no.beint.brev.billing.PaymentInstruction; -import no.beint.brev.billing.PeppolBillingWriter; -import no.beint.brev.billing.PostalAddress; -import no.beint.brev.billing.Quantity; -import no.beint.brev.billing.TaxCategoryCode; -import no.beint.brev.billing.UnitPrice; -import no.beint.brev.billing.VatCategory; +import no.beint.brev.documents.AdditionalDocument; +import no.beint.brev.documents.BillingDocument; +import no.beint.brev.documents.BillingLine; +import no.beint.brev.documents.Documents; +import no.beint.brev.documents.Money; +import no.beint.brev.documents.OrderReference; +import no.beint.brev.documents.Party; +import no.beint.brev.documents.PaymentInstruction; +import no.beint.brev.documents.PostalAddress; +import no.beint.brev.documents.Quantity; +import no.beint.brev.documents.UnitPrice; +import no.beint.brev.documents.VatCategory; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.w3c.dom.Document; @@ -28,65 +34,191 @@ import javax.xml.parsers.DocumentBuilderFactory; import java.io.ByteArrayInputStream; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; -import java.util.List; import java.util.Locale; +import java.util.Optional; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertFalse; final class CurrentBillingConformanceTest { + private static final CurrencyCode NOK = new CurrencyCode("NOK"); + private static ValidationExecutorSetRegistry registry; + + @BeforeAll + static void registerCurrentBillingRules() { + registry = new ValidationExecutorSetRegistry<>(); + try { + PeppolValidation2026_05.class.getMethod("initBilling", IValidationExecutorSetRegistry.class) + .invoke(null, registry); + } catch (ReflectiveOperationException exception) { + PeppolValidation2026_05.init(registry); + } + } + @Test - void generatedFixturePassesPeppolBilling3021() throws Exception { - Document document = parseSecurely(PeppolBillingWriter.toByteArray(invoice())); - ValidationExecutorSetRegistry registry = new ValidationExecutorSetRegistry<>(); - PeppolValidation2026_05.init(registry); - var executorSet = registry.getOfID(PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + void positiveInvoicePassesPeppolBilling3021() throws Exception { + assertValid(Documents.toByteArray(positiveInvoice()), PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + } + + @Test + void creditNotePassesPeppolBilling3021() throws Exception { + assertValid(Documents.toByteArray(creditNote()), PeppolValidation2026_05.VID_OPENPEPPOL_CREDIT_NOTE_UBL_V3); + } + + @Test + void norwegianInvoiceWithAttachmentPasses() throws Exception { + assertValid(Documents.toByteArray(norwegianInvoice()), PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + } + + @Test + void exemptExportAndReverseChargeInvoicesPass() throws Exception { + assertValid(Documents.toByteArray(singleCategoryInvoice("E-1", VatCategory.exempt("Exempt from VAT"))), + PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + assertValid(Documents.toByteArray(singleCategoryInvoice("G-1", VatCategory.export("Export outside the EU"))), + PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + assertValid(Documents.toByteArray(singleCategoryInvoice("AE-1", VatCategory.reverseCharge("Reverse charge"))), + PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + assertValid(Documents.toByteArray(outsideScopeInvoice()), + PeppolValidation2026_05.VID_OPENPEPPOL_INVOICE_UBL_V3); + } + + private static void assertValid(byte[] xml, DVRCoordinate vesId) throws Exception { + Document document = parseSecurely(xml); + IValidationExecutorSet executorSet = registry.getOfID(vesId); if (executorSet == null) { - throw new IllegalStateException("Peppol Billing 3.0.21 validation executor was not registered"); + throw new IllegalStateException("validation executor was not registered: " + vesId); } var result = ValidationExecutionManager.executeValidation( IValidityDeterminator.createDefault(), executorSet, ValidationSourceXML.create(null, document)); - assertFalse( result.containsAtLeastOneError(), () -> result.getAllErrors().stream() .map(error -> error.getErrorText(Locale.ENGLISH)) - .toList() - .toString()); + .collect(Collectors.joining("\n"))); + } + + private static BillingDocument positiveInvoice() { + return baseInvoice("INV-2026-1") + .line(new BillingLine( + "1", + "Rådgivning", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")))) + .build(); + } + + private static BillingDocument creditNote() { + return BillingDocument.creditNote() + .id("CN-2026-1") + .issueDate(LocalDate.of(2026, 8, 18)) + .currency(NOK) + .buyerReference("buyer@example.no") + .referencedInvoiceId("INV-2026-1") + .seller(seller()) + .buyer(buyer()) + .payment(new PaymentInstruction("NO9386011117947", "CN-2026-1")) + .line(new BillingLine( + "1", + "Kreditert rådgivning", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")))) + .build(); + } + + private static BillingDocument norwegianInvoice() { + return baseInvoice("INV-NO-1") + .orderReference(OrderReference.salesOrder("SO-1")) + .payment(new PaymentInstruction( + "NO9386011117947", + Optional.of("INV-NO-1"), + Optional.of("DNBANOKK"))) + .additionalDocument(new AdditionalDocument( + "invoice-pdf.pdf", + "application/pdf", + "Invoice-INV-NO-1.pdf", + "%PDF-1.4 test".getBytes(StandardCharsets.US_ASCII))) + .line(new BillingLine( + "1", + "Konsulenttime", + new Quantity(new BigDecimal("10"), UnitCode.HOUR), + new UnitPrice(NOK, new BigDecimal("1250")), + VatCategory.standard(new BigDecimal("25")), + Money.of(NOK, new BigDecimal("12500.00")), + Optional.of(Money.of(NOK, new BigDecimal("50.00"))))) + .build(); + } + + private static BillingDocument singleCategoryInvoice(String id, VatCategory vat) { + return baseInvoice(id) + .line(new BillingLine( + "1", + "Line", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("100")), + vat)) + .build(); + } + + private static BillingDocument outsideScopeInvoice() { + return BillingDocument.invoice() + .id("O-1") + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(NOK) + .buyerReference("buyer@example.no") + .seller(Party.withoutVat( + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "922989451"), + "Brev AS", + "922989451", + new PostalAddress("Dokumentveien 1", "Oslo", "0150", new CountryCode("NO"))) + .withRegistrationScheme(SchemeId.NORWEGIAN_ORGANIZATION) + .withForetaksregisteret()) + .buyer(buyer()) + .payment(new PaymentInstruction("NO9386011117947", "1234567890123456789012345")) + .line(new BillingLine( + "1", + "Line", + new Quantity(BigDecimal.ONE, UnitCode.EA), + new UnitPrice(NOK, new BigDecimal("100")), + VatCategory.outsideScope("Not subject to VAT"))) + .build(); + } + + private static BillingDocument.Builder baseInvoice(String id) { + return BillingDocument.invoice() + .id(id) + .issueDate(LocalDate.of(2026, 8, 17)) + .dueDate(LocalDate.of(2026, 9, 1)) + .currency(NOK) + .buyerReference("buyer@example.no") + .seller(seller()) + .buyer(buyer()) + .payment(new PaymentInstruction("NO9386011117947", "1234567890123456789012345")); + } + + private static Party seller() { + return Party.withVat( + new EndpointId(SchemeId.NORWEGIAN_ORGANIZATION, "922989451"), + "Brev AS", + "922989451", + "NO922989451MVA", + new PostalAddress("Dokumentveien 1", "Oslo", "0150", new CountryCode("NO"))) + .withRegistrationScheme(SchemeId.NORWEGIAN_ORGANIZATION) + .withForetaksregisteret(); } - private static Invoice invoice() { - CurrencyCode nok = new CurrencyCode("NOK"); - SchemeId organization = new SchemeId("0192"); - Party seller = Party.withVat( - new EndpointId(organization, "922989451"), - "Brev AS", - "922989451", - "NO922989451MVA", - new PostalAddress("Dokumentveien 1", "Oslo", "0150", new CountryCode("NO"))); - Party buyer = Party.withoutVat( - new EndpointId(new SchemeId("9922"), "OPTBCNTRLP2001"), - "Kjøper AS", - "SE4598375937", - new PostalAddress("Testgata 2", "Stockholm", "11122", new CountryCode("SE"))); - InvoiceLine line = new InvoiceLine( - "1", - "Rådgivning", - new Quantity(new BigDecimal("10"), new UnitCode("HUR")), - new UnitPrice(nok, new BigDecimal("1250")), - new VatCategory(TaxCategoryCode.STANDARD_RATE, new BigDecimal("25"))); - return new Invoice( - "INV-2026-1", - LocalDate.of(2026, 8, 17), - LocalDate.of(2026, 9, 1), - nok, - "buyer@example.no", - seller, - buyer, - new PaymentInstruction("NO9386011117947", "1234567890123456789012345"), - List.of(line)); + private static Party buyer() { + return Party.withoutVat( + new EndpointId(new SchemeId("9922"), "OPTBCNTRLP2001"), + "Kjøper AS", + "SE4598375937", + new PostalAddress("Testgata 2", "Stockholm", "11122", new CountryCode("SE"))); } private static Document parseSecurely(byte[] bytes) throws Exception { diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..31bf855 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,47 @@ +# Modules + +Brev is one repository and four independently published artifacts. Take the +smallest module that contains the types you call. + +```text +application + │ + ├── brev-core + │ + ├── brev-documents current Billing write / read + │ + ├── brev-smp typed lookup results (client later) + │ + └── brev-ap typed send/receive (Phase4 adapter later) +``` + +`brev-documents` depends on `brev-core` only. It does not depend on SMP or +AP. SMP and AP do not depend on documents. Nothing in the published runtime +depends on PHIVE, JAXB, Saxon, or Phase4. + +## brev-core + +Participant, endpoint, document-type, process, scheme, currency, country, and +unit value types. `PeppolRelease.CURRENT` is the only bundled rule target. + +## brev-documents + +The first complete vertical: Peppol BIS Billing 3.0.21 invoices and credit +notes. This is the replacement for Digipost + `ph-ubl` in ReAI. + +- immutable model +- derived tax totals +- direct UTF-8 writer +- bounded StAX reader +- no DOM or JAXB on the production path + +## brev-smp + +Types for an SMP lookup result. A JDK HTTP client is a later change to this +module, not a new repository. + +## brev-ap + +Types for an outbound Peppol message and a send receipt. The first transport +implementation will be a Phase4 adapter behind these types. Native AS4 is a +separate decision. diff --git a/settings.gradle.kts b/settings.gradle.kts index a9805c1..ada5d1d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,4 +14,11 @@ dependencyResolutionManagement { rootProject.name = "brev" -include("brev-core", "brev-billing", "benchmark", "conformance") +include( + "brev-core", + "brev-documents", + "brev-smp", + "brev-ap", + "benchmark", + "conformance" +)