produzione 1 #9

Merged
JoeKung merged 135 commits from dev into main 2026-03-03 09:58:04 +01:00
26 changed files with 839 additions and 142 deletions
Showing only changes of commit c1652798b4 - Show all commits

5
.gitignore vendored
View File

@@ -41,3 +41,8 @@ target/
build/
.gradle/
.mvn/
./storage_orders
./storage_quotes
storage_orders
storage_quotes

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
@@ -201,6 +204,9 @@ public class OrderService {
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>
<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>
</div>
</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="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</td>
<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));
}
}

3
db.sql
View File

@@ -553,7 +553,7 @@ CREATE TABLE IF NOT EXISTS payments
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF',
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
@@ -564,6 +564,7 @@ CREATE TABLE IF NOT EXISTS payments
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
initiated_at timestamptz NOT NULL DEFAULT now(),
reported_at timestamptz,
received_at timestamptz
);

View File

@@ -1,44 +1,4 @@
services:
backend:
platform: linux/amd64
build:
context: ./backend
platforms:
- linux/amd64
container_name: print-calculator-backend
ports:
- "8000:8000"
environment:
- DB_URL=jdbc:postgresql://db:5432/printcalc
- DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
- PRINTER_POWER_WATTS=150
- MARKUP_PERCENT=20
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
- CLAMAV_HOST=clamav
- CLAMAV_PORT=3310
depends_on:
- db
- clamav
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: print-calculator-frontend
ports:
- "80:80"
depends_on:
- backend
- db
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: print-calculator-db

View File

@@ -152,6 +152,13 @@ export class QuoteEstimatorService {
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
}
reportPayment(orderId: string, method: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
}
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
// @ts-ignore

View File

@@ -4,14 +4,41 @@
</div>
<div class="container">
<div class="confirmation-layout">
<div class="confirmation-layout" *ngIf="order() as o">
<app-card class="status-card">
<div class="status-badge">{{ 'ORDER_CONFIRMED.STATUS' | translate }}</div>
<div class="status-badge">{{ o.status === 'SHIPPED' ? ('TRACKING.STEP_SHIPPED' | translate) : ('ORDER_CONFIRMED.STATUS' | translate) }}</div>
<h2>{{ 'ORDER_CONFIRMED.HEADING' | translate }}</h2>
<p class="order-ref" *ngIf="orderNumber">
{{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong>
</p>
<div class="status-timeline">
<div class="timeline-step"
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
<div class="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'">
<div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
</div>
</div>
<div class="message-block">
<p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p>
<p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p>

View File

@@ -60,3 +60,100 @@ h2 {
.actions {
max-width: 320px;
}
.status-timeline {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-6);
position: relative;
&::before {
content: '';
position: absolute;
top: 15px;
left: 20px;
right: 20px;
height: 2px;
background: var(--color-border);
z-index: 1;
}
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
text-align: center;
.circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-neutral-100);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--color-text-muted);
transition: all 0.3s ease;
}
.label {
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
&.active {
.circle {
border-color: var(--color-primary);
background: var(--color-primary-light);
color: var(--color-primary);
}
.label {
color: var(--color-text);
font-weight: 600;
}
}
&.completed {
.circle {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.label {
color: var(--color-text);
}
}
}
@media (max-width: 600px) {
.status-timeline {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
&::before {
top: 10px;
bottom: 10px;
left: 15px;
width: 2px;
height: auto;
}
.timeline-step {
flex-direction: row;
gap: var(--space-3);
.circle {
margin-bottom: 0;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@@ -20,6 +20,7 @@ export class OrderConfirmedComponent implements OnInit {
orderId: string | null = null;
orderNumber: string | null = null;
order = signal<any>(null);
ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId');
@@ -30,6 +31,7 @@ export class OrderConfirmedComponent implements OnInit {
this.orderNumber = this.extractOrderNumber(this.orderId);
this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => {
this.order.set(order);
this.orderNumber = order?.orderNumber ?? this.orderNumber;
},
error: () => {

View File

@@ -6,6 +6,13 @@
<div class="container">
<div class="payment-layout" *ngIf="order() as o">
<div class="payment-main">
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
<div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
</div>
</app-card>
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
@@ -69,8 +76,11 @@
</div>
<div class="actions">
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
{{ 'PAYMENT.CONFIRM' | translate }}
<app-button
(click)="completeOrder()"
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
[fullWidth]="true">
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
</app-button>
</div>
</app-card>

View File

@@ -116,11 +116,22 @@ export class PaymentComponent implements OnInit {
}
completeOrder(): void {
if (!this.orderId) {
this.router.navigate(['/']);
if (!this.orderId || !this.selectedPaymentMethod) {
return;
}
this.router.navigate(['/order-confirmed', this.orderId]);
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
next: (order) => {
this.order.set(order);
// The UI will re-render and show the 'REPORTED' state.
// We stay on this page to let the user see the "In verifica"
// status along with payment instructions.
},
error: (err) => {
console.error('Failed to report payment', err);
this.error.set('Failed to report payment. Please try again.');
}
});
}
getDisplayOrderNumber(order: any): string {

View File

@@ -194,7 +194,18 @@
"SHIPPING": "Shipping",
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"LOADING": "Loading order details..."
"LOADING": "Loading order details...",
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Bank Transfer / QR",
"STATUS_REPORTED_TITLE": "Payment Reported",
"STATUS_REPORTED_DESC": "We are verifying your transaction. Your order will move to production as soon as the payment is confirmed.",
"IN_VERIFICATION": "Verifying Payment"
},
"TRACKING": {
"STEP_PENDING": "Pending",
"STEP_REPORTED": "Verifying",
"STEP_PRODUCTION": "Production",
"STEP_SHIPPED": "Shipped"
},
"ORDER_CONFIRMED": {
"TITLE": "Order Confirmed",

View File

@@ -267,7 +267,16 @@
"TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine...",
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Fattura QR / Bonifico"
"METHOD_BANK": "Fattura QR / Bonifico",
"STATUS_REPORTED_TITLE": "Abbiamo ricevuto la tua segnalazione",
"STATUS_REPORTED_DESC": "Stiamo verificando la transazione. Il tuo ordine passerà in produzione non appena l'accredito sarà confermato.",
"IN_VERIFICATION": "Pagamento in verifica"
},
"TRACKING": {
"STEP_PENDING": "In attesa",
"STEP_REPORTED": "In verifica",
"STEP_PRODUCTION": "In Produzione",
"STEP_SHIPPED": "Spedito"
},
"ORDER_CONFIRMED": {
"TITLE": "Ordine Confermato",