feat(back-end front-end): new UX for
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 10s

This commit is contained in:
2026-02-23 18:56:24 +01:00
parent ec4d512136
commit c1652798b4
26 changed files with 839 additions and 142 deletions

View File

@@ -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());

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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());

View File

@@ -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 {

View File

@@ -0,0 +1,2 @@
app.mail.enabled=false
app.mail.admin.enabled=false

View File

@@ -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}

View File

@@ -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">

View File

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

View File

@@ -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));
}
}