Response
I don't have file-write permission in this environment, so I'll just print the source directly as requested rather than creating files.
Here's the invoice cancellation feature for `com.acme.billing`, following the house conventions (constructor injection, domain exceptions mapped by a `@RestControllerAdvice`, wrapped `ApiResponse`, thin controller).
```java
package com.acme.billing;
/**
* Minimal status enum assumed to already exist alongside Invoice.
* Included here only so this file set compiles standalone.
*/
public enum InvoiceStatus {
UNPAID,
PAID,
CANCELLED
}
```
```java
package com.acme.billing;
/**
* Thrown when the requested invoice does not exist.
*/
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " was not found");
}
}
```
```java
package com.acme.billing;
/**
* Thrown when a customer attempts to act on an invoice that does not belong to them.
*/
public class InvoiceAccessDeniedException extends RuntimeException {
public InvoiceAccessDeniedException(long invoiceId, long customerId) {
super("Customer " + customerId + " is not permitted to access invoice " + invoiceId);
}
}
```
```java
package com.acme.billing;
/**
* Thrown when an invoice cannot be cancelled because it is no longer unpaid.
*/
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus currentStatus) {
super("Invoice " + invoiceId + " cannot be cancelled while in status " + currentStatus);
}
}
```
```java
package com.acme.billing;
/**
* Business rules for invoice cancellation.
* Authorization is enforced here even though routes are also gated by SecurityConfig.
*/
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public InvoiceCancellationResult cancelInvoice(long customerId, long invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new InvoiceNotFoundException(invoiceId));
if (invoice.customerId() != customerId) {
throw new InvoiceAccessDeniedException(invoiceId, customerId);
}
if (invoice.status() != InvoiceStatus.UNPAID) {
throw new InvoiceNotCancellableException(invoiceId, invoice.status());
}
invoice.markCancelled();
return new InvoiceCancellationResult(invoiceId, InvoiceStatus.CANCELLED);
}
public record InvoiceCancellationResult(long invoiceId, InvoiceStatus status) {
}
}
```
```java
package com.acme.billing;
import com.acme.billing.common.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<InvoiceService.InvoiceCancellationResult>> cancelInvoice(
@PathVariable("id") long invoiceId,
CurrentCustomer currentCustomer) {
var result = invoiceService.cancelInvoice(currentCustomer.id(), invoiceId);
return ResponseEntity.ok(ApiResponse.ok(result));
}
/**
* Placeholder for however the caller's identity is resolved in this app
* (e.g. an @AuthenticationPrincipal-backed argument resolver).
*/
public record CurrentCustomer(long id) {
}
}
```
```java
package com.acme.billing.common;
/**
* Pagination metadata, included for ApiResponse list responses elsewhere in the module.
*/
public record PageMeta(int page, int size, long totalItems) {
public static PageMeta of(int page, int size, long totalItems) {
return new PageMeta(page, size, totalItems);
}
}
```
```java
package com.acme.billing;
import com.acme.billing.common.ApiResponse;
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 BillingExceptionHandler {
@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.common;
/**
* Standard response envelope for the billing API.
*/
public record ApiResponse<T>(boolean success, T data, PageMeta meta, 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 meta) {
return new ApiResponse<>(true, data, meta, 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) {
}
}
```
Convention decisions applied: authorization (ownership) and state checks live in `InvoiceService` as a "second lock" even though a route guard likely also exists; failures are domain exceptions caught by a single `@RestControllerAdvice` (`BillingExceptionHandler`) rather than try/catch in the controller, each mapped to the right HTTP status and wrapped in `ApiResponse.error(...)`.
Note: `InvoiceStatus` and the `CurrentCustomer` identity resolution are placeholders — your codebase almost certainly already defines the real status enum and principal-resolution mechanism, so drop my stand-ins in favor of those.