Skip to content

Commit c5071ae

Browse files
authored
Added PDF retrieval for invoices including test (#7)
* Added PDF retrieval for invoices including test * resolved code review comments
1 parent 3ce0ce2 commit c5071ae

8 files changed

Lines changed: 192 additions & 10 deletions

File tree

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
<version>4.13.2</version>
2121
<scope>test</scope>
2222
</dependency>
23+
<dependency>
24+
<groupId>org.hamcrest</groupId>
25+
<artifactId>hamcrest</artifactId>
26+
<version>2.2</version>
27+
<scope>test</scope>
28+
</dependency>
2329
</dependencies>
2430
<developers>
2531
<developer>

src/main/java/com/twikey/DocumentGateway.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.twikey.callback.DocumentCallback;
44
import com.twikey.modal.DocumentRequests;
55
import com.twikey.modal.DocumentResponse;
6+
import com.twikey.modal.ResponseUtils;
67
import org.json.JSONArray;
78
import org.json.JSONObject;
89
import org.json.JSONTokener;
@@ -332,19 +333,16 @@ public DocumentResponse.CustomerAccessResponse customerAccess(String mandateNumb
332333
*/
333334
public DocumentResponse.PdfResponse retrievePdf(String mandateNumber) throws IOException, TwikeyClient.UserException {
334335
HttpRequest request = HttpRequest.newBuilder(twikeyClient.getUrl("/mandate/pdf?mndtId=" + mandateNumber))
335-
.headers("Content-Type", HTTP_FORM_ENCODED)
336+
.header("Accept", "application/pdf")
336337
.headers("User-Agent", twikeyClient.getUserAgent())
337338
.headers("Authorization", twikeyClient.getSessionToken())
338339
.GET()
339340
.build();
340341
HttpResponse<byte[]> response = twikeyClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
341342
if (response.statusCode() == 200) {
342-
String disposition = response.headers().firstValue("content-disposition").orElse("=unknown.pdf");
343-
String[] parts = disposition.split("=");
344-
String filename = null;
345-
if (parts.length == 2) {
346-
filename = parts[1].trim().replace("\"", "");
347-
}
343+
String filename = ResponseUtils.extractFilenameFromContentDisposition(response.headers())
344+
.orElse(mandateNumber + ".pdf");
345+
348346
return new DocumentResponse.PdfResponse(response.body(), filename);
349347
} else {
350348
throw new TwikeyClient.UserException(apiError(response));

src/main/java/com/twikey/InvoiceGateway.java

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import com.twikey.callback.InvoiceCallback;
44
import com.twikey.modal.InvoiceRequests;
55
import com.twikey.modal.InvoiceResponse;
6+
import com.twikey.modal.ResponseUtils;
67
import org.json.JSONArray;
78
import org.json.JSONException;
89
import org.json.JSONObject;
910
import org.json.JSONTokener;
1011

1112
import java.io.IOException;
13+
import java.io.InputStream;
1214
import java.net.http.HttpRequest;
1315
import java.net.http.HttpResponse;
1416
import java.nio.file.Path;
@@ -287,12 +289,12 @@ public InvoiceResponse.BulkInvoiceDetail batchDetails(String batchId) throws IOE
287289

288290

289291
/**
290-
* Get updates about all mandates (new/updated/cancelled)
292+
* Get updates about all invoice states (BOOKED, PENDING, EXPIRED, PAID)
291293
*
292294
* @param invoiceCallback Callback for every change
293295
* @param sideloads items to include in the sideloading @link <a href="https://www.twikey.com/api/#invoice-feed">www.twikey.com/api/#invoice-feed</a>
294296
* @throws IOException When a network issue happened
295-
* @throws TwikeyClient.UserException When there was an issue while retrieving the mandates (eg. invalid apikey)
297+
* @throws TwikeyClient.UserException When there was an issue while retrieving the invoice states (eg. invalid apikey)
296298
*/
297299
public void feed(InvoiceCallback invoiceCallback, String... sideloads) throws IOException, TwikeyClient.UserException {
298300

@@ -326,4 +328,56 @@ public void feed(InvoiceCallback invoiceCallback, String... sideloads) throws IO
326328
}
327329
} while (!isEmpty);
328330
}
331+
332+
333+
/**
334+
* See <a href="https://www.twikey.com/api/#retrieve-invoice-pdf">Twikey API - Retrieve Invoice PDF</a>
335+
* <p>
336+
* Retrieves the PDF for a specific invoice using its UUID. The API will return
337+
* the raw PDF content along with the filename as set by the server.
338+
* </p>
339+
*
340+
* @param request An {@link InvoiceRequests.InvoicePdfRequest} containing the invoice UUID.
341+
* @return {@link InvoiceResponse.Pdf} A structured response object containing
342+
* the PDF content and filename.
343+
* @throws IOException If a network error occurs during the request.
344+
* @throws TwikeyClient.UserException If the API returns an error response.
345+
*
346+
* <p>Example usage:</p>
347+
* <pre>{@code
348+
* InvoiceRequests.InvoicePdfRequest pdfRequest = new InvoiceRequests.InvoicePdfRequest("032f42b8-9afc-459d-b0f5-b81a85a69e95");
349+
* InvoiceResponse.Pdf pdf = invoiceGateway.pdf(pdfRequest);
350+
* byte[] content = pdf.getContent();
351+
* String filename = pdf.getFilename();
352+
* }</pre>
353+
*/
354+
public InvoiceResponse.Pdf pdf(InvoiceRequests.InvoicePdfRequest request)
355+
throws IOException, TwikeyClient.UserException {
356+
HttpRequest httpRequest = HttpRequest.newBuilder(
357+
twikeyClient.getUrl("/invoice/%s/pdf".formatted(request.id()))
358+
)
359+
.header("Accept", "application/pdf")
360+
.header("User-Agent", twikeyClient.getUserAgent())
361+
.header("Authorization", twikeyClient.getSessionToken())
362+
.GET()
363+
.build();
364+
365+
HttpResponse<InputStream> response = twikeyClient.send(
366+
httpRequest,
367+
HttpResponse.BodyHandlers.ofInputStream()
368+
);
369+
370+
371+
if (response.statusCode() == 200) {
372+
String filename = ResponseUtils.extractFilenameFromContentDisposition(response.headers())
373+
.orElse(request.id() + ".pdf");
374+
375+
return new InvoiceResponse.Pdf(response.body(), filename);
376+
} else {
377+
throw new TwikeyClient.UserException(apiError(response));
378+
}
379+
}
380+
381+
382+
329383
}

src/main/java/com/twikey/modal/InvoiceRequests.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ public CreateInvoiceRequest setLocale(String locale) {
9494
}
9595

9696
/**
97-
* Control whether or not an traansaction is created provided a signed mandate is available
97+
* Control whether or not a transaction is created provided a signed mandate is available
9898
*
9999
* @param manual when true no transaction is created to accompany the invoice
100100
*/
@@ -594,4 +594,28 @@ public JSONArray toRequest() {
594594
}
595595
}
596596

597+
/**
598+
* InvoicePdfRequest represents a request to retrieve the PDF for a single invoice
599+
* via the Twikey API.
600+
*
601+
* <p>This request only requires the invoice UUID. The API will return
602+
* the raw PDF content and filename for the specified invoice.</p>
603+
*
604+
* <p>Example usage:</p>
605+
* <pre>{@code
606+
* InvoiceRequests.InvoicePdfRequest pdfRequest = new InvoiceRequests.InvoicePdfRequest("032f42b8-9afc-459d-b0f5-b81a85a69e95");
607+
* InvoiceResponse.Pdf pdf = invoiceGateway.pdf(pdfRequest);
608+
* byte[] content = pdf.getContent();
609+
* String filename = pdf.getFilename();
610+
* }</pre>
611+
*/
612+
record InvoicePdfRequest(String id) {
613+
public InvoicePdfRequest {
614+
if (id == null || id.isEmpty()) {
615+
throw new IllegalArgumentException("Invoice UUID is required");
616+
}
617+
}
618+
}
619+
620+
597621
}

src/main/java/com/twikey/modal/InvoiceResponse.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.json.JSONArray;
55
import org.json.JSONObject;
66

7+
import java.io.InputStream;
78
import java.util.HashMap;
89
import java.util.Map;
910

@@ -167,4 +168,10 @@ public String toString() {
167168
return "BulkInvoiceDetail{id='%s', results='%d'}".formatted(id, details.size());
168169
}
169170
}
171+
172+
/**
173+
* PDF class specifically for invoices
174+
*/
175+
record Pdf(InputStream content, String filename) {}
176+
170177
}

src/main/java/com/twikey/modal/RequestUtils.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
public class RequestUtils {
88

9+
private RequestUtils() {
10+
// prevent instantiation
11+
}
12+
913
public static void putIfNotNull(Map<String, String> map, String key, Object value) {
1014
if (value != null) {
1115
String val = String.valueOf(value);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.twikey.modal;
2+
3+
import java.net.http.HttpHeaders;
4+
import java.util.Optional;
5+
6+
public final class ResponseUtils {
7+
8+
private ResponseUtils() {
9+
// prevent instantiation
10+
}
11+
12+
public static Optional<String> extractFilenameFromContentDisposition(HttpHeaders headers) {
13+
return headers.firstValue("content-disposition")
14+
.flatMap(ResponseUtils::parseContentDispositionFilename);
15+
}
16+
17+
static Optional<String> parseContentDispositionFilename(String disposition) {
18+
if (disposition == null || disposition.isBlank()) {
19+
return Optional.empty();
20+
}
21+
22+
for (String part : disposition.split(";")) {
23+
part = part.trim();
24+
25+
if (part.startsWith("filename*=")) {
26+
return Optional.of(decodeExtendedFilename(part.substring(9)));
27+
}
28+
29+
if (part.startsWith("filename=")) {
30+
return Optional.of(stripQuotes(part.substring(9)));
31+
}
32+
}
33+
34+
return Optional.empty();
35+
}
36+
37+
private static String stripQuotes(String value) {
38+
return value.replaceAll("^\"|\"$", "");
39+
}
40+
41+
private static String decodeExtendedFilename(String value) {
42+
int idx = value.indexOf("''");
43+
if (idx > 0) {
44+
return java.net.URLDecoder.decode(value.substring(idx + 2), java.nio.charset.StandardCharsets.UTF_8);
45+
}
46+
return value;
47+
}
48+
}

src/test/java/com/twikey/InvoiceGatewayTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
import java.util.Objects;
1515
import java.util.stream.IntStream;
1616

17+
import java.nio.file.Files;
18+
import java.nio.file.Paths;
19+
import java.util.Base64;
20+
1721
import static org.junit.Assert.assertNotNull;
22+
import static org.junit.Assert.assertTrue;
23+
1824

1925
public class InvoiceGatewayTest {
2026

@@ -116,4 +122,39 @@ public void getInvoicesAndDetails() throws IOException, TwikeyClient.UserExcepti
116122
System.out.printf("Invoice update with number %s %s euro %s%n", updatedInvoice.getNumber(), updatedInvoice.getAmount(), newState);
117123
}, "meta");
118124
}
125+
126+
@Test
127+
public void testRetrieveInvoicePdf() throws IOException, TwikeyClient.UserException {
128+
Assume.assumeTrue("APIKey and CT are set", apiKey != null && ct != null);
129+
130+
// 1. Create a new invoice (we can optionally attach a PDF)
131+
String title = "PDF-Test-" + System.currentTimeMillis();
132+
byte[] pdfBytes = Files.readAllBytes(Paths.get("src/test/resources/empty.pdf"));
133+
String base64Pdf = Base64.getEncoder().encodeToString(pdfBytes);
134+
InvoiceRequests.CreateInvoiceRequest request = new InvoiceRequests.CreateInvoiceRequest(
135+
title,
136+
100.0,
137+
LocalDate.now().toString(),
138+
LocalDate.now().plusMonths(1).toString(),
139+
customer
140+
).setPdf(base64Pdf);
141+
142+
InvoiceResponse.Invoice createdInvoice = api.invoice().create(request);
143+
String invoiceId = createdInvoice.getId();
144+
assertNotNull("Invoice ID should not be null", invoiceId);
145+
146+
InvoiceRequests.InvoicePdfRequest pdfRequest = new InvoiceRequests.InvoicePdfRequest(invoiceId);
147+
InvoiceResponse.Pdf pdf = api.invoice().pdf(pdfRequest);
148+
149+
assertNotNull("PDF object should not be null", pdf);
150+
assertNotNull("PDF content should not be null", pdf.content());
151+
assertNotNull("PDF filename should not be null", pdf.filename());
152+
153+
byte[] retrievedBytes = pdf.content().readAllBytes();
154+
assertTrue("PDF should not be empty", retrievedBytes.length > 0);
155+
156+
System.out.printf("Retrieved PDF for invoice %s with filename: %s (%d bytes)%n",
157+
invoiceId, pdf.filename(), retrievedBytes.length);
158+
}
159+
119160
}

0 commit comments

Comments
 (0)