diff --git a/with-java-springboot/.env.example b/with-java-springboot/.env.example new file mode 100644 index 0000000..623c426 --- /dev/null +++ b/with-java-springboot/.env.example @@ -0,0 +1,8 @@ +# https://docs.polar.sh/integrate/oat +POLAR_ACCESS_TOKEN="polar_oat_..." +# https://docs.polar.sh/integrate/webhooks/endpoints#setup-webhooks +POLAR_WEBHOOK_SECRET="polar_whs_..." +# Polar server mode - production or sandbox +POLAR_MODE="sandbox" +# client url - this is the URL the customer would be led to if they purchase something. +POLAR_SUCCESS_URL="http://localhost:8080" diff --git a/with-java-springboot/.gitignore b/with-java-springboot/.gitignore new file mode 100644 index 0000000..5f5ade2 --- /dev/null +++ b/with-java-springboot/.gitignore @@ -0,0 +1,8 @@ +/target/ +/.env +/.idea/ +/*.iml +/.vscode/ +/node_modules/ +.DS_Store +/.mvn/ \ No newline at end of file diff --git a/with-java-springboot/.mvn/wrapper/maven-wrapper.properties b/with-java-springboot/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..7c6b218 --- /dev/null +++ b/with-java-springboot/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip diff --git a/with-java-springboot/README.md b/with-java-springboot/README.md new file mode 100644 index 0000000..bf3a7b1 --- /dev/null +++ b/with-java-springboot/README.md @@ -0,0 +1,29 @@ +![](../logo.svg) + +# Getting started with Polar and Java Spring Boot + +## Clone the repository + +```bash +npx degit polarsource/examples/with-java-springboot ./with-java-springboot +``` + +## How to use + +1. Run the command below to copy the `.env.example` file: + +```bash +cp .env.example .env +``` + +2. Run the command below to install project dependencies: + +```bash +mvn clean install +``` + +3. Run the Spring Boot application using the following command: + +```bash +mvn spring-boot:run +``` diff --git a/with-java-springboot/pom.xml b/with-java-springboot/pom.xml new file mode 100644 index 0000000..dcf459e --- /dev/null +++ b/with-java-springboot/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.1 + + + com.example + polar-springboot-example + 0.0.1-SNAPSHOT + polar-springboot-example + Polar with Spring Boot Example + + 17 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.github.cdimascio + dotenv-java + 3.1.0 + + + + com.fasterxml.jackson.core + jackson-databind + + + + org.projectlombok + lombok + true + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/with-java-springboot/src/main/java/com/example/polar/PolarApplication.java b/with-java-springboot/src/main/java/com/example/polar/PolarApplication.java new file mode 100644 index 0000000..cd20d06 --- /dev/null +++ b/with-java-springboot/src/main/java/com/example/polar/PolarApplication.java @@ -0,0 +1,21 @@ +package com.example.polar; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class PolarApplication { + + public static void main(String[] args) { + SpringApplication.run(PolarApplication.class, args); + } + + @Bean + public Dotenv dotenv() { + return Dotenv.configure() + .ignoreIfMissing() + .load(); + } +} diff --git a/with-java-springboot/src/main/java/com/example/polar/controller/PolarController.java b/with-java-springboot/src/main/java/com/example/polar/controller/PolarController.java new file mode 100644 index 0000000..7f01303 --- /dev/null +++ b/with-java-springboot/src/main/java/com/example/polar/controller/PolarController.java @@ -0,0 +1,118 @@ +package com.example.polar.controller; + +import com.example.polar.service.PolarService; +import com.example.polar.util.WebhookVerifier; +import com.fasterxml.jackson.databind.JsonNode; +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.web.servlet.view.RedirectView; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +@Controller +public class PolarController { + + private final PolarService polarService; + private final String webhookSecret; + private final String successUrl; + + public PolarController(PolarService polarService, Dotenv dotenv) { + this.polarService = polarService; + this.webhookSecret = dotenv.get("POLAR_WEBHOOK_SECRET"); + this.successUrl = dotenv.get("POLAR_SUCCESS_URL"); + } + + @GetMapping("/") + @ResponseBody + public String home() { + try { + JsonNode products = polarService.listProducts(); + StringBuilder html = new StringBuilder(); + html.append("") + .append("
") + .append("") + .append("") + .append("
"); + + if (products != null) { + JsonNode items = products.get("items"); + if (items != null && items.isArray()) { + for (JsonNode item : items) { + String id = item.get("id").asText(); + String name = item.get("name").asText(); + html.append("
").append(name).append("
"); + } + } + } + + html.append(""); + return html.toString(); + } catch (Exception e) { + return "Error: " + e.getMessage() + ""; + } + } + + @GetMapping("/checkout") + public Object checkout(@RequestParam(name = "products") String products, HttpServletRequest request) { + try { + String productId = products; + String host = request.getHeader("host"); + String effectiveSuccessUrl = (successUrl != null && !successUrl.isEmpty()) + ? successUrl + : "http://" + host + "/"; + + JsonNode checkout = polarService.createCheckout(productId, effectiveSuccessUrl); + if (checkout == null || !checkout.has("url")) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to create checkout session"); + } + return new RedirectView(checkout.get("url").asText()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error: " + e.getMessage()); + } + } + + @GetMapping("/portal") + public Object portal(@RequestParam String email) { + try { + JsonNode customers = polarService.findCustomerByEmail(email); + if (customers == null || !customers.has("items")) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Customer not found or API error"); + } + + JsonNode items = customers.get("items"); + if (items == null || !items.isArray() || items.size() == 0) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Customer not found"); + } + + String customerId = items.get(0).get("id").asText(); + JsonNode session = polarService.createCustomerSession(customerId); + if (session == null || !session.has("customer_portal_url")) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to create customer session"); + } + return new RedirectView(session.get("customer_portal_url").asText()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error: " + e.getMessage()); + } + } + + @PostMapping("/polar/webhooks") + @ResponseBody + public ResponseEntity webhooks(@RequestBody String body, HttpServletRequest request) { + String id = request.getHeader("webhook-id"); + String timestamp = request.getHeader("webhook-timestamp"); + String signature = request.getHeader("webhook-signature"); + + boolean isValid = WebhookVerifier.verify(body, id, timestamp, signature, webhookSecret); + if (!isValid) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid signature"); + } + + System.out.println("Received webhook: " + body); + return ResponseEntity.ok(body); + } +} diff --git a/with-java-springboot/src/main/java/com/example/polar/service/PolarService.java b/with-java-springboot/src/main/java/com/example/polar/service/PolarService.java new file mode 100644 index 0000000..9a4629c --- /dev/null +++ b/with-java-springboot/src/main/java/com/example/polar/service/PolarService.java @@ -0,0 +1,92 @@ +package com.example.polar.service; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; + +@Service +public class PolarService { + + private final RestClient restClient; + private final String serverUrl; + + public PolarService(Dotenv dotenv) { + String accessToken = dotenv.get("POLAR_ACCESS_TOKEN"); + String mode = dotenv.get("POLAR_MODE", "production"); + + System.out.println("PolarService initialized with Mode: " + mode); + if (accessToken == null || accessToken.isEmpty()) { + System.err.println("CRITICAL: POLAR_ACCESS_TOKEN is missing!"); + } else { + System.out.println("POLAR_ACCESS_TOKEN found (starts with: " + + accessToken.substring(0, Math.min(10, accessToken.length())) + "...)"); + } + + this.serverUrl = mode.equals("sandbox") + ? "https://sandbox-api.polar.sh" + : "https://api.polar.sh"; + + this.restClient = RestClient.builder() + .baseUrl(this.serverUrl) + .defaultHeader("Authorization", "Bearer " + accessToken) + .defaultHeader("Content-Type", "application/json") + .build(); + } + + public JsonNode listProducts() { + try { + return restClient.get() + .uri("/v1/products/?is_archived=false") + .retrieve() + .body(JsonNode.class); + } catch (Exception e) { + System.err.println("Error in listProducts: " + e.getMessage()); + return null; + } + } + + public JsonNode createCheckout(String productId, String successUrl) { + try { + return restClient.post() + .uri("/v1/checkouts/") + .body(Map.of( + "products", new String[] { productId }, + "success_url", successUrl)) + .retrieve() + .body(JsonNode.class); + } catch (Exception e) { + System.err.println("Error in createCheckout: " + e.getMessage()); + return null; + } + } + + public JsonNode findCustomerByEmail(String email) { + try { + return restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/v1/customers/") + .queryParam("email", email) + .build()) + .retrieve() + .body(JsonNode.class); + } catch (Exception e) { + System.err.println("Error in findCustomerByEmail: " + e.getMessage()); + return null; + } + } + + public JsonNode createCustomerSession(String customerId) { + try { + return restClient.post() + .uri("/v1/customer-sessions/") + .body(Map.of("customer_id", customerId)) + .retrieve() + .body(JsonNode.class); + } catch (Exception e) { + System.err.println("Error in createCustomerSession: " + e.getMessage()); + return null; + } + } +} diff --git a/with-java-springboot/src/main/java/com/example/polar/util/WebhookVerifier.java b/with-java-springboot/src/main/java/com/example/polar/util/WebhookVerifier.java new file mode 100644 index 0000000..ab7c32d --- /dev/null +++ b/with-java-springboot/src/main/java/com/example/polar/util/WebhookVerifier.java @@ -0,0 +1,47 @@ +package com.example.polar.util; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class WebhookVerifier { + + private static final String HMAC_SHA256 = "HmacSHA256"; + + public static boolean verify(String body, String id, String timestamp, String signature, String secret) { + if (body == null || id == null || timestamp == null || signature == null || secret == null) { + return false; + } + + try { + // parse signature + String[] signatures = signature.split(" "); + String expectedSignature = ""; + for (String s : signatures) { + if (s.startsWith("v1,")) { + expectedSignature = s.substring(3); + break; + } + } + + if (expectedSignature.isEmpty()) { + return false; + } + + // signed payload + String signedPayload = id + "." + timestamp + "." + body; + + Mac sha256Hmac = Mac.getInstance(HMAC_SHA256); + SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256); + sha256Hmac.init(secretKey); + + byte[] hash = sha256Hmac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8)); + String actualSignature = Base64.getEncoder().encodeToString(hash); + + return actualSignature.equals(expectedSignature); + } catch (Exception e) { + return false; + } + } +}