Response
Here's the full implementation — service, domain exceptions, the `@RestControllerAdvice` that turns them into `ApiResponse.error(...)`, and a thin controller wiring it up.
```java
package com.acme.billing;
public enum InvoiceStatus {
UNPAID,
PAID,
CANCELLED
}
```
```java
package com.acme.billing;
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice %d not found".formatted(invoiceId));
}
}
```
```java
package com.acme.billing;
public class InvoiceAccessDeniedException extends RuntimeException {
public InvoiceAccessDeniedException(long invoiceId) {
super("Invoice %d does not belong to the current customer".formatted(invoiceId));
}
}
```
```java
package com.acme.billing;
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus status) {
super("Invoice %d cannot be cancelled because it is %s".formatted(invoiceId, status));
}
}
```
```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 ApiResponse<Void> error(String code, String message) {
return new ApiResponse<>(false, null, new ApiError(code, message));
}
public record ApiError(String code, String message) {}
}
```
```java
package com.acme.billing;
import org.springframework.stereotype.Service;
@Service
public class InvoiceCancellationService {
private final InvoiceRepository invoiceRepository;
public InvoiceCancellationService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public void cancel(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();
}
}
```
```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 InvoiceExceptionHandler {
@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;
import org.springframework.http.ResponseEntity;
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/invoices")
public class InvoiceController {
private final InvoiceCancellationService invoiceCancellationService;
public InvoiceController(InvoiceCancellationService invoiceCancellationService) {
this.invoiceCancellationService = invoiceCancellationService;
}
@PostMapping("/{id}/cancel")
public ResponseEntity<ApiResponse<Void>> cancel(@PathVariable("id") long invoiceId,
@CurrentCustomerId long customerId) {
invoiceCancellationService.cancel(invoiceId, customerId);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}
```
Convention decisions: constructor injection (no Lombok/field injection); ownership and unpaid-status checks live in the service as the "second lock" even though the route would already be authenticated, with three distinct domain exceptions (404/403/409) mapped by a single `@RestControllerAdvice` into `ApiResponse.error(...)` rather than any try/catch in the controller.
Two assumptions I had to make since they weren't given: `InvoiceStatus` is an enum with an `UNPAID` value (status()'s return type wasn't specified), and `@CurrentCustomerId` is a pre-existing argument-resolver annotation your app uses to pull the authenticated customer's id — swap it for however you actually get `customerId` today.