Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [1.3.0] - 2026-06-07

### Added

- Add `receipts.toInvoice(Map<String, Object> data)` for `POST /receipts/to-invoice`.
- Add `receipts.previewToInvoicePdf(Map<String, Object> data)` for `POST /receipts/to-invoice/preview`.
- Expose structured API error metadata on `FacturapiException`, including `errorLocation`, `errors`, `logId`, and response `headers`.

## [1.2.0] - 2026-04-23

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>io.facturapi</groupId>
<artifactId>facturapi-java</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
<name>facturapi-java</name>
<description>Official Java SDK for Facturapi</description>
<url>https://github.com/facturapi/facturapi-java</url>
Expand Down
48 changes: 47 additions & 1 deletion src/main/java/io/facturapi/FacturapiException.java
Original file line number Diff line number Diff line change
@@ -1,26 +1,56 @@
package io.facturapi;

import com.fasterxml.jackson.databind.JsonNode;
import java.util.Collections;
import java.util.List;
import java.util.Map;

public class FacturapiException extends RuntimeException {
private final int statusCode;
private final Object errorCode;
private final String errorPath;
private final String errorLocation;
private final JsonNode errors;
private final String logId;
private final Map<String, List<String>> headers;

public FacturapiException(String message) {
this(message, -1, null, null);
this(message, -1, null, null, null, null, null, Collections.emptyMap());
}

public FacturapiException(String message, Throwable cause) {
super(message, cause);
this.statusCode = -1;
this.errorCode = null;
this.errorPath = null;
this.errorLocation = null;
this.errors = null;
this.logId = null;
this.headers = Collections.emptyMap();
}

public FacturapiException(String message, int statusCode, Object errorCode, String errorPath) {
this(message, statusCode, errorCode, errorPath, null, null, null, Collections.emptyMap());
}

public FacturapiException(
String message,
int statusCode,
Object errorCode,
String errorPath,
String errorLocation,
JsonNode errors,
String logId,
Map<String, List<String>> headers
) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
this.errorPath = errorPath;
this.errorLocation = errorLocation;
this.errors = errors;
this.logId = logId;
this.headers = headers == null ? Collections.emptyMap() : headers;
}

public int getStatusCode() {
Expand All @@ -34,4 +64,20 @@ public Object getErrorCode() {
public String getErrorPath() {
return errorPath;
}

public String getErrorLocation() {
return errorLocation;
}

public JsonNode getErrors() {
return errors;
}

public String getLogId() {
return logId;
}

public Map<String, List<String>> getHeaders() {
return headers;
}
}
32 changes: 27 additions & 5 deletions src/main/java/io/facturapi/http/FacturapiHttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ private JsonNode requestJsonNode(
Request request = buildRequest(method, path, queryParams, body, multipartBody);
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw buildApiException(readBodyText(response), response.code());
throw buildApiException(readBodyText(response), response);
}

ResponseBody responseBody = response.body();
Expand All @@ -131,7 +131,7 @@ private byte[] requestBytes(String method, String path, Object body) {
Request request = buildRequest(method, path, null, body, null);
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw buildApiException(readBodyText(response), response.code());
throw buildApiException(readBodyText(response), response);
}
ResponseBody responseBody = response.body();
return responseBody == null ? new byte[0] : responseBody.bytes();
Expand All @@ -146,7 +146,7 @@ private InputStream requestStream(String method, String path, Object body) {
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
try {
throw buildApiException(readBodyText(response), response.code());
throw buildApiException(readBodyText(response), response);
} finally {
response.close();
}
Expand Down Expand Up @@ -207,10 +207,13 @@ private static JsonNode firstDefined(JsonNode node, String... keys) {
return null;
}

private FacturapiException buildApiException(String bodyText, int statusCode) {
private FacturapiException buildApiException(String bodyText, Response response) {
int statusCode = response.code();
int resolvedStatus = statusCode;
Object errorCode = null;
String errorPath = null;
String errorLocation = null;
JsonNode errors = null;
String message = "Request failed with status " + statusCode;

if (bodyText != null && !bodyText.isBlank()) {
Expand Down Expand Up @@ -261,6 +264,16 @@ private FacturapiException buildApiException(String bodyText, int statusCode) {
errorPath = pathNode.asText();
}

JsonNode locationNode = firstDefined(root, "location");
if (locationNode != null && locationNode.isTextual()) {
errorLocation = locationNode.asText();
}

JsonNode errorsNode = firstDefined(root, "errors");
if (errorsNode != null && errorsNode.isArray()) {
errors = errorsNode;
}

if (firstDefined(root, "message", "error", "detail") == null) {
message = bodyText;
}
Expand All @@ -270,7 +283,16 @@ private FacturapiException buildApiException(String bodyText, int statusCode) {
}
}

return new FacturapiException(message, resolvedStatus, errorCode, errorPath);
return new FacturapiException(
message,
resolvedStatus,
errorCode,
errorPath,
errorLocation,
errors,
response.header("x-facturapi-log-id"),
response.headers().toMultimap()
);
}

private static String readBodyText(Response response) throws IOException {
Expand Down
11 changes: 10 additions & 1 deletion src/test/java/io/facturapi/FacturapiHttpClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ void returnsBinaryBytesForPdf() {
@Test
void throwsFacturapiExceptionWithApiMessage() {
StubHttpClient httpClient = new StubHttpClient();
httpClient.enqueueJson(400, "{\"message\":\"Invalid customer\",\"status\":\"400\",\"code\":\"validation_error\",\"path\":\"customer.tax_id\"}");
httpClient.enqueueJson(
400,
"{\"message\":\"Invalid customer\",\"status\":\"400\",\"code\":\"validation_error\",\"path\":\"customer.tax_id\",\"location\":\"body\",\"errors\":[{\"code\":\"required\",\"message\":\"tax id is required\",\"path\":\"customer.tax_id\",\"location\":\"body\"}]}",
Map.of("Retry-After", java.util.List.of("3"), "x-facturapi-log-id", java.util.List.of("log_123"))
);

FacturapiHttpClient client = new FacturapiHttpClient(
FacturapiConfig.builder("sk_test_123")
Expand All @@ -67,5 +71,10 @@ void throwsFacturapiExceptionWithApiMessage() {
assertTrue(ex.getMessage().contains("Invalid customer"));
assertEquals("validation_error", ex.getErrorCode());
assertEquals("customer.tax_id", ex.getErrorPath());
assertEquals("body", ex.getErrorLocation());
assertEquals("log_123", ex.getLogId());
assertEquals("required", ex.getErrors().get(0).get("code").asText());
assertEquals("3", ex.getHeaders().get("Retry-After").get(0));
assertEquals("log_123", ex.getHeaders().get("x-facturapi-log-id").get(0));
}
}
8 changes: 7 additions & 1 deletion src/test/java/io/facturapi/StubHttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,17 @@ OkHttpClient client() {
}

void enqueueJson(int statusCode, String json) {
enqueueJson(statusCode, json, Map.of());
}

void enqueueJson(int statusCode, String json, Map<String, List<String>> extraHeaders) {
Map<String, List<String>> headers = new java.util.HashMap<>(extraHeaders);
headers.put("Content-Type", List.of("application/json"));
queue.add(
new QueuedResponse(
statusCode,
json.getBytes(StandardCharsets.UTF_8),
Map.of("Content-Type", List.of("application/json"))
headers
)
);
}
Expand Down
Loading