Response
# Implementation
```java
package com.acme.billing;
/** Thrown when no invoice exists for the given id. */
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " was not found");
}
}
```
```java
package com.acme.billing;
/** Thrown when the requesting customer does not own the invoice. */
public class InvoiceAccessDeniedException extends RuntimeException {
public InvoiceAccessDeniedException(long invoiceId) {
super("Invoice " + invoiceId + " does not belong to the requesting customer");
}
}
```
```java
package com.acme.billing;
/** Thrown when an invoice is no longer eligible for cancellation. */
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus status) {
super("Invoice " + invoiceId + " cannot be cancelled while in status " + status);
}
}
```
```java
package com.acme.billing;
public record ApiError(String code, String message) {
}
```
```java
package com.acme.billing;
public record ApiResponse<T>(boolean success, T data, ApiError error) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(false, null, new ApiError(code, message));
}
}
```
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvoiceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(InvoiceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("INVOICE_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(InvoiceAccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(InvoiceAccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("INVOICE_ACCESS_DENIED", ex.getMessage()));
}
@ExceptionHandler(InvoiceNotCancellableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotCancellable(InvoiceNotCancellableException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error("INVOICE_NOT_CANCELLABLE", ex.getMessage()));
}
}
```
```java
package com.acme.billing;
public final class InvoiceDtos {
private InvoiceDtos() {
}
public record CancelledInvoice(long invoiceId, InvoiceStatus status) {
}
}
```
```java
package com.acme.billing;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Transactional
public InvoiceDtos.CancelledInvoice cancelInvoice(long invoiceId, long customerId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new InvoiceNotFoundException(invoiceId));
if (invoice.customerId() != customerId) {
throw new InvoiceAccessDeniedException(invoiceId);
}
if (invoice.status() != InvoiceStatus.UNPAID) {
throw new InvoiceNotCancellableException(invoiceId, invoice.status());
}
invoice.markCancelled();
return new InvoiceDtos.CancelledInvoice(invoiceId, InvoiceStatus.CANCELLED);
}
}
```
```java
package com.acme.billing;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceService invoiceService;
public InvoiceController(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@PostMapping("/{id}/cancel")
public ApiResponse<InvoiceDtos.CancelledInvoice> cancel(
@PathVariable("id") long id,
@RequestAttribute("customerId") long customerId) {
return ApiResponse.ok(invoiceService.cancelInvoice(id, customerId));
}
}
```
**Convention notes:** Constructor injection throughout, `InvoiceService` enforces both rules (existence → ownership → unpaid, in that order) even though the route could also be gated upstream, and all failures are domain exceptions mapped by one `@RestControllerAdvice` to `ApiResponse.error(...)` rather than try/catch in the controller.
Two assumptions worth flagging: `InvoiceStatus` (with `UNPAID`/`CANCELLED`) is assumed to already exist alongside `Invoice`/`InvoiceRepository`, and the controller's `customerId` resolution (`@RequestAttribute`) is a placeholder for whatever your auth filter already does — swap it for your real mechanism. Also, returning `403` for a mismatched owner reveals that the invoice id exists; if that's a concern for this domain, map `InvoiceAccessDeniedException` to `404` instead.