Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,34 @@ credentials `freight/freight`).
The server starts on **http://localhost:8080**. On first boot, `data.sql` seeds sample ports, a
vessel, and containers.

### 3. Try the API
### 3. Configure Email (Optional - for invoice sending)

The application includes email functionality using SMTP. For local development, use **MailHog** to capture emails without sending them:

**Start MailHog:**

```bash
docker run --rm -p 1025:1025 -p 8025:8025 mailhog/mailhog
```

- SMTP server: `localhost:1025` (configured in `application.properties`)
- Web UI: **http://localhost:8025** — view all captured emails in real-time

The `application.properties` is pre-configured:
```properties
spring.mail.host=localhost
spring.mail.port=1025
app.email.enabled=true
app.email.from-address=noreply@apgl-shipping.com
app.email.reply-to=support@apgl-shipping.com
```

To disable email sending (e.g., in tests):
```properties
app.email.enabled=false
```

### 4. Try the API

**Create a vessel:**

Expand Down Expand Up @@ -116,6 +143,13 @@ curl -X POST http://localhost:8080/api/v1/freight-orders \
curl http://localhost:8080/api/v1/freight-orders/1/invoice --output invoice.pdf
```

**Send invoice to customer email:**
```bash
curl -X POST http://localhost:8080/api/v1/invoices/1/send
```

(Requires order status = DELIVERED; email is sent to customer's registered email address)

## Running Tests

Tests use an **H2 in-memory database** — no PostgreSQL needed.
Expand Down
46 changes: 45 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

<properties>
<java.version>21</java.version>
<mockito.version>5.14.2</mockito.version>
<byte-buddy.version>1.15.10</byte-buddy.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -91,6 +93,10 @@
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!--APGL dependcy for pdf gernation -->
<dependency>
<groupId>com.itextpdf</groupId>
Expand All @@ -114,6 +120,24 @@
<artifactId>rome</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>${byte-buddy.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand All @@ -122,7 +146,27 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Google Java Format check (optional, run with: mvn fmt:check) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
</configuration>
</plugin>
<!-- Google Java Format check (optional, run with: mvn fmt:check) -->
<plugin>
<groupId>com.spotify.fmt</groupId>
<artifactId>fmt-maven-plugin</artifactId>
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/com/shipping/freightops/config/EmailProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.shipping.freightops.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "app.email")
public class EmailProperties {

/** Whether email sending is enabled. Default: true */
private boolean enabled = true;

/** "From" address for all outgoing emails. Default: noreply@apgl-shipping.com */
private String fromAddress = "noreply@apgl-shipping.com";

/** "Reply-To" address for all outgoing emails. Default: support@apgl-shipping.com */
private String replyTo = "support@apgl-shipping.com";

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public String getFromAddress() {
return fromAddress;
}

public void setFromAddress(String fromAddress) {
this.fromAddress = fromAddress;
}

public String getReplyTo() {
return replyTo;
}

public void setReplyTo(String replyTo) {
this.replyTo = replyTo;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.shipping.freightops.controller;

import com.itextpdf.text.DocumentException;
import com.shipping.freightops.service.InvoiceEmailService;
import com.shipping.freightops.service.InvoiceService;
import java.io.FileNotFoundException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/invoices")
public class InvoiceController {

@Autowired private InvoiceService invoiceService;

@Autowired private InvoiceEmailService invoiceEmailService;

@GetMapping("/{orderId}")
public ResponseEntity<byte[]> getInvoice(@PathVariable Long orderId)
throws DocumentException, FileNotFoundException {
byte[] pdfBytes = invoiceService.generateInvoice(orderId);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDisposition(
ContentDisposition.attachment().filename("invoice-" + orderId + ".pdf").build());
headers.setContentLength(pdfBytes.length);

return ResponseEntity.ok().headers(headers).body(pdfBytes);
}

@PostMapping("/{orderId}/send")
public ResponseEntity<String> sendInvoiceToCustomer(@PathVariable Long orderId)
throws DocumentException, FileNotFoundException {
invoiceEmailService.sendInvoiceToCustomer(orderId);
return ResponseEntity.ok("Invoice sent successfully to customer email");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public TrackingEvent(
String performedBy,
LocalDateTime eventTime) {
this.freightOrder = freightOrder;
this.eventType = eventType;
this.description = description;
this.location = location;
this.performedBy = performedBy;
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/shipping/freightops/service/EmailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.shipping.freightops.service;

public interface EmailService {

void sendEmail(String to, String subject, String body);

void sendEmailWithAttachment(
String to,
String subject,
String body,
String attachmentName,
byte[] attachmentContent,
String mimeType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.shipping.freightops.service;

import com.itextpdf.text.DocumentException;
import com.shipping.freightops.entity.FreightOrder;
import com.shipping.freightops.enums.OrderStatus;
import com.shipping.freightops.repository.FreightOrderRepository;
import java.io.FileNotFoundException;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
public class InvoiceEmailService {

private final FreightOrderRepository freightOrderRepository;

private final InvoiceService invoiceService;

private final EmailService emailService;

@Transactional
public void sendInvoiceToCustomer(Long orderId) throws DocumentException, FileNotFoundException {
FreightOrder order = getOrderOrThrow(orderId);

validateDelivered(order);

byte[] invoicePdf = invoiceService.generateInvoice(orderId);

sendInvoiceEmail(order, invoicePdf);

log.info("Invoice sent to customer: {}", order.getCustomer().getEmail());
}

/** Fetch order or throw if not found */
private FreightOrder getOrderOrThrow(Long orderId) {
return freightOrderRepository
.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
}

/** Ensure order is in DELIVERED status */
private void validateDelivered(FreightOrder order) {
if (order.getStatus() != OrderStatus.DELIVERED) {
throw new IllegalStateException(
"Cannot send invoice for order in "
+ order.getStatus()
+ " status. Order must be DELIVERED.");
}
}

/** Build email request with subject, body, and attachment */
private void sendInvoiceEmail(FreightOrder order, byte[] invoicePdf) {
String invoiceNo = generateInvoiceNumber(order);
String voyageNo = generateVoyageNumber(order);
String subject = String.format("Invoice %s - Voyage %s", invoiceNo, voyageNo);
String body =
String.format(
"Dear %s,\n\nPlease find your invoice attached. Thank you for your business.\n\nBest regards,\nAPGL Freight Operations",
order.getCustomer().getCompanyName());

emailService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
subject,
body,
invoiceNo + ".pdf",
invoicePdf,
"application/pdf");
}

/** Generate invoice number based on year and order ID */
private String generateInvoiceNumber(FreightOrder order) {
return String.format("INV-%d-%05d", LocalDate.now().getYear(), order.getId());
}

/** Generate voyage number in format VOY-XXXXX */
private String generateVoyageNumber(FreightOrder order) {
return String.format("VOY-%05d", order.getVoyage().getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.shipping.freightops.service.impl;

import com.shipping.freightops.service.EmailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@ConditionalOnMissingBean(JavaMailSender.class)
public class NoOpEmailService implements EmailService {

@Override
public void sendEmail(String to, String subject, String body) {
log.debug("NoOp email service: skipping email to {} with subject '{}'", to, subject);
}

@Override
public void sendEmailWithAttachment(
String to,
String subject,
String body,
String attachmentName,
byte[] attachmentContent,
String mimeType) {
log.debug(
"NoOp email service: skipping email to {} with subject '{}' and attachment '{}'",
to,
subject,
attachmentName);
}
}
Loading
Loading