invoicer initial version

Change-Id: Ib20a96c224f5c055874f72f8f9a04a4dc8bbbc24
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/BoostrapInitialData.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/BoostrapInitialData.java
new file mode 100644
index 0000000..b00920c
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/BoostrapInitialData.java
@@ -0,0 +1,49 @@
+package pl.hackerspace;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+import pl.hackerspace.domain.Client;
+import pl.hackerspace.domain.Invoice;
+import pl.hackerspace.repository.ClientRepository;
+import pl.hackerspace.repository.InvoiceRepository;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class BoostrapInitialData implements CommandLineRunner {
+
+    private final ClientRepository clientRepository;
+    private final InvoiceRepository invoiceRepository;
+
+    @Override
+    public void run(String... args) {
+        log.info("Saving new clients");
+        Client client = Client.builder()
+                .price(BigDecimal.valueOf(200))
+                .nip("PL5252497215")
+                .name("Arseniy Sorokin")
+                .addressLine1("ul. Bródnowska 3/23")
+                .addressLine2("03-439 Warszawa, Polska")
+                .serviceName("Dostęp do Internetu - Umowa HSWAW/666 - Opłata abonamentowa %invoice_month_string%")
+                .email("arssorokin@gmail.com")
+                .build();
+        clientRepository.save(client);
+        clientRepository.save(Client.builder()
+                .price(BigDecimal.valueOf(100))
+                .nip("PL111")
+                .name("Pope Francis")
+                .addressLine1("St.Peter's square")
+                .addressLine2("Rome")
+                .serviceName("Dostęp do Internetu - Umowa HSWAW/2137 - Opłata abonamentowa %invoice_month_string%")
+                .email("pope@vatican.va")
+                .build());
+        log.info("Saving last invoice");
+        invoiceRepository.save(Invoice.builder().id(21196).invoiceTitle("FV21196").creationDate(LocalDateTime.now())
+                .client(client).pdfContent(new byte[]{}).build());
+    }
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/SpringBootReactApplication.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/SpringBootReactApplication.java
new file mode 100644
index 0000000..f25cd9c
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/SpringBootReactApplication.java
@@ -0,0 +1,15 @@
+package pl.hackerspace;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+
+@SpringBootApplication
+@EnableJpaRepositories
+public class SpringBootReactApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(SpringBootReactApplication.class, args);
+    }
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/controller/ClientsController.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/controller/ClientsController.java
new file mode 100644
index 0000000..8bf0650
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/controller/ClientsController.java
@@ -0,0 +1,54 @@
+package pl.hackerspace.controller;
+
+import pl.hackerspace.domain.Client;
+import pl.hackerspace.repository.ClientRepository;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+
+@RestController
+@RequestMapping("/clients")
+public class ClientsController {
+
+    private final ClientRepository clientRepository;
+
+    public ClientsController(ClientRepository clientRepository) {
+        this.clientRepository = clientRepository;
+    }
+
+    @GetMapping
+    public List<Client> getClients() {
+        return clientRepository.findAll();
+    }
+
+    @GetMapping("/{nip}")
+    public Client getClient(@PathVariable String nip) {
+        return clientRepository.findById(nip).orElseThrow(RuntimeException::new);
+    }
+
+    @PostMapping
+    public ResponseEntity<Client> createClient(@RequestBody Client client) throws URISyntaxException {
+        Client savedClient = clientRepository.save(client);
+        return ResponseEntity.created(new URI("/clients/" + savedClient.getNip())).body(savedClient);
+    }
+
+    @PutMapping("/{nip}")
+    public ResponseEntity<Client> updateClient(@PathVariable String nip, @RequestBody Client client) {
+        Client currentClient = clientRepository.findById(nip).orElseThrow(RuntimeException::new);
+        currentClient.setName(client.getName());
+        currentClient.setEmail(client.getEmail());
+        currentClient = clientRepository.save(client);
+
+        return ResponseEntity.ok(currentClient);
+    }
+
+    @DeleteMapping("/{nip}")
+    public ResponseEntity<Void> deleteClient(@PathVariable String nip) {
+        clientRepository.deleteById(nip);
+        return ResponseEntity.ok().build();
+    }
+
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/controller/InvoicesController.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/controller/InvoicesController.java
new file mode 100644
index 0000000..45844cb
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/controller/InvoicesController.java
@@ -0,0 +1,42 @@
+package pl.hackerspace.controller;
+
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import pl.hackerspace.domain.Client;
+import pl.hackerspace.domain.Invoice;
+import pl.hackerspace.dto.InvoiceGenerationDto;
+import pl.hackerspace.service.InvoiceService;
+
+import java.io.IOException;
+import java.util.List;
+
+@RestController
+@RequestMapping("/invoices")
+@RequiredArgsConstructor
+@CrossOrigin(value = {"*"}, exposedHeaders = {"Content-Disposition"})
+public class InvoicesController {
+
+    private final InvoiceService invoiceService;
+
+    @PostMapping
+    @ResponseBody
+    public ResponseEntity<Resource> generateSingleInvoice(@RequestBody InvoiceGenerationDto request)
+            throws IOException {
+        return invoiceService.generateNewInvoice(request);
+    }
+
+    @GetMapping("/all")
+    @ResponseBody
+    public void generateAllSubscriberInvoices(HttpServletResponse response, @RequestParam String monthOfInvoice)
+            throws IOException {
+        invoiceService.generateInvoicesForAllSubscribers(response, monthOfInvoice);
+    }
+
+    @GetMapping
+    public List<Invoice> getInvoices() {
+        return invoiceService.findAll();
+    }
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/domain/Client.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/domain/Client.java
new file mode 100644
index 0000000..2dab10f
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/domain/Client.java
@@ -0,0 +1,69 @@
+package pl.hackerspace.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.Set;
+
+@Entity
+@Table(name = "client")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class Client {
+
+    @Id
+    private String nip;
+
+    @Column(nullable = false)
+    private String name;
+
+    @Column(nullable = false)
+    private String email;
+
+    private String addressLine1;
+    private String addressLine2;
+
+    @Column(nullable = false)
+    private String serviceName;
+
+    @Column(nullable = false)
+    private BigDecimal price;
+
+    @Builder.Default
+    @Column(nullable = false)
+    private BigDecimal amount = BigDecimal.valueOf(1);
+
+    @Builder.Default
+    @Column(nullable = false)
+    private BigDecimal vat = BigDecimal.valueOf(23);
+
+    @Builder.Default
+    @Column(nullable = false)
+    private int paymentOffsetDays = 14;
+
+    @Builder.Default
+    private boolean subscriber = true;
+
+    private boolean prepaid;
+
+    @OneToMany(mappedBy="client")
+    Set<Invoice> invoices;
+
+    public byte[] getInvoiceForSubscriptionMonth(String monthOfInvoice) {
+        return invoices.stream()
+                .filter(i -> monthOfInvoice.equals(i.getMonthOfSubscription()))
+                .map(Invoice::getPdfContent)
+                .findFirst()
+                .orElse(null);
+    }
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/domain/Invoice.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/domain/Invoice.java
new file mode 100644
index 0000000..e32f5fe
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/domain/Invoice.java
@@ -0,0 +1,41 @@
+package pl.hackerspace.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "invoice")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class Invoice {
+
+    @Id
+    private long id;
+
+    @Column(nullable = false)
+    private String invoiceTitle;
+
+    private String monthOfSubscription;
+
+    @Column(nullable = false)
+    private LocalDateTime creationDate;
+
+    @Column(columnDefinition = "VARBINARY(50000)", nullable = false)
+    private byte[] pdfContent;
+
+    @ManyToOne
+    @JoinColumn(nullable = false)
+    private Client client;
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/dto/CustomInvoiceDataDto.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/dto/CustomInvoiceDataDto.java
new file mode 100644
index 0000000..06494e2
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/dto/CustomInvoiceDataDto.java
@@ -0,0 +1,23 @@
+package pl.hackerspace.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class CustomInvoiceDataDto {
+
+    private String customServiceName;
+
+    private BigDecimal customPrice;
+
+    private BigDecimal customAmount = BigDecimal.valueOf(1);
+
+    private BigDecimal customVat = BigDecimal.valueOf(23);
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/dto/InvoiceGenerationDto.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/dto/InvoiceGenerationDto.java
new file mode 100644
index 0000000..ce86085
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/dto/InvoiceGenerationDto.java
@@ -0,0 +1,22 @@
+package pl.hackerspace.dto;
+
+import jakarta.persistence.Column;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class InvoiceGenerationDto {
+
+    private String nip;
+
+    private String monthOfInvoice;
+
+    private CustomInvoiceDataDto customInvoiceData;
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/repository/ClientRepository.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/repository/ClientRepository.java
new file mode 100644
index 0000000..d3eb10f
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/repository/ClientRepository.java
@@ -0,0 +1,12 @@
+package pl.hackerspace.repository;
+
+import org.springframework.stereotype.Repository;
+import pl.hackerspace.domain.Client;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+@Repository
+public interface ClientRepository extends JpaRepository<Client, String> {
+    List<Client> findBySubscriberTrue();
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/repository/InvoiceRepository.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/repository/InvoiceRepository.java
new file mode 100644
index 0000000..f112c3b
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/repository/InvoiceRepository.java
@@ -0,0 +1,14 @@
+package pl.hackerspace.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+import pl.hackerspace.domain.Client;
+import pl.hackerspace.domain.Invoice;
+
+@Repository
+public interface InvoiceRepository extends JpaRepository<Invoice, String> {
+
+    @Query(value = "SELECT max(id) FROM Invoice")
+    long getMaxId();
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/service/InvoiceService.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/service/InvoiceService.java
new file mode 100644
index 0000000..2cdb88e
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/service/InvoiceService.java
@@ -0,0 +1,143 @@
+package pl.hackerspace.service;
+
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StreamUtils;
+import pl.hackerspace.domain.Client;
+import pl.hackerspace.domain.Invoice;
+import pl.hackerspace.dto.CustomInvoiceDataDto;
+import pl.hackerspace.dto.InvoiceGenerationDto;
+import pl.hackerspace.repository.ClientRepository;
+import pl.hackerspace.repository.InvoiceRepository;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static pl.hackerspace.service.TemplateService.withTemplate;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class InvoiceService {
+
+    private final ClientRepository clientRepository;
+
+    private final InvoiceRepository invoiceRepository;
+
+    @Transactional
+    public ResponseEntity<Resource> generateNewInvoice(InvoiceGenerationDto generationRequest) throws IOException {
+        Client client = clientRepository.findById(generationRequest.getNip()).orElseThrow(() -> new RuntimeException("Not found"));
+        return withTemplate(template -> {
+            byte[] invoice;
+            if (!client.isSubscriber() || generationRequest.getCustomInvoiceData() != null) {
+                invoice = createPdfInvoice(client, template, null, generationRequest.getCustomInvoiceData());
+            } else {
+                invoice = getOrCreateSubscriptionPdfInvoice(template, client, generationRequest.getMonthOfInvoice());
+            }
+            return ResponseEntity.ok()
+                    .headers(getHttpHeaders(client.getName() + " " + generationRequest.getMonthOfInvoice()))
+                    .contentLength(-1)
+                    .contentType(MediaType.APPLICATION_PDF)
+                    .body(new ByteArrayResource(invoice));
+        });
+    }
+
+
+    @Transactional
+    public void generateInvoicesForAllSubscribers(HttpServletResponse response, String monthOfInvoice) throws IOException {
+        setHeaders(response, "application/zip", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + ".zip");
+        List<Client> subscribers = clientRepository.findBySubscriberTrue();
+        withTemplate(template -> {
+            try (ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream())) {
+                for (Client client : subscribers) {
+                    try (InputStream inputStream = new ByteArrayInputStream(
+                            getOrCreateSubscriptionPdfInvoice(template, client, monthOfInvoice))) {
+                        zipOutputStream.putNextEntry(new ZipEntry(getPdfFilename(client.getName() + " "
+                                + monthOfInvoice)));
+                        StreamUtils.copy(inputStream, zipOutputStream);
+                        zipOutputStream.flush();
+                    }
+                }
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+            return null;
+        });
+    }
+
+    private byte[] getOrCreateSubscriptionPdfInvoice(String template, Client client, final String monthOfInvoice) {
+        byte[] invoice = client.getInvoiceForSubscriptionMonth(monthOfInvoice);
+        if (invoice == null) {
+            invoice = createPdfInvoice(client, template, monthOfInvoice, null);
+        }
+        return invoice;
+    }
+
+    private static void setHeaders(HttpServletResponse response, String contentType, String filename) {
+        response.setContentType(contentType);
+        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
+                .filename(filename, StandardCharsets.UTF_8)
+                .build()
+                .toString());
+    }
+
+    private byte[] createPdfInvoice(Client client, String template, String monthOfSubscription,
+                                    CustomInvoiceDataDto customInvoiceData) {
+        LocalDateTime creationDate = LocalDateTime.now();
+        Invoice newInvoice = createNewInvoice(client, creationDate, monthOfSubscription);
+        byte[] bytes = TemplateService.convertHtmlToPdf(template, client, creationDate,
+                newInvoice.getInvoiceTitle(), monthOfSubscription, customInvoiceData);
+        newInvoice.setPdfContent(bytes);
+        save(newInvoice);
+        return bytes;
+    }
+
+    private static HttpHeaders getHttpHeaders(final String filename) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION);
+        headers.add(HttpHeaders.CONTENT_TYPE, "application/octet-stream");
+        headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + getPdfFilename(filename));
+        return headers;
+    }
+
+    private static String getPdfFilename(String filename) {
+        return filename + ".pdf";
+    }
+
+    public Invoice createNewInvoice(Client client, final LocalDateTime creationDate, String monthOfSubscription) {
+        long nextInvoiceId = invoiceRepository.getMaxId() + 1;
+        return Invoice.builder().id(nextInvoiceId)
+                .invoiceTitle("FV" + nextInvoiceId)
+                .creationDate(creationDate)
+                .monthOfSubscription(monthOfSubscription)
+                .client(client)
+                .build();
+    }
+
+    public void save(Invoice newInvoice) {
+        invoiceRepository.save(newInvoice);
+    }
+
+    public List<Invoice> findAll() {
+        return invoiceRepository.findAll();
+    }
+}
diff --git a/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/service/TemplateService.java b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/service/TemplateService.java
new file mode 100644
index 0000000..5f8ee31
--- /dev/null
+++ b/personal/arsenicum/invoicer/src/main/java/pl/hackerspace/service/TemplateService.java
@@ -0,0 +1,130 @@
+package pl.hackerspace.service;
+
+import com.lowagie.text.pdf.BaseFont;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.springframework.stereotype.Service;
+import org.xhtmlrenderer.layout.SharedContext;
+import org.xhtmlrenderer.pdf.ITextRenderer;
+import pl.hackerspace.domain.Client;
+import pl.hackerspace.dto.CustomInvoiceDataDto;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Slf4j
+@Service
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class TemplateService {
+
+    public static final DecimalFormat MONEY_FORMAT = twoDecimalsFormatter();
+    public static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM");
+    public static final String HTML_TEMPLATE_FILE = "invoiceTemplates/invoice_one_service.html";
+
+    public static <A> A withTemplate(Function<String, A> execute) throws IOException {
+        try (InputStream resourceAsStream = TemplateService.class.getClassLoader().getResourceAsStream(HTML_TEMPLATE_FILE)) {
+            String template = new String(resourceAsStream.readAllBytes());
+            return execute.apply(template);
+        }
+    }
+
+    static String populateTemplate(String unprocessedTemplate, Client client, final String subscriptionMonth,
+                                   final LocalDateTime invoiceCreationDate, final String invoiceTitle,
+                                   CustomInvoiceDataDto customInvoiceData) {
+        boolean isCustom = customInvoiceData != null;
+        BigDecimal price = isCustom ? customInvoiceData.getCustomPrice() : client.getPrice();
+        BigDecimal amount = isCustom ? customInvoiceData.getCustomAmount() : client.getAmount();
+        BigDecimal vat = isCustom ? customInvoiceData.getCustomVat() : client.getVat();
+        String serviceName = isCustom ? customInvoiceData.getCustomServiceName() : client.getServiceName();
+        BigDecimal totalNet = amount.multiply(price);
+        String processedHtml = unprocessedTemplate
+                .replace("%client_name%", client.getName())
+                .replace("%invoice_title%", invoiceTitle)
+                .replace("%client_price%", formatAsMoney(price))
+                .replace("%client_addressLine1%", client.getAddressLine1())
+                .replace("%client_addressLine2%", client.getAddressLine2())
+                .replace("%client_service_name%", serviceName)
+                .replace("%client_nip%", Optional.ofNullable(client.getNip()).map(nip -> "NIP: " + nip).orElse(""))
+                .replace("%client_total_net%", formatAsMoney(totalNet))
+                .replace("%client_total_gross%", formatAsMoney(totalNet.add(percentageValue(totalNet, vat))))
+                .replace("%client_amount%", formatAsMoney(amount))
+                .replace("%client_vat%", formatAsMoney(vat))
+                .replace("%client_total_tax%", formatAsMoney(percentageValue(totalNet, vat)))
+                .replace("%invoice_month_string%", subscriptionMonth)
+                .replace("%client_payment_date%", formatAsDate(invoiceCreationDate.plusDays(client.getPaymentOffsetDays())))
+                .replace("%invoice_date%", formatAsDate(invoiceCreationDate));
+        validateAllPlaceholdersSubstituted(processedHtml);
+        return processedHtml;
+    }
+
+    private static String formatAsMoney(BigDecimal totalNet) {
+        return MONEY_FORMAT.format(totalNet);
+    }
+
+    private static String formatAsDate(LocalDateTime invoiceCreationDate) {
+        return invoiceCreationDate.format(DateTimeFormatter.ISO_LOCAL_DATE);
+    }
+
+    private static void validateAllPlaceholdersSubstituted(String processed) {
+        Set<String> allMatches = new HashSet<>();
+        Matcher m = Pattern.compile("%(\\w*?)%").matcher(processed);
+        while (m.find()) {
+            allMatches.add(m.group());
+        }
+        if (!allMatches.isEmpty()) {
+            throw new IllegalStateException("There are unsubstituted placeholders in the template: " + allMatches);
+        }
+    }
+
+    private static DecimalFormat twoDecimalsFormatter() {
+        DecimalFormat df = new DecimalFormat();
+        df.setMaximumFractionDigits(2);
+        df.setMinimumFractionDigits(2);
+        df.setGroupingUsed(false);
+        return df;
+    }
+
+    public static BigDecimal percentageValue(BigDecimal base, BigDecimal pct) {
+        return base.multiply(pct).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP);
+    }
+
+    static byte[] convertHtmlToPdf(String unprocessedTemplate, Client client, final LocalDateTime creationDate,
+                                   final String invoiceTitle, final String monthOfInvoice, CustomInvoiceDataDto customInvoiceData) {
+        String processedHtml = populateTemplate(unprocessedTemplate, client, monthOfInvoice, creationDate, invoiceTitle,
+                customInvoiceData);
+        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+            ITextRenderer renderer = new ITextRenderer();
+            SharedContext sharedContext = renderer.getSharedContext();
+            sharedContext.setPrint(true);
+            sharedContext.setInteractive(false);
+            renderer.getFontResolver().addFont("fonts/calibri.ttf", BaseFont.IDENTITY_H, true);
+            renderer.setDocumentFromString(htmlToXhtml(processedHtml));
+            renderer.layout();
+            renderer.createPDF(outputStream);
+            return outputStream.toByteArray();
+        } catch (IOException e) {
+            throw new RuntimeException("Exception during pdf conversion", e);
+        }
+    }
+
+    private static String htmlToXhtml(String html) {
+        Document document = Jsoup.parse(html);
+        document.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
+        return document.html();
+    }
+}