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();
+ }
+}