diff --git a/.env.example b/.env.example
index a7480ed..b14d614 100644
--- a/.env.example
+++ b/.env.example
@@ -7,8 +7,11 @@ PGPASSWORD=root
SERVER=http://localhost:8080
CLIENT=http://localhost:3000
SECRET=vasco-da-gama
-GHCI=ID
-GHCS=SECRET
+GHCI=GHCI
+GHCS=GHCS
+SWK=SWK
+SPK=SPK
+SSK=SSK
RABBITMQ_USER=user
RABBITMQ_PASSWORD=root
RABBITMQ_ADDRESSES=amqp://user:root@rabbitmq:5672
diff --git a/Dockerfile b/Dockerfile
index 2d01e16..3fd9477 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,6 @@ RUN ./mvnw clean install
FROM eclipse-temurin:21-jdk-alpine
-COPY --from=build ./target/NoteHub-1.7.0.jar app.jar
+COPY --from=build ./target/NoteHub-2.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
index cab6c16..0c2dddf 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@
diff --git a/pom.xml b/pom.xml
index d1ecfb8..ab8e8e0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
br.com.notehub
NoteHub
- 1.7.0
+ 2.0.0
NoteHub
https://notehub.com.br
@@ -119,6 +119,11 @@
5.20.0
test
+
+ com.stripe
+ stripe-java
+ 31.0.0
+
diff --git a/src/main/java/br/com/notehub/application/controller/payment/PaymentController.java b/src/main/java/br/com/notehub/application/controller/payment/PaymentController.java
new file mode 100644
index 0000000..d1c00ed
--- /dev/null
+++ b/src/main/java/br/com/notehub/application/controller/payment/PaymentController.java
@@ -0,0 +1,99 @@
+package br.com.notehub.application.controller.payment;
+
+import br.com.notehub.application.dto.payment.SponsorshipREQ;
+import br.com.notehub.application.dto.payment.SponsorshipRES;
+import br.com.notehub.application.dto.payment.SponsorshipStatusRES;
+import br.com.notehub.application.payment.StripeService;
+import com.auth0.jwt.JWT;
+import com.stripe.exception.SignatureVerificationException;
+import com.stripe.model.Event;
+import com.stripe.net.Webhook;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@CrossOrigin(origins = {"https://notehub.com.br"})
+@RequestMapping("/api/v1/payment")
+@Tag(name = "Payment Controller", description = "Endpoints for managing payments")
+@RequiredArgsConstructor
+public class PaymentController {
+
+ @Value("${payment.stripe.webhook.key}")
+ private String secret;
+
+ private final StripeService stripeService;
+
+ private String getSubject(String bearerToken) {
+ if (bearerToken == null) return null;
+ return JWT.decode(bearerToken.replace("Bearer ", "")).getSubject();
+ }
+
+ @Operation(
+ summary = "Initiates the Stripe sponsorship checkout process",
+ description = "Creates a Stripe Checkout Session for a sponsorship with the specified currency and amount."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Checkout session created successfully."),
+ @ApiResponse(responseCode = "400", description = "Invalid input data.", content = @Content(mediaType = "application/json")),
+ @ApiResponse(responseCode = "403", description = "Invalid token."),
+ @ApiResponse(responseCode = "500", description = "Internal server error.", content = @Content(examples = {}))
+ })
+ @PostMapping("/stripe/sponsorship")
+ public ResponseEntity sponsorshipCheckout(
+ @Parameter(hidden = true) @RequestHeader("Authorization") String accessToken,
+ @Valid @RequestBody SponsorshipREQ dto
+ ) {
+ String idFromToken = getSubject(accessToken);
+ SponsorshipRES res = stripeService.sponsorshipCheckout(idFromToken, dto.locale(), dto.currency(), dto.amount());
+ return ResponseEntity.status(HttpStatus.OK).body(res);
+ }
+
+ @Operation(
+ summary = "Verifies the status of a Stripe sponsorship checkout session",
+ description = "Queries Stripe to determine if the payment for the specified session has been successfully completed."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Session status returned successfully."),
+ @ApiResponse(responseCode = "403", description = "Invalid token."),
+ @ApiResponse(responseCode = "500", description = "Internal server error.", content = @Content(examples = {}))
+ })
+ @GetMapping("/stripe/sponsorship/verify/{sessionId}")
+ public ResponseEntity verifySession(
+ @PathVariable String sessionId,
+ @Parameter(hidden = true) @RequestHeader("Authorization") String accessToken
+ ) {
+ String uIdFromToken = getSubject(accessToken);
+ return ResponseEntity.status(HttpStatus.OK).body(stripeService.verifySession(sessionId, uIdFromToken));
+ }
+
+ @Hidden
+ @PostMapping("/stripe/sponsorship/webhook")
+ public ResponseEntity handleWebhook(
+ @RequestBody String payload,
+ @RequestHeader("Stripe-Signature") String sigHeader
+ ) {
+ Event event;
+ try {
+ event = Webhook.constructEvent(payload, sigHeader, secret);
+ } catch (SignatureVerificationException e) {
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
+ }
+ if ("checkout.session.completed".equals(event.getType())) {
+ stripeService.handleCheckoutSessionCompleted(event);
+ return ResponseEntity.status(HttpStatus.OK).build();
+ }
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/br/com/notehub/application/dto/payment/SponsorshipREQ.java b/src/main/java/br/com/notehub/application/dto/payment/SponsorshipREQ.java
new file mode 100644
index 0000000..062ba57
--- /dev/null
+++ b/src/main/java/br/com/notehub/application/dto/payment/SponsorshipREQ.java
@@ -0,0 +1,20 @@
+package br.com.notehub.application.dto.payment;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record SponsorshipREQ(
+
+ @NotBlank(message = "Não pode ser vazia.")
+ String locale,
+
+ @NotBlank(message = "Não pode ser vazia.")
+ String currency,
+
+ @NotNull(message = "Não pode ser nulo.")
+ @Min(value = 1, message = "Deve ser maior que 0.")
+ Long amount
+
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/br/com/notehub/application/dto/payment/SponsorshipRES.java b/src/main/java/br/com/notehub/application/dto/payment/SponsorshipRES.java
new file mode 100644
index 0000000..8c19cef
--- /dev/null
+++ b/src/main/java/br/com/notehub/application/dto/payment/SponsorshipRES.java
@@ -0,0 +1,13 @@
+package br.com.notehub.application.dto.payment;
+
+import lombok.Builder;
+
+@Builder
+public record SponsorshipRES(
+ String status,
+ String message,
+ String sessionId,
+ String sessionUrl,
+ String uId
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/br/com/notehub/application/dto/payment/SponsorshipStatusRES.java b/src/main/java/br/com/notehub/application/dto/payment/SponsorshipStatusRES.java
new file mode 100644
index 0000000..56f334d
--- /dev/null
+++ b/src/main/java/br/com/notehub/application/dto/payment/SponsorshipStatusRES.java
@@ -0,0 +1,11 @@
+package br.com.notehub.application.dto.payment;
+
+public record SponsorshipStatusRES(
+ String sessionId,
+ String paymentStatus,
+ String status,
+ String locale,
+ String currency,
+ Long amountTotal
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/br/com/notehub/application/implementation/user/UserServiceImpl.java b/src/main/java/br/com/notehub/application/implementation/user/UserServiceImpl.java
index e9e47a2..9b44b73 100644
--- a/src/main/java/br/com/notehub/application/implementation/user/UserServiceImpl.java
+++ b/src/main/java/br/com/notehub/application/implementation/user/UserServiceImpl.java
@@ -142,6 +142,12 @@ public void activate(UUID idFromToken) {
changeField(idFromToken, "active", User::isActive, user -> user.setActive(true));
}
+ @Transactional
+ @Override
+ public void promote(UUID idFromToken) {
+ changeField(idFromToken, "sponsor", User::isSponsor, user -> user.setSponsor(true));
+ }
+
@Transactional
@Override
public void changePassword(String email, String newPassword) {
diff --git a/src/main/java/br/com/notehub/application/payment/StripeService.java b/src/main/java/br/com/notehub/application/payment/StripeService.java
new file mode 100644
index 0000000..d580700
--- /dev/null
+++ b/src/main/java/br/com/notehub/application/payment/StripeService.java
@@ -0,0 +1,116 @@
+package br.com.notehub.application.payment;
+
+import br.com.notehub.application.dto.payment.SponsorshipRES;
+import br.com.notehub.application.dto.payment.SponsorshipStatusRES;
+import br.com.notehub.domain.user.UserService;
+import br.com.notehub.infra.exception.CustomExceptions;
+import com.stripe.Stripe;
+import com.stripe.exception.StripeException;
+import com.stripe.model.Event;
+import com.stripe.model.checkout.Session;
+import com.stripe.param.checkout.SessionCreateParams;
+import jakarta.annotation.Nullable;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Objects;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class StripeService {
+
+ @Value("${payment.stripe.secret.key}")
+ private String secret;
+
+ @Value("${api.client.host}")
+ private String client;
+
+ private final UserService userService;
+
+ private static final String SPONSORSHIP_NAME = "Patrocínio";
+ private static final Long SPONSORSHIP_QUANTITY = 1L;
+
+ private void validateAccess(@Nullable String idFromToken, String idFromSession) {
+ if (Objects.equals(idFromToken, idFromSession)) return;
+ throw new AccessDeniedException("Usuário sem permissão.");
+ }
+
+ public SponsorshipRES sponsorshipCheckout(String idFromToken, String locale, String currency, Long amount) {
+
+ Stripe.apiKey = secret;
+
+ try {
+
+ var productData = SessionCreateParams.LineItem.PriceData.ProductData.builder()
+ .setName(SPONSORSHIP_NAME)
+ .build();
+
+ var priceData = SessionCreateParams.LineItem.PriceData.builder()
+ .setCurrency(currency)
+ .setUnitAmount(amount)
+ .setProductData(productData)
+ .build();
+
+ var lineItem = SessionCreateParams.LineItem.builder()
+ .setQuantity(SPONSORSHIP_QUANTITY)
+ .setPriceData(priceData)
+ .build();
+
+ var params = SessionCreateParams.builder()
+ .setMode(SessionCreateParams.Mode.PAYMENT)
+ .setLocale(SessionCreateParams.Locale.valueOf(locale))
+ .setSuccessUrl(String.format("%s/sponsorship/success?session_id={CHECKOUT_SESSION_ID}", client))
+ .setCancelUrl(String.format("%s/sponsorship", client))
+ .addLineItem(lineItem)
+ .putMetadata("purchaseType", "sponsorship")
+ .putMetadata("uId", idFromToken)
+ .build();
+
+ Session session = Session.create(params);
+ return new SponsorshipRES(
+ "SUCCESS",
+ "Payment session created",
+ session.getId(),
+ session.getUrl(),
+ idFromToken
+ );
+
+ } catch (StripeException e) {
+ throw new CustomExceptions.CustomStripeException(e);
+ }
+
+ }
+
+ public SponsorshipStatusRES verifySession(String sessionId, String uIdFromToken) {
+ try {
+ Stripe.apiKey = secret;
+ Session session = Session.retrieve(sessionId);
+ String uIdFromSession = session.getMetadata().get("uId");
+ validateAccess(uIdFromToken, uIdFromSession);
+ return new SponsorshipStatusRES(
+ session.getId(),
+ session.getPaymentStatus(),
+ session.getStatus(),
+ session.getLocale(),
+ session.getCurrency(),
+ session.getAmountTotal()
+ );
+ } catch (StripeException e) {
+ throw new CustomExceptions.CustomStripeException(e);
+ }
+ }
+
+ @Transactional
+ public void handleCheckoutSessionCompleted(Event event) {
+ Session session = (Session) event.getDataObjectDeserializer()
+ .getObject()
+ .orElseThrow(() -> new RuntimeException("Failed to deserialize session"));
+ UUID uId = UUID.fromString(session.getMetadata().get("uId"));
+ if ("paid".equals(session.getPaymentStatus())) userService.promote(uId);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/br/com/notehub/application/validation/NoForbiddenWordsValidator.java b/src/main/java/br/com/notehub/application/validation/NoForbiddenWordsValidator.java
index cf86a92..32f2931 100644
--- a/src/main/java/br/com/notehub/application/validation/NoForbiddenWordsValidator.java
+++ b/src/main/java/br/com/notehub/application/validation/NoForbiddenWordsValidator.java
@@ -17,6 +17,7 @@ public class NoForbiddenWordsValidator implements ConstraintValidator> handleMethdArgumentNotValidExceptio
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
+ @ExceptionHandler(CustomExceptions.CustomStripeException.class)
+ private ResponseEntity> handleStripeException(CustomExceptions.CustomStripeException ex) {
+ List errors = new ArrayList<>();
+ errors.add(new FieldError("Payment", "Payment", ex.getMessage()));
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors.stream().map(CustomResponse::new).toList());
+ }
+
@ExceptionHandler(CustomExceptions.InvalidSecretException.class)
private ResponseEntity> handleInvalidSecretException(CustomExceptions.InvalidSecretException ex) {
List errors = new ArrayList<>();
diff --git a/src/main/java/br/com/notehub/infra/exception/CustomExceptions.java b/src/main/java/br/com/notehub/infra/exception/CustomExceptions.java
index 4f6b636..ea79282 100644
--- a/src/main/java/br/com/notehub/infra/exception/CustomExceptions.java
+++ b/src/main/java/br/com/notehub/infra/exception/CustomExceptions.java
@@ -1,5 +1,7 @@
package br.com.notehub.infra.exception;
+import com.stripe.exception.StripeException;
+
import java.util.UUID;
public class CustomExceptions {
@@ -10,6 +12,22 @@ public BusinessException(String message) {
}
}
+ public static class CustomStripeException extends BusinessException {
+
+ private static String customizeStripeExceptionMessage(StripeException e) {
+ String code = e.getCode();
+ String message = e.getMessage();
+ if ("amount_too_small".equals(code)) return "Minimum value not reached.";
+ if (message.contains("currency")) return "Currency not available.";
+ return "Error while processing payment. Please, try again later or contact support.";
+ }
+
+ public CustomStripeException(StripeException e) {
+ super(customizeStripeExceptionMessage(e));
+ }
+
+ }
+
public static class InvalidSecretException extends BusinessException {
public InvalidSecretException() {
super("Segredo incorreto.");
diff --git a/src/main/java/br/com/notehub/infra/security/SecurityConfig.java b/src/main/java/br/com/notehub/infra/security/SecurityConfig.java
index e291244..f4f4f75 100644
--- a/src/main/java/br/com/notehub/infra/security/SecurityConfig.java
+++ b/src/main/java/br/com/notehub/infra/security/SecurityConfig.java
@@ -27,7 +27,8 @@ public class SecurityConfig {
};
private static final String[] PUBLIC_POST_ROUTES = {
- "/api/v1/users/register", "/api/v1/auth/**"
+ "/api/v1/users/register", "/api/v1/auth/**",
+ "/api/v1/payment/stripe/sponsorship/webhook"
};
private static final String[] PUBLIC_DELETE_ROUTES = {
@@ -41,7 +42,8 @@ public class SecurityConfig {
"/api/v1/notes", "/api/v1/notes/**",
"/api/v1/flames", "/api/v1/flames/**",
"/api/v1/replies", "/api/v1/replies/**",
- "/api/v1/mail", "/api/v1/mail/**"
+ "/api/v1/mail", "/api/v1/mail/**",
+ "/api/v1/payment", "/api/v1/payment/**",
};
private static final String[] PRIVATE_GET_ROUTES = {
diff --git a/src/main/java/br/com/notehub/infra/security/SecurityFilter.java b/src/main/java/br/com/notehub/infra/security/SecurityFilter.java
index 22ab9d0..bc77db7 100644
--- a/src/main/java/br/com/notehub/infra/security/SecurityFilter.java
+++ b/src/main/java/br/com/notehub/infra/security/SecurityFilter.java
@@ -48,6 +48,10 @@ private String getToken(HttpServletRequest request) {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = getToken(request);
+ if (request.getServletPath().contains("/api/v1/payment/stripe/sponsorship/webhook")) {
+ filterChain.doFilter(request, response);
+ return;
+ }
if (accessToken != null) {
try {
UUID id = UUID.fromString(service.validateToken(accessToken));
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
index d959776..7e9b7a2 100644
--- a/src/main/resources/application-dev.properties
+++ b/src/main/resources/application-dev.properties
@@ -25,6 +25,10 @@ api.server.security.token.secret=${SECRET}
oauth.github.client.id=${GHCI}
oauth.github.client.secret=${GHCS}
+payment.stripe.webhook.key=${SWK}
+payment.stripe.publishable.key=${SPK}
+payment.stripe.secret.key=${SSK}
+
spring.rabbitmq.addresses=${RABBITMQ_ADDRESSES}
broker.queue.activation.name=default.activation
broker.queue.secret.name=default.secret
diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties
index 6a22878..65b5fb4 100644
--- a/src/main/resources/application-prod.properties
+++ b/src/main/resources/application-prod.properties
@@ -13,6 +13,10 @@ api.server.security.token.secret=${SECRET}
oauth.github.client.id=${GHCI}
oauth.github.client.secret=${GHCS}
+payment.stripe.webhook.key=${SWK}
+payment.stripe.publishable.key=${SPK}
+payment.stripe.secret.key=${SSK}
+
spring.rabbitmq.addresses=${RABBITMQ_ADDRESSES}
broker.queue.activation.name=default.activation
broker.queue.secret.name=default.secret
diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties
index f33d2d0..7f3a3da 100644
--- a/src/main/resources/application-test.properties
+++ b/src/main/resources/application-test.properties
@@ -25,6 +25,10 @@ api.server.security.token.secret=vasco-da-gama
oauth.github.client.id=1898
oauth.github.client.secret=98
+payment.stripe.webhook.key=whsec
+payment.stripe.publishable.key=pk
+payment.stripe.secret.key=sk
+
spring.mail.friendly.name=NoteHub
spring.mail.host=mailhog
spring.mail.port=1025