From c58d674a709fe3f0d94836906b24f14738d1a3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 25 Feb 2026 16:35:58 +0100 Subject: [PATCH] feat(back-end - front end): improvements in email and invoice rendering --- .../controller/OrderController.java | 74 +++----------- .../event/PaymentConfirmedEvent.java | 24 +++++ .../event/listener/OrderEmailListener.java | 50 ++++++++++ .../service/InvoicePdfRenderingService.java | 97 ++++++++++++++++++- .../printcalculator/service/OrderService.java | 3 +- .../service/PaymentService.java | 25 +++++ .../service/QrBillService.java | 7 +- .../email/EmailNotificationService.java | 12 +++ .../email/SmtpEmailNotificationService.java | 10 ++ .../src/main/resources/templates/invoice.html | 71 ++++++++++---- .../services/quote-estimator.service.ts | 10 ++ .../app/features/order/order.component.html | 26 +++-- .../src/app/features/order/order.component.ts | 8 +- frontend/src/assets/i18n/en.json | 1 + frontend/src/assets/i18n/it.json | 1 + 15 files changed, 317 insertions(+), 102 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/event/PaymentConfirmedEvent.java diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 0d46ab9..99f1754 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -140,72 +140,30 @@ public class OrderController { return getOrder(orderId); } + @GetMapping("/{orderId}/confirmation") + public ResponseEntity getConfirmation(@PathVariable UUID orderId) { + return generateDocument(orderId, true); + } + @GetMapping("/{orderId}/invoice") public ResponseEntity getInvoice(@PathVariable UUID orderId) { + // Paid invoices are sent by email after back-office payment confirmation. + // The public endpoint must not expose a "paid" invoice download. + return ResponseEntity.notFound().build(); + } + + private ResponseEntity generateDocument(UUID orderId, boolean isConfirmation) { Order order = orderRepo.findById(orderId) .orElseThrow(() -> new RuntimeException("Order not found")); List items = orderItemRepo.findByOrder_Id(orderId); + Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null); - Map vars = new HashMap<>(); - vars.put("sellerDisplayName", "3D Fab Switzerland"); - vars.put("sellerAddressLine1", "Sede Ticino, Svizzera"); - vars.put("sellerAddressLine2", "Sede Bienne, Svizzera"); - vars.put("sellerEmail", "info@3dfab.ch"); - - vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase()); - vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); - vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); - - String buyerName = order.getBillingCustomerType().equals("BUSINESS") - ? order.getBillingCompanyName() - : order.getBillingFirstName() + " " + order.getBillingLastName(); - vars.put("buyerDisplayName", buyerName); - vars.put("buyerAddressLine1", order.getBillingAddressLine1()); - vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode()); - - List> invoiceLineItems = items.stream().map(i -> { - Map line = new HashMap<>(); - line.put("description", "Stampa 3D: " + i.getOriginalFilename()); - line.put("quantity", i.getQuantity()); - line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); - line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); - return line; - }).collect(Collectors.toList()); - - Map setupLine = new HashMap<>(); - setupLine.put("description", "Costo Setup"); - setupLine.put("quantity", 1); - setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf())); - setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf())); - invoiceLineItems.add(setupLine); - - Map shippingLine = new HashMap<>(); - shippingLine.put("description", "Spedizione"); - shippingLine.put("quantity", 1); - shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf())); - shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf())); - invoiceLineItems.add(shippingLine); - - vars.put("invoiceLineItems", invoiceLineItems); - vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf())); - vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf())); - vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie."); - - String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8); - - // Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page - if (qrBillSvg.contains(" templateData = new HashMap<>(); templateData.put("customerName", order.getCustomer().getFirstName()); @@ -94,6 +116,34 @@ public class OrderEmailListener { ); } + private void sendPaidInvoiceEmail(Order order, Payment payment) { + Map templateData = new HashMap<>(); + templateData.put("customerName", order.getCustomer().getFirstName()); + templateData.put("orderId", order.getId()); + templateData.put("orderNumber", getDisplayOrderNumber(order)); + templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order)); + templateData.put("totalCost", String.format("%.2f", order.getTotalChf())); + + byte[] pdf = null; + try { + java.util.List items = orderItemRepository.findByOrder_Id(order.getId()); + pdf = invoicePdfRenderingService.generateDocumentPdf(order, items, false, qrBillService, payment); + } catch (Exception e) { + log.error("Failed to generate PDF for paid invoice email: {}", e.getMessage(), e); + } + + String filename = "Fattura-" + getDisplayOrderNumber(order) + ".pdf"; + + emailNotificationService.sendEmailWithAttachment( + order.getCustomer().getEmail(), + "Fattura Pagata (Ordine #" + getDisplayOrderNumber(order) + ") - 3D-Fab", + "payment-confirmed", + templateData, + filename, + pdf + ); + } + private void sendAdminNotificationEmail(Order order) { Map templateData = new HashMap<>(); templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName()); diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java index a21e59f..04da768 100644 --- a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java @@ -8,10 +8,17 @@ import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; import java.io.ByteArrayOutputStream; -import java.io.IOException; +import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; + @Service public class InvoicePdfRenderingService { @@ -45,4 +52,92 @@ public class InvoicePdfRenderingService { throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException); } } + + public byte[] generateDocumentPdf(Order order, List items, boolean isConfirmation, QrBillService qrBillService, Payment payment) { + Map vars = new HashMap<>(); + vars.put("isConfirmation", isConfirmation); + vars.put("sellerDisplayName", "3D Fab Küng Caletti"); + vars.put("sellerAddressLine1", "Joe Küng e Matteo Caletti"); + vars.put("sellerAddressLine2", "Sede Bienne, Svizzera"); + vars.put("sellerEmail", "info@3dfab.ch"); + + String displayOrderNumber = order.getOrderNumber() != null && !order.getOrderNumber().isBlank() + ? order.getOrderNumber() + : order.getId().toString(); + + vars.put("invoiceNumber", "INV-" + displayOrderNumber.toUpperCase()); + vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); + vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); + + String buyerName = order.getBillingCustomerType().equals("BUSINESS") + ? order.getBillingCompanyName() + : order.getBillingFirstName() + " " + order.getBillingLastName(); + vars.put("buyerDisplayName", buyerName); + vars.put("buyerAddressLine1", order.getBillingAddressLine1()); + vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode()); + + // Setup Shipping Info + if (order.getShippingAddressLine1() != null && !order.getShippingAddressLine1().isBlank()) { + String shippingName = order.getShippingCompanyName() != null && !order.getShippingCompanyName().isBlank() + ? order.getShippingCompanyName() + : order.getShippingFirstName() + " " + order.getShippingLastName(); + vars.put("shippingDisplayName", shippingName); + vars.put("shippingAddressLine1", order.getShippingAddressLine1()); + vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode()); + } + + List> invoiceLineItems = items.stream().map(i -> { + Map line = new HashMap<>(); + line.put("description", "Stampa 3D: " + i.getOriginalFilename()); + line.put("quantity", i.getQuantity()); + line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); + line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); + return line; + }).collect(Collectors.toList()); + + Map setupLine = new HashMap<>(); + setupLine.put("description", "Costo Setup"); + setupLine.put("quantity", 1); + setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf())); + setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf())); + invoiceLineItems.add(setupLine); + + Map shippingLine = new HashMap<>(); + shippingLine.put("description", "Spedizione"); + shippingLine.put("quantity", 1); + shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf())); + shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf())); + invoiceLineItems.add(shippingLine); + + vars.put("invoiceLineItems", invoiceLineItems); + vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf())); + vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf())); + vars.put("paymentTermsText", isConfirmation ? "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie." : "Pagato. Grazie per l'acquisto."); + + String paymentMethodText = "QR / Bonifico oppure TWINT"; + if (payment != null && payment.getMethod() != null) { + paymentMethodText = switch (payment.getMethod().toUpperCase()) { + case "TWINT" -> "TWINT"; + case "BANK_TRANSFER", "BONIFICO" -> "Bonifico Bancario"; + case "QR_BILL", "QR" -> "QR Bill"; + case "CASH" -> "Contanti"; + default -> payment.getMethod(); + }; + } + vars.put("paymentMethodText", paymentMethodText); + + String qrBillSvg = null; + if (isConfirmation) { + qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8); + + if (qrBillSvg.contains(" new RuntimeException("Order not found with id " + orderId)); + + Payment payment = paymentRepo.findByOrder_Id(orderId) + .orElseGet(() -> getOrCreatePaymentForOrder(order, method != null ? method : "OTHER")); + + payment.setStatus("COMPLETED"); + if (method != null && !method.isBlank()) { + payment.setMethod(method.toUpperCase()); + } + payment.setReceivedAt(OffsetDateTime.now()); + payment = paymentRepo.save(payment); + + order.setStatus("IN_PRODUCTION"); + order.setPaidAt(OffsetDateTime.now()); + orderRepo.save(order); + + eventPublisher.publishEvent(new PaymentConfirmedEvent(this, order, payment)); + + return payment; + } } diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/QrBillService.java index 430e193..71eb47c 100644 --- a/backend/src/main/java/com/printcalculator/service/QrBillService.java +++ b/backend/src/main/java/com/printcalculator/service/QrBillService.java @@ -22,7 +22,7 @@ public class QrBillService { // Creditor (Merchant) bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN bill.setCreditor(createAddress( - "Küng, Joe", + "Joe Küng", "Via G. Pioda 29a", "6710", "Biasca", @@ -49,10 +49,7 @@ public class QrBillService { bill.setAmount(order.getTotalChf()); bill.setCurrency("CHF"); - // Reference - // bill.setReference(QRBill.createCreditorReference("...")); // If using QRR - String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString(); - bill.setUnstructuredMessage("Order " + orderRef); + bill.setUnstructuredMessage(order.getId().toString()); return bill; } diff --git a/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java b/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java index 5c2448a..f4bf4fb 100644 --- a/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java +++ b/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java @@ -14,4 +14,16 @@ public interface EmailNotificationService { */ void sendEmail(String to, String subject, String templateName, Map contextData); + /** + * Sends an HTML email using a Thymeleaf template, with an optional attachment. + * + * @param to The recipient email address. + * @param subject The subject of the email. + * @param templateName The name of the Thymeleaf template (e.g., "order-confirmation"). + * @param contextData The data to populate the template with. + * @param attachmentName The name for the attachment file. + * @param attachmentData The raw bytes of the attachment. + */ + void sendEmailWithAttachment(String to, String subject, String templateName, Map contextData, String attachmentName, byte[] attachmentData); + } diff --git a/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java b/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java index c10fcce..42b716b 100644 --- a/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java +++ b/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java @@ -10,6 +10,7 @@ import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; +import org.springframework.core.io.ByteArrayResource; import java.util.Map; @@ -29,6 +30,11 @@ public class SmtpEmailNotificationService implements EmailNotificationService { @Override public void sendEmail(String to, String subject, String templateName, Map contextData) { + sendEmailWithAttachment(to, subject, templateName, contextData, null, null); + } + + @Override + public void sendEmailWithAttachment(String to, String subject, String templateName, Map contextData, String attachmentName, byte[] attachmentData) { if (!mailEnabled) { log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to); return; @@ -49,6 +55,10 @@ public class SmtpEmailNotificationService implements EmailNotificationService { helper.setSubject(subject); helper.setText(process, true); // true indicates HTML format + if (attachmentName != null && attachmentData != null) { + helper.addAttachment(attachmentName, new ByteArrayResource(attachmentData)); + } + emailSender.send(mimeMessage); log.info("Email successfully sent to {}", to); diff --git a/backend/src/main/resources/templates/invoice.html b/backend/src/main/resources/templates/invoice.html index 3f6cb55..f0ff3b5 100644 --- a/backend/src/main/resources/templates/invoice.html +++ b/backend/src/main/resources/templates/invoice.html @@ -31,6 +31,7 @@ .invoice-page { page: invoice; width: 100%; + page-break-after: always; } /* Top Header Layout */ @@ -48,17 +49,17 @@ .logo-block { width: 33%; - font-size: 20pt; + font-size: 24pt; font-weight: bold; - color: #005eb8; /* Brand blue similar to reference */ letter-spacing: -0.5px; } - .logo-d { - font-style: italic; - color: #d22630; - font-size: 22pt; - margin-right: 2px; + .logo-3d { + color: #111827; /* Dark black/blue */ + } + + .logo-fab { + color: #eab308; /* Yellow/Gold */ } .seller-block { @@ -231,17 +232,26 @@ /* QR Page */ .qr-only-page { page: qrpage; - position: relative; width: 100%; - height: 100%; + height: 297mm; background: #fff; - page-break-before: always; + page-break-inside: avoid; + } + + .qr-only-layout { + width: 100%; + height: 297mm; + border-collapse: collapse; + table-layout: fixed; + } + + .qr-only-layout td { + vertical-align: bottom; + /* Keep the QR slip at page bottom, but with a safer print margin. */ + padding: 0 0 1mm 0; } .qr-bill-bottom { - position: absolute; - left: 0; - bottom: 0; width: 100%; height: 105mm; overflow: hidden; @@ -262,7 +272,8 @@ - + @@ -308,10 +322,19 @@
- 3D-fab.ch + 3D fab +
Küng Caletti
3D Fab Switzerland
@@ -277,7 +288,10 @@
- Conferma dell'ordine 141052743 + Conferma dell'ordine + Fattura + 141052743 + PAGATO
@@ -299,7 +313,7 @@
Metodo di pagamentoQR / Bonifico oppure TWINTQR / Bonifico oppure TWINT
Valuta
+
Indirizzo di fatturazione:
Joe Küng
Via G.Pioda, 29a
6710 biasca
Svizzera
+
+
+
Indirizzo di spedizione:
+
Joe Küng
+
Via G.Pioda, 29a
+
6710 biasca
+
Svizzera
+
@@ -346,10 +369,14 @@ Totale di tutte le consegne e di tutti i servizi CHF 1'094.90 - + Importo dovuto 1'094.90 + + Importo dovuto + CHF 0.00 + @@ -372,8 +399,14 @@
-
-
+ + + + +
+
+
+
diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index d55c70a..f2f7a9c 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -170,6 +170,16 @@ export class QuoteEstimatorService { }); } + getOrderConfirmation(orderId: string): Observable { + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, { + headers, + responseType: 'blob' + }); + } + getTwintPayment(orderId: string): Observable { const headers: any = {}; // @ts-ignore diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index cb51108..55b522b 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -11,26 +11,26 @@
-
1
{{ 'TRACKING.STEP_PENDING' | translate }}
-
2
{{ 'TRACKING.STEP_REPORTED' | translate }}
-
3
{{ 'TRACKING.STEP_PRODUCTION' | translate }}
-
4
{{ 'TRACKING.STEP_SHIPPED' | translate }}
@@ -87,18 +87,15 @@ align-items: center;" (click)="openTwintPayment()">
-
+

{{ 'PAYMENT.BANK_TITLE' | translate }}

-

{{ 'PAYMENT.BANK_OWNER' | translate }}: Küng, Joe

-

{{ 'PAYMENT.BANK_IBAN' | translate }}: CH74 0900 0000 1548 2158 1

-

{{ 'PAYMENT.BANK_REF' | translate }}: {{ getDisplayOrderNumber(o) }}

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

- +
- + {{ 'PAYMENT.DOWNLOAD_QR' | translate }}
@@ -138,6 +135,7 @@ align-items: center;" (click)="openTwintPayment()"> {{ o.totalChf | currency:'CHF' }}
+
diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts index f159b7d..5375e83 100644 --- a/frontend/src/app/features/order/order.component.ts +++ b/frontend/src/app/features/order/order.component.ts @@ -58,21 +58,21 @@ export class OrderComponent implements OnInit { this.selectedPaymentMethod = method; } - downloadInvoice() { + downloadQrInvoice() { const orderId = this.orderId; if (!orderId) return; - this.quoteService.getOrderInvoice(orderId).subscribe({ + this.quoteService.getOrderConfirmation(orderId).subscribe({ next: (blob) => { const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const fallbackOrderNumber = this.extractOrderNumber(orderId); const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber; - a.download = `invoice-${orderNumber}.pdf`; + a.download = `qr-invoice-${orderNumber}.pdf`; a.click(); window.URL.revokeObjectURL(url); }, - error: (err) => console.error('Failed to download invoice', err) + error: (err) => console.error('Failed to download QR invoice', err) }); } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 20e1b6a..94f4eb5 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -199,6 +199,7 @@ "BANK_REF": "Reference", "BILLING_INFO_HINT": "Add the same information used in billing.", "DOWNLOAD_QR": "Download QR-Invoice (PDF)", + "DOWNLOAD_CONFIRMATION": "Download Confirmation (PDF)", "CONFIRM": "I have completed the payment", "SUMMARY_TITLE": "Order Summary", "SUBTOTAL": "Subtotal", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 4526a0d..17b0de1 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -271,6 +271,7 @@ "BANK_REF": "Riferimento", "BILLING_INFO_HINT": "Abbiamo compilato i campi per te, per favore non modificare il motivo del pagamento", "DOWNLOAD_QR": "Scarica QR-Fattura (PDF)", + "DOWNLOAD_CONFIRMATION": "Scarica Conferma (PDF)", "CONFIRM": "Ho completato il pagamento", "SUMMARY_TITLE": "Riepilogo Ordine", "SUBTOTAL": "Subtotale",