From d07afd7c62962accaf165f634e2d380fd6e9b84b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 31 Aug 2026 22:13:04 +0100 Subject: [PATCH] feat(templates): give an invoice line the mark its design draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design that opens each service line with a glyph — a card for a billing line, a shield for fraud screening, a globe for a hosted service — had nowhere to say which one, and deriving it from the description would have been guesswork dressed as a feature. InvoiceServiceLines.Line now carries icon, a plain string blank when absent, exactly as CvEntry.icon already works on the CV side: the token means something only to the preset that packages it, and a preset that draws no marks ignores it. It costs the layout nothing where nothing is set. Both constructors that predate it — the one before the per-line tax rate and the one before the mark — are kept explicitly, so existing calls compile and link unchanged and every line built through them still carries no mark. --- CHANGELOG.md | 11 +++++++ .../StructuredInvoiceCompatibilityTest.java | 30 ++++++++++++++++++- .../data/invoice/InvoiceServiceLines.java | 30 +++++++++++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8562a383..693f4b206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **An invoice line carries a mark.** A design that opens each service line with a + glyph — a card for a billing line, a shield for fraud screening, a globe for a hosted + service — had nowhere to say which one, and deriving it from the description would + have been guesswork dressed as a feature. `InvoiceServiceLines.Line` now carries + `icon`, a plain string blank when absent, exactly as `CvEntry.icon` already works on + the CV side: the token means something only to the preset that packages it, and a + preset that draws no marks ignores it. It costs the layout nothing where nothing is + set. Both constructors that predate it — the one before the per-line tax rate and the + one before the mark — are kept explicitly, so existing calls compile and link + unchanged and every line built through them still carries no mark. + - **`CvSkill` carries the level as the document words it.** A rated skill could say how much — a number in `[0, 1]` a preset draws as dots or a meter — or it could say it in words by not being a skill at all and living in a `RowsSection` instead. It could not diff --git a/qa/src/test/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceCompatibilityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceCompatibilityTest.java index ce58edb86..5283b491f 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceCompatibilityTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceCompatibilityTest.java @@ -59,14 +59,42 @@ void theColumnsConstructorThatPredatesTheVatColumnLeavesItBlank() { } @Test - void theLineConstructorThatPredatesTheVatRateLeavesItBlank() { + void theLineConstructorThatPredatesTheVatRateLeavesItAndTheMarkBlank() { InvoiceServiceLines.Line line = new InvoiceServiceLines.Line( 1, "Workshop", "A day of it", "May", BigDecimal.ONE, "day", new BigDecimal("1200"), new BigDecimal("1200")); assertThat(line.vatRate()).isEmpty(); + assertThat(line.icon()).isEmpty(); assertThat(line.amount()).isEqualByComparingTo("1200"); } + @Test + void theLineConstructorThatPredatesTheMarkLeavesItBlank() { + // The tax rate arrived before the mark did, so a caller written against + // that version keeps compiling and keeps drawing no mark. + InvoiceServiceLines.Line line = new InvoiceServiceLines.Line( + 1, "Workshop", "A day of it", "May", BigDecimal.ONE, "day", + new BigDecimal("1200"), new BigDecimal("1200"), "20%"); + assertThat(line.vatRate()).isEqualTo("20%"); + assertThat(line.icon()).isEmpty(); + } + + @Test + void aLineCarriesTheMarkItIsGiven() { + InvoiceServiceLines.Line line = new InvoiceServiceLines.Line( + 1, "Workshop", "A day of it", "May", BigDecimal.ONE, "day", + new BigDecimal("1200"), new BigDecimal("1200"), "20%", "card"); + assertThat(line.icon()).isEqualTo("card"); + } + + @Test + void aNullMarkNormalizesToBlankLikeTheFieldsBesideIt() { + InvoiceServiceLines.Line line = new InvoiceServiceLines.Line( + 1, "Workshop", "A day of it", "May", BigDecimal.ONE, "day", + new BigDecimal("1200"), new BigDecimal("1200"), "20%", null); + assertThat(line.icon()).isEmpty(); + } + @Test void theDataConstructorThatPredatesShipToLeavesItEmptyRatherThanNull() { // The record normalizes an absent block to its empty form, so preset diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceServiceLines.java b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceServiceLines.java index 65f3c7983..0a2326bbe 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceServiceLines.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceServiceLines.java @@ -100,6 +100,10 @@ public Columns(String index, String description, String servicePeriod, * the design shows it (e.g. {@code "20%"}) rather * than as a number, because the wording differs by * jurisdiction; blank when absent + * @param icon the mark a preset draws for this line; the token + * means something only to the preset that packages + * it, and a preset that draws no marks ignores it. + * Blank when absent */ public record Line( int lineNumber, @@ -110,7 +114,8 @@ public record Line( String unit, BigDecimal unitPrice, BigDecimal amount, - String vatRate) { + String vatRate, + String icon) { /** * Normalizes optional fields. @@ -124,6 +129,27 @@ public record Line( unitPrice = Objects.requireNonNullElse(unitPrice, BigDecimal.ZERO); amount = Objects.requireNonNullElse(amount, BigDecimal.ZERO); vatRate = Objects.requireNonNullElse(vatRate, ""); + icon = Objects.requireNonNullElse(icon, ""); + } + + /** + * Backward-compatible constructor for callers that predate the mark. + * + * @param lineNumber the printed line number + * @param title the service title + * @param description the description under the title + * @param servicePeriod the period this line covers + * @param quantity how much was delivered + * @param unit what the quantity counts + * @param unitPrice the price per unit + * @param amount the line total + * @param vatRate the tax rate printed for this line + */ + public Line(int lineNumber, String title, String description, String servicePeriod, + BigDecimal quantity, String unit, BigDecimal unitPrice, + BigDecimal amount, String vatRate) { + this(lineNumber, title, description, servicePeriod, quantity, unit, + unitPrice, amount, vatRate, ""); } /** @@ -143,7 +169,7 @@ public Line(int lineNumber, String title, String description, String servicePeri BigDecimal quantity, String unit, BigDecimal unitPrice, BigDecimal amount) { this(lineNumber, title, description, servicePeriod, quantity, unit, - unitPrice, amount, ""); + unitPrice, amount, "", ""); } } }