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

5
.gitignore vendored
View File

@@ -41,3 +41,8 @@ target/
build/ build/
.gradle/ .gradle/
.mvn/ .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.repository.*;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.StorageService;
import com.printcalculator.service.TwintPaymentService; import com.printcalculator.service.TwintPaymentService;
@@ -43,6 +44,8 @@ public class OrderController {
private final InvoicePdfRenderingService invoiceService; private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService; private final TwintPaymentService twintPaymentService;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderController(OrderService orderService, public OrderController(OrderService orderService,
@@ -54,7 +57,9 @@ public class OrderController {
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService,
TwintPaymentService twintPaymentService) { TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService; this.orderService = orderService;
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
@@ -65,6 +70,8 @@ public class OrderController {
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService; this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
} }
@@ -122,6 +129,17 @@ public class OrderController {
.orElse(ResponseEntity.notFound().build()); .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") @GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId) Order order = orderRepo.findById(orderId)
@@ -251,6 +269,12 @@ public class OrderController {
dto.setId(order.getId()); dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order)); dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus()); dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail()); dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone()); dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType()); dto.setBillingCustomerType(order.getBillingCustomerType());

View File

@@ -9,6 +9,8 @@ public class OrderDto {
private UUID id; private UUID id;
private String orderNumber; private String orderNumber;
private String status; private String status;
private String paymentStatus;
private String paymentMethod;
private String customerEmail; private String customerEmail;
private String customerPhone; private String customerPhone;
private String billingCustomerType; private String billingCustomerType;
@@ -34,6 +36,12 @@ public class OrderDto {
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = 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 String getCustomerEmail() { return customerEmail; }
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; } public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }

View File

@@ -52,6 +52,9 @@ public class Payment {
@Column(name = "initiated_at", nullable = false) @Column(name = "initiated_at", nullable = false)
private OffsetDateTime initiatedAt; private OffsetDateTime initiatedAt;
@Column(name = "reported_at")
private OffsetDateTime reportedAt;
@Column(name = "received_at") @Column(name = "received_at")
private OffsetDateTime receivedAt; private OffsetDateTime receivedAt;
@@ -135,6 +138,14 @@ public class Payment {
this.initiatedAt = initiatedAt; this.initiatedAt = initiatedAt;
} }
public OffsetDateTime getReportedAt() {
return reportedAt;
}
public void setReportedAt(OffsetDateTime reportedAt) {
this.reportedAt = reportedAt;
}
public OffsetDateTime getReceivedAt() { public OffsetDateTime getReceivedAt() {
return receivedAt; 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; package com.printcalculator.event.listener;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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) { private void sendCustomerConfirmationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>(); Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName()); templateData.put("customerName", order.getCustomer().getFirstName());
@@ -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) { private void sendAdminNotificationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>(); Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName()); templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Payment; import com.printcalculator.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, 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 InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepo, public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -45,7 +46,8 @@ public class OrderService {
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService,
ApplicationEventPublisher eventPublisher) { ApplicationEventPublisher eventPublisher,
PaymentService paymentService) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
@@ -55,6 +57,7 @@ public class OrderService {
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.paymentService = paymentService;
} }
@Transactional @Transactional
@@ -201,6 +204,9 @@ public class OrderService {
Order savedOrder = orderRepo.save(order); Order savedOrder = orderRepo.save(order);
// ALWAYS initialize payment as PENDING
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder)); eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -17,12 +18,15 @@ import java.util.stream.Stream;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
@Service @Service
public class ProfileManager { public class ProfileManager {
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
private final String profilesRoot; private final String profilesRoot;
private final Path resolvedProfilesRoot;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Map<String, String> profileAliases; private final Map<String, String> profileAliases;
@@ -32,6 +36,8 @@ public class ProfileManager {
this.mapper = mapper; this.mapper = mapper;
this.profileAliases = new HashMap<>(); this.profileAliases = new HashMap<>();
initializeAliases(); initializeAliases();
this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot);
logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'");
} }
private void initializeAliases() { private void initializeAliases() {
@@ -55,13 +61,18 @@ public class ProfileManager {
public ObjectNode getMergedProfile(String profileName, String type) throws IOException { public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
Path profilePath = findProfileFile(profileName, type); Path profilePath = findProfileFile(profileName, type);
if (profilePath == null) { 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); logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
return resolveInheritance(profilePath); return resolveInheritance(profilePath);
} }
private Path findProfileFile(String name, String type) { 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 // Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name); String resolvedName = profileAliases.getOrDefault(name, name);
@@ -69,7 +80,7 @@ public class ProfileManager {
// collisions across vendors/profile families with same filename. // collisions across vendors/profile families with same filename.
String filename = toJsonFilename(resolvedName); String filename = toJsonFilename(resolvedName);
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) { try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
List<Path> candidates = stream List<Path> candidates = stream
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.sorted() .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 { private ObjectNode resolveInheritance(Path currentPath) throws IOException {
// 1. Load current // 1. Load current
JsonNode currentNode = mapper.readTree(currentPath.toFile()); JsonNode currentNode = mapper.readTree(currentPath.toFile());

View File

@@ -24,8 +24,16 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
@Value("${app.mail.from}") @Value("${app.mail.from}")
private String fromAddress; private String fromAddress;
@Value("${app.mail.enabled:true}")
private boolean mailEnabled;
@Override @Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) { public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
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); log.info("Preparing to send email to {} with template {}", to, templateName);
try { 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} spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
# Application Mail Settings # Application Mail Settings
app.mail.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}

View File

@@ -3,17 +3,18 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<style> <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; } @page qrpage { size: A4; margin: 0; }
body { body {
page: invoice; page: invoice;
font-family: Helvetica, Arial, sans-serif; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 10pt; font-size: 9.5pt;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #fff; background: #fff;
color: #1a1a1a; color: #191919;
line-height: 1.35;
} }
.invoice-page { .invoice-page {
@@ -21,112 +22,186 @@
width: 100%; width: 100%;
} }
.header-table { .top-layout {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; margin-bottom: 8mm;
} }
.header-table td { .top-layout td {
vertical-align: top; vertical-align: top;
padding: 0; padding: 0;
} }
.header-left { .doc-title {
width: 58%; font-size: 18pt;
line-height: 1.35;
}
.header-right {
width: 42%;
text-align: right;
line-height: 1.35;
}
.invoice-title {
font-size: 15pt;
font-weight: 700; font-weight: 700;
margin: 0 0 4mm 0; margin: 0 0 1.5mm 0;
letter-spacing: 0.3px;
}
.section-title {
margin: 9mm 0 2mm 0;
font-size: 10.5pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.buyer-box { .doc-subtitle {
margin: 0; color: #4b4b4b;
line-height: 1.4; font-size: 10pt;
min-height: 20mm; }
.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 { .line-items {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
margin-top: 8mm; margin-top: 3mm;
border-top: 1px solid #cfcfcf;
} }
.line-items th, .line-items th,
.line-items td { .line-items td {
border-bottom: 1px solid #d8d8d8; border-bottom: 1px solid #dedede;
padding: 2.8mm 2.2mm; padding: 2.4mm 2mm;
vertical-align: top; vertical-align: top;
word-break: break-word; word-wrap: break-word;
} }
.line-items th { .line-items th {
text-align: left; text-align: left;
font-weight: 700; 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 th:nth-child(1),
.line-items td:nth-child(1) { .line-items td:nth-child(1) {
width: 54%; width: 50%;
} }
.line-items th:nth-child(2), .line-items th:nth-child(2),
.line-items td:nth-child(2) { .line-items td:nth-child(2) {
width: 12%; width: 10%;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
.line-items th:nth-child(3), .line-items th:nth-child(3),
.line-items td:nth-child(3) { .line-items td:nth-child(3) {
width: 17%; width: 20%;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
.line-items th:nth-child(4), .line-items th:nth-child(4),
.line-items td:nth-child(4) { .line-items td:nth-child(4) {
width: 17%; width: 20%;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
font-weight: 600; 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 { .totals {
margin-top: 7mm; width: 42%;
margin-left: auto; margin-left: auto;
width: 76mm;
border-collapse: collapse; border-collapse: collapse;
} }
.totals td { .totals td {
border: none; border: none;
padding: 1.6mm 0; padding: 1.3mm 0;
} }
.totals-label { .totals-label {
text-align: left; text-align: left;
color: #3a3a3a; color: #4a4a4a;
} }
.totals-value { .totals-value {
@@ -136,16 +211,17 @@
} }
.total-strong td { .total-strong td {
font-size: 11pt; font-size: 10.5pt;
font-weight: 700; font-weight: 700;
padding-top: 2.4mm; padding-top: 2mm;
border-top: 1px solid #d8d8d8; border-top: 1px solid #cfcfcf;
} }
.payment-terms { .due-row td {
margin-top: 9mm; font-size: 10pt;
line-height: 1.4; font-weight: 700;
color: #2b2b2b; border-top: 1px solid #cfcfcf;
padding-top: 2.2mm;
} }
.qr-only-page { .qr-only-page {
@@ -155,7 +231,6 @@
height: 297mm; height: 297mm;
background: #fff; background: #fff;
page-break-before: always; page-break-before: always;
break-before: page;
} }
.qr-bill-bottom { .qr-bill-bottom {
@@ -178,38 +253,58 @@
<body> <body>
<div class="invoice-page"> <div class="invoice-page">
<table class="header-table"> <table class="top-layout">
<tr> <tr>
<td class="header-left"> <td>
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div> <div class="doc-title">Conferma ordine</div>
<div th:text="${sellerAddressLine1}">Via Esempio 12</div> <div class="doc-subtitle">Ricevuta semplificata</div>
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
<div th:text="${sellerEmail}">email@example.com</div>
</td> </td>
<td class="header-right"> <td class="seller-block">
<div class="invoice-title">Fattura</div> <div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div> <div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div> <div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div> <div th:text="${sellerEmail}">info@3dfab.ch</div>
</td> </td>
</tr> </tr>
</table> </table>
<div class="section-title">Fatturare a</div> <table class="meta-layout">
<div class="buyer-box"> <tr>
<div> <td class="order-details">
<div th:text="${buyerDisplayName}">Cliente SA</div> <table class="details-table">
<div th:text="${buyerAddressLine1}">Via Cliente 7</div> <tr>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div> <td class="details-label">Data ordine / fattura</td>
</div> <td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
</div> </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"> <table class="line-items">
<thead> <thead>
<tr> <tr>
<th>Descrizione</th> <th>Descrizione</th>
<th>Qtà</th> <th>Qtà</th>
<th>Prezzo</th> <th>Prezzo unit.</th>
<th>Totale</th> <th>Totale</th>
</tr> </tr>
</thead> </thead>
@@ -223,21 +318,36 @@
</tbody> </tbody>
</table> </table>
<table class="totals"> <table class="summary-layout">
<tr> <tr>
<td class="totals-label">Subtotale</td> <td class="notes">
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td> <div class="section-caption">Informazioni</div>
</tr> <div th:text="${paymentTermsText}">
<tr class="total-strong"> Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
<td class="totals-label">Totale</td> </div>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td> <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> </tr>
</table> </table>
<div class="payment-terms" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div>
</div> </div>
<div class="qr-only-page"> <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, 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')), 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', currency char(3) NOT NULL DEFAULT 'CHF',
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0), 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 qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
initiated_at timestamptz NOT NULL DEFAULT now(), initiated_at timestamptz NOT NULL DEFAULT now(),
reported_at timestamptz,
received_at timestamptz received_at timestamptz
); );

View File

@@ -1,44 +1,4 @@
services: 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: db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: print-calculator-db container_name: print-calculator-db

View File

@@ -152,6 +152,13 @@ export class QuoteEstimatorService {
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers }); 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> { getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
// @ts-ignore // @ts-ignore

View File

@@ -4,14 +4,41 @@
</div> </div>
<div class="container"> <div class="container">
<div class="confirmation-layout"> <div class="confirmation-layout" *ngIf="order() as o">
<app-card class="status-card"> <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> <h2>{{ 'ORDER_CONFIRMED.HEADING' | translate }}</h2>
<p class="order-ref" *ngIf="orderNumber"> <p class="order-ref" *ngIf="orderNumber">
{{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong> {{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong>
</p> </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"> <div class="message-block">
<p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p> <p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p>
<p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p> <p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p>

View File

@@ -60,3 +60,100 @@ h2 {
.actions { .actions {
max-width: 320px; 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 { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -20,6 +20,7 @@ export class OrderConfirmedComponent implements OnInit {
orderId: string | null = null; orderId: string | null = null;
orderNumber: string | null = null; orderNumber: string | null = null;
order = signal<any>(null);
ngOnInit(): void { ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId'); this.orderId = this.route.snapshot.paramMap.get('orderId');
@@ -30,6 +31,7 @@ export class OrderConfirmedComponent implements OnInit {
this.orderNumber = this.extractOrderNumber(this.orderId); this.orderNumber = this.extractOrderNumber(this.orderId);
this.quoteService.getOrder(this.orderId).subscribe({ this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => { next: (order) => {
this.order.set(order);
this.orderNumber = order?.orderNumber ?? this.orderNumber; this.orderNumber = order?.orderNumber ?? this.orderNumber;
}, },
error: () => { error: () => {

View File

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

View File

@@ -116,11 +116,22 @@ export class PaymentComponent implements OnInit {
} }
completeOrder(): void { completeOrder(): void {
if (!this.orderId) { if (!this.orderId || !this.selectedPaymentMethod) {
this.router.navigate(['/']);
return; 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 { getDisplayOrderNumber(order: any): string {

View File

@@ -194,7 +194,18 @@
"SHIPPING": "Shipping", "SHIPPING": "Shipping",
"SETUP_FEE": "Setup Fee", "SETUP_FEE": "Setup Fee",
"TOTAL": "Total", "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": { "ORDER_CONFIRMED": {
"TITLE": "Order Confirmed", "TITLE": "Order Confirmed",

View File

@@ -267,7 +267,16 @@
"TOTAL": "Totale", "TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine...", "LOADING": "Caricamento dettagli ordine...",
"METHOD_TWINT": "TWINT", "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": { "ORDER_CONFIRMED": {
"TITLE": "Ordine Confermato", "TITLE": "Ordine Confermato",