From 72e9538a8ffa650eb083a770f36b70cf13f24131 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 27 Aug 2026 15:50:00 +0200 Subject: [PATCH] feat(invoice): add invoice text rendering --- lib/src/invoice/render.dart | 236 ++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 lib/src/invoice/render.dart diff --git a/lib/src/invoice/render.dart b/lib/src/invoice/render.dart new file mode 100644 index 0000000..710ddbd --- /dev/null +++ b/lib/src/invoice/render.dart @@ -0,0 +1,236 @@ +import 'invoice.dart'; + +String _date(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; + +String _quantity(num value) { + if (value is int || value == value.roundToDouble()) return value.toInt().toString(); + return value.toString(); +} + +String renderInvoiceText(Invoice invoice) { + final buffer = StringBuffer(); + buffer.writeln('INVOICE ${invoice.number}'); + buffer.writeln('Issued: ${_date(invoice.issueDate)}' + '${invoice.dueDate == null ? '' : ' Due: ${_date(invoice.dueDate!)}'}'); + buffer.writeln(); + + buffer.writeln('Seller: ${invoice.seller.name}'); + if (invoice.seller.address != null) buffer.writeln(' ${invoice.seller.address}'); + if (invoice.seller.taxId != null) buffer.writeln(' Tax ID: ${invoice.seller.taxId}'); + buffer.writeln('Buyer: ${invoice.buyer.name}'); + if (invoice.buyer.address != null) buffer.writeln(' ${invoice.buyer.address}'); + if (invoice.buyer.taxId != null) buffer.writeln(' Tax ID: ${invoice.buyer.taxId}'); + buffer.writeln(); + + final rows = >[ + ['#', 'Description', 'Qty', 'Unit', 'Unit net', 'Disc', 'Net', 'VAT'], + ]; + for (var i = 0; i < invoice.lines.length; i++) { + final line = invoice.lines[i]; + rows.add([ + '${i + 1}', + line.description, + _quantity(line.quantity), + line.unit, + line.unitPrice.formatPlain(), + line.discountRate == 0 ? '-' : '${(line.discountRate * 100).round()}%', + line.net.formatPlain(), + line.vat.label, + ]); + } + buffer.write(_renderTable(rows, const {2, 4, 5, 6})); + buffer.writeln(); + + buffer.writeln('VAT summary (${invoice.currency.code}):'); + final summaryRows = >[ + ['Rate', 'Net', 'VAT', 'Gross'], + ]; + for (final row in invoice.vatSummary()) { + summaryRows.add([ + row.rate.label, + row.net.formatPlain(), + row.vat.formatPlain(), + row.gross.formatPlain(), + ]); + } + buffer.write(_renderTable(summaryRows, const {1, 2, 3})); + buffer.writeln(); + + buffer.writeln('Subtotal (net): ${invoice.totalNet.format()}'); + buffer.writeln('VAT total: ${invoice.totalVat.format()}'); + buffer.writeln('TOTAL: ${invoice.totalGross.format()}'); + final reportingGross = invoice.reportingTotalGross; + if (reportingGross != null) { + buffer.writeln('Reporting total: ${reportingGross.format()} ' + '(rate ${invoice.reportingRate})'); + } + if (invoice.paymentTerms != null) { + buffer.writeln(); + buffer.writeln('Payment terms: ${invoice.paymentTerms}'); + } + if (invoice.notes != null) { + buffer.writeln(); + buffer.writeln('Notes: ${invoice.notes}'); + } + return buffer.toString(); +} + +String _renderTable(List> rows, Set rightAligned) { + final columns = rows.first.length; + final widths = List.filled(columns, 0); + for (final row in rows) { + for (var c = 0; c < columns; c++) { + if (row[c].length > widths[c]) widths[c] = row[c].length; + } + } + final buffer = StringBuffer(); + void writeRow(List row) { + final cells = []; + for (var c = 0; c < columns; c++) { + final cell = rightAligned.contains(c) + ? row[c].padLeft(widths[c]) + : row[c].padRight(widths[c]); + cells.add(cell); + } + buffer.writeln(cells.join(' ')); + } + + writeRow(rows.first); + buffer.writeln('-' * (widths.reduce((a, b) => a + b) + (columns - 1) * 2)); + for (final row in rows.skip(1)) { + writeRow(row); + } + return buffer.toString(); +} + +String renderInvoiceHtml(Invoice invoice) { + final buffer = StringBuffer(); + buffer.writeln(''); + buffer.writeln(''); + buffer.writeln(''); + buffer.writeln(''); + buffer.writeln('Invoice ${_escape(invoice.number)}'); + buffer.writeln(''); + buffer.writeln(''); + buffer.writeln(''); + buffer.writeln('
'); + buffer.writeln('
'); + buffer.writeln('

Invoice

'); + buffer.writeln('
'); + buffer.writeln('
Number${_escape(invoice.number)}
'); + buffer.writeln('
Issued${_date(invoice.issueDate)}
'); + if (invoice.dueDate != null) { + buffer.writeln('
Due${_date(invoice.dueDate!)}
'); + } + buffer.writeln('
'); + buffer.writeln('
'); + + buffer.writeln('
'); + buffer.write(_partyHtml('Seller', invoice.seller)); + buffer.write(_partyHtml('Buyer', invoice.buyer)); + buffer.writeln('
'); + + buffer.writeln(''); + buffer.writeln('' + '' + '' + ''); + buffer.writeln(''); + for (var i = 0; i < invoice.lines.length; i++) { + final line = invoice.lines[i]; + buffer.writeln('' + '' + '' + '' + '' + '' + '' + '' + '' + ''); + } + buffer.writeln(''); + buffer.writeln('
#DescriptionQtyUnitUnit netDiscNetVAT
${i + 1}${_escape(line.description)}${_quantity(line.quantity)}${_escape(line.unit)}${line.unitPrice.formatPlain()}${line.discountRate == 0 ? '-' : '${(line.discountRate * 100).round()}%'}${line.net.formatPlain()}${_escape(line.vat.label)}
'); + + buffer.writeln(''); + buffer.writeln('' + ''); + buffer.writeln(''); + for (final row in invoice.vatSummary()) { + buffer.writeln('' + '' + '' + '' + '' + ''); + } + buffer.writeln(''); + buffer.writeln('
VAT rateNetVATGross
${_escape(row.rate.label)}${row.net.formatPlain()}${row.vat.formatPlain()}${row.gross.formatPlain()}
'); + + buffer.writeln('
'); + buffer.writeln('
Subtotal (net)${invoice.totalNet.format()}
'); + buffer.writeln('
VAT total${invoice.totalVat.format()}
'); + buffer.writeln('
Total${invoice.totalGross.format()}
'); + final reportingGross = invoice.reportingTotalGross; + if (reportingGross != null) { + buffer.writeln('
Reporting total${reportingGross.format()}
'); + } + buffer.writeln('
'); + + if (invoice.paymentTerms != null) { + buffer.writeln('

Payment terms: ${_escape(invoice.paymentTerms!)}

'); + } + if (invoice.notes != null) { + buffer.writeln('

${_escape(invoice.notes!)}

'); + } + buffer.writeln('
'); + buffer.writeln(''); + buffer.writeln(''); + return buffer.toString(); +} + +String _partyHtml(String role, Party party) { + final buffer = StringBuffer(); + buffer.writeln('
'); + buffer.writeln('

$role

'); + buffer.writeln('

${_escape(party.name)}

'); + if (party.address != null) buffer.writeln('

${_escape(party.address!)}

'); + if (party.taxId != null) buffer.writeln('

Tax ID: ${_escape(party.taxId!)}

'); + if (party.email != null) buffer.writeln('

${_escape(party.email!)}

'); + buffer.writeln('
'); + return buffer.toString(); +} + +String _escape(String value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + +const _css = ''' +body { font-family: Arial, Helvetica, sans-serif; color: #1c2530; background: #eef1f5; margin: 0; padding: 32px; } +.invoice { max-width: 820px; margin: 0 auto; background: #ffffff; padding: 40px; border-radius: 12px; box-shadow: 0 12px 40px rgba(20,30,50,0.12); } +header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #2f5bd0; padding-bottom: 20px; } +header h1 { margin: 0; font-size: 34px; letter-spacing: 2px; color: #2f5bd0; } +.meta { text-align: right; font-size: 13px; } +.meta div { margin-bottom: 4px; } +.meta span { display: inline-block; width: 70px; color: #6b7684; } +.parties { display: flex; gap: 40px; margin: 28px 0; } +.party { flex: 1; } +.party h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #6b7684; margin: 0 0 8px; } +.party .name { font-weight: bold; margin: 0 0 4px; } +.party p { margin: 2px 0; font-size: 13px; } +table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13px; } +th { text-align: left; background: #f3f6fb; color: #48566a; padding: 10px; border-bottom: 2px solid #d6ddea; } +td { padding: 10px; border-bottom: 1px solid #eef1f5; } +.num { text-align: right; } +.totals { margin-top: 24px; margin-left: auto; width: 300px; } +.totals div { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; } +.totals .grand { border-top: 2px solid #2f5bd0; margin-top: 6px; padding-top: 12px; font-size: 18px; color: #2f5bd0; } +.terms, .notes { font-size: 12px; color: #6b7684; margin-top: 20px; } +''';