feat(back-end front-end): new UX for
This commit is contained in:
@@ -5,6 +5,7 @@ import com.printcalculator.entity.*;
|
||||
import com.printcalculator.repository.*;
|
||||
import com.printcalculator.service.InvoicePdfRenderingService;
|
||||
import com.printcalculator.service.OrderService;
|
||||
import com.printcalculator.service.PaymentService;
|
||||
import com.printcalculator.service.QrBillService;
|
||||
import com.printcalculator.service.StorageService;
|
||||
import com.printcalculator.service.TwintPaymentService;
|
||||
@@ -43,6 +44,8 @@ public class OrderController {
|
||||
private final InvoicePdfRenderingService invoiceService;
|
||||
private final QrBillService qrBillService;
|
||||
private final TwintPaymentService twintPaymentService;
|
||||
private final PaymentService paymentService;
|
||||
private final PaymentRepository paymentRepo;
|
||||
|
||||
|
||||
public OrderController(OrderService orderService,
|
||||
@@ -54,7 +57,9 @@ public class OrderController {
|
||||
StorageService storageService,
|
||||
InvoicePdfRenderingService invoiceService,
|
||||
QrBillService qrBillService,
|
||||
TwintPaymentService twintPaymentService) {
|
||||
TwintPaymentService twintPaymentService,
|
||||
PaymentService paymentService,
|
||||
PaymentRepository paymentRepo) {
|
||||
this.orderService = orderService;
|
||||
this.orderRepo = orderRepo;
|
||||
this.orderItemRepo = orderItemRepo;
|
||||
@@ -65,6 +70,8 @@ public class OrderController {
|
||||
this.invoiceService = invoiceService;
|
||||
this.qrBillService = qrBillService;
|
||||
this.twintPaymentService = twintPaymentService;
|
||||
this.paymentService = paymentService;
|
||||
this.paymentRepo = paymentRepo;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +129,17 @@ public class OrderController {
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/{orderId}/payments/report")
|
||||
@Transactional
|
||||
public ResponseEntity<OrderDto> reportPayment(
|
||||
@PathVariable UUID orderId,
|
||||
@RequestBody Map<String, String> payload
|
||||
) {
|
||||
String method = payload.get("method");
|
||||
paymentService.reportPayment(orderId, method);
|
||||
return getOrder(orderId);
|
||||
}
|
||||
|
||||
@GetMapping("/{orderId}/invoice")
|
||||
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
||||
Order order = orderRepo.findById(orderId)
|
||||
@@ -251,6 +269,12 @@ public class OrderController {
|
||||
dto.setId(order.getId());
|
||||
dto.setOrderNumber(getDisplayOrderNumber(order));
|
||||
dto.setStatus(order.getStatus());
|
||||
|
||||
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
|
||||
dto.setPaymentStatus(p.getStatus());
|
||||
dto.setPaymentMethod(p.getMethod());
|
||||
});
|
||||
|
||||
dto.setCustomerEmail(order.getCustomerEmail());
|
||||
dto.setCustomerPhone(order.getCustomerPhone());
|
||||
dto.setBillingCustomerType(order.getBillingCustomerType());
|
||||
|
||||
@@ -9,6 +9,8 @@ public class OrderDto {
|
||||
private UUID id;
|
||||
private String orderNumber;
|
||||
private String status;
|
||||
private String paymentStatus;
|
||||
private String paymentMethod;
|
||||
private String customerEmail;
|
||||
private String customerPhone;
|
||||
private String billingCustomerType;
|
||||
@@ -34,6 +36,12 @@ public class OrderDto {
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
public String getPaymentStatus() { return paymentStatus; }
|
||||
public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; }
|
||||
|
||||
public String getPaymentMethod() { return paymentMethod; }
|
||||
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
|
||||
|
||||
public String getCustomerEmail() { return customerEmail; }
|
||||
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ public class Payment {
|
||||
@Column(name = "initiated_at", nullable = false)
|
||||
private OffsetDateTime initiatedAt;
|
||||
|
||||
@Column(name = "reported_at")
|
||||
private OffsetDateTime reportedAt;
|
||||
|
||||
@Column(name = "received_at")
|
||||
private OffsetDateTime receivedAt;
|
||||
|
||||
@@ -135,6 +138,14 @@ public class Payment {
|
||||
this.initiatedAt = initiatedAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getReportedAt() {
|
||||
return reportedAt;
|
||||
}
|
||||
|
||||
public void setReportedAt(OffsetDateTime reportedAt) {
|
||||
this.reportedAt = reportedAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getReceivedAt() {
|
||||
return receivedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.printcalculator.event;
|
||||
|
||||
import com.printcalculator.entity.Order;
|
||||
import com.printcalculator.entity.Payment;
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
public class PaymentReportedEvent extends ApplicationEvent {
|
||||
|
||||
private final Order order;
|
||||
private final Payment payment;
|
||||
|
||||
public PaymentReportedEvent(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,7 +1,9 @@
|
||||
package com.printcalculator.event.listener;
|
||||
|
||||
import com.printcalculator.entity.Order;
|
||||
import com.printcalculator.entity.Payment;
|
||||
import com.printcalculator.event.OrderCreatedEvent;
|
||||
import com.printcalculator.event.PaymentReportedEvent;
|
||||
import com.printcalculator.service.email.EmailNotificationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -47,6 +49,19 @@ public class OrderEmailListener {
|
||||
}
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener
|
||||
public void handlePaymentReportedEvent(PaymentReportedEvent event) {
|
||||
Order order = event.getOrder();
|
||||
log.info("Processing PaymentReportedEvent for order id: {}", order.getId());
|
||||
|
||||
try {
|
||||
sendPaymentReportedEmail(order);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send payment reported email for order id: {}", order.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendCustomerConfirmationEmail(Order order) {
|
||||
Map<String, Object> templateData = new HashMap<>();
|
||||
templateData.put("customerName", order.getCustomer().getFirstName());
|
||||
@@ -64,6 +79,21 @@ public class OrderEmailListener {
|
||||
);
|
||||
}
|
||||
|
||||
private void sendPaymentReportedEmail(Order order) {
|
||||
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));
|
||||
|
||||
emailNotificationService.sendEmail(
|
||||
order.getCustomer().getEmail(),
|
||||
"Stiamo verificando il tuo pagamento (Ordine #" + getDisplayOrderNumber(order) + ")",
|
||||
"payment-reported",
|
||||
templateData
|
||||
);
|
||||
}
|
||||
|
||||
private void sendAdminNotificationEmail(Order order) {
|
||||
Map<String, Object> templateData = new HashMap<>();
|
||||
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
||||
import com.printcalculator.entity.Payment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
|
||||
Optional<Payment> findByOrder_Id(UUID orderId);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ public class OrderService {
|
||||
private final InvoicePdfRenderingService invoiceService;
|
||||
private final QrBillService qrBillService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final PaymentService paymentService;
|
||||
|
||||
public OrderService(OrderRepository orderRepo,
|
||||
OrderItemRepository orderItemRepo,
|
||||
@@ -45,7 +46,8 @@ public class OrderService {
|
||||
StorageService storageService,
|
||||
InvoicePdfRenderingService invoiceService,
|
||||
QrBillService qrBillService,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
PaymentService paymentService) {
|
||||
this.orderRepo = orderRepo;
|
||||
this.orderItemRepo = orderItemRepo;
|
||||
this.quoteSessionRepo = quoteSessionRepo;
|
||||
@@ -55,6 +57,7 @@ public class OrderService {
|
||||
this.invoiceService = invoiceService;
|
||||
this.qrBillService = qrBillService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.paymentService = paymentService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -198,9 +201,12 @@ public class OrderService {
|
||||
|
||||
// Generate Invoice and QR Bill
|
||||
generateAndSaveDocuments(order, savedItems);
|
||||
|
||||
|
||||
Order savedOrder = orderRepo.save(order);
|
||||
|
||||
|
||||
// ALWAYS initialize payment as PENDING
|
||||
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
|
||||
|
||||
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
|
||||
|
||||
return savedOrder;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.printcalculator.service;
|
||||
|
||||
import com.printcalculator.entity.Order;
|
||||
import com.printcalculator.entity.Payment;
|
||||
import com.printcalculator.event.PaymentReportedEvent;
|
||||
import com.printcalculator.repository.OrderRepository;
|
||||
import com.printcalculator.repository.PaymentRepository;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class PaymentService {
|
||||
|
||||
private final PaymentRepository paymentRepo;
|
||||
private final OrderRepository orderRepo;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public PaymentService(PaymentRepository paymentRepo,
|
||||
OrderRepository orderRepo,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.paymentRepo = paymentRepo;
|
||||
this.orderRepo = orderRepo;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Payment getOrCreatePaymentForOrder(Order order, String defaultMethod) {
|
||||
Optional<Payment> existing = paymentRepo.findByOrder_Id(order.getId());
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
|
||||
Payment payment = new Payment();
|
||||
payment.setOrder(order);
|
||||
payment.setMethod(defaultMethod != null ? defaultMethod : "OTHER");
|
||||
payment.setStatus("PENDING");
|
||||
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
|
||||
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
|
||||
payment.setInitiatedAt(OffsetDateTime.now());
|
||||
|
||||
return paymentRepo.save(payment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Payment reportPayment(UUID orderId, String method) {
|
||||
Order order = orderRepo.findById(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
|
||||
|
||||
Payment payment = paymentRepo.findByOrder_Id(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("No active payment found for order " + orderId));
|
||||
|
||||
if (!"PENDING".equals(payment.getStatus())) {
|
||||
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
|
||||
}
|
||||
|
||||
payment.setStatus("REPORTED");
|
||||
payment.setReportedAt(OffsetDateTime.now());
|
||||
if (method != null && !method.isBlank()) {
|
||||
payment.setMethod(method);
|
||||
}
|
||||
|
||||
payment = paymentRepo.save(payment);
|
||||
|
||||
eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment));
|
||||
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Logger;
|
||||
@@ -17,12 +18,15 @@ import java.util.stream.Stream;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class ProfileManager {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
|
||||
private final String profilesRoot;
|
||||
private final Path resolvedProfilesRoot;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
private final Map<String, String> profileAliases;
|
||||
@@ -32,6 +36,8 @@ public class ProfileManager {
|
||||
this.mapper = mapper;
|
||||
this.profileAliases = new HashMap<>();
|
||||
initializeAliases();
|
||||
this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot);
|
||||
logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'");
|
||||
}
|
||||
|
||||
private void initializeAliases() {
|
||||
@@ -55,13 +61,18 @@ public class ProfileManager {
|
||||
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
|
||||
Path profilePath = findProfileFile(profileName, type);
|
||||
if (profilePath == null) {
|
||||
throw new IOException("Profile not found: " + profileName);
|
||||
throw new IOException("Profile not found: " + profileName + " (root=" + resolvedProfilesRoot + ")");
|
||||
}
|
||||
logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
|
||||
return resolveInheritance(profilePath);
|
||||
}
|
||||
|
||||
private Path findProfileFile(String name, String type) {
|
||||
if (!Files.isDirectory(resolvedProfilesRoot)) {
|
||||
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check aliases first
|
||||
String resolvedName = profileAliases.getOrDefault(name, name);
|
||||
|
||||
@@ -69,7 +80,7 @@ public class ProfileManager {
|
||||
// collisions across vendors/profile families with same filename.
|
||||
String filename = toJsonFilename(resolvedName);
|
||||
|
||||
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
|
||||
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
|
||||
List<Path> candidates = stream
|
||||
.filter(p -> p.getFileName().toString().equals(filename))
|
||||
.sorted()
|
||||
@@ -95,6 +106,37 @@ public class ProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveProfilesRoot(String configuredRoot) {
|
||||
Set<Path> candidates = new LinkedHashSet<>();
|
||||
Path cwd = Paths.get("").toAbsolutePath().normalize();
|
||||
|
||||
if (configuredRoot != null && !configuredRoot.isBlank()) {
|
||||
Path configured = Paths.get(configuredRoot);
|
||||
candidates.add(configured.toAbsolutePath().normalize());
|
||||
if (!configured.isAbsolute()) {
|
||||
candidates.add(cwd.resolve(configuredRoot).normalize());
|
||||
}
|
||||
}
|
||||
|
||||
candidates.add(cwd.resolve("profiles").normalize());
|
||||
candidates.add(cwd.resolve("backend/profiles").normalize());
|
||||
candidates.add(Paths.get("/app/profiles").toAbsolutePath().normalize());
|
||||
|
||||
List<String> checkedPaths = new ArrayList<>();
|
||||
for (Path candidate : candidates) {
|
||||
checkedPaths.add(candidate.toString());
|
||||
if (Files.isDirectory(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warning("No profiles directory found. Checked: " + String.join(", ", checkedPaths));
|
||||
if (configuredRoot != null && !configuredRoot.isBlank()) {
|
||||
return Paths.get(configuredRoot).toAbsolutePath().normalize();
|
||||
}
|
||||
return cwd.resolve("profiles").normalize();
|
||||
}
|
||||
|
||||
private ObjectNode resolveInheritance(Path currentPath) throws IOException {
|
||||
// 1. Load current
|
||||
JsonNode currentNode = mapper.readTree(currentPath.toFile());
|
||||
|
||||
@@ -24,8 +24,16 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
|
||||
@Value("${app.mail.from}")
|
||||
private String fromAddress;
|
||||
|
||||
@Value("${app.mail.enabled:true}")
|
||||
private boolean mailEnabled;
|
||||
|
||||
@Override
|
||||
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
|
||||
if (!mailEnabled) {
|
||||
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Preparing to send email to {} with template {}", to, templateName);
|
||||
|
||||
try {
|
||||
|
||||
2
backend/src/main/resources/application-local.properties
Normal file
2
backend/src/main/resources/application-local.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
app.mail.enabled=false
|
||||
app.mail.admin.enabled=false
|
||||
@@ -36,6 +36,7 @@ spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false}
|
||||
spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
|
||||
|
||||
# Application Mail Settings
|
||||
app.mail.enabled=${APP_MAIL_ENABLED:true}
|
||||
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
|
||||
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
|
||||
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<style>
|
||||
@page invoice { size: 8.5in 11in; margin: 0.65in; }
|
||||
@page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; }
|
||||
@page qrpage { size: A4; margin: 0; }
|
||||
|
||||
body {
|
||||
page: invoice;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
color: #191919;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.invoice-page {
|
||||
@@ -21,112 +22,186 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-table {
|
||||
.top-layout {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin-bottom: 8mm;
|
||||
}
|
||||
|
||||
.header-table td {
|
||||
.top-layout td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 58%;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 42%;
|
||||
text-align: right;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.invoice-title {
|
||||
font-size: 15pt;
|
||||
.doc-title {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4mm 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 9mm 0 2mm 0;
|
||||
font-size: 10.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 1.5mm 0;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.buyer-box {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
min-height: 20mm;
|
||||
.doc-subtitle {
|
||||
color: #4b4b4b;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.seller-block {
|
||||
text-align: right;
|
||||
line-height: 1.45;
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
.seller-name {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-layout {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8mm;
|
||||
}
|
||||
|
||||
.meta-layout td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.order-details {
|
||||
width: 60%;
|
||||
padding-right: 5mm;
|
||||
}
|
||||
|
||||
.customer-box {
|
||||
width: 40%;
|
||||
background: #f7f7f7;
|
||||
border: 1px solid #e2e2e2;
|
||||
padding: 3mm 3.2mm;
|
||||
}
|
||||
|
||||
.box-title {
|
||||
font-size: 8.8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #5a5a5a;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.details-table td {
|
||||
padding: 1.1mm 0;
|
||||
border-bottom: 1px solid #ececec;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.details-label {
|
||||
color: #636363;
|
||||
width: 56%;
|
||||
white-space: nowrap;
|
||||
padding-right: 3mm;
|
||||
}
|
||||
|
||||
.details-value {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-items {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin-top: 8mm;
|
||||
margin-top: 3mm;
|
||||
border-top: 1px solid #cfcfcf;
|
||||
}
|
||||
|
||||
.line-items th,
|
||||
.line-items td {
|
||||
border-bottom: 1px solid #d8d8d8;
|
||||
padding: 2.8mm 2.2mm;
|
||||
border-bottom: 1px solid #dedede;
|
||||
padding: 2.4mm 2mm;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.line-items th {
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
background: #f7f7f7;
|
||||
background: #f2f2f2;
|
||||
color: #2c2c2c;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25px;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(1),
|
||||
.line-items td:nth-child(1) {
|
||||
width: 54%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(2),
|
||||
.line-items td:nth-child(2) {
|
||||
width: 12%;
|
||||
width: 10%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(3),
|
||||
.line-items td:nth-child(3) {
|
||||
width: 17%;
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(4),
|
||||
.line-items td:nth-child(4) {
|
||||
width: 17%;
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-layout {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6mm;
|
||||
}
|
||||
|
||||
.summary-layout td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notes {
|
||||
width: 58%;
|
||||
padding-right: 5mm;
|
||||
color: #383838;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notes .section-caption {
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2mm 0;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 7mm;
|
||||
width: 42%;
|
||||
margin-left: auto;
|
||||
width: 76mm;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.totals td {
|
||||
border: none;
|
||||
padding: 1.6mm 0;
|
||||
padding: 1.3mm 0;
|
||||
}
|
||||
|
||||
.totals-label {
|
||||
text-align: left;
|
||||
color: #3a3a3a;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.totals-value {
|
||||
@@ -136,16 +211,17 @@
|
||||
}
|
||||
|
||||
.total-strong td {
|
||||
font-size: 11pt;
|
||||
font-size: 10.5pt;
|
||||
font-weight: 700;
|
||||
padding-top: 2.4mm;
|
||||
border-top: 1px solid #d8d8d8;
|
||||
padding-top: 2mm;
|
||||
border-top: 1px solid #cfcfcf;
|
||||
}
|
||||
|
||||
.payment-terms {
|
||||
margin-top: 9mm;
|
||||
line-height: 1.4;
|
||||
color: #2b2b2b;
|
||||
.due-row td {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
border-top: 1px solid #cfcfcf;
|
||||
padding-top: 2.2mm;
|
||||
}
|
||||
|
||||
.qr-only-page {
|
||||
@@ -155,7 +231,6 @@
|
||||
height: 297mm;
|
||||
background: #fff;
|
||||
page-break-before: always;
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
.qr-bill-bottom {
|
||||
@@ -178,38 +253,58 @@
|
||||
<body>
|
||||
<div class="invoice-page">
|
||||
|
||||
<table class="header-table">
|
||||
<table class="top-layout">
|
||||
<tr>
|
||||
<td class="header-left">
|
||||
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
|
||||
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
|
||||
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
|
||||
<div th:text="${sellerEmail}">email@example.com</div>
|
||||
<td>
|
||||
<div class="doc-title">Conferma ordine</div>
|
||||
<div class="doc-subtitle">Ricevuta semplificata</div>
|
||||
</td>
|
||||
<td class="header-right">
|
||||
<div class="invoice-title">Fattura</div>
|
||||
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
|
||||
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
|
||||
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
|
||||
<td class="seller-block">
|
||||
<div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div>
|
||||
<div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
|
||||
<div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
|
||||
<div th:text="${sellerEmail}">info@3dfab.ch</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Fatturare a</div>
|
||||
<div class="buyer-box">
|
||||
<div>
|
||||
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
||||
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
||||
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="meta-layout">
|
||||
<tr>
|
||||
<td class="order-details">
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td class="details-label">Data ordine / fattura</td>
|
||||
<td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="details-label">Numero documento</td>
|
||||
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="details-label">Data di scadenza</td>
|
||||
<td class="details-value" th:text="${dueDate}">2026-02-20</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="details-label">Valuta</td>
|
||||
<td class="details-value">CHF</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="customer-box">
|
||||
<div class="box-title">Cliente</div>
|
||||
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
||||
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
||||
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="line-items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Descrizione</th>
|
||||
<th>Qtà</th>
|
||||
<th>Prezzo</th>
|
||||
<th>Prezzo unit.</th>
|
||||
<th>Totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -223,21 +318,36 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<table class="summary-layout">
|
||||
<tr>
|
||||
<td class="totals-label">Subtotale</td>
|
||||
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
<tr class="total-strong">
|
||||
<td class="totals-label">Totale</td>
|
||||
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||
<td class="notes">
|
||||
<div class="section-caption">Informazioni</div>
|
||||
<div th:text="${paymentTermsText}">
|
||||
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
|
||||
</div>
|
||||
<div style="margin-top: 2.5mm;">
|
||||
Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<table class="totals">
|
||||
<tr>
|
||||
<td class="totals-label">Subtotale</td>
|
||||
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
<tr class="total-strong">
|
||||
<td class="totals-label">Totale ordine</td>
|
||||
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
<tr class="due-row">
|
||||
<td class="totals-label">Importo dovuto</td>
|
||||
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="payment-terms" th:text="${paymentTermsText}">
|
||||
Pagamento entro 7 giorni. Grazie.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="qr-only-page">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.printcalculator.event.listener;
|
||||
|
||||
import com.printcalculator.entity.Customer;
|
||||
import com.printcalculator.entity.Order;
|
||||
import com.printcalculator.event.OrderCreatedEvent;
|
||||
import com.printcalculator.service.email.EmailNotificationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OrderEmailListenerTest {
|
||||
|
||||
@Mock
|
||||
private EmailNotificationService emailNotificationService;
|
||||
|
||||
@InjectMocks
|
||||
private OrderEmailListener orderEmailListener;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Map<String, Object>> templateDataCaptor;
|
||||
|
||||
private Order order;
|
||||
private OrderCreatedEvent event;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Customer customer = new Customer();
|
||||
customer.setFirstName("John");
|
||||
customer.setLastName("Doe");
|
||||
customer.setEmail("john.doe@test.com");
|
||||
|
||||
order = new Order();
|
||||
order.setId(UUID.randomUUID());
|
||||
order.setCustomer(customer);
|
||||
order.setCreatedAt(OffsetDateTime.parse("2026-02-21T10:00:00Z"));
|
||||
order.setTotalChf(new BigDecimal("150.50"));
|
||||
|
||||
event = new OrderCreatedEvent(this, order);
|
||||
|
||||
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
|
||||
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
|
||||
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleOrderCreatedEvent_ShouldSendCustomerAndAdminEmails() {
|
||||
// Act
|
||||
orderEmailListener.handleOrderCreatedEvent(event);
|
||||
|
||||
// Assert Customer Email
|
||||
verify(emailNotificationService, times(1)).sendEmail(
|
||||
eq("john.doe@test.com"),
|
||||
eq("Conferma Ordine #" + order.getOrderNumber() + " - 3D-Fab"),
|
||||
eq("order-confirmation"),
|
||||
templateDataCaptor.capture()
|
||||
);
|
||||
|
||||
Map<String, Object> customerData = templateDataCaptor.getAllValues().get(0);
|
||||
assertEquals("John", customerData.get("customerName"));
|
||||
assertEquals(order.getId(), customerData.get("orderId"));
|
||||
assertEquals(order.getOrderNumber(), customerData.get("orderNumber"));
|
||||
assertEquals("https://tuosito.it/ordine/" + order.getId(), customerData.get("orderDetailsUrl"));
|
||||
assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate"));
|
||||
assertEquals("150.50", customerData.get("totalCost"));
|
||||
|
||||
// Assert Admin Email
|
||||
verify(emailNotificationService, times(1)).sendEmail(
|
||||
eq("admin@printcalculator.local"),
|
||||
eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - Doe"),
|
||||
eq("order-confirmation"),
|
||||
templateDataCaptor.capture()
|
||||
);
|
||||
|
||||
Map<String, Object> adminData = templateDataCaptor.getAllValues().get(1);
|
||||
assertEquals("John Doe", adminData.get("customerName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleOrderCreatedEvent_WithAdminDisabled_ShouldOnlySendCustomerEmail() {
|
||||
// Arrange
|
||||
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", false);
|
||||
|
||||
// Act
|
||||
orderEmailListener.handleOrderCreatedEvent(event);
|
||||
|
||||
// Assert
|
||||
verify(emailNotificationService, times(1)).sendEmail(
|
||||
eq("john.doe@test.com"),
|
||||
anyString(),
|
||||
anyString(),
|
||||
any()
|
||||
);
|
||||
|
||||
verify(emailNotificationService, never()).sendEmail(
|
||||
eq("admin@printcalculator.local"),
|
||||
anyString(),
|
||||
anyString(),
|
||||
any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleOrderCreatedEvent_ExceptionHandling_ShouldNotPropagate() {
|
||||
// Arrange
|
||||
doThrow(new RuntimeException("Simulated Mail Failure"))
|
||||
.when(emailNotificationService).sendEmail(anyString(), anyString(), anyString(), any());
|
||||
|
||||
// Act & Assert
|
||||
// Event listener shouldn't throw exception back, thus passing the test.
|
||||
orderEmailListener.handleOrderCreatedEvent(event);
|
||||
|
||||
verify(emailNotificationService, times(1)).sendEmail(anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.printcalculator.service.email;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SmtpEmailNotificationServiceTest {
|
||||
|
||||
@Mock
|
||||
private JavaMailSender emailSender;
|
||||
|
||||
@Mock
|
||||
private TemplateEngine templateEngine;
|
||||
|
||||
@Mock
|
||||
private MimeMessage mimeMessage;
|
||||
|
||||
@InjectMocks
|
||||
private SmtpEmailNotificationService emailNotificationService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(emailNotificationService, "fromAddress", "noreply@test.com");
|
||||
ReflectionTestUtils.setField(emailNotificationService, "mailEnabled", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEmail_Success() {
|
||||
// Arrange
|
||||
String to = "user@test.com";
|
||||
String subject = "Test Subject";
|
||||
String templateName = "test-template";
|
||||
Map<String, Object> contextData = new HashMap<>();
|
||||
contextData.put("key", "value");
|
||||
|
||||
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenReturn("<html>Test</html>");
|
||||
when(emailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
// Act
|
||||
emailNotificationService.sendEmail(to, subject, templateName, contextData);
|
||||
|
||||
// Assert
|
||||
verify(templateEngine, times(1)).process(eq("email/" + templateName), any(Context.class));
|
||||
verify(emailSender, times(1)).createMimeMessage();
|
||||
verify(emailSender, times(1)).send(mimeMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEmail_Exception_ShouldNotThrow() {
|
||||
// Arrange
|
||||
String to = "user@test.com";
|
||||
String subject = "Test Subject";
|
||||
String templateName = "test-template";
|
||||
Map<String, Object> contextData = new HashMap<>();
|
||||
|
||||
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenThrow(new RuntimeException("Template error"));
|
||||
|
||||
// Act & Assert
|
||||
// We expect the exception to be caught and logged, not propagated
|
||||
assertDoesNotThrow(() -> emailNotificationService.sendEmail(to, subject, templateName, contextData));
|
||||
|
||||
verify(emailSender, never()).createMimeMessage();
|
||||
verify(emailSender, never()).send(any(MimeMessage.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user