Response
Here's the complete implementation: the service, the domain exceptions it throws, and the advice that turns them into HTTP error responses. `Invoice`, `InvoiceRepository`, and `InvoiceStatus` (used only for the `UNPAID` constant) are assumed to already exist, per your prompt.
```java
package com.acme.billing;
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public void 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();
}
}
```
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
public abstract class BillingException extends RuntimeException {
private final HttpStatus status;
protected BillingException(String message, HttpStatus status) {
super(message);
this.status = status;
}
public HttpStatus status() {
return status;
}
}
```
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
public class InvoiceNotFoundException extends BillingException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " was not found", HttpStatus.NOT_FOUND);
}
}
```
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
public class InvoiceAccessDeniedException extends BillingException {
public InvoiceAccessDeniedException(long invoiceId) {
super("Invoice " + invoiceId + " does not belong to the current customer", HttpStatus.FORBIDDEN);
}
}
```
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
public class InvoiceNotCancellableException extends BillingException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus status) {
super("Invoice " + invoiceId + " cannot be cancelled because it is " + status, HttpStatus.CONFLICT);
}
}
```
```java
package com.acme.billing;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class BillingExceptionHandler {
@ExceptionHandler(BillingException.class)
public ResponseEntity<ApiResponse<Void>> handleBillingException(BillingException ex) {
return ResponseEntity.status(ex.status()).body(ApiResponse.error(ex.getMessage()));
}
}
```
```java
package com.acme.billing;
public record ApiResponse<T>(boolean success, T data, String error) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message);
}
}
```
Notes: ownership and unpaid-status are both checked in the service (not the controller) per the "two locks on the door" rule, and each failure is a distinct exception type so the advice can map it to the right HTTP status instead of a generic 400/500. If `ApiResponse` already exists in your codebase, drop the last file and reuse yours.