diff --git a/CHANGELOG.md b/CHANGELOG.md index ba72fd2..f13e13c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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). +## [2.0.0] - 2026-08-24 + +### Added + +- Add draft support methods for retentions: `retentions.updateDraft(String id, Map data)`, `retentions.copyToDraft(String id)`, `retentions.stampDraft(String id)`, and `retentions.cancel(String id)`. + +- Add `invoices.createZipRequest(Map data)` for `POST /invoices/zip-requests`. +- Add `invoices.listZipRequests(Map params)` for `GET /invoices/zip-requests`. +- Add `invoices.retrieveZipRequest(String id)` for `GET /invoices/zip-requests/{id}`. +- Add `invoices.downloadZipRequestStream(String id)` for streaming `GET /invoices/zip-requests/{id}/zip`. + +### Changed + +- Change `FacturapiException.getErrorCode()` to return strings, including converted legacy numeric codes. +- Change `InvoiceItem.property_tax_account` to a list of property tax account numbers. + ## [1.3.0] - 2026-06-07 ### Added diff --git a/README.es.md b/README.es.md index 0e1dc20..d5ba3bd 100644 --- a/README.es.md +++ b/README.es.md @@ -29,14 +29,14 @@ Maven: io.facturapi facturapi-java - 1.1.0 + 2.0.0 ``` Gradle: ```gradle -implementation("io.facturapi:facturapi-java:1.1.0") +implementation("io.facturapi:facturapi-java:2.0.0") ``` ## Inicio rĂ¡pido diff --git a/README.md b/README.md index fe0f67b..b2ca541 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,14 @@ Maven: io.facturapi facturapi-java - 1.1.0 + 2.0.0 ``` Gradle: ```gradle -implementation("io.facturapi:facturapi-java:1.1.0") +implementation("io.facturapi:facturapi-java:2.0.0") ``` ## Quickstart diff --git a/pom.xml b/pom.xml index 4235f4a..a69187f 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.facturapi facturapi-java - 1.3.0 + 2.0.0 facturapi-java Official Java SDK for Facturapi https://github.com/facturapi/facturapi-java diff --git a/src/main/java/io/facturapi/FacturapiException.java b/src/main/java/io/facturapi/FacturapiException.java index 0e0839c..482baed 100644 --- a/src/main/java/io/facturapi/FacturapiException.java +++ b/src/main/java/io/facturapi/FacturapiException.java @@ -7,7 +7,7 @@ public class FacturapiException extends RuntimeException { private final int statusCode; - private final Object errorCode; + private final String errorCode; private final String errorPath; private final String errorLocation; private final JsonNode errors; @@ -29,14 +29,14 @@ public FacturapiException(String message, Throwable cause) { this.headers = Collections.emptyMap(); } - public FacturapiException(String message, int statusCode, Object errorCode, String errorPath) { + public FacturapiException(String message, int statusCode, String errorCode, String errorPath) { this(message, statusCode, errorCode, errorPath, null, null, null, Collections.emptyMap()); } public FacturapiException( String message, int statusCode, - Object errorCode, + String errorCode, String errorPath, String errorLocation, JsonNode errors, @@ -57,7 +57,7 @@ public int getStatusCode() { return statusCode; } - public Object getErrorCode() { + public String getErrorCode() { return errorCode; } diff --git a/src/main/java/io/facturapi/http/FacturapiHttpClient.java b/src/main/java/io/facturapi/http/FacturapiHttpClient.java index 0fee0d7..a25c3b4 100644 --- a/src/main/java/io/facturapi/http/FacturapiHttpClient.java +++ b/src/main/java/io/facturapi/http/FacturapiHttpClient.java @@ -210,7 +210,7 @@ private static JsonNode firstDefined(JsonNode node, String... keys) { private FacturapiException buildApiException(String bodyText, Response response) { int statusCode = response.code(); int resolvedStatus = statusCode; - Object errorCode = null; + String errorCode = null; String errorPath = null; String errorLocation = null; JsonNode errors = null; @@ -245,18 +245,8 @@ private FacturapiException buildApiException(String bodyText, Response response) } JsonNode codeNode = firstDefined(root, "code"); - if (codeNode != null && !codeNode.isNull()) { - if (codeNode.isTextual()) { - errorCode = codeNode.asText(); - } else if (codeNode.isIntegralNumber()) { - errorCode = codeNode.intValue(); - } else if (codeNode.isNumber()) { - errorCode = codeNode.numberValue(); - } else if (codeNode.isBoolean()) { - errorCode = codeNode.asBoolean(); - } else { - errorCode = codeNode.toString(); - } + if (codeNode != null && (codeNode.isTextual() || codeNode.isNumber())) { + errorCode = codeNode.asText(); } JsonNode pathNode = firstDefined(root, "path"); diff --git a/src/main/java/io/facturapi/models/InvoiceItem.java b/src/main/java/io/facturapi/models/InvoiceItem.java index adcb675..dd35db8 100644 --- a/src/main/java/io/facturapi/models/InvoiceItem.java +++ b/src/main/java/io/facturapi/models/InvoiceItem.java @@ -14,7 +14,8 @@ public class InvoiceItem { private InvoiceItemThirdParty thirdParty; private String complement; private List parts = new ArrayList<>(); - private String propertyTaxAccount; + @JsonProperty("property_tax_account") + private List propertyTaxAccounts = new ArrayList<>(); public Double getQuantity() { return quantity; } public void setQuantity(Double quantity) { this.quantity = quantity; } @@ -30,6 +31,8 @@ public class InvoiceItem { public void setComplement(String complement) { this.complement = complement; } public List getParts() { return parts; } public void setParts(List parts) { this.parts = parts; } - public String getPropertyTaxAccount() { return propertyTaxAccount; } - public void setPropertyTaxAccount(String propertyTaxAccount) { this.propertyTaxAccount = propertyTaxAccount; } + @JsonProperty("property_tax_account") + public List getPropertyTaxAccounts() { return propertyTaxAccounts; } + @JsonProperty("property_tax_account") + public void setPropertyTaxAccounts(List propertyTaxAccounts) { this.propertyTaxAccounts = propertyTaxAccounts; } } diff --git a/src/main/java/io/facturapi/resources/InvoicesResource.java b/src/main/java/io/facturapi/resources/InvoicesResource.java index f0d2911..6dac015 100644 --- a/src/main/java/io/facturapi/resources/InvoicesResource.java +++ b/src/main/java/io/facturapi/resources/InvoicesResource.java @@ -142,6 +142,63 @@ public InputStream downloadZipStream(String id) { return client.getStream("/invoices/" + id + "/zip"); } + /** + * Creates a ZIP request or returns the existing request for the same criteria. + * + * @param data ZIP request criteria. + * @return ZIP request data. + * @see API reference + */ + public Map createZipRequest(Map data) { + return post( + "/invoices/zip-requests", + data, + null, + new TypeReference>() {} + ); + } + + /** + * Gets a paginated list of ZIP requests. + * + * @param params Search and pagination parameters. + * @return Paginated ZIP request result. + * @see API reference + */ + public SearchResult> listZipRequests(Map params) { + return get( + "/invoices/zip-requests", + params, + new TypeReference>>() {} + ); + } + + /** + * Gets a ZIP request by id. + * + * @param id ZIP request id. + * @return ZIP request data. + * @see API reference + */ + public Map retrieveZipRequest(String id) { + return get( + "/invoices/zip-requests/" + id, + null, + new TypeReference>() {} + ); + } + + /** + * Downloads the ZIP generated for a request. + * + * @param id ZIP request id. + * @return ZIP stream. Caller owns closing it. + * @see API reference + */ + public InputStream downloadZipRequestStream(String id) { + return client.getStream("/invoices/zip-requests/" + id + "/zip"); + } + /** * Downloads the cancellation receipt XML file. * diff --git a/src/main/java/io/facturapi/resources/RetentionsResource.java b/src/main/java/io/facturapi/resources/RetentionsResource.java index 4fd50fc..5eafbd7 100644 --- a/src/main/java/io/facturapi/resources/RetentionsResource.java +++ b/src/main/java/io/facturapi/resources/RetentionsResource.java @@ -19,7 +19,7 @@ public RetentionsResource(FacturapiHttpClient client) { } /** - * Creates a new valid retention (CFDI). + * Creates a new retention (CFDI). * * @param data Retention payload. * @return Created retention. @@ -63,6 +63,51 @@ public Retention cancel(String id, Map params) { return delete("/retentions/" + id, params, Retention.class); } + /** + * Cancels a retention, or deletes it directly when it is a draft. + * + * @param id Retention id. + * @return Canceled or deleted retention. + * @see API reference + */ + public Retention cancel(String id) { + return delete("/retentions/" + id, null, Retention.class); + } + + /** + * Updates a draft retention. + * + * @param id Retention id. + * @param data Retention updates. + * @return Updated draft retention. + * @see API reference + */ + public Retention updateDraft(String id, Map data) { + return put("/retentions/" + id, data, null, Retention.class); + } + + /** + * Creates a draft copy from an existing retention. + * + * @param id Retention id. + * @return Draft retention. + * @see API reference + */ + public Retention copyToDraft(String id) { + return post("/retentions/" + id + "/copy", null, null, Retention.class); + } + + /** + * Stamps an existing draft retention. + * + * @param id Retention id. + * @return Stamped retention. + * @see API reference + */ + public Retention stampDraft(String id) { + return post("/retentions/" + id + "/stamp", null, null, Retention.class); + } + /** * Sends the retention to the customer's email. * diff --git a/src/test/java/io/facturapi/FacturapiHttpClientTest.java b/src/test/java/io/facturapi/FacturapiHttpClientTest.java index 4917970..e8d6eac 100644 --- a/src/test/java/io/facturapi/FacturapiHttpClientTest.java +++ b/src/test/java/io/facturapi/FacturapiHttpClientTest.java @@ -77,4 +77,23 @@ void throwsFacturapiExceptionWithApiMessage() { assertEquals("3", ex.getHeaders().get("Retry-After").get(0)); assertEquals("log_123", ex.getHeaders().get("x-facturapi-log-id").get(0)); } + + @Test + void convertsNumericApiErrorCodesToStrings() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(400, "{\"message\":\"Invalid customer\",\"code\":400}"); + + FacturapiHttpClient client = new FacturapiHttpClient( + FacturapiConfig.builder("sk_test_123") + .httpClient(httpClient.client()) + .build() + ); + + FacturapiException ex = assertThrows( + FacturapiException.class, + () -> client.get("/customers/cus_1", null, GenericResponse.class) + ); + + assertEquals("400", ex.getErrorCode()); + } } diff --git a/src/test/java/io/facturapi/FacturapiResourcesTest.java b/src/test/java/io/facturapi/FacturapiResourcesTest.java index b523184..ef43828 100644 --- a/src/test/java/io/facturapi/FacturapiResourcesTest.java +++ b/src/test/java/io/facturapi/FacturapiResourcesTest.java @@ -16,10 +16,12 @@ import io.facturapi.enums.Taxability; import io.facturapi.http.FacturapiConfig; import io.facturapi.models.Customer; +import io.facturapi.models.InvoiceItem; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDate; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -112,6 +114,192 @@ void invoicePdfCanBeStreamed() throws Exception { assertEquals("/v2/invoices/inv_1/pdf", request.uri().getPath()); } + @Test + void retentionDraftCreateSupportsDraftStatus() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(200, "{\"id\":\"ret_draft_1\",\"status\":\"draft\"}"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + Map payload = new java.util.HashMap<>(); + payload.put("status", "draft"); + payload.put("customer", null); + + var response = sdk.retentions().create(payload); + + assertEquals("ret_draft_1", response.getId()); + assertEquals("draft", response.getStatus()); + var request = httpClient.requests().get(0); + assertEquals("POST", request.method()); + assertEquals("/v2/retentions", request.uri().getPath()); + assertTrue(request.bodyUtf8().contains("\"status\":\"draft\"")); + assertTrue(request.bodyUtf8().contains("\"customer\":null")); + } + + @Test + void retentionDraftUpdateUsesExpectedPath() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(200, "{\"id\":\"ret_1\",\"folio_int\":\"R-2026-001\"}"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + var response = sdk.retentions().updateDraft("ret_1", Map.of("folio_int", "R-2026-001")); + + assertEquals("ret_1", response.getId()); + assertEquals("R-2026-001", response.getFolioInt()); + var request = httpClient.requests().get(0); + assertEquals("PUT", request.method()); + assertEquals("/v2/retentions/ret_1", request.uri().getPath()); + assertTrue(request.bodyUtf8().contains("\"folio_int\":\"R-2026-001\"")); + } + + @Test + void retentionDraftCopyAndStampUseExpectedPaths() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(200, "{\"id\":\"ret_copy_1\",\"status\":\"draft\"}"); + httpClient.enqueueJson(200, "{\"id\":\"ret_1\",\"status\":\"valid\"}"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + var draft = sdk.retentions().copyToDraft("ret_1"); + var stamped = sdk.retentions().stampDraft("ret_copy_1"); + + assertEquals("ret_copy_1", draft.getId()); + assertEquals("ret_1", stamped.getId()); + assertEquals("POST", httpClient.requests().get(0).method()); + assertEquals("/v2/retentions/ret_1/copy", httpClient.requests().get(0).uri().getPath()); + assertEquals("POST", httpClient.requests().get(1).method()); + assertEquals("/v2/retentions/ret_copy_1/stamp", httpClient.requests().get(1).uri().getPath()); + } + + @Test + void retentionDraftCancelAndListUseExpectedPaths() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(200, "{\"id\":\"ret_draft_1\",\"status\":\"canceled\"}"); + httpClient.enqueueJson(200, "{\"data\":[{\"id\":\"ret_draft_2\",\"status\":\"draft\"}]}"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + var deleted = sdk.retentions().cancel("ret_draft_1"); + var drafts = sdk.retentions().list(Map.of("status", "draft")); + + assertEquals("ret_draft_1", deleted.getId()); + assertEquals(1, drafts.getData().size()); + assertEquals("DELETE", httpClient.requests().get(0).method()); + assertEquals("/v2/retentions/ret_draft_1", httpClient.requests().get(0).uri().getPath()); + var listRequest = httpClient.requests().get(1); + assertEquals("GET", listRequest.method()); + assertEquals("/v2/retentions?status=draft", listRequest.uri().getPath() + "?" + listRequest.uri().getQuery()); + } + + @Test + void invoiceZipRequestCanBeCreated() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(200, "{\"id\":\"zip_1\",\"status\":\"pending\"}"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + var response = sdk.invoices().createZipRequest( + Map.of( + "year", 2025, + "month", 3, + "issuer_type", "issuing", + "invoice_types", java.util.List.of("I", "E") + ) + ); + + assertEquals("zip_1", response.get("id")); + var request = httpClient.requests().get(0); + assertEquals("POST", request.method()); + assertEquals("/v2/invoices/zip-requests", request.uri().getPath()); + assertTrue(request.bodyUtf8().contains("\"issuer_type\":\"issuing\"")); + } + + @Test + void invoiceZipRequestsCanBeListed() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson( + 200, + "{\"page\":1,\"total_pages\":1,\"total_results\":1,\"data\":[{\"id\":\"zip_1\"}]}" + ); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + var response = sdk.invoices().listZipRequests( + Map.of("year", 2025, "month", 3, "status", "finished", "limit", 20, "page", 1) + ); + + assertEquals("zip_1", response.getData().get(0).get("id")); + var request = httpClient.requests().get(0); + assertEquals("GET", request.method()); + assertEquals("/v2/invoices/zip-requests", request.uri().getPath()); + assertTrue(request.uri().getQuery().contains("year=2025")); + assertTrue(request.uri().getQuery().contains("status=finished")); + } + + @Test + void invoiceZipRequestCanBeRetrieved() { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueJson(200, "{\"id\":\"zip_1\",\"status\":\"finished\"}"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + var response = sdk.invoices().retrieveZipRequest("zip_1"); + + assertEquals("finished", response.get("status")); + var request = httpClient.requests().get(0); + assertEquals("GET", request.method()); + assertEquals("/v2/invoices/zip-requests/zip_1", request.uri().getPath()); + } + + @Test + void invoiceZipRequestCanBeDownloaded() throws Exception { + StubHttpClient httpClient = new StubHttpClient(); + httpClient.enqueueBinary(200, "ZIP-CONTENT".getBytes(StandardCharsets.UTF_8), "application/zip"); + + Facturapi sdk = new Facturapi( + FacturapiConfig.builder("sk_test") + .httpClient(httpClient.client()) + .build() + ); + + try (InputStream stream = sdk.invoices().downloadZipRequestStream("zip_1")) { + assertEquals("ZIP-CONTENT", new String(stream.readAllBytes(), StandardCharsets.UTF_8)); + } + + var request = httpClient.requests().get(0); + assertEquals("GET", request.method()); + assertEquals("/v2/invoices/zip-requests/zip_1/zip", request.uri().getPath()); + } + @Test void organizationUploadsAcceptBytes() { StubHttpClient httpClient = new StubHttpClient(); @@ -320,4 +508,18 @@ void objectMapperDeserializesCodeEnums() throws Exception { assertEquals(TaxType.IEPS, tax.getType()); assertEquals(TaxFactor.EXENTO, tax.getFactor()); } + + @Test + void objectMapperDeserializesPropertyTaxAccountsAsArrays() throws Exception { + var mapper = FacturapiConfig.builder("sk_test").build().getObjectMapper(); + + var empty = mapper.readValue("{\"property_tax_account\":[]}", InvoiceItem.class); + var accounts = mapper.readValue( + "{\"property_tax_account\":[\"0102030405\"]}", + InvoiceItem.class + ); + + assertEquals(List.of(), empty.getPropertyTaxAccounts()); + assertEquals(List.of("0102030405"), accounts.getPropertyTaxAccounts()); + } }