diff --git a/CHANGELOG.md b/CHANGELOG.md index f1990f136..7ef738a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **The structured invoice model carries what a second sheet needs.** It landed with + one consumer, `ConsultingInvoice`, and a model shaped around one document is a model + nobody has tested. Fitting a second published invoice to it found six things it could + not say, each of them general rather than one design's whim: a brand lockup drawn as + a two-line monogram instead of a logo (`InvoiceBrand.monogramTop` / `monogramBottom`); + a second labelled registration, because a UK sender prints both a company number and + a VAT number (`InvoiceContactBlock.taxRegistrationLabel` / `taxRegistrationNumber`); + a delivery address beside the billing one (`StructuredInvoiceData.shipTo`); a tax + rate printed per line and its column (`InvoiceServiceLines.Line.vatRate`, + `Columns.vat`), written as the design shows it because the wording differs by + jurisdiction; and who the money is paid to, with the closing line beside the due + notice (`InvoicePaymentBlock.accountHolder` / `signOff`). Every addition is blank + when absent, and every constructor that predates one is kept explicitly, so existing + calls compile and link unchanged — `ConsultingInvoice` passes its snapshot and pixel + gates untouched, which is the proof. + - **`CvEntry` carries a link.** An entry that points somewhere — a repository, a case study, a company — had no way to say so, and a preset had no way to make its title reachable. `CvEntry` now carries `link`, a plain string blank when absent like the 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 new file mode 100644 index 000000000..ce58edb86 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceCompatibilityTest.java @@ -0,0 +1,94 @@ +package com.demcha.compose.document.templates.data.invoice; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the compatibility promise made when the structured invoice model grew + * the fields a second sheet needed: the constructors that predate them are + * still there and still mean what they meant, so a caller written against + * any of them keeps compiling and linking. + */ +class StructuredInvoiceCompatibilityTest { + + @Test + void theBrandConstructorThatPredatesTheMonogramLeavesItBlank() { + InvoiceBrand brand = new InvoiceBrand(null, "Luma", "Studio", "Design."); + assertThat(brand.monogramTop()).isEmpty(); + assertThat(brand.monogramBottom()).isEmpty(); + assertThat(brand.hasMonogram()).isFalse(); + assertThat(brand.name()).isEqualTo("Luma"); + } + + @Test + void aBrandWithEitherMonogramLineHasOne() { + assertThat(new InvoiceBrand(null, "Luma", "", "", "L", "").hasMonogram()).isTrue(); + assertThat(new InvoiceBrand(null, "Luma", "", "", "", "&Co.").hasMonogram()).isTrue(); + } + + @Test + void theContactConstructorThatPredatesTheSecondRegistrationLeavesItBlank() { + InvoiceContactBlock supplier = new InvoiceContactBlock( + "Luma Ltd", List.of("14 Gower Street"), "+44", "a@b.c", "b.c", + "Company No.", "12578934"); + assertThat(supplier.taxRegistrationLabel()).isEmpty(); + assertThat(supplier.taxRegistrationNumber()).isEmpty(); + assertThat(supplier.registrationNumber()).isEqualTo("12578934"); + } + + @Test + void thePaymentConstructorThatPredatesTheAccountHolderLeavesItBlank() { + InvoicePaymentBlock payment = new InvoicePaymentBlock( + "PAYMENT", List.of(new InvoicePaymentBlock.Field("BANK", "Starling")), + "Pay by transfer.", "Due in 30 days", "30 days"); + assertThat(payment.accountHolder()).isEmpty(); + assertThat(payment.signOff()).isEmpty(); + assertThat(payment.dueNoticeEmphasis()).isEqualTo("30 days"); + } + + @Test + void theColumnsConstructorThatPredatesTheVatColumnLeavesItBlank() { + InvoiceServiceLines.Columns columns = new InvoiceServiceLines.Columns( + "#", "DESCRIPTION", "PERIOD", "QTY", "UNIT", "AMOUNT"); + assertThat(columns.vat()).isEmpty(); + assertThat(columns.amount()).isEqualTo("AMOUNT"); + } + + @Test + void theLineConstructorThatPredatesTheVatRateLeavesItBlank() { + 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.amount()).isEqualByComparingTo("1200"); + } + + @Test + void theDataConstructorThatPredatesShipToLeavesItEmptyRatherThanNull() { + // The record normalizes an absent block to its empty form, so preset + // code reads it without a null check — the promise the whole model + // makes. + StructuredInvoiceData data = new StructuredInvoiceData( + null, null, null, + new InvoiceRecipient("BILL TO", "Northfield", "", List.of("21 Jubilee Way"), + "", ""), + null, null, null, null, null, "GBP"); + assertThat(data.shipTo()).isNotNull(); + assertThat(data.shipTo().name()).isEmpty(); + assertThat(data.billTo().name()).isEqualTo("Northfield"); + } + + @Test + void theBuilderCarriesShipToThrough() { + StructuredInvoiceData data = StructuredInvoiceData.builder() + .billTo(new InvoiceRecipient("BILL TO", "Northfield", "", List.of(), "", "")) + .shipTo(new InvoiceRecipient("SHIP TO", "The Foundry", "", List.of(), "", "")) + .build(); + assertThat(data.shipTo().name()).isEqualTo("The Foundry"); + assertThat(data.shipTo().heading()).isEqualTo("SHIP TO"); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceBrand.java b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceBrand.java index 90fc685a0..758044b8e 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceBrand.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceBrand.java @@ -20,12 +20,18 @@ * @param qualifier the second wordmark line under the name (e.g. the * business type) * @param tagline the tagline under the lockup + * @param monogramTop the first line of a drawn monogram, for the lockups + * that set initials rather than an image; blank when + * absent + * @param monogramBottom the second monogram line; blank when absent */ public record InvoiceBrand( DocumentImageData logo, String name, String qualifier, - String tagline) { + String tagline, + String monogramTop, + String monogramBottom) { /** * Normalizes the optional text fields; the logo stays nullable. @@ -34,6 +40,30 @@ public record InvoiceBrand( name = Objects.requireNonNullElse(name, ""); qualifier = Objects.requireNonNullElse(qualifier, ""); tagline = Objects.requireNonNullElse(tagline, ""); + monogramTop = Objects.requireNonNullElse(monogramTop, ""); + monogramBottom = Objects.requireNonNullElse(monogramBottom, ""); + } + + /** + * Backward-compatible constructor for callers that predate the monogram. + * + * @param logo the logo image, or {@code null} + * @param name the brand name + * @param qualifier the second wordmark line + * @param tagline the tagline under the lockup + */ + public InvoiceBrand(DocumentImageData logo, String name, String qualifier, + String tagline) { + this(logo, name, qualifier, tagline, "", ""); + } + + /** + * Whether this lockup carries a drawn monogram. + * + * @return {@code true} when either monogram line was supplied + */ + public boolean hasMonogram() { + return !monogramTop.isBlank() || !monogramBottom.isBlank(); } /** diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceContactBlock.java b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceContactBlock.java index d31873d86..22477ede2 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceContactBlock.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoiceContactBlock.java @@ -20,6 +20,10 @@ * @param registrationLabel the label of the registration number * (e.g. {@code "ABN"}) * @param registrationNumber the registration number itself + * @param taxRegistrationLabel the label of a second registration a + * jurisdiction requires beside the first + * (e.g. {@code "VAT No."}); blank when absent + * @param taxRegistrationNumber that second number itself; blank when absent */ public record InvoiceContactBlock( String legalName, @@ -28,7 +32,9 @@ public record InvoiceContactBlock( String email, String website, String registrationLabel, - String registrationNumber) { + String registrationNumber, + String taxRegistrationLabel, + String taxRegistrationNumber) { /** * Normalizes optional fields and freezes the address lines. @@ -41,5 +47,26 @@ public record InvoiceContactBlock( website = Objects.requireNonNullElse(website, ""); registrationLabel = Objects.requireNonNullElse(registrationLabel, ""); registrationNumber = Objects.requireNonNullElse(registrationNumber, ""); + taxRegistrationLabel = Objects.requireNonNullElse(taxRegistrationLabel, ""); + taxRegistrationNumber = Objects.requireNonNullElse(taxRegistrationNumber, ""); + } + + /** + * Backward-compatible constructor for callers that predate the second + * registration. + * + * @param legalName the registered business name + * @param addressLines the address lines, in order + * @param phone the phone channel + * @param email the email channel + * @param website the website channel + * @param registrationLabel the label of the registration number + * @param registrationNumber the registration number itself + */ + public InvoiceContactBlock(String legalName, List addressLines, String phone, + String email, String website, String registrationLabel, + String registrationNumber) { + this(legalName, addressLines, phone, email, website, + registrationLabel, registrationNumber, "", ""); } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoicePaymentBlock.java b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoicePaymentBlock.java index f8d2184b0..8b5ae46b8 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoicePaymentBlock.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/InvoicePaymentBlock.java @@ -23,13 +23,20 @@ * the emphasis — usually the term itself, as in * {@code "30 days"}; empty leaves the notice * evenly set + * @param accountHolder who the money is paid to, set above the fields + * where a design names the account before listing + * it; blank when absent + * @param signOff the closing line a design sets beside the due + * notice, usually a thank-you; blank when absent */ public record InvoicePaymentBlock( String heading, List fields, String instruction, String dueNotice, - String dueNoticeEmphasis) { + String dueNoticeEmphasis, + String accountHolder, + String signOff) { /** * Normalizes optional fields and freezes the field list. @@ -40,6 +47,23 @@ public record InvoicePaymentBlock( instruction = Objects.requireNonNullElse(instruction, ""); dueNotice = Objects.requireNonNullElse(dueNotice, ""); dueNoticeEmphasis = Objects.requireNonNullElse(dueNoticeEmphasis, ""); + accountHolder = Objects.requireNonNullElse(accountHolder, ""); + signOff = Objects.requireNonNullElse(signOff, ""); + } + + /** + * Backward-compatible constructor for callers that predate the account + * holder and the sign-off. + * + * @param heading the block heading + * @param fields the labelled payment fields, in print order + * @param instruction the sentence under the fields + * @param dueNotice the short due-by notice + * @param dueNoticeEmphasis the emphasised run inside the notice + */ + public InvoicePaymentBlock(String heading, List fields, String instruction, + String dueNotice, String dueNoticeEmphasis) { + this(heading, fields, instruction, dueNotice, dueNoticeEmphasis, "", ""); } /** 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 e89e3624d..65f3c7983 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 @@ -24,7 +24,9 @@ public record InvoiceServiceLines(Columns columns, List lines) { * Normalizes an absent column set and freezes the line list. */ public InvoiceServiceLines { - columns = columns == null ? new Columns(null, null, null, null, null, null) : columns; + columns = columns == null + ? new Columns(null, null, null, null, null, null, null) + : columns; lines = List.copyOf(Objects.requireNonNullElse(lines, List.of())); } @@ -37,6 +39,9 @@ public record InvoiceServiceLines(Columns columns, List lines) { * @param quantity label of the quantity column * @param unitPrice label of the unit-price column * @param amount label of the amount column + * @param vat label of the tax-rate column, for the + * jurisdictions that print the rate per line; + * blank leaves the column out */ public record Columns( String index, @@ -44,7 +49,8 @@ public record Columns( String servicePeriod, String quantity, String unitPrice, - String amount) { + String amount, + String vat) { /** * Normalizes optional labels to empty strings. @@ -56,6 +62,23 @@ public record Columns( quantity = Objects.requireNonNullElse(quantity, ""); unitPrice = Objects.requireNonNullElse(unitPrice, ""); amount = Objects.requireNonNullElse(amount, ""); + vat = Objects.requireNonNullElse(vat, ""); + } + + /** + * Backward-compatible constructor for callers that predate the + * tax-rate column. + * + * @param index label of the line-number column + * @param description label of the description column + * @param servicePeriod label of the service-period column + * @param quantity label of the quantity column + * @param unitPrice label of the unit-price column + * @param amount label of the amount column + */ + public Columns(String index, String description, String servicePeriod, + String quantity, String unitPrice, String amount) { + this(index, description, servicePeriod, quantity, unitPrice, amount, ""); } } @@ -73,6 +96,10 @@ public record Columns( * {@link BigDecimal#ZERO} * @param amount the line total; {@code null} normalizes to * {@link BigDecimal#ZERO} + * @param vatRate the tax rate printed for this line, written as + * the design shows it (e.g. {@code "20%"}) rather + * than as a number, because the wording differs by + * jurisdiction; blank when absent */ public record Line( int lineNumber, @@ -82,7 +109,8 @@ public record Line( BigDecimal quantity, String unit, BigDecimal unitPrice, - BigDecimal amount) { + BigDecimal amount, + String vatRate) { /** * Normalizes optional fields. @@ -95,6 +123,27 @@ public record Line( unit = Objects.requireNonNullElse(unit, ""); unitPrice = Objects.requireNonNullElse(unitPrice, BigDecimal.ZERO); amount = Objects.requireNonNullElse(amount, BigDecimal.ZERO); + vatRate = Objects.requireNonNullElse(vatRate, ""); + } + + /** + * Backward-compatible constructor for callers that predate the + * per-line tax rate. + * + * @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 + */ + public Line(int lineNumber, String title, String description, String servicePeriod, + BigDecimal quantity, String unit, BigDecimal unitPrice, + BigDecimal amount) { + this(lineNumber, title, description, servicePeriod, quantity, unit, + unitPrice, amount, ""); } } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceData.java b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceData.java index d685766bb..21d964bd7 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceData.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/invoice/StructuredInvoiceData.java @@ -26,6 +26,9 @@ * @param supplier the sender's address and contact channels * @param masthead the document title and its metadata rows * @param billTo the billed-to block + * @param shipTo the shipped-to block, for the designs that print a + * delivery address beside the billing one; empty when + * the two are the same or the design shows only one * @param summary what the invoice covers, and for which period * @param serviceLines the line-items table * @param totals the totals stack and its total band @@ -38,6 +41,7 @@ public record StructuredInvoiceData( InvoiceContactBlock supplier, InvoiceMasthead masthead, InvoiceRecipient billTo, + InvoiceRecipient shipTo, InvoiceSummaryBlock summary, InvoiceServiceLines serviceLines, InvoiceTotalsBlock totals, @@ -55,6 +59,8 @@ public record StructuredInvoiceData( masthead = masthead == null ? new InvoiceMasthead(null, null) : masthead; billTo = billTo == null ? new InvoiceRecipient(null, null, null, null, null, null) : billTo; + shipTo = shipTo == null + ? new InvoiceRecipient(null, null, null, null, null, null) : shipTo; summary = summary == null ? new InvoiceSummaryBlock(null, null, null) : summary; serviceLines = serviceLines == null ? new InvoiceServiceLines(null, null) : serviceLines; @@ -65,6 +71,31 @@ public record StructuredInvoiceData( currencyCode = Objects.requireNonNullElse(currencyCode, ""); } + /** + * Backward-compatible constructor for callers that predate the + * shipped-to block. + * + * @param brand the sender's brand lockup + * @param supplier the sender's address and contact channels + * @param masthead the document title and its metadata rows + * @param billTo the billed-to block + * @param summary what the invoice covers, and for which period + * @param serviceLines the line-items table + * @param totals the totals stack and its total band + * @param payment where to send the money, and by when + * @param notes the closing notes and query channels + * @param currencyCode the ISO currency code the figures are stated in + */ + public StructuredInvoiceData(InvoiceBrand brand, InvoiceContactBlock supplier, + InvoiceMasthead masthead, InvoiceRecipient billTo, + InvoiceSummaryBlock summary, + InvoiceServiceLines serviceLines, + InvoiceTotalsBlock totals, InvoicePaymentBlock payment, + InvoiceNotesBlock notes, String currencyCode) { + this(brand, supplier, masthead, billTo, null, summary, serviceLines, + totals, payment, notes, currencyCode); + } + /** * Starts a fluent structured invoice data builder. * @@ -82,6 +113,7 @@ public static final class Builder { private InvoiceContactBlock supplier; private InvoiceMasthead masthead; private InvoiceRecipient billTo; + private InvoiceRecipient shipTo; private InvoiceSummaryBlock summary; private InvoiceServiceLines serviceLines; private InvoiceTotalsBlock totals; @@ -136,6 +168,18 @@ public Builder billTo(InvoiceRecipient billTo) { return this; } + /** + * Sets the shipped-to block, for the designs that print a delivery + * address beside the billing one. + * + * @param shipTo recipient block + * @return this builder + */ + public Builder shipTo(InvoiceRecipient shipTo) { + this.shipTo = shipTo; + return this; + } + /** * Sets what the invoice covers, and for which period. * @@ -208,8 +252,8 @@ public Builder currencyCode(String currencyCode) { * @return structured invoice data */ public StructuredInvoiceData build() { - return new StructuredInvoiceData(brand, supplier, masthead, billTo, summary, - serviceLines, totals, payment, notes, currencyCode); + return new StructuredInvoiceData(brand, supplier, masthead, billTo, shipTo, + summary, serviceLines, totals, payment, notes, currencyCode); } } }