Response
Since this is straightforward Spring Boot code generation from a fully specified contract (no existing repo to explore, no unfamiliar codebase conventions to check), I'll write it directly rather than delegating.
One design call worth flagging: for "may cancel only their own invoice," I return **404** (not 403) when the invoice belongs to someone else — otherwise the endpoint would leak the *existence* of other customers' invoice IDs via a distinguishable 403 response. Cancelling a paid/already-cancelled invoice returns **409 Conflict**.
```java
// InvoiceStatus.java
// Assumed to already exist in the domain model — shown here only so the
// snippets below compile standalone.
package com.acme.billing;
public enum InvoiceStatus {
UNPAID,
PAID,
CANCELLED
}
```
```java
// InvoiceNotFoundException.java
package com.acme.billing;
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " was not found");
}
}
```
```java
// InvoiceNotCancellableException.java
package com.acme.billing;
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus status) {
super("Invoice " + invoiceId + " cannot be cancelled because it is " + status);
}
}
```
```java
// InvoiceCancellationService.java
package com.acme.billing;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InvoiceCancellationService {
private final InvoiceRepository invoiceRepository;
public InvoiceCancellationService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Transactional
public void cancelInvoice(long invoiceId, long customerId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.filter(inv -> inv.customerId() == customerId)
.orElseThrow(() -> new InvoiceNotFoundException(invoiceId));
if (invoice.status() != InvoiceStatus.UNPAID) {
throw new InvoiceNotCancellableException(invoiceId, invoice.status());
}
invoice.markCancelled();
}
}
```
```java
// InvoiceExceptionHandler.java
package com.acme.billing;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class InvoiceExceptionHandler {
@ExceptionHandler(InvoiceNotFoundException.class)
public ProblemDetail handleNotFound(InvoiceNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(InvoiceNotCancellableException.class)
public ProblemDetail handleNotCancellable(InvoiceNotCancellableException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
}
```
```java
// InvoiceController.java
package com.acme.billing;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
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("/invoices")
public class InvoiceController {
private final InvoiceCancellationService cancellationService;
public InvoiceController(InvoiceCancellationService cancellationService) {
this.cancellationService = cancellationService;
}
@PostMapping("/{invoiceId}/cancel")
public ResponseEntity<Void> cancel(@PathVariable long invoiceId, Authentication authentication) {
long customerId = Long.parseLong(authentication.getName());
cancellationService.cancelInvoice(invoiceId, customerId);
return ResponseEntity.noContent().build();
}
}
```
Notes:
- `ProblemDetail` (RFC 7807, built into Spring 6/Boot 3) gives the client a structured JSON error body (`type`, `title`, `status`, `detail`) instead of a raw stack trace or generic 500 — that's the "proper error response" piece.
- The controller's `Authentication.getName()` → `customerId` parsing is a placeholder; wire it to however this module actually resolves the current customer's identity from the security context.
- No `@Transactional` boundary was assumed to already exist elsewhere, so it's added here to make `markCancelled()`'s persistence atomic with the checks.