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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A structured invoice document model.** The invoice family's data layer knew one
shape — an invoice as pre-formatted display strings, with one address block per
party, line items whose quantity and money are already rendered, and a flat list of
summary rows. That cannot carry the structured business invoice: a brand lockup with
the sender's own logo, labelled masthead metadata, a contact block with a business
registration, priced service lines carrying `BigDecimal` figures and the unit they
are counted in, a totals stack with its own total band, bank payment fields, and a
footer line. `templates.data.invoice` now carries that second model —
`StructuredInvoiceData` (+ its section records) wrapped by
`StructuredInvoiceDocumentSpec` — alongside the display one; a preset consumes the
model whose shape it renders. The brand logo arrives as `DocumentImageData`, because
the logo is caller-supplied content rather than template chrome, and it stays
optional so a wordmark-only lockup composes. Every component normalizes `null` to
its empty form, money and quantities default to zero, and collections are frozen.

- **A structured proposal document model.** The proposal family's data layer knew one
shape — a titled run of prose sections with a flat timeline and pricing list — which
cannot carry the structured business proposal: brand marks, an authored multi-line
Expand All @@ -21,6 +36,19 @@ follow semantic versioning; release dates are ISO 8601.

### Templates

- **A professional-services invoice preset: `ConsultingInvoice`.** A corporate masthead
— brand lockup and contact channels beside the document title and its metadata —
over priced service lines that carry a service period and a unit per line, closing
with an emphasized total band and the bank details beside the notes and the due-by
chip. Ships as `invoice.presets.ConsultingInvoice` consuming the new structured
invoice model, with its contact marks, bank badge and calendar packaged in the
templates artifact, porting the rendered layout of the published standalone
`northpoint-consulting-invoice` template. Long invoices flow: the line-items table
repeats its header and the totals stack stays whole. Guarded by a smoke test
(including the empty document, the wordmark fallback, the rendered figures and the
repeated header), exact layout snapshots for the single page and the overflow, and a
pixel-parity gate; the examples showcase gains `invoice-consulting-v2`.

- **A second invoice preset: `ClassicInvoice`.** The letterhead-style invoice — a header
band with the company name and a 28pt INVOICE title, a TOTAL DUE hero strip,
BILL TO / FROM party columns, and a dedicated Summary table composed after the
Expand Down
Binary file added assets/readme/examples/invoice-consulting-v2.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
import com.demcha.examples.templates.cv.v2.CvSidebarPortraitExample;
import com.demcha.examples.templates.cv.v2.CvTimelineMinimalExample;
import com.demcha.examples.templates.invoice.ClassicInvoiceV2Example;
import com.demcha.examples.templates.invoice.ConsultingInvoiceV2Example;
import com.demcha.examples.templates.invoice.InvoiceCinematicFileExample;
import com.demcha.examples.templates.invoice.ModernInvoiceV2Example;
import com.demcha.examples.templates.proposal.CinematicProposalFileExample;
Expand Down Expand Up @@ -164,6 +165,7 @@ public static void main(String[] args) throws Exception {
System.out.println("Generated: " + InvoiceCinematicFileExample.generate());
System.out.println("Generated: " + ModernInvoiceV2Example.generate());
System.out.println("Generated: " + ClassicInvoiceV2Example.generate());
System.out.println("Generated: " + ConsultingInvoiceV2Example.generate());

// Proposals
System.out.println("Generated: " + ProposalCinematicFileExample.generate());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.demcha.examples.support;

import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.templates.data.invoice.InvoiceBrand;
import com.demcha.compose.document.templates.data.invoice.InvoiceContactBlock;
import com.demcha.compose.document.templates.data.invoice.InvoiceMasthead;
import com.demcha.compose.document.templates.data.invoice.InvoiceNotesBlock;
import com.demcha.compose.document.templates.data.invoice.InvoicePaymentBlock;
import com.demcha.compose.document.templates.data.invoice.InvoiceRecipient;
import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines;
import com.demcha.compose.document.templates.data.invoice.InvoiceSummaryBlock;
import com.demcha.compose.document.templates.data.invoice.InvoiceTotalsBlock;
import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData;
import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceDocumentSpec;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

/**
* Shared sample data for the Consulting Invoice example.
*
* <p>The sample is the preset's reference content: a
* professional-services invoice with a brand logo, five masthead metadata
* rows (one emphasized), five priced service lines, a subtotal / tax
* stack with the total band, five bank fields, and notes naming both query
* channels.</p>
*
* <p>Kept in lockstep with the qa module's
* {@code ConsultingInvoiceFixtures} — the two modules cannot share a
* source file, so a content change here belongs there too.</p>
*/
public final class ConsultingInvoiceSampleData {

private ConsultingInvoiceSampleData() {
}

/** The single-page reference invoice. */
public static StructuredInvoiceDocumentSpec sample() {
return StructuredInvoiceDocumentSpec.from(baseBuilder(serviceLines(5)).build());
}

/** The sample logo, read from the examples resources. */
public static DocumentImageData sampleLogo() {
String path = "/consulting-invoice-logo.png";
try (InputStream input = ConsultingInvoiceSampleData.class.getResourceAsStream(path)) {
if (input == null) {
throw new IllegalStateException("Missing sample logo: " + path);
}
return DocumentImageData.fromBytes(input.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException("Failed to read sample logo: " + path, e);
}
}

private static StructuredInvoiceData.Builder baseBuilder(InvoiceServiceLines lines) {
return StructuredInvoiceData.builder()
.brand(new InvoiceBrand(sampleLogo(), "NORTHPOINT", "CONSULTING",
"Strategy. Solutions. Results."))
.supplier(new InvoiceContactBlock("Northpoint Consulting Pty Ltd",
List.of("Level 8, 1 Collins Street", "Melbourne VIC 3000 Australia"),
"+61 3 9876 5432", "hello@northpoint.com.au", "northpoint.com.au",
"ABN", "12 345 678 901"))
.masthead(new InvoiceMasthead("INVOICE", List.of(
new InvoiceMasthead.Entry("Invoice Number:", "INV-2025-0478", false),
new InvoiceMasthead.Entry("Issue Date:", "26 May 2025", false),
new InvoiceMasthead.Entry("Due Date:", "25 June 2025", true),
new InvoiceMasthead.Entry("Project:", "Digital Strategy Engagement", false),
new InvoiceMasthead.Entry("PO Number:", "PO-7892", false))))
.billTo(new InvoiceRecipient("BILLED TO", "Greenfield Industries Ltd.",
"Accounts Payable Department",
List.of("12 Innovation Drive", "Melbourne VIC 3000", "Australia"),
"Email:", "ap@greenfield.com.au"))
.summary(new InvoiceSummaryBlock("INVOICE SUMMARY",
"Professional services rendered in accordance with the statement of "
+ "work for the period",
"1 May 2025 – 25 May 2025."))
.serviceLines(lines)
.totals(new InvoiceTotalsBlock(List.of(
new InvoiceTotalsBlock.Row("SUBTOTAL", new BigDecimal("14000.00")),
new InvoiceTotalsBlock.Row("GST (10%)", new BigDecimal("1400.00"))),
"TOTAL DUE", new BigDecimal("15400.00")))
.payment(new InvoicePaymentBlock("PAYMENT INFORMATION", List.of(
new InvoicePaymentBlock.Field("Bank Name:", "Example Bank"),
new InvoicePaymentBlock.Field("Account Name:",
"Northpoint Consulting Pty Ltd"),
new InvoicePaymentBlock.Field("BSB:", "123-456"),
new InvoicePaymentBlock.Field("Account Number:", "12345678"),
new InvoicePaymentBlock.Field("Reference:", "INV-2025-0478")),
"Please ensure the invoice number is included in the payment reference.",
"Payment is due within 30 days from the issue date.", "30 days"))
.notes(new InvoiceNotesBlock("NOTES", List.of(
"Thank you for your business.",
"If you have any questions regarding this invoice, please contact us "
+ "at accounts@northpoint.com.au or +61 3 9876 5432."),
"accounts@northpoint.com.au", "+61 3 9876 5432"))
.currencyCode("AUD");
}

private static InvoiceServiceLines serviceLines(int count) {
String[][] source = {
{"Strategic Consultation", "Leadership alignment workshops", "1–10 May 2025",
"10.00", "hrs", "250.00", "2500.00"},
{"Market & Competitive Analysis", "Research and analysis report", "1–15 May 2025",
"1.00", "ea", "3500.00", "3500.00"},
{"Digital Roadmap Development", "Strategy and roadmap creation", "5–20 May 2025",
"1.00", "ea", "4800.00", "4800.00"},
{"Stakeholder Review Sessions", "Facilitation and documentation", "15–22 May 2025",
"6.00", "hrs", "220.00", "1320.00"},
{"Presentation & Final Report", "Executive presentation and delivery",
"20–25 May 2025", "1.00", "ea", "1880.00", "1880.00"}};
List<InvoiceServiceLines.Line> lines = new ArrayList<>();
for (int index = 0; index < count; index++) {
String[] row = source[index % source.length];
lines.add(new InvoiceServiceLines.Line(index + 1, row[0], row[1], row[2],
new BigDecimal(row[3]), row[4], new BigDecimal(row[5]),
new BigDecimal(row[6])));
}
return new InvoiceServiceLines(
new InvoiceServiceLines.Columns("#", "DESCRIPTION", "SERVICE PERIOD", "QTY",
"UNIT PRICE", "AMOUNT"),
lines);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ record Entry(String title, String description, List<String> tags, String codeUrl

// ===== Templates / Invoice =====
invoice("invoice-cinematic", "InvoiceCinematicFileExample", "Cinematic Invoice", "Layered ModernInvoice preset with theme-driven layout, advanced tables, and totals.", "invoice", "cinematic");
invoice("invoice-consulting-v2", "v2/ConsultingInvoiceV2Example", "Consulting Invoice", "The ConsultingInvoice preset on the structured invoice model — brand lockup with the caller's logo, labelled masthead metadata, priced service lines with service periods, a totals stack and bank payment fields.", "invoice");
invoice("invoice-modern-v2", "v2/ModernInvoiceV2Example", "Modern Invoice", "The ModernInvoice preset composed straight from an InvoiceDocumentSpec — line items, totals and payment block driven by the BrandTheme rather than per-document styling.", "invoice");
invoice("invoice-classic-v2", "v2/ClassicInvoiceV2Example", "Classic Invoice", "The ClassicInvoice preset — letterhead header band, TOTAL DUE hero strip, BILL TO / FROM columns, and a dedicated Summary table after the line items.", "invoice");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.demcha.examples.templates.invoice;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceDocumentSpec;
import com.demcha.compose.document.templates.invoice.presets.ConsultingInvoice;
import com.demcha.examples.support.ConsultingInvoiceSampleData;
import com.demcha.examples.support.ExampleOutputPaths;

import java.nio.file.Path;

/**
* Renders the layered {@code invoice.v2} Consulting Invoice preset against
* the shared structured invoice sample data.
*
* <p>Output:
* {@code examples/target/generated-pdfs/templates/invoice/invoice-consulting-v2.pdf}.</p>
*
* <p>The preset owns its page geometry — size, margins, the page fills and
* the footer band — so the session starts unconfigured; see
* {@code ConsultingInvoice.RECOMMENDED_MARGIN}. The brand logo is supplied
* through the data, the way a caller supplies its own.</p>
*/
public final class ConsultingInvoiceV2Example {

private ConsultingInvoiceV2Example() {
}

/**
* @return absolute path of the rendered PDF
* @throws Exception if rendering fails
*/
public static Path generate() throws Exception {
Path outputFile = ExampleOutputPaths.prepare(
"templates/invoice", "invoice-consulting-v2.pdf");
StructuredInvoiceDocumentSpec spec = ConsultingInvoiceSampleData.sample();
DocumentTemplate<StructuredInvoiceDocumentSpec> template = ConsultingInvoice.create();

try (DocumentSession document = GraphCompose.document(outputFile).create()) {
template.compose(document, spec);
document.buildPdf();
}
return outputFile;
}

/**
* @param args ignored
* @throws Exception if rendering fails
*/
public static void main(String[] args) throws Exception {
System.out.println("Generated: " + generate());
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading