Browse Source

working on document rendering

feature/document
Stephan Richter 4 months ago
parent
commit
877edadd50
  1. 2
      backend/src/main/java/de/srsoftware/umbrella/backend/Application.java
  2. 5
      core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java
  3. 2
      core/src/main/java/de/srsoftware/umbrella/core/Tuple.java
  4. 5
      core/src/main/java/de/srsoftware/umbrella/core/exceptions/UmbrellaException.java
  5. 1
      documents/build.gradle.kts
  6. 2
      documents/src/main/java/de/srsoftware/umbrella/documents/Constants.java
  7. 151
      documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java
  8. 6
      documents/src/main/java/de/srsoftware/umbrella/documents/PriceFormat.java
  9. 188
      documents/src/main/java/de/srsoftware/umbrella/documents/TemplateDoc.java
  10. 37
      documents/src/main/java/de/srsoftware/umbrella/documents/TemplateProcessor.java
  11. 224
      documents/src/main/resources/SRSoftware 2020.html.template

2
backend/src/main/java/de/srsoftware/umbrella/backend/Application.java

@ -60,7 +60,7 @@ public class Application { @@ -60,7 +60,7 @@ public class Application {
var userModule = new UserModule(config,messageSystem);
var companyModule = new CompanyModule(config, userModule);
var documentApi = new DocumentApi(companyModule, config);
var documentApi = new DocumentApi(companyModule, translationModule, config);
var itemApi = new ItemApi(config,companyModule);
var legacyApi = new LegacyApi(userModule.userDb(),config);
var markdownApi = new MarkdownApi(userModule);

5
core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java

@ -5,7 +5,6 @@ import static de.srsoftware.tools.Optionals.nullable; @@ -5,7 +5,6 @@ import static de.srsoftware.tools.Optionals.nullable;
import static java.lang.System.Logger.Level.DEBUG;
import static java.lang.System.Logger.Level.WARNING;
import static java.net.HttpURLConnection.*;
import static java.text.MessageFormat.format;
import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.tools.Path;
@ -72,7 +71,5 @@ public abstract class BaseHandler extends PathHandler { @@ -72,7 +71,5 @@ public abstract class BaseHandler extends PathHandler {
return sendEmptyResponse(HTTP_UNAUTHORIZED,ex);
}
public boolean notImplemented(HttpExchange ex,String method,Object clazz) throws IOException{
return sendContent(ex,HTTP_NOT_IMPLEMENTED,format("{0}.{1} not implemented",clazz.getClass().getSimpleName(),method));
}
}

2
core/src/main/java/de/srsoftware/umbrella/core/Tuple.java

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.core;
import de.srsoftware.umbrella.core.model.Company;
public class Tuple<A,B>{
public final A a;

5
core/src/main/java/de/srsoftware/umbrella/core/exceptions/UmbrellaException.java

@ -7,6 +7,7 @@ import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE; @@ -7,6 +7,7 @@ import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE;
import static java.lang.System.Logger.Level.ERROR;
import static java.lang.System.Logger.Level.WARNING;
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.text.MessageFormat.format;
@ -51,6 +52,10 @@ public class UmbrellaException extends Exception{ @@ -51,6 +52,10 @@ public class UmbrellaException extends Exception{
return new UmbrellaException(HTTP_UNPROCESSABLE, ERROR_MISSING_FIELD, field);
}
public static UmbrellaException notFound(String message, Object... fills) {
return new UmbrellaException(HTTP_NOT_FOUND,message,fills);
}
public int statusCode(){
return statusCode;
}

1
documents/build.gradle.kts

@ -4,6 +4,7 @@ dependencies{ @@ -4,6 +4,7 @@ dependencies{
implementation(project(":company"))
implementation(project(":core"))
implementation("de.srsoftware:configuration.json:1.0.3")
implementation("de.srsoftware:document.api:1.0.1")
implementation("de.srsoftware:document.file:1.0.0")
implementation("de.srsoftware:document.processor:1.0.2")

2
documents/src/main/java/de/srsoftware/umbrella/documents/Constants.java

@ -16,7 +16,9 @@ public class Constants { @@ -16,7 +16,9 @@ public class Constants {
public static final String COMPANY = "company";
public static final String CONFIG_DATABASE = "umbrella.modules.document.database";
public static final String CONFIG_TEMPLATES = "umbrella.modules.document.templates";
public static final String CONTACTS = "contacts";
public static final String CONTENT_DISPOSITION = "Content-Disposition";
public static final String CUSTOMERS = "customers";
public static final String ERROR_ADDRESS_MISSING = "{0} address does not contain street address / post code / city";

151
documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java

@ -1,8 +1,14 @@ @@ -1,8 +1,14 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.documents;
import static de.srsoftware.document.mustang.Constants.*;
import static de.srsoftware.document.mustang.Constants.KEY_DATA;
import static de.srsoftware.document.mustang.Constants.KEY_PDF;
import static de.srsoftware.document.mustang.Constants.KEY_PRODUCER;
import static de.srsoftware.document.mustang.Constants.KEY_TEMPLATE;
import static de.srsoftware.tools.MimeType.MIME_FORM_URL;
import static de.srsoftware.tools.Optionals.isSet;
import static de.srsoftware.tools.Strings.escapeHtmlEntities;
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Paths.LIST;
@ -19,24 +25,36 @@ import static java.util.stream.Collectors.toMap; @@ -19,24 +25,36 @@ import static java.util.stream.Collectors.toMap;
import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.configuration.Configuration;
import de.srsoftware.document.api.Content;
import de.srsoftware.document.api.DocumentRegistry;
import de.srsoftware.document.api.RenderError;
import de.srsoftware.document.files.DocumentDirectory;
import de.srsoftware.document.processor.latex.LatexFactory;
import de.srsoftware.document.processor.weasyprint.WeasyFactory;
import de.srsoftware.document.zugferd.ZugferdFactory;
import de.srsoftware.document.zugferd.data.*;
import de.srsoftware.document.zugferd.data.Currency;
import de.srsoftware.tools.Pair;
import de.srsoftware.tools.Path;
import de.srsoftware.tools.SessionToken;
import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.Tuple;
import de.srsoftware.umbrella.core.api.CompanyService;
import de.srsoftware.umbrella.core.api.Translator;
import de.srsoftware.umbrella.core.api.UserService;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.Company;
import de.srsoftware.umbrella.core.model.Token;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import de.srsoftware.umbrella.documents.model.*;
import de.srsoftware.umbrella.documents.model.Customer;
import java.io.File;
import java.io.IOException;
import java.text.MessageFormat;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.json.JSONArray;
@ -50,13 +68,22 @@ public class DocumentApi extends BaseHandler { @@ -50,13 +68,22 @@ public class DocumentApi extends BaseHandler {
private final Configuration config;
private final DocumentDb db;
private final UserService users;
private final Translator translator;
public DocumentApi(CompanyService companyService, Configuration config) throws UmbrellaException {
public DocumentApi(CompanyService companyService, Translator translator, Configuration config) throws UmbrellaException {
this.config = config;
this.translator = translator;
var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE));
db = new SqliteDb(connect(dbFile));
companies = companyService;
users = companyService.userService();
Optional<String> templates = config.get(CONFIG_TEMPLATES);
if (templates.isEmpty()) throw missingFieldException(CONFIG_TEMPLATES);
registry.add(new DocumentDirectory(new File(templates.get())));
registry.add(new TemplateProcessor(), new LatexFactory(), new WeasyFactory(), new ZugferdFactory());
registry.documents().forEach(d -> LOG.log(DEBUG,"found template: {0}",d));
}
@Override
@ -233,6 +260,86 @@ public class DocumentApi extends BaseHandler { @@ -233,6 +260,86 @@ public class DocumentApi extends BaseHandler {
return sendContent(ex,doc.renderToMap());
}
private PriceFormat priceFormat(String currency, String language) {
var pattern = switch (currency){
case "$" -> "$&nbsp;{0,number,#,###.00}";
default -> "{0,number,#,###.00}&nbsp;"+currency;
};
var message = new MessageFormat(pattern, "de".equals(language)? Locale.GERMAN:Locale.US);
return val -> message.format(new Object[]{val/100d});
}
private DocumentData convert(Document document) throws Converter.ConversionError {
var currency = switch (document.currency()){
case "€" -> Currency.EUR;
case "$" -> Currency.USD;
default -> throw new Converter.ConversionError("Unsupported currency: ",document.currency());
};
var typeCode = switch (document.type().name()){
case "invoice" -> TypeCode.HANDELSRECHNUNG;
default -> throw new Converter.ConversionError("Unsupported document type: ",document.type().name());
};
var countryID = CountryCode.DE;
LOG.log(WARNING,"countryID is hardcoded to be \"DE\", should be field of company!");
var sender = document.sender();
var match = POST_CODE.matcher(sender.name());
if (!match.find()) throw new Converter.ConversionError(ERROR_ADDRESS_MISSING, SENDER);
var name = match.group(1).trim();
var streetAddress = match.group(2).trim();
var postCode = match.group(3);
var city = match.group(4).trim();
var taxNumber = sender.taxNumber();
if (!taxNumber.startsWith("DE")) throw new Converter.ConversionError("Invalid sender tax number ({0})!",taxNumber);
var author = new Author(name,countryID,postCode,city,streetAddress,taxNumber);
match = POST_CODE.matcher(document.customer().name());
if (!match.find()) throw new Converter.ConversionError(ERROR_ADDRESS_MISSING,FIELD_CUSTOMER);
name = escapeHtmlEntities(match.group(1).trim());
streetAddress = escapeHtmlEntities(match.group(2).trim());
postCode = match.group(3);
city = match.group(4).trim();
var customer = new de.srsoftware.document.zugferd.data.Customer(name,countryID,postCode,streetAddress,city);
var footer = document.footer();
var head = document.head();
var notes = new ArrayList<String>();
if (!head.isBlank()) notes.add(head);
if (!footer.isBlank()) notes.add(footer);
LocalDate deliveryDate;
try {
deliveryDate = LocalDate.parse(document.delivery());
} catch (RuntimeException ex) {
throw new Converter.ConversionError("\"{0}\" is not a valid delivery date (cannot be parsed)!",document.delivery());
}
var lineItems = new ArrayList<LineItem>();
for (var entry : document.positions().entrySet()){
var number = entry.getKey();
var pos = entry.getValue();
UnitCode unit = switch (pos.unit()){
case "jährlich" -> UnitCode.per_year;
case "pauschal" -> UnitCode.fixed;
case "h", "Stunden" -> UnitCode.hours;
case "Stück" -> UnitCode.pieces;
default -> throw new Converter.ConversionError("No unit code defined for {0}",pos.unit());
};
var percent = pos.tax();
var taxType = percent == 0 ? TaxType.Z : TaxType.S;
LOG.log(WARNING,"tax type is hardcoded to be \"{0}\", should be field of document / document position!",taxType.name());
var taxSet = new LineItemTaxSet(percent*100L,taxType);
// bei Gutschriften soll die Menge negativ sein, nicht aber der Einheitspreis. Deshalb wird das ggf. invertiert.
var neg = pos.unitPrice() < 0;
var unitPrice = (neg ? -1 : 1) * pos.unitPrice();
var amount = (neg ? -1 : 1) * pos.amount();
lineItems.add(new LineItem(number,pos.itemCode(),pos.title(),pos.description(),null,unitPrice,unit,amount,taxSet));
}
String terms = footer;
return new DocumentData(document.number(),currency,typeCode,document.date(),author,customer,notes,deliveryDate,null,terms,lineItems);
}
private boolean getRenderedDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException {
var document = getDocumentWithCompanyData(docId,user);
var template = document.template().name();
@ -243,9 +350,41 @@ public class DocumentApi extends BaseHandler { @@ -243,9 +350,41 @@ public class DocumentApi extends BaseHandler {
var optDoc = registry.documents()
.filter(filter)
.findAny();
if (optDoc.isEmpty()) throw new UmbrellaException(404,"Cannot render {0} {1}: Missing template \"{2}\"",type,document.number(),template);
if (optDoc.isEmpty()) throw UmbrellaException.notFound("Cannot render {0} {1}: Missing template \"{2}\"",type,document.number(),template);
Function<String,String> translate = text -> translator.translate(user.language(),text);
var pdfData = new HashMap<String,Object>();
pdfData.put(FIELD_DOCUMENT,document.renderToMap());
pdfData.put("translate",translate);
pdfData.put(USER,user);
pdfData.put(FIELD_PRICE_FORMAT, priceFormat(document.currency(),user.language()));
Map<String,Object> data = pdfData;
if (zugferd) try { // create additional data
data = Map.of(
KEY_XML,Map.of(
KEY_PROFILE,"EN16931",
KEY_DATA,convert(document)
),
KEY_PDF,Map.of(
KEY_TEMPLATE, templateName,
KEY_DATA, pdfData
),
KEY_PRODUCER,"SRSoftware Umbrella"
);
} catch (Converter.ConversionError e){
throw new UmbrellaException(500,"Failed to convert data: {0}",e.getMessage()).causedBy(e);
}
var rendered = optDoc.get().render(data);
var source = optDoc.get();
if (rendered instanceof RenderError err) throw new UmbrellaException(500,"Failed to render {0}: {1}",source.name(),err.toString());
if (rendered instanceof Content content) {
var headers = ex.getResponseHeaders();
headers.add(CONTENT_TYPE, source.mimeType());
headers.add(CONTENT_DISPOSITION,"attachment; filename=\""+document.number()+".pdf\"");
return sendContent(ex,content.bytes());
}
throw new UmbrellaException(500,"Unknown result type ({0}) returned from render process!",rendered.getClass().getSimpleName());
return sendContent(ex,document.renderToMap());
}
private JSONArray getLegacyContacts(HttpExchange ex, UmbrellaUser umbrellaUser, Token token) throws IOException, UmbrellaException {

6
documents/src/main/java/de/srsoftware/umbrella/documents/PriceFormat.java

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.documents;
public interface PriceFormat {
String format(long val);
}

188
documents/src/main/java/de/srsoftware/umbrella/documents/TemplateDoc.java

@ -0,0 +1,188 @@ @@ -0,0 +1,188 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.documents;
import de.srsoftware.configuration.JsonConfig;
import de.srsoftware.document.api.*;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import org.json.JSONObject;
import java.util.*;
import java.util.regex.Pattern;
import static de.srsoftware.tools.MimeType.*;
import static de.srsoftware.umbrella.core.Constants.ERROR_MISSING_FIELD;
import static de.srsoftware.umbrella.core.Constants.USER;
import static de.srsoftware.umbrella.documents.Constants.FIELD_PRICE_FORMAT;
import static java.lang.System.Logger.Level.TRACE;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.text.MessageFormat.format;
public abstract class TemplateDoc implements Document {
private static final Pattern TOKEN_PATTERN = Pattern.compile("<\\? (([^?]|\\?[^>])+) \\?>");
private static final Pattern POSITION_PATTERN = Pattern.compile("<!-- positions -->(.*)<!-- positions -->",Pattern.DOTALL);
private static final Pattern TAXES_PATTERN = Pattern.compile("<!-- tax list -->(.*)<!-- tax list -->",Pattern.DOTALL);
private String id, mime;
@Override
public String description() {
return format("File created by TemplateProcessor from \"{0}\"",source().name());
}
@Override
public String id() {
if (id == null) {
id = source().name();
if (id.endsWith(".template")) id = id.substring(0,id.length()-9);
}
return id;
}
@Override
public String name() {
return "processed "+id();
}
@Override
public String mimeType() {
if (mime == null) {
mime = MIME_UNKNOWN;
if (id().endsWith("html")) mime = MIME_HTML;
if (id().endsWith("tex")) mime = MIME_LATEX;
}
return mime;
}
@Override
public RenderResult render(Map<String, Object> data) {
data = new HashMap<>(data);
if (!(data.get(FIELD_PRICE_FORMAT) instanceof PriceFormat price)) return RenderError.of(ERROR_MISSING_FIELD,FIELD_PRICE_FORMAT);
if (data.get(USER) instanceof UmbrellaUser user) data.put(USER,user.toMap());
var precursor = source().render(data);
if (precursor instanceof Content content){
var source = new String(content.bytes(), UTF_8);
var config = new JsonConfig(new JSONObject(data));
source = renderPositions(source,config.subset("document.positions"),price);
source = renderTaxes(source,config.subset("document.taxes"),price);
var tokens = findTokensIn(source);
for (var token : tokens){
var val = config.get(token).map(Object::toString);
if (val.isPresent()) {
var value = val.get().trim();
value = switch (token){
case "document.net_sum","document.gross_sum" -> price.format(Long.parseLong(value));
default -> value;
};
if (MIME_HTML.equals(mimeType())) value = value.replace("\n","<span class=\"break\"></span>\n");
source = source.replace("<? "+token+" ?>",value);
}
}
tokens = findTokensIn(source);
for (var token : tokens){
var value = config.get("translations."+token).map(Object::toString).orElse(token).trim();
if (MIME_HTML.equals(mimeType())) value = value.replace("\n","<span class=\"break\"></span>\n");
source = source.replace("<? "+token+" ?>",value);
}
return new StringContent(source);
}
return precursor;
}
private String renderPositions(String source, Optional<JsonConfig> opt, PriceFormat price) {
if (opt.isPresent()){
var positions = opt.get();
System.getLogger(id()).log(TRACE,"Positions: {0}",positions);
var matcher = POSITION_PATTERN.matcher(source);
if (matcher.find()){
var posCode = matcher.group(1).trim();
var start = matcher.start(1);
var end = matcher.end(1);
var tokens = findTokensIn(posCode);
StringBuilder sb = new StringBuilder();
sb.append(source, 0, start);
for (var pos : positions.keys().stream().sorted(this::byIntValue).toList()){
var copy = posCode;
for (var token : tokens){
var key = pos+"."+token;
var val = positions.get(key).orElse(token);
String value = switch (token){
case "amount" -> ""+(Math.round(1000d*(double)val)/1000d);
case "net_price", "unit_price" -> price.format((long)val);
case "optional" -> val == Boolean.TRUE ? "optional" : "";
case null -> "";
default -> val+"";
};
copy = copy.replace("<? "+token+" ?>",value);
}
sb.append(copy).append("\n");
}
sb.append(source.substring(end));
return sb.toString();
}
}
return source;
}
private String renderTaxes(String source, Optional<JsonConfig> opt, PriceFormat price) {
if (opt.isPresent()){
var taxes = opt.get();
System.getLogger(id()).log(TRACE,"Taxes: {0}",taxes);
var matcher = TAXES_PATTERN.matcher(source);
if (matcher.find()){
var taxCode = matcher.group(1).trim();
var start = matcher.start(1);
var end = matcher.end(1);
var tokens = findTokensIn(taxCode);
StringBuilder sb = new StringBuilder();
sb.append(source, 0, start);
for (var rate : taxes.keys()){
var percent = Integer.parseInt(rate);
Optional<Long> o = taxes.get(rate);
long netSum = o.orElse(0L);
long tax = Math.round(percent * netSum / 100d);
long grossSum = netSum + tax;
var copy = taxCode;
for (var token : tokens){
var value = switch (token){
case "net_sum" -> price.format(netSum);
case "rate" -> rate+" %";
case "amount" -> price.format(tax);
case "gross_sum" -> price.format(grossSum);
default -> null;
};
if (value != null) copy = copy.replace("<? "+token+" ?>",value);
}
sb.append(copy).append("\n");
}
sb.append(source.substring(end));
return sb.toString();
}
}
return source;
}
private int byIntValue(String a, String b) {
try {
var i1 = Integer.parseInt(a);
var i2 = Integer.parseInt(b);
return i1-i2;
} catch (NumberFormatException ignored){
return a.compareTo(b);
}
}
private static Set<String> findTokensIn(String content) {
var matcher = TOKEN_PATTERN.matcher(content);
var token = new HashSet<String>();
while (matcher.find()) token.add(matcher.group(1));
return token;
}
protected abstract Document source();
}

37
documents/src/main/java/de/srsoftware/umbrella/documents/TemplateProcessor.java

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.documents;
import de.srsoftware.document.api.Document;
import de.srsoftware.document.api.DocumentFactory;
import de.srsoftware.document.api.DocumentRegistry;
import java.util.stream.Stream;
public class TemplateProcessor implements DocumentFactory {
private DocumentRegistry registry;
private Document createGenerator(final Document source) {
return new TemplateDoc() {
protected Document source() {
return source;
}
};
}
@Override
public String description() {
return "Creates documents from template files";
}
@Override
public Stream<Document> documents() {
return this.registry.documents().filter(document -> document.name().endsWith(".template")).map(this::createGenerator);
}
@Override
public DocumentFactory setRegistry(DocumentRegistry registry) {
this.registry = registry;
return this;
}
}

224
documents/src/main/resources/SRSoftware 2020.html.template

@ -0,0 +1,224 @@ @@ -0,0 +1,224 @@
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
}
header{
width: 100%;
height: 4cm;
position: relative;
}
header .receiver{
position: absolute;
left: 0;
bottom: 0;
}
header .sender{
position: absolute;
right: 0;
text-align: right;
}
header .company{
font-size: 20px;
font-weight: bold;
}
header .address .small{
font-size: 10px;
font-weight: bold;
padding-bottom: 5px;
}
header .address .small .break{
display: inline;
}
header .address .small .break:after{
content: ' / ';
}
span.break{
display: block;
}
h2 {
position: relative;
text-align: center;
font-size: 14px;
font-weight: bold;
}
.bottom .company,
h2 .left {
position: absolute;
left: 0;
}
.bottom .bank_account,
h2 .right {
position: absolute;
right: 0;
}
.right{
text-align: right;
}
.center{
text-align: center;
}
tr {
vertical-align: baseline;
}
.foot,
.head{
margin: 15px 0;
}
td.description{
font-size: 12px;
color: #333;
}
header .logo{
width: 150px;
position: absolute;
left: 37%;
top: -60px;
}
.bottom{
width: 100%;
position: absolute;
bottom: 0;
text-align: center;
}
.bottom div{
display: inline;
text-align: left;
top: 0;
}
th {
background: paleGreen;
}
table:not(.bottom) tr:nth-child(4n+1),
tr:nth-child(4n){
background: #eaffea;
}
.ad{
font-size: 10px;
margin-top: 20px;
}
table{
width: 100%;
}
table.bottom td:nth-child(2n+1){
text-align: left;
}
</style>
</head>
<body>
<header>
<svg width="42mm" height="37mm" version="1.1" viewBox="0 0 840 727" xmlns="http://www.w3.org/2000/svg" class="logo">
<path d="m420 20c-200 0-400 120-400 300" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m420 20c200 0 400 120 400 300" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m380 160c-100-160-300 60-100 160" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m276 318c194 102 94 332-104 238" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m20 320c0 100 50 180 160 240" fill="none" stroke="#1E8449" stroke-width="40"/>
<line x1="440" x2="440" y1="80" y2="442" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m440 320c180 0 152 92 232 192" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m440 100c200 0 280 220 0 220" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m626 578c-80 30-186 62-186-138" fill="none" stroke="#1E8449" stroke-width="40"/>
<path d="m620 580c116-40 200-150 200-260" fill="none" stroke="#1E8449" stroke-width="40"/>
<text x="100" y="700" fill="#1E8449" font-family="sans-serif" font-size="112.89px"><tspan x="100" y="700">SRSoftware</tspan></text>
</svg>
<div class="company"><? document.company.name ?></div>
<div class="receiver address">
<div class="small"><? document.sender.name ?></div>
<? document.customer.name ?>
</div>
<div class="sender data">
<? document.sender.name ?><br/>
<br/>
<? Delivery date ?>: <? document.delivery ?>
</div>
</header>
<h1><? document.type ?></h1>
<h2>
<span class="left"><? document.type ?> <? document.number ?></span>
<span class="center"><? customer number ?>: <? document.customer.id ?></span>
<span class="right"><? document.date ?></span>
</h2>
<hr/>
<div class="head"><? document.head ?></div>
<table>
<tr>
<th><? Position ?></th>
<th><? Description ?></th>
<th><? Price per unit ?></th>
<th><? Amount ?></th>
<th><? Price ?></th>
<th><? Tax rate ?></th>
</tr>
<!-- positions -->
<tr>
<td class="center"><? number ?></td>
<td><b><? title ?></b></td>
<td class="right"><? unit_price ?></td>
<td class="right"><? amount ?>&nbsp;<? unit ?></td>
<td class="right"><? net_price ?></td>
<td class="right"><? tax ?>&nbsp;%</td>
</tr>
<tr>
<td><? optional ?></td>
<td colspan="5" class="description"><? description ?></td>
</tr>
<!-- positions -->
<tr>
<th class="right" colspan="5"><? Net sum ?>:</th>
<th class="right"><? document.net_sum ?></th>
</tr>
<!-- tax list -->
<tr class="right">
<td></td>
<td><? net_sum ?>&nbsp;+&nbsp;<? rate ?>&nbsp;=</td>
<td><? gross_sum ?></td>
<td colspan="2"><? contained tax: ?></td>
<td><? amount ?></td>
</tr>
<!-- tax list -->
<tr>
<th class="right" colspan="5"><? Gross sum ?>:</th>
<th class="right"><? document.gross_sum ?></th>
</tr>
</table>
<div class="foot"><? document.footer ?></div>
<table class="bottom">
<tr>
<td><? document.sender.name ?></td>
<td>
<? Tax id ?>: <? document.sender.tax_id ?><br/>
<? local court ?>: <? document.sender.court ?><br/><br/>
<div class="ad"><? document.type ?> <? created with ?> <a href="https://umbrella.srsoftware.de">Umbrella</a> <? by ?> <a href="https://srsoftware.de">SRSoftware</a></div>
</td>
<td>
<? Bank account ?>:<br/>
<? document.sender.bank_account ?>
</td>
</tr>
</table>
<!-- <div class="bottom">
<div class="company">
<? document.sender.name ?>
</div>
<div class="tax_data">
<? Tax id ?>: <? document.sender.tax_id ?><br/>
<? local court ?>: <? document.sender.court ?><br/><br/>
<div class="ad"><? document.type ?> <? created with ?> <a href="https://umbrella.srsoftware.de">Umbrella</a> <? by ?> <a href="https://srsoftware.de">SRSoftware</a></div>
</div>
<div class="bank_account">
<? Bank account ?>:<br/>
<? document.sender.bank_account ?>
</div>
</div> -->
</body>
</html>
Loading…
Cancel
Save