Response
Added invoice cancellation to `com.acme.billing`. Full source below.
```java
package com.acme.billing;
public record ApiResponse<T>(boolean success, T data, PageMeta page, ApiError error) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, null);
}
public static <T> ApiResponse<T> ok(T data, PageMeta page) {
return new ApiResponse<>(true, data, page, null);
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(false, null, null, new ApiError(code, message));
}
public record ApiError(String code, String message) {}
public record PageMeta(int page, int size, long total) {
public static PageMeta of(int page, int size, long total) {
return new PageMeta(page, size, total);
}
}
}
```
```java
package com.acme.billing;
/**
* Thrown when an invoice id does not resolve to an invoice owned by the
* calling customer. Deliberately used for both "no such invoice" and
* "invoice belongs to someone else" so the API never discloses that a
* given invoice id exists under another customer's account.
*/
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " not found");
}
}
```
```java
package com.acme.billing;
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId) {
super("Invoice " + invoiceId + " is no longer cancellable");
}
}
```
```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(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.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(found -> found.customerId() == customerId)
.orElseThrow(() -> new InvoiceNotFoundException(invoiceId));
if (invoice.status() != InvoiceStatus.UNPAID) {
throw new InvoiceNotCancellableException(invoiceId);
}
invoice.markCancelled();
}
}
```
Convention notes:
- Ownership check is folded into the `findById(...).filter(...)` lookup and both "missing" and "not yours" map to the same `InvoiceNotFoundException` → 404, rather than a separate 403, so the endpoint doesn't leak which invoice ids exist under other customers' accounts (IDOR-style enumeration).
- `InvoiceStatus` (with an `UNPAID` constant) is assumed to already exist alongside `Invoice`/`InvoiceRepository`, same as those were given; `ApiResponse` was not stated to exist so I defined it to match the house error-wrapping convention.