invoicer initial version

Change-Id: Ib20a96c224f5c055874f72f8f9a04a4dc8bbbc24
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();
+    }
+}