feat(back-end - front end): improvements in email and invoice rendering
This commit is contained in:
@@ -140,72 +140,30 @@ public class OrderController {
|
|||||||
return getOrder(orderId);
|
return getOrder(orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}/confirmation")
|
||||||
|
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
|
||||||
|
return generateDocument(orderId, true);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{orderId}/invoice")
|
@GetMapping("/{orderId}/invoice")
|
||||||
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
||||||
|
// Paid invoices are sent by email after back-office payment confirmation.
|
||||||
|
// The public endpoint must not expose a "paid" invoice download.
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<byte[]> generateDocument(UUID orderId, boolean isConfirmation) {
|
||||||
Order order = orderRepo.findById(orderId)
|
Order order = orderRepo.findById(orderId)
|
||||||
.orElseThrow(() -> new RuntimeException("Order not found"));
|
.orElseThrow(() -> new RuntimeException("Order not found"));
|
||||||
|
|
||||||
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
|
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
|
||||||
|
Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null);
|
||||||
|
|
||||||
Map<String, Object> vars = new HashMap<>();
|
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
|
||||||
vars.put("sellerDisplayName", "3D Fab Switzerland");
|
String typePrefix = isConfirmation ? "confirmation-" : "invoice-";
|
||||||
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
|
String truncatedUuid = order.getId().toString().substring(0, 8);
|
||||||
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
|
||||||
vars.put("sellerEmail", "info@3dfab.ch");
|
|
||||||
|
|
||||||
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
|
|
||||||
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
|
||||||
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
|
||||||
|
|
||||||
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
|
|
||||||
? order.getBillingCompanyName()
|
|
||||||
: order.getBillingFirstName() + " " + order.getBillingLastName();
|
|
||||||
vars.put("buyerDisplayName", buyerName);
|
|
||||||
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
|
|
||||||
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
|
|
||||||
|
|
||||||
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
|
||||||
Map<String, Object> line = new HashMap<>();
|
|
||||||
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
|
||||||
line.put("quantity", i.getQuantity());
|
|
||||||
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
|
|
||||||
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
|
|
||||||
return line;
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
|
|
||||||
Map<String, Object> setupLine = new HashMap<>();
|
|
||||||
setupLine.put("description", "Costo Setup");
|
|
||||||
setupLine.put("quantity", 1);
|
|
||||||
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
|
||||||
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
|
||||||
invoiceLineItems.add(setupLine);
|
|
||||||
|
|
||||||
Map<String, Object> shippingLine = new HashMap<>();
|
|
||||||
shippingLine.put("description", "Spedizione");
|
|
||||||
shippingLine.put("quantity", 1);
|
|
||||||
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
|
||||||
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
|
||||||
invoiceLineItems.add(shippingLine);
|
|
||||||
|
|
||||||
vars.put("invoiceLineItems", invoiceLineItems);
|
|
||||||
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
|
|
||||||
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
|
|
||||||
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
|
|
||||||
|
|
||||||
String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
|
|
||||||
if (qrBillSvg.contains("<?xml")) {
|
|
||||||
int svgStartIndex = qrBillSvg.indexOf("<svg");
|
|
||||||
if (svgStartIndex != -1) {
|
|
||||||
qrBillSvg = qrBillSvg.substring(svgStartIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"")
|
.header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"")
|
||||||
.contentType(MediaType.APPLICATION_PDF)
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
.body(pdf);
|
.body(pdf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.printcalculator.event;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.entity.Payment;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
public class PaymentConfirmedEvent extends ApplicationEvent {
|
||||||
|
private final Order order;
|
||||||
|
private final Payment payment;
|
||||||
|
|
||||||
|
public PaymentConfirmedEvent(Object source, Order order, Payment payment) {
|
||||||
|
super(source);
|
||||||
|
this.order = order;
|
||||||
|
this.payment = payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order getOrder() {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Payment getPayment() {
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.printcalculator.event.listener;
|
package com.printcalculator.event.listener;
|
||||||
|
|
||||||
import com.printcalculator.entity.Order;
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.entity.OrderItem;
|
||||||
import com.printcalculator.entity.Payment;
|
import com.printcalculator.entity.Payment;
|
||||||
import com.printcalculator.event.OrderCreatedEvent;
|
import com.printcalculator.event.OrderCreatedEvent;
|
||||||
import com.printcalculator.event.PaymentReportedEvent;
|
import com.printcalculator.event.PaymentReportedEvent;
|
||||||
|
import com.printcalculator.event.PaymentConfirmedEvent;
|
||||||
import com.printcalculator.service.email.EmailNotificationService;
|
import com.printcalculator.service.email.EmailNotificationService;
|
||||||
|
import com.printcalculator.service.InvoicePdfRenderingService;
|
||||||
|
import com.printcalculator.service.QrBillService;
|
||||||
|
import com.printcalculator.repository.OrderItemRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -22,6 +27,9 @@ import java.util.Map;
|
|||||||
public class OrderEmailListener {
|
public class OrderEmailListener {
|
||||||
|
|
||||||
private final EmailNotificationService emailNotificationService;
|
private final EmailNotificationService emailNotificationService;
|
||||||
|
private final InvoicePdfRenderingService invoicePdfRenderingService;
|
||||||
|
private final OrderItemRepository orderItemRepository;
|
||||||
|
private final QrBillService qrBillService;
|
||||||
|
|
||||||
@Value("${app.mail.admin.enabled:true}")
|
@Value("${app.mail.admin.enabled:true}")
|
||||||
private boolean adminMailEnabled;
|
private boolean adminMailEnabled;
|
||||||
@@ -62,6 +70,20 @@ public class OrderEmailListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) {
|
||||||
|
Order order = event.getOrder();
|
||||||
|
Payment payment = event.getPayment();
|
||||||
|
log.info("Processing PaymentConfirmedEvent for order id: {}", order.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendPaidInvoiceEmail(order, payment);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to send paid invoice email for order id: {}", order.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void sendCustomerConfirmationEmail(Order order) {
|
private void sendCustomerConfirmationEmail(Order order) {
|
||||||
Map<String, Object> templateData = new HashMap<>();
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
templateData.put("customerName", order.getCustomer().getFirstName());
|
templateData.put("customerName", order.getCustomer().getFirstName());
|
||||||
@@ -94,6 +116,34 @@ public class OrderEmailListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendPaidInvoiceEmail(Order order, Payment payment) {
|
||||||
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
|
templateData.put("customerName", order.getCustomer().getFirstName());
|
||||||
|
templateData.put("orderId", order.getId());
|
||||||
|
templateData.put("orderNumber", getDisplayOrderNumber(order));
|
||||||
|
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
|
||||||
|
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
|
||||||
|
|
||||||
|
byte[] pdf = null;
|
||||||
|
try {
|
||||||
|
java.util.List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
|
||||||
|
pdf = invoicePdfRenderingService.generateDocumentPdf(order, items, false, qrBillService, payment);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to generate PDF for paid invoice email: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
String filename = "Fattura-" + getDisplayOrderNumber(order) + ".pdf";
|
||||||
|
|
||||||
|
emailNotificationService.sendEmailWithAttachment(
|
||||||
|
order.getCustomer().getEmail(),
|
||||||
|
"Fattura Pagata (Ordine #" + getDisplayOrderNumber(order) + ") - 3D-Fab",
|
||||||
|
"payment-confirmed",
|
||||||
|
templateData,
|
||||||
|
filename,
|
||||||
|
pdf
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private void sendAdminNotificationEmail(Order order) {
|
private void sendAdminNotificationEmail(Order order) {
|
||||||
Map<String, Object> templateData = new HashMap<>();
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
|
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import org.thymeleaf.TemplateEngine;
|
|||||||
import org.thymeleaf.context.Context;
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.util.stream.Collectors;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.entity.OrderItem;
|
||||||
|
import com.printcalculator.entity.Payment;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class InvoicePdfRenderingService {
|
public class InvoicePdfRenderingService {
|
||||||
|
|
||||||
@@ -45,4 +52,92 @@ public class InvoicePdfRenderingService {
|
|||||||
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
|
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public byte[] generateDocumentPdf(Order order, List<OrderItem> items, boolean isConfirmation, QrBillService qrBillService, Payment payment) {
|
||||||
|
Map<String, Object> vars = new HashMap<>();
|
||||||
|
vars.put("isConfirmation", isConfirmation);
|
||||||
|
vars.put("sellerDisplayName", "3D Fab Küng Caletti");
|
||||||
|
vars.put("sellerAddressLine1", "Joe Küng e Matteo Caletti");
|
||||||
|
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||||
|
vars.put("sellerEmail", "info@3dfab.ch");
|
||||||
|
|
||||||
|
String displayOrderNumber = order.getOrderNumber() != null && !order.getOrderNumber().isBlank()
|
||||||
|
? order.getOrderNumber()
|
||||||
|
: order.getId().toString();
|
||||||
|
|
||||||
|
vars.put("invoiceNumber", "INV-" + displayOrderNumber.toUpperCase());
|
||||||
|
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
|
||||||
|
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
|
||||||
|
? order.getBillingCompanyName()
|
||||||
|
: order.getBillingFirstName() + " " + order.getBillingLastName();
|
||||||
|
vars.put("buyerDisplayName", buyerName);
|
||||||
|
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
|
||||||
|
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
|
||||||
|
|
||||||
|
// Setup Shipping Info
|
||||||
|
if (order.getShippingAddressLine1() != null && !order.getShippingAddressLine1().isBlank()) {
|
||||||
|
String shippingName = order.getShippingCompanyName() != null && !order.getShippingCompanyName().isBlank()
|
||||||
|
? order.getShippingCompanyName()
|
||||||
|
: order.getShippingFirstName() + " " + order.getShippingLastName();
|
||||||
|
vars.put("shippingDisplayName", shippingName);
|
||||||
|
vars.put("shippingAddressLine1", order.getShippingAddressLine1());
|
||||||
|
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
||||||
|
Map<String, Object> line = new HashMap<>();
|
||||||
|
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
||||||
|
line.put("quantity", i.getQuantity());
|
||||||
|
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
|
||||||
|
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
|
||||||
|
return line;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<String, Object> setupLine = new HashMap<>();
|
||||||
|
setupLine.put("description", "Costo Setup");
|
||||||
|
setupLine.put("quantity", 1);
|
||||||
|
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||||
|
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
|
||||||
|
invoiceLineItems.add(setupLine);
|
||||||
|
|
||||||
|
Map<String, Object> shippingLine = new HashMap<>();
|
||||||
|
shippingLine.put("description", "Spedizione");
|
||||||
|
shippingLine.put("quantity", 1);
|
||||||
|
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||||
|
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
|
||||||
|
invoiceLineItems.add(shippingLine);
|
||||||
|
|
||||||
|
vars.put("invoiceLineItems", invoiceLineItems);
|
||||||
|
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
|
||||||
|
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
|
||||||
|
vars.put("paymentTermsText", isConfirmation ? "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie." : "Pagato. Grazie per l'acquisto.");
|
||||||
|
|
||||||
|
String paymentMethodText = "QR / Bonifico oppure TWINT";
|
||||||
|
if (payment != null && payment.getMethod() != null) {
|
||||||
|
paymentMethodText = switch (payment.getMethod().toUpperCase()) {
|
||||||
|
case "TWINT" -> "TWINT";
|
||||||
|
case "BANK_TRANSFER", "BONIFICO" -> "Bonifico Bancario";
|
||||||
|
case "QR_BILL", "QR" -> "QR Bill";
|
||||||
|
case "CASH" -> "Contanti";
|
||||||
|
default -> payment.getMethod();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
vars.put("paymentMethodText", paymentMethodText);
|
||||||
|
|
||||||
|
String qrBillSvg = null;
|
||||||
|
if (isConfirmation) {
|
||||||
|
qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
if (qrBillSvg.contains("<?xml")) {
|
||||||
|
int svgStartIndex = qrBillSvg.indexOf("<svg");
|
||||||
|
if (svgStartIndex != -1) {
|
||||||
|
qrBillSvg = qrBillSvg.substring(svgStartIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package com.printcalculator.service;
|
|||||||
import com.printcalculator.dto.AddressDto;
|
import com.printcalculator.dto.AddressDto;
|
||||||
import com.printcalculator.dto.CreateOrderRequest;
|
import com.printcalculator.dto.CreateOrderRequest;
|
||||||
import com.printcalculator.entity.*;
|
import com.printcalculator.entity.*;
|
||||||
|
import com.printcalculator.entity.Payment;
|
||||||
import com.printcalculator.repository.CustomerRepository;
|
import com.printcalculator.repository.CustomerRepository;
|
||||||
import com.printcalculator.repository.OrderItemRepository;
|
import com.printcalculator.repository.OrderItemRepository;
|
||||||
import com.printcalculator.repository.OrderRepository;
|
import com.printcalculator.repository.OrderRepository;
|
||||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
import com.printcalculator.repository.PricingPolicyRepository;
|
import com.printcalculator.repository.PricingPolicyRepository;
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
|
||||||
import com.printcalculator.event.OrderCreatedEvent;
|
import com.printcalculator.event.OrderCreatedEvent;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -332,6 +332,7 @@ public class OrderService {
|
|||||||
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
|
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
|
||||||
|
|
||||||
// 3. Generate PDF
|
// 3. Generate PDF
|
||||||
|
Payment payment = null; // New order, payment not received yet
|
||||||
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||||
|
|
||||||
// Save PDF
|
// Save PDF
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.printcalculator.service;
|
|||||||
import com.printcalculator.entity.Order;
|
import com.printcalculator.entity.Order;
|
||||||
import com.printcalculator.entity.Payment;
|
import com.printcalculator.entity.Payment;
|
||||||
import com.printcalculator.event.PaymentReportedEvent;
|
import com.printcalculator.event.PaymentReportedEvent;
|
||||||
|
import com.printcalculator.event.PaymentConfirmedEvent;
|
||||||
import com.printcalculator.repository.OrderRepository;
|
import com.printcalculator.repository.OrderRepository;
|
||||||
import com.printcalculator.repository.PaymentRepository;
|
import com.printcalculator.repository.PaymentRepository;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
@@ -73,4 +74,28 @@ public class PaymentService {
|
|||||||
|
|
||||||
return payment;
|
return payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Payment confirmPayment(UUID orderId, String method) {
|
||||||
|
Order order = orderRepo.findById(orderId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
|
||||||
|
|
||||||
|
Payment payment = paymentRepo.findByOrder_Id(orderId)
|
||||||
|
.orElseGet(() -> getOrCreatePaymentForOrder(order, method != null ? method : "OTHER"));
|
||||||
|
|
||||||
|
payment.setStatus("COMPLETED");
|
||||||
|
if (method != null && !method.isBlank()) {
|
||||||
|
payment.setMethod(method.toUpperCase());
|
||||||
|
}
|
||||||
|
payment.setReceivedAt(OffsetDateTime.now());
|
||||||
|
payment = paymentRepo.save(payment);
|
||||||
|
|
||||||
|
order.setStatus("IN_PRODUCTION");
|
||||||
|
order.setPaidAt(OffsetDateTime.now());
|
||||||
|
orderRepo.save(order);
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(new PaymentConfirmedEvent(this, order, payment));
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class QrBillService {
|
|||||||
// Creditor (Merchant)
|
// Creditor (Merchant)
|
||||||
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
|
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
|
||||||
bill.setCreditor(createAddress(
|
bill.setCreditor(createAddress(
|
||||||
"Küng, Joe",
|
"Joe Küng",
|
||||||
"Via G. Pioda 29a",
|
"Via G. Pioda 29a",
|
||||||
"6710",
|
"6710",
|
||||||
"Biasca",
|
"Biasca",
|
||||||
@@ -49,10 +49,7 @@ public class QrBillService {
|
|||||||
bill.setAmount(order.getTotalChf());
|
bill.setAmount(order.getTotalChf());
|
||||||
bill.setCurrency("CHF");
|
bill.setCurrency("CHF");
|
||||||
|
|
||||||
// Reference
|
bill.setUnstructuredMessage(order.getId().toString());
|
||||||
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
|
|
||||||
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
|
|
||||||
bill.setUnstructuredMessage("Order " + orderRef);
|
|
||||||
|
|
||||||
return bill;
|
return bill;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,16 @@ public interface EmailNotificationService {
|
|||||||
*/
|
*/
|
||||||
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
|
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an HTML email using a Thymeleaf template, with an optional attachment.
|
||||||
|
*
|
||||||
|
* @param to The recipient email address.
|
||||||
|
* @param subject The subject of the email.
|
||||||
|
* @param templateName The name of the Thymeleaf template (e.g., "order-confirmation").
|
||||||
|
* @param contextData The data to populate the template with.
|
||||||
|
* @param attachmentName The name for the attachment file.
|
||||||
|
* @param attachmentData The raw bytes of the attachment.
|
||||||
|
*/
|
||||||
|
void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.mail.javamail.MimeMessageHelper;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.thymeleaf.TemplateEngine;
|
import org.thymeleaf.TemplateEngine;
|
||||||
import org.thymeleaf.context.Context;
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
|
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
|
||||||
|
sendEmailWithAttachment(to, subject, templateName, contextData, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData) {
|
||||||
if (!mailEnabled) {
|
if (!mailEnabled) {
|
||||||
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
|
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
|
||||||
return;
|
return;
|
||||||
@@ -49,6 +55,10 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
|
|||||||
helper.setSubject(subject);
|
helper.setSubject(subject);
|
||||||
helper.setText(process, true); // true indicates HTML format
|
helper.setText(process, true); // true indicates HTML format
|
||||||
|
|
||||||
|
if (attachmentName != null && attachmentData != null) {
|
||||||
|
helper.addAttachment(attachmentName, new ByteArrayResource(attachmentData));
|
||||||
|
}
|
||||||
|
|
||||||
emailSender.send(mimeMessage);
|
emailSender.send(mimeMessage);
|
||||||
log.info("Email successfully sent to {}", to);
|
log.info("Email successfully sent to {}", to);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
.invoice-page {
|
.invoice-page {
|
||||||
page: invoice;
|
page: invoice;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
page-break-after: always;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Header Layout */
|
/* Top Header Layout */
|
||||||
@@ -48,17 +49,17 @@
|
|||||||
|
|
||||||
.logo-block {
|
.logo-block {
|
||||||
width: 33%;
|
width: 33%;
|
||||||
font-size: 20pt;
|
font-size: 24pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #005eb8; /* Brand blue similar to reference */
|
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-d {
|
.logo-3d {
|
||||||
font-style: italic;
|
color: #111827; /* Dark black/blue */
|
||||||
color: #d22630;
|
}
|
||||||
font-size: 22pt;
|
|
||||||
margin-right: 2px;
|
.logo-fab {
|
||||||
|
color: #eab308; /* Yellow/Gold */
|
||||||
}
|
}
|
||||||
|
|
||||||
.seller-block {
|
.seller-block {
|
||||||
@@ -231,17 +232,26 @@
|
|||||||
/* QR Page */
|
/* QR Page */
|
||||||
.qr-only-page {
|
.qr-only-page {
|
||||||
page: qrpage;
|
page: qrpage;
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 297mm;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
page-break-before: always;
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-only-layout {
|
||||||
|
width: 100%;
|
||||||
|
height: 297mm;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-only-layout td {
|
||||||
|
vertical-align: bottom;
|
||||||
|
/* Keep the QR slip at page bottom, but with a safer print margin. */
|
||||||
|
padding: 0 0 1mm 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-bill-bottom {
|
.qr-bill-bottom {
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 105mm;
|
height: 105mm;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -262,7 +272,8 @@
|
|||||||
<table class="header-layout">
|
<table class="header-layout">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="logo-block">
|
<td class="logo-block">
|
||||||
3D-fab.ch
|
<span class="logo-3d">3D</span> <span class="logo-fab">fab</span>
|
||||||
|
<div style="font-size: 14pt; font-weight: normal; margin-top: 4px; color: #111827;">Küng Caletti</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="seller-block">
|
<td class="seller-block">
|
||||||
<div th:text="${sellerDisplayName}">3D Fab Switzerland</div>
|
<div th:text="${sellerDisplayName}">3D Fab Switzerland</div>
|
||||||
@@ -277,7 +288,10 @@
|
|||||||
|
|
||||||
<!-- Document Title -->
|
<!-- Document Title -->
|
||||||
<div class="doc-title">
|
<div class="doc-title">
|
||||||
Conferma dell'ordine <span th:text="${invoiceNumber}">141052743</span>
|
<span th:if="${isConfirmation}">Conferma dell'ordine</span>
|
||||||
|
<span th:unless="${isConfirmation}">Fattura</span>
|
||||||
|
<span th:text="${invoiceNumber}">141052743</span>
|
||||||
|
<span th:unless="${isConfirmation}" style="color: #2e7d32; font-weight: bold; font-size: 18pt; padding-left: 15px;">PAGATO</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details block (Meta and Customer) -->
|
<!-- Details block (Meta and Customer) -->
|
||||||
@@ -299,7 +313,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="meta-label">Metodo di pagamento</td>
|
<td class="meta-label">Metodo di pagamento</td>
|
||||||
<td class="meta-value">QR / Bonifico oppure TWINT</td>
|
<td class="meta-value" th:text="${paymentMethodText}">QR / Bonifico oppure TWINT</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="meta-label">Valuta</td>
|
<td class="meta-label">Valuta</td>
|
||||||
@@ -308,10 +322,19 @@
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
<td class="customer-container">
|
<td class="customer-container">
|
||||||
|
<div style="font-weight: bold; margin-bottom: 2mm;">Indirizzo di fatturazione:</div>
|
||||||
<div th:text="${buyerDisplayName}">Joe Küng</div>
|
<div th:text="${buyerDisplayName}">Joe Küng</div>
|
||||||
<div th:text="${buyerAddressLine1}">Via G.Pioda, 29a</div>
|
<div th:text="${buyerAddressLine1}">Via G.Pioda, 29a</div>
|
||||||
<div th:text="${buyerAddressLine2}">6710 biasca</div>
|
<div th:text="${buyerAddressLine2}">6710 biasca</div>
|
||||||
<div>Svizzera</div>
|
<div>Svizzera</div>
|
||||||
|
<br/>
|
||||||
|
<div th:if="${shippingDisplayName != null}">
|
||||||
|
<div style="font-weight: bold; margin-bottom: 2mm;">Indirizzo di spedizione:</div>
|
||||||
|
<div th:text="${shippingDisplayName}">Joe Küng</div>
|
||||||
|
<div th:text="${shippingAddressLine1}">Via G.Pioda, 29a</div>
|
||||||
|
<div th:text="${shippingAddressLine2}">6710 biasca</div>
|
||||||
|
<div>Svizzera</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -346,10 +369,14 @@
|
|||||||
<td class="totals-label">Totale di tutte le consegne e di tutti i servizi CHF</td>
|
<td class="totals-label">Totale di tutte le consegne e di tutti i servizi CHF</td>
|
||||||
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
|
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="no-border">
|
<tr class="no-border" th:if="${isConfirmation}">
|
||||||
<td class="totals-label">Importo dovuto</td>
|
<td class="totals-label">Importo dovuto</td>
|
||||||
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
|
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr class="no-border" th:unless="${isConfirmation}">
|
||||||
|
<td class="totals-label">Importo dovuto</td>
|
||||||
|
<td class="totals-value">CHF 0.00</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Footer Notes -->
|
<!-- Footer Notes -->
|
||||||
@@ -372,8 +399,14 @@
|
|||||||
|
|
||||||
<!-- QR Bill Page (only renders if QR data is passed) -->
|
<!-- QR Bill Page (only renders if QR data is passed) -->
|
||||||
<div class="qr-only-page" th:if="${qrBillSvg != null}">
|
<div class="qr-only-page" th:if="${qrBillSvg != null}">
|
||||||
|
<table class="qr-only-layout">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
|
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -170,6 +170,16 @@ export class QuoteEstimatorService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrderConfirmation(orderId: string): Observable<Blob> {
|
||||||
|
const headers: any = {};
|
||||||
|
// @ts-ignore
|
||||||
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
|
||||||
|
headers,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getTwintPayment(orderId: string): Observable<any> {
|
getTwintPayment(orderId: string): Observable<any> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -87,18 +87,15 @@ align-items: center;" (click)="openTwintPayment()">
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
|
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'bill'">
|
||||||
<div class="details-header">
|
<div class="details-header">
|
||||||
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="bank-details">
|
<div class="bank-details">
|
||||||
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> Küng, Joe</p>
|
|
||||||
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH74 0900 0000 1548 2158 1</p>
|
|
||||||
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</p>
|
|
||||||
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
|
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
|
||||||
|
<br>
|
||||||
<div class="qr-bill-actions">
|
<div class="qr-bill-actions">
|
||||||
<app-button (click)="downloadInvoice()">
|
<app-button (click)="downloadQrInvoice()">
|
||||||
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +135,7 @@ align-items: center;" (click)="openTwintPayment()">
|
|||||||
<span>{{ o.totalChf | currency:'CHF' }}</span>
|
<span>{{ o.totalChf | currency:'CHF' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,21 +58,21 @@ export class OrderComponent implements OnInit {
|
|||||||
this.selectedPaymentMethod = method;
|
this.selectedPaymentMethod = method;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadInvoice() {
|
downloadQrInvoice() {
|
||||||
const orderId = this.orderId;
|
const orderId = this.orderId;
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
this.quoteService.getOrderInvoice(orderId).subscribe({
|
this.quoteService.getOrderConfirmation(orderId).subscribe({
|
||||||
next: (blob) => {
|
next: (blob) => {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
const fallbackOrderNumber = this.extractOrderNumber(orderId);
|
const fallbackOrderNumber = this.extractOrderNumber(orderId);
|
||||||
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
|
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
|
||||||
a.download = `invoice-${orderNumber}.pdf`;
|
a.download = `qr-invoice-${orderNumber}.pdf`;
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
error: (err) => console.error('Failed to download invoice', err)
|
error: (err) => console.error('Failed to download QR invoice', err)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@
|
|||||||
"BANK_REF": "Reference",
|
"BANK_REF": "Reference",
|
||||||
"BILLING_INFO_HINT": "Add the same information used in billing.",
|
"BILLING_INFO_HINT": "Add the same information used in billing.",
|
||||||
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
||||||
|
"DOWNLOAD_CONFIRMATION": "Download Confirmation (PDF)",
|
||||||
"CONFIRM": "I have completed the payment",
|
"CONFIRM": "I have completed the payment",
|
||||||
"SUMMARY_TITLE": "Order Summary",
|
"SUMMARY_TITLE": "Order Summary",
|
||||||
"SUBTOTAL": "Subtotal",
|
"SUBTOTAL": "Subtotal",
|
||||||
|
|||||||
@@ -271,6 +271,7 @@
|
|||||||
"BANK_REF": "Riferimento",
|
"BANK_REF": "Riferimento",
|
||||||
"BILLING_INFO_HINT": "Abbiamo compilato i campi per te, per favore non modificare il motivo del pagamento",
|
"BILLING_INFO_HINT": "Abbiamo compilato i campi per te, per favore non modificare il motivo del pagamento",
|
||||||
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
||||||
|
"DOWNLOAD_CONFIRMATION": "Scarica Conferma (PDF)",
|
||||||
"CONFIRM": "Ho completato il pagamento",
|
"CONFIRM": "Ho completato il pagamento",
|
||||||
"SUMMARY_TITLE": "Riepilogo Ordine",
|
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||||
"SUBTOTAL": "Subtotale",
|
"SUBTOTAL": "Subtotale",
|
||||||
|
|||||||
Reference in New Issue
Block a user