Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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<String> addressLines, String phone,
String email, String website, String registrationLabel,
String registrationNumber) {
this(legalName, addressLines, phone, email, website,
registrationLabel, registrationNumber, "", "");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Field> fields,
String instruction,
String dueNotice,
String dueNoticeEmphasis) {
String dueNoticeEmphasis,
String accountHolder,
String signOff) {

/**
* Normalizes optional fields and freezes the field list.
Expand All @@ -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<Field> fields, String instruction,
String dueNotice, String dueNoticeEmphasis) {
this(heading, fields, instruction, dueNotice, dueNoticeEmphasis, "", "");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ public record InvoiceServiceLines(Columns columns, List<Line> 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()));
}

Expand All @@ -37,14 +39,18 @@ public record InvoiceServiceLines(Columns columns, List<Line> 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,
String description,
String servicePeriod,
String quantity,
String unitPrice,
String amount) {
String amount,
String vat) {

/**
* Normalizes optional labels to empty strings.
Expand All @@ -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, "");
}
}

Expand All @@ -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,
Expand All @@ -82,7 +109,8 @@ public record Line(
BigDecimal quantity,
String unit,
BigDecimal unitPrice,
BigDecimal amount) {
BigDecimal amount,
String vatRate) {

/**
* Normalizes optional fields.
Expand All @@ -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, "");
}
}
}
Loading
Loading