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