StripeClient.java
package com.rbctcsworld.ecommerce.payment;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* The two Stripe API calls this shop needs, over plain HTTP (no SDK): our own request building and
* response parsing is exactly what the contract tests check against stripe-mock and recorded answers.
*
* - POST /v1/payment_intents (create + confirm in one call)
* - POST /v1/refunds
*
* Stripe expects form encoding, amounts in the smallest unit (cents) and an Idempotency-Key on every
* POST: a retried request with the same key returns the first result instead of charging again.
*/
class StripeClient {
record PaymentIntent(String id, String status, long amount, String currency) {
}
record Refund(String id, String status, long amount) {
}
private static final ObjectMapper JSON = JsonMapper.builder().build();
private final URI base;
private final String secretKey;
private final Duration timeout;
private final HttpClient http;
StripeClient(String baseUrl, String secretKey, Duration timeout) {
this.base = URI.create(baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl);
this.secretKey = secretKey;
this.timeout = timeout;
this.http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(timeout).build();
}
PaymentIntent createPaymentIntent(long amountCents, String currency, String paymentMethod, Long orderId,
String idempotencyKey) {
Map<String, String> form = new LinkedHashMap<>();
form.put("amount", Long.toString(amountCents));
form.put("currency", currency);
form.put("payment_method", paymentMethod);
form.put("confirm", "true");
form.put("automatic_payment_methods[enabled]", "true");
form.put("automatic_payment_methods[allow_redirects]", "never"); // no return_url: card only
form.put("metadata[order_id]", String.valueOf(orderId));
JsonNode n = post("/v1/payment_intents", form, idempotencyKey);
return new PaymentIntent(text(n, "id"), text(n, "status"), n.path("amount").asLong(), text(n, "currency"));
}
Refund createRefund(String paymentIntentId, long amountCents, Long orderId, String idempotencyKey) {
Map<String, String> form = new LinkedHashMap<>();
form.put("payment_intent", paymentIntentId);
form.put("amount", Long.toString(amountCents));
form.put("metadata[order_id]", String.valueOf(orderId));
JsonNode n = post("/v1/refunds", form, idempotencyKey);
return new Refund(text(n, "id"), text(n, "status"), n.path("amount").asLong());
}
static String encode(Map<String, String> form) {
return form.entrySet().stream()
.map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
private JsonNode post(String path, Map<String, String> form, String idempotencyKey) {
HttpRequest request = HttpRequest.newBuilder(URI.create(base + path))
.timeout(timeout)
.header("Authorization", "Bearer " + secretKey)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(encode(form)))
.build();
HttpResponse<String> response;
try {
response = http.send(request, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
throw new StripeException(0, "api_connection_error", null, null, "Stripe not reachable: " + e, null);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new StripeException(0, "api_connection_error", null, null, "Interrupted", null);
}
JsonNode body = parse(response);
if (response.statusCode() / 100 == 2) {
return body;
}
JsonNode error = body.path("error");
throw new StripeException(response.statusCode(), text(error, "type"), text(error, "code"),
text(error, "decline_code"), text(error, "message"), text(error.path("payment_intent"), "id"));
}
private static JsonNode parse(HttpResponse<String> response) {
try {
return JSON.readTree(response.body());
} catch (JacksonException e) {
throw new StripeException(response.statusCode(), "api_error", null, null,
"Unreadable answer from Stripe (HTTP " + response.statusCode() + ")", null);
}
}
private static String text(JsonNode node, String field) {
JsonNode v = node.path(field);
return v.isString() ? v.asString() : null;
}
}