diff --git a/kubernetes-dev/config/paypal-secret.yml b/kubernetes-dev/config/paypal-secret.yml index 09aadecc..0ee64b4e 100644 --- a/kubernetes-dev/config/paypal-secret.yml +++ b/kubernetes-dev/config/paypal-secret.yml @@ -7,3 +7,4 @@ type: Opaque data: paypal-client-id: SETUP_PAYPAL_CLIENT_ID_REPLACEME paypal-client-secret: SETUP_PAYPAL_CLIENT_SECRET_REPLACEME + paypal-webhook-id: SETUP_PAYPAL_WEBHOOK_ID_REPLACEME diff --git a/kubernetes-dev/ingress.yml b/kubernetes-dev/ingress.yml index 7dc5b1ce..a3f791ff 100644 --- a/kubernetes-dev/ingress.yml +++ b/kubernetes-dev/ingress.yml @@ -29,6 +29,14 @@ spec: port: number: 80 + - path: /paypal/webhooks + pathType: Prefix + backend: + service: + name: payment-paypal + port: + number: 80 + - path: /accounting pathType: Prefix backend: diff --git a/kubernetes-dev/microservices/payment-paypal-microservice.yml b/kubernetes-dev/microservices/payment-paypal-microservice.yml index 9fa4e348..467cbc69 100644 --- a/kubernetes-dev/microservices/payment-paypal-microservice.yml +++ b/kubernetes-dev/microservices/payment-paypal-microservice.yml @@ -78,6 +78,11 @@ spec: secretKeyRef: name: paypal-secret key: paypal-client-secret + - name: PAYPAL_WEBHOOK_ID + valueFrom: + secretKeyRef: + name: paypal-secret + key: paypal-webhook-id - name: SENTRY_DSN valueFrom: secretKeyRef: diff --git a/kubernetes/config/paypal-secret.yml b/kubernetes/config/paypal-secret.yml index 7c6c3e3d..e383d648 100644 --- a/kubernetes/config/paypal-secret.yml +++ b/kubernetes/config/paypal-secret.yml @@ -7,3 +7,4 @@ type: Opaque data: paypal-client-id: SETUP_PAYPAL_CLIENT_ID_REPLACEME paypal-client-secret: SETUP_PAYPAL_CLIENT_SECRET_REPLACEME + paypal-webhook-id: SETUP_PAYPAL_WEBHOOK_ID_REPLACEME diff --git a/kubernetes/ingress.yml b/kubernetes/ingress.yml index c0160583..6a4a5e62 100644 --- a/kubernetes/ingress.yml +++ b/kubernetes/ingress.yml @@ -29,6 +29,14 @@ spec: port: number: 80 + - path: /paypal/webhooks + pathType: Prefix + backend: + service: + name: payment-paypal + port: + number: 80 + - path: /accounting pathType: Prefix backend: diff --git a/kubernetes/microservices/payment-paypal-microservice.yml b/kubernetes/microservices/payment-paypal-microservice.yml index 3e0ab43d..7c7cef91 100644 --- a/kubernetes/microservices/payment-paypal-microservice.yml +++ b/kubernetes/microservices/payment-paypal-microservice.yml @@ -76,6 +76,11 @@ spec: secretKeyRef: name: paypal-secret key: paypal-client-secret + - name: PAYPAL_WEBHOOK_ID + valueFrom: + secretKeyRef: + name: paypal-secret + key: paypal-webhook-id - name: SENTRY_DSN valueFrom: secretKeyRef: diff --git a/modules/payment/billing/client/src/main/java/com/funixproductions/api/payment/billing/client/dtos/BillingDTO.java b/modules/payment/billing/client/src/main/java/com/funixproductions/api/payment/billing/client/dtos/BillingDTO.java index 7e5bbd36..3de54bc4 100644 --- a/modules/payment/billing/client/src/main/java/com/funixproductions/api/payment/billing/client/dtos/BillingDTO.java +++ b/modules/payment/billing/client/src/main/java/com/funixproductions/api/payment/billing/client/dtos/BillingDTO.java @@ -7,10 +7,7 @@ import com.funixproductions.core.tools.pdf.tools.VATInformation; import jakarta.validation.Valid; import jakarta.validation.constraints.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import org.springframework.lang.Nullable; import java.io.Serializable; @@ -108,5 +105,19 @@ public static class Price implements Serializable { @Nullable @Min(value = 0, message = "Le prix de la réduction doit être supérieur ou égal à 0") private Double discount; + + public Price(@NonNull Double ht, @Nullable Double tax) { + this.ht = ht; + this.tax = tax == null ? 0 : tax; + this.ttc = ht + (tax == null ? 0 : tax); + this.discount = null; + } + + public Price(@NonNull Double ht) { + this.ht = ht; + this.tax = 0.0; + this.ttc = ht; + this.discount = null; + } } } diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalPlanClient.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalPlanClient.java new file mode 100644 index 00000000..4dcd9ca0 --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalPlanClient.java @@ -0,0 +1,38 @@ +package com.funixproductions.api.payment.paypal.client.clients; + +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.core.crud.dtos.PageDTO; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.*; + +/** + * Client pour gérer les plans d'abonnements Paypal. + */ +public interface PaypalPlanClient { + + /** + * Crée un abonnement mensuel sur Paypal. + * @param paypalPlanDTO Dto pour créer un abonnement mensuel sur Paypal + * @return le plan créé (201 Http) + */ + @PostMapping + PaypalPlanDTO create(@RequestBody @Valid PaypalPlanDTO paypalPlanDTO); + + /** + * Récupère un plan par son id. + * @param id id du plan + * @return le plan (200 Http) + */ + @GetMapping("{id}") + PaypalPlanDTO getPlanById(@PathVariable @Valid @NotBlank String id); + + @GetMapping + PageDTO getAll( + @RequestParam(value = "page",defaultValue = "0") String page, + @RequestParam(value = "elemsPerPage",defaultValue = "300") String elemsPerPage, + @RequestParam(value = "search",defaultValue = "") String search, + @RequestParam(value = "sort",defaultValue = "") String sort + ); + +} diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalPlanFeignClient.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalPlanFeignClient.java new file mode 100644 index 00000000..d0f124f6 --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalPlanFeignClient.java @@ -0,0 +1,11 @@ +package com.funixproductions.api.payment.paypal.client.clients; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient( + name = "FunixProductionsPaypalPlanFeignClient", + url = "http://payment-paypal", + path = "/paypal/plans" +) +public interface PaypalPlanFeignClient extends PaypalPlanClient { +} diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalSubscriptionClient.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalSubscriptionClient.java new file mode 100644 index 00000000..893b1446 --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalSubscriptionClient.java @@ -0,0 +1,38 @@ +package com.funixproductions.api.payment.paypal.client.clients; + +import com.funixproductions.api.payment.paypal.client.dtos.requests.paypal.PaypalCreateSubscriptionDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.core.crud.dtos.PageDTO; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.*; + +/** + * Client pour gérer les abonnements des utilisateurs sur Paypal. + */ +public interface PaypalSubscriptionClient { + + @PostMapping + PaypalSubscriptionDTO subscribe(@RequestBody @Valid PaypalCreateSubscriptionDTO request); + + @GetMapping("{id}") + PaypalSubscriptionDTO getSubscriptionById(@PathVariable @Valid @NotBlank String id); + + @PostMapping("{id}/pause") + PaypalSubscriptionDTO pauseSubscription(@PathVariable @Valid @NotBlank String id); + + @PostMapping("{id}/activate") + PaypalSubscriptionDTO activateSubscription(@PathVariable @Valid @NotBlank String id); + + @PostMapping("{id}/cancel") + void cancelSubscription(@PathVariable @Valid @NotBlank String id); + + @GetMapping + PageDTO getAll( + @RequestParam(value = "page",defaultValue = "0") String page, + @RequestParam(value = "elemsPerPage",defaultValue = "300") String elemsPerPage, + @RequestParam(value = "search",defaultValue = "") String search, + @RequestParam(value = "sort",defaultValue = "") String sort + ); + +} diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalSubscriptionFeignClient.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalSubscriptionFeignClient.java new file mode 100644 index 00000000..62bcf6b1 --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/clients/PaypalSubscriptionFeignClient.java @@ -0,0 +1,11 @@ +package com.funixproductions.api.payment.paypal.client.clients; + +import org.springframework.cloud.openfeign.FeignClient; + +@FeignClient( + name = "FunixProductionsPaypalSubscriptionFeignClient", + url = "http://payment-paypal", + path = "/paypal/subscriptions" +) +public interface PaypalSubscriptionFeignClient extends PaypalSubscriptionClient { +} diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/requests/paypal/PaypalCreateSubscriptionDTO.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/requests/paypal/PaypalCreateSubscriptionDTO.java new file mode 100644 index 00000000..f14b6c63 --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/requests/paypal/PaypalCreateSubscriptionDTO.java @@ -0,0 +1,34 @@ +package com.funixproductions.api.payment.paypal.client.dtos.requests.paypal; + +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PaypalCreateSubscriptionDTO { + + @NotNull(message = "Le plan est obligatoire") + private PaypalPlanDTO plan; + + @NotNull(message = "L'utilisateur id est obligatoire") + private UUID funixProdUserId; + + @NotBlank(message = "L'URL d'annulation est obligatoire") + private String cancelUrl; + + @NotBlank(message = "L'URL de retour valide est obligatoire") + private String returnUrl; + + @NotBlank(message = "La marque est obligatoire") + private String brandName; + +} diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/responses/PaypalPlanDTO.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/responses/PaypalPlanDTO.java new file mode 100644 index 00000000..5fcc899e --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/responses/PaypalPlanDTO.java @@ -0,0 +1,107 @@ +package com.funixproductions.api.payment.paypal.client.dtos.responses; + +import com.funixproductions.core.crud.dtos.ApiDTO; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Dto pour créer un abonnement mensuel sur Paypal. + * Create Product + * Create plan + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PaypalPlanDTO extends ApiDTO { + + /** + * The plan id de paypal + */ + private String planId; + + /** + * The subscription name. + */ + @NotBlank(message = "Le nom du produit est obligatoire") + @Size(min = 1, max = 127, message = "Le nom du produit doit contenir entre 1 et 127 caractères") + private String name; + + /** + * The subscription description + */ + @NotBlank(message = "La description du produit est obligatoire") + @Size(min = 1, max = 256, message = "La description du produit doit contenir entre 1 et 256 caractères") + private String description; + + /** + * The image URL for the product. + */ + @NotBlank(message = "L'URL de l'image du produit est obligatoire") + @Size(min = 1, max = 2000, message = "L'URL de l'image du produit doit contenir entre 1 et 2000 caractères") + private String imageUrl; + + /** + * The home page URL for the product. + */ + @NotBlank(message = "L'URL de la page d'accueil du produit est obligatoire") + @Size(min = 1, max = 2000, message = "L'URL de la page d'accueil du produit doit contenir entre 1 et 2000 caractères") + private String homeUrl; + + /** + * The subscription price. HT Hors taxes + */ + @NotNull(message = "Le prix du produit est obligatoire") + @Min(value = 1, message = "Le prix du produit doit être supérieur à 1") + private Double price; + + /** + * Le nom du projet auquel est associé le plan, exemple pacifista pour un pacifista+ + */ + @NotNull(message = "Le nom du projet est obligatoire") + @Size(min = 1, max = 50, message = "Le nom du projet doit contenir entre 1 et 50 caractères") + private String projectName; + + public PaypalPlanDTO(String name, String description, String imageUrl, String homeUrl, Double price, String projectName) { + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.homeUrl = homeUrl; + this.price = price; + this.projectName = projectName; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof final PaypalPlanDTO other) { + return this.name.equals(other.name) && + this.description.equals(other.description) && + this.imageUrl.equals(other.imageUrl) && + this.homeUrl.equals(other.homeUrl) && + this.price.equals(other.price) && + this.projectName.equals(other.projectName) && + (this.planId != null && other.planId != null && this.planId.equals(other.planId)) && + super.equals(obj); + } else { + return false; + } + } + + @Override + public int hashCode() { + return this.name.hashCode() + + this.description.hashCode() + + this.imageUrl.hashCode() + + this.homeUrl.hashCode() + + this.price.hashCode() + + this.projectName.hashCode() + + (this.planId != null ? this.planId.hashCode() : 0) + + super.hashCode(); + } +} diff --git a/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/responses/PaypalSubscriptionDTO.java b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/responses/PaypalSubscriptionDTO.java new file mode 100644 index 00000000..d93eed8c --- /dev/null +++ b/modules/payment/paypal/client/src/main/java/com/funixproductions/api/payment/paypal/client/dtos/responses/PaypalSubscriptionDTO.java @@ -0,0 +1,66 @@ +package com.funixproductions.api.payment.paypal.client.dtos.responses; + +import com.funixproductions.core.crud.dtos.ApiDTO; +import lombok.*; +import org.jetbrains.annotations.Nullable; + +import java.util.Date; +import java.util.UUID; + +/** + * Création d'un sub + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PaypalSubscriptionDTO extends ApiDTO { + + /** + * Le plan, pour lequel l'abonnement est créé. Doit au moins contenir l'id et le planId du plan pour la création. + */ + @NonNull + private PaypalPlanDTO plan; + + /** + * L'id de l'abonnement par PayPal + */ + private String subscriptionId; + + /** + * L'id utilisateur de la funixproductions + */ + @NonNull + private UUID funixProdUserId; + + /** + * Si l'abonnement est actif ou non. (pause ou pas) + */ + @NonNull + private Boolean active; + + /** + * Le nombre de cycles d'abonnement terminés + */ + @NonNull + private Integer cyclesCompleted; + + /** + * La date du dernier paiement + */ + @Nullable + private Date lastPaymentDate; + + /** + * La date du prochain paiement + */ + @Nullable + private Date nextPaymentDate; + + /** + * Le lien pour approuver l'abonnement + */ + @Nullable + private String approveLink; + +} diff --git a/modules/payment/paypal/service/pom.xml b/modules/payment/paypal/service/pom.xml index 80e688bb..3d3886c0 100644 --- a/modules/payment/paypal/service/pom.xml +++ b/modules/payment/paypal/service/pom.xml @@ -26,13 +26,14 @@ - org.springframework.boot - spring-boot-starter-actuator + com.funixproductions.api.payment.billing.client + funixproductions-payment-billing-client + ${com.funixproductions.api.version} - com.funixproductions.api.payment.billing.client - funixproductions-payment-billing-client + com.funixproductions.api.user.client + funixproductions-user-client ${com.funixproductions.api.version} @@ -41,6 +42,11 @@ postgresql ${org.postgresql.version} + + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/FunixProductionsPaymentPaypalApp.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/FunixProductionsPaymentPaypalApp.java index ea0c6398..5831b2cc 100644 --- a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/FunixProductionsPaymentPaypalApp.java +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/FunixProductionsPaymentPaypalApp.java @@ -8,7 +8,8 @@ @EnableScheduling @EnableFeignClients(basePackages = { "com.funixproductions.api.payment.paypal.service", - "com.funixproductions.api.payment.billing.client" + "com.funixproductions.api.payment.billing.client", + "com.funixproductions.api.user.client.clients" }) @SpringBootApplication(scanBasePackages = "com.funixproductions") public class FunixProductionsPaymentPaypalApp { diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/config/PaypalConfig.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/config/PaypalConfig.java index d5d41e9d..c2e889d6 100644 --- a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/config/PaypalConfig.java +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/config/PaypalConfig.java @@ -31,4 +31,9 @@ public class PaypalConfig { */ private String paypalOwnerEmail; + /** + * PayPal webhook id + */ + private String webhookId; + } diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/security/WebSecurity.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/security/WebSecurity.java new file mode 100644 index 00000000..6dda46b1 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/security/WebSecurity.java @@ -0,0 +1,20 @@ +package com.funixproductions.api.payment.paypal.service.security; + +import com.funixproductions.api.user.client.security.ApiWebSecurity; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +@Configuration +@EnableWebSecurity +public class WebSecurity extends ApiWebSecurity { + @NotNull + @Override + public Customizer.AuthorizationManagerRequestMatcherRegistry> getUrlsMatchers() { + return ex -> ex + .anyRequest().permitAll(); + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServicePlansClient.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServicePlansClient.java new file mode 100644 index 00000000..46065391 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServicePlansClient.java @@ -0,0 +1,30 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.clients; + +import com.funixproductions.api.payment.paypal.service.config.PaypalFeignInterceptor; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalPlanRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalPlanResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +/** + * Plans + */ +@FeignClient( + name = "PaypalServicePlansClient", + url = "${paypal.paypal-domain}", + path = "/v1/billing/plans/", + configuration = PaypalFeignInterceptor.class +) +public interface PaypalServicePlansClient { + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + PaypalPlanResponse createPlan( + @RequestHeader(name = "PayPal-Request-Id") String requestId, + @RequestBody CreatePaypalPlanRequest request + ); + + @GetMapping(value = "{id}", produces = MediaType.APPLICATION_JSON_VALUE) + PaypalPlanResponse getPlan(@PathVariable String id); + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServiceProductsClient.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServiceProductsClient.java new file mode 100644 index 00000000..6194eb55 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServiceProductsClient.java @@ -0,0 +1,29 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.clients; + +import com.funixproductions.api.payment.paypal.service.config.PaypalFeignInterceptor; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalProductRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalProductResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** + * Products + */ +@FeignClient( + name = "PaypalServiceProductsClient", + url = "${paypal.paypal-domain}", + path = "/v1/catalogs/products/", + configuration = PaypalFeignInterceptor.class +) +public interface PaypalServiceProductsClient { + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + PaypalProductResponse createProduct( + @RequestHeader(name = "PayPal-Request-Id") String requestId, + @RequestBody CreatePaypalProductRequest request + ); + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServiceSubscriptionsClient.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServiceSubscriptionsClient.java new file mode 100644 index 00000000..efad91c2 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/clients/PaypalServiceSubscriptionsClient.java @@ -0,0 +1,39 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.clients; + +import com.funixproductions.api.payment.paypal.service.config.PaypalFeignInterceptor; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalSubscriptionRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalSubscriptionResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +/** + * Create Subscription + */ +@FeignClient( + name = "PaypalServiceSubscriptionsClient", + url = "${paypal.paypal-domain}", + path = "/v1/billing/subscriptions/", + configuration = PaypalFeignInterceptor.class +) +public interface PaypalServiceSubscriptionsClient { + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + PaypalSubscriptionResponse createSubscription( + @RequestHeader(name = "PayPal-Request-Id") String requestId, + @RequestBody CreatePaypalSubscriptionRequest request + ); + + @GetMapping(value = "{id}", produces = MediaType.APPLICATION_JSON_VALUE) + PaypalSubscriptionResponse getSubscription(@PathVariable("id") String id); + + @PostMapping(value = "{id}/suspend", produces = MediaType.APPLICATION_JSON_VALUE) + void pauseSubscription(@PathVariable("id") String id, @RequestBody String reason); + + @PostMapping(value = "{id}/activate", produces = MediaType.APPLICATION_JSON_VALUE) + void activateSubscription(@PathVariable("id") String id); + + @PostMapping(value = "{id}/cancel", produces = MediaType.APPLICATION_JSON_VALUE) + void cancelSubscription(@PathVariable("id") String id, @RequestBody String reason); + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalPlanRequest.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalPlanRequest.java new file mode 100644 index 00000000..f370915c --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalPlanRequest.java @@ -0,0 +1,158 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.funixproductions.api.payment.paypal.client.dtos.requests.PaymentDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalProductResponse; +import lombok.Getter; + +import java.util.List; + +/** + * Create plan + */ +@Getter +public class CreatePaypalPlanRequest { + + /** + * The ID of the product created through Catalog Products API. + */ + @JsonProperty(value = "product_id") + private final String productId; + + /** + * The plan name. + */ + private final String name; + + /** + * The initial state of the plan. Allowed input values are CREATED and ACTIVE. + */ + private final String status; + + /** + * The detailed description of the plan. + */ + private final String description; + + /** + * An array of billing cycles for trial billing and regular billing. A plan can have at most two trial cycles and only one regular cycle. + */ + @JsonProperty(value = "billing_cycles") + private final List billingCycles; + + @JsonProperty(value = "payment_preferences") + private final PaymentPreference paymentPreferences; + + public CreatePaypalPlanRequest( + PaypalProductResponse product, + String name, + String description, + String price + ) { + this.productId = product.getId(); + this.name = name; + this.status = "ACTIVE"; + this.description = description; + this.billingCycles = List.of(new BillingCycle(price)); + this.paymentPreferences = new PaymentPreference(); + } + + public CreatePaypalPlanRequest(final PaypalPlanDTO request, final PaypalProductResponse product) { + this.productId = product.getId(); + this.name = request.getName(); + this.status = "ACTIVE"; + this.description = request.getDescription(); + this.billingCycles = List.of(new BillingCycle( + Double.toString(PaymentDTO.formatPrice(request.getPrice())) + )); + this.paymentPreferences = new PaymentPreference(); + } + + @Getter + private static class BillingCycle { + + /** + * The tenure type of the billing cycle. In case of a plan having trial cycle, only 2 trial cycles are allowed per plan. + */ + @JsonProperty(value = "tenure_type") + private final String tenureType; + + /** + * The order in which this cycle is to run among other billing cycles. For example, a trial billing cycle has a sequence of 1 while a regular billing cycle has a sequence of 2, so that trial cycle runs before the regular cycle. + */ + private final Integer sequence; + + @JsonProperty(value = "total_cycles") + private final Integer totalCycles; + + @JsonProperty(value = "pricing_scheme") + private final PricingScheme pricingScheme; + + private final Frequency frequency; + + public BillingCycle(final String price) { + this.tenureType = "REGULAR"; + this.sequence = 1; + this.totalCycles = 0; + this.pricingScheme = new PricingScheme(price); + this.frequency = new Frequency(); + } + + @Getter + private static class PricingScheme { + @JsonProperty(value = "fixed_price") + private final FixedPrice fixedPrice; + + public PricingScheme(final String value) { + this.fixedPrice = new FixedPrice(value); + } + } + + @Getter + private static class FixedPrice { + private final String value; + + @JsonProperty(value = "currency_code") + private final String currencyCode; + + public FixedPrice(final String value) { + this.value = value; + this.currencyCode = "EUR"; + } + } + + @Getter + private static class Frequency { + @JsonProperty(value = "interval_unit") + private final String intervalUnit; + + @JsonProperty(value = "interval_count") + private final Integer interval; + + public Frequency() { + this.intervalUnit = "MONTH"; + this.interval = 1; + } + } + } + + @Getter + private static class PaymentPreference { + @JsonProperty(value = "auto_bill_outstanding") + private final Boolean autoBillOutstanding; + + @JsonProperty(value = "setup_fee_failure_action") + private final String setupFeeFailureAction; + + @JsonProperty(value = "payment_failure_threshold") + private final Integer paymentFailureThreshold; + + public PaymentPreference() { + this.autoBillOutstanding = true; + this.setupFeeFailureAction = "CANCEL"; + this.paymentFailureThreshold = 3; + } + } + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalProductRequest.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalProductRequest.java new file mode 100644 index 00000000..0e4e5960 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalProductRequest.java @@ -0,0 +1,50 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import lombok.Getter; + +/** + * Create product + */ +@Getter +public class CreatePaypalProductRequest { + + private final String name; + + private final String description; + + private final String type; + + private final String category; + + @JsonProperty(value = "home_url") + private final String homeUrl; + + @JsonProperty(value = "image_url") + private final String imageUrl; + + public CreatePaypalProductRequest( + final String name, + final String description, + final String homeUrl, + final String imageUrl) { + this.name = name; + this.description = description; + this.type = "DIGITAL"; + this.category = "DIGITAL_GAMES"; + this.homeUrl = homeUrl; + this.imageUrl = imageUrl; + } + + public CreatePaypalProductRequest(final PaypalPlanDTO request) { + this.name = request.getName(); + this.description = request.getDescription(); + this.type = "DIGITAL"; + this.category = "DIGITAL_GAMES"; + this.homeUrl = request.getHomeUrl(); + this.imageUrl = request.getImageUrl(); + } + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalSubscriptionRequest.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalSubscriptionRequest.java new file mode 100644 index 00000000..90708a4e --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/requests/CreatePaypalSubscriptionRequest.java @@ -0,0 +1,144 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.funixproductions.api.user.client.dtos.UserDTO; +import com.funixproductions.core.tools.pdf.tools.VATInformation; +import lombok.Getter; +import lombok.NonNull; + +/** + * Create sub + */ +@Getter +public class CreatePaypalSubscriptionRequest { + + @JsonProperty(value = "plan_id") + private final String planId; + + private final String quantity; + + private final Subscriber subscriber; + + @JsonProperty(value = "application_context") + private final ApplicationContext applicationContext; + + private final Plan plan; + + public CreatePaypalSubscriptionRequest( + @NonNull String planId, + @NonNull UserDTO user, + @NonNull String brand, + @NonNull String returnUrl, + @NonNull String cancelUrl + ) { + this.planId = planId; + this.quantity = "1"; + this.subscriber = new Subscriber(user.getEmail(), user.getUsername()); + this.applicationContext = new ApplicationContext(brand, returnUrl, cancelUrl); + + final VATInformation vatInformation = VATInformation.getVATInformation(user.getCountry().getCountryCode2Chars()); + if (vatInformation != null) { + this.plan = new Plan(vatInformation); + } else { + this.plan = null; + } + } + + @Getter + private static class Subscriber { + @JsonProperty(value = "email_address") + private final String email; + + private final Name name; + + public Subscriber(String email, String username) { + this.email = email; + this.name = new Name(username); + } + + @Getter + private static class Name { + @JsonProperty(value = "given_name") + private final String name; + + @JsonProperty(value = "surname") + private final String familyName; + + public Name(String username) { + this.name = username; + this.familyName = "."; + } + } + + } + + @Getter + private static class ApplicationContext { + @JsonProperty(value = "brand_name") + private final String brandName; + + private final String locale; + + @JsonProperty(value = "shipping_preference") + private final String shippingPreference; + + @JsonProperty(value = "user_action") + private final String userAction; + + @JsonProperty(value = "return_url") + private final String returnUrl; + + @JsonProperty(value = "cancel_url") + private final String cancelUrl; + + @JsonProperty(value = "payment_method") + private final PaymentMethod paymentMethod; + + public ApplicationContext(String brand, String returnUrl, String cancelUrl) { + this.brandName = brand; + this.locale = "fr-FR"; + this.shippingPreference = "NO_SHIPPING"; + this.userAction = "SUBSCRIBE_NOW"; + this.returnUrl = returnUrl; + this.cancelUrl = cancelUrl; + this.paymentMethod = new PaymentMethod(); + } + + @Getter + private static class PaymentMethod { + @JsonProperty(value = "payer_selected") + private final String payerSelected; + + @JsonProperty(value = "payee_preferred") + private final String payeePreferred; + + public PaymentMethod() { + this.payerSelected = "PAYPAL"; + this.payeePreferred = "IMMEDIATE_PAYMENT_REQUIRED"; + } + } + } + + @Getter + private static class Plan { + + private final Taxes taxes; + + public Plan(@NonNull VATInformation vatInformation) { + this.taxes = new Taxes(vatInformation); + } + + @Getter + private static class Taxes { + private final Boolean inclusive; + private final String percentage; + + public Taxes(@NonNull VATInformation vatInformation) { + this.inclusive = false; + this.percentage = String.format("%.2f", vatInformation.getVatRate()); + } + } + + } + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalPlanResponse.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalPlanResponse.java new file mode 100644 index 00000000..37801081 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalPlanResponse.java @@ -0,0 +1,19 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Create plan + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PaypalPlanResponse { + + private String id; + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalProductResponse.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalProductResponse.java new file mode 100644 index 00000000..b6d98630 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalProductResponse.java @@ -0,0 +1,19 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Create product + */ +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PaypalProductResponse { + + private String id; + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalSubscriptionResponse.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalSubscriptionResponse.java new file mode 100644 index 00000000..58395029 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/dtos/responses/PaypalSubscriptionResponse.java @@ -0,0 +1,143 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class PaypalSubscriptionResponse { + + private String id; + + @JsonProperty(value = "plan_id") + private String planId; + + private String status; + + @JsonProperty(value = "billing_info") + private BillingInfo billingInfo; + + private List links; + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + public static class BillingInfo { + + @JsonProperty(value = "cycle_executions") + private List cycleExecutions; + + @JsonProperty(value = "last_payment") + private LastPayment lastPayment; + + /** + * The next date and time for billing this subscription, in Internet date and time format. + */ + @JsonProperty(value = "next_billing_time") + private String nextBillingTime; + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + public static class CycleExecution { + @JsonProperty(value = "cycles_completed") + private Integer cyclesCompleted; + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + public static class LastPayment { + private String status; + + /** + * The date and time when the last payment was made, in Internet date and time format. + */ + private String time; + } + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + public static class Link { + + private String href; + + private String rel; + + private String method; + + } + + public boolean isActive() { + return "ACTIVE".equals(status); + } + + public boolean isPaused() { + return "SUSPENDED".equals(status); + } + + public int getCyclesCompleted() { + try { + final BillingInfo.CycleExecution cycleExecution = billingInfo.getCycleExecutions().getFirst(); + + return Objects.requireNonNullElse(cycleExecution.getCyclesCompleted(), 0); + } catch (NoSuchElementException e) { + return 0; + } + } + + @Nullable + public Date getNextBillingDate() { + final String time = this.billingInfo.nextBillingTime; + if (time == null) return null; + + try { + return Date.from(Instant.parse(time)); + } catch (Exception e) { + return null; + } + } + + @Nullable + public Date getLastPaymentDate() { + if (!"COMPLETED".equals(this.billingInfo.lastPayment.status)) return null; + final String time = this.billingInfo.lastPayment.time; + if (time == null) return null; + + try { + return Date.from(Instant.parse(time)); + } catch (Exception e) { + return null; + } + } + + @Nullable + public String getApproveLink() { + for (final Link link : this.links) { + if ("approve".equals(link.rel) && "GET".equalsIgnoreCase(link.method)) { + return link.href; + } + } + + return null; + } + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/entities/PaypalPlan.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/entities/PaypalPlan.java new file mode 100644 index 00000000..0a8f8b14 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/entities/PaypalPlan.java @@ -0,0 +1,38 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.entities; + +import com.funixproductions.core.crud.entities.ApiEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +/** + * Entité pour gérer les plans d'abonnements Paypal. + */ +@Getter +@Setter +@Entity(name = "paypal_plans") +public class PaypalPlan extends ApiEntity { + + @Column(name = "plan_id") + private String planId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String description; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Column(name = "home_url", nullable = false) + private String homeUrl; + + @Column(nullable = false) + private Double price; + + @Column(name = "project_name", nullable = false) + private String projectName; + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/entities/PaypalSubscription.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/entities/PaypalSubscription.java new file mode 100644 index 00000000..1c931ad8 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/entities/PaypalSubscription.java @@ -0,0 +1,58 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.entities; + +import com.funixproductions.core.crud.entities.ApiEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.Setter; +import org.jetbrains.annotations.Nullable; + +import java.util.Date; +import java.util.UUID; + +@Getter +@Setter +@Entity(name = "paypal_subscriptions") +public class PaypalSubscription extends ApiEntity { + + @ManyToOne + @JoinColumn(name = "plan_id", nullable = false, updatable = false) + private PaypalPlan plan; + + @Column(name = "funix_prod_user_id", nullable = false, updatable = false) + private String funixProdUserId; + + @Column(name = "subscription_id") + private String subscriptionId; + + @Column(name = "active", nullable = false) + private Boolean active; + + @Column(name = "cycles_completed", nullable = false) + private Integer cyclesCompleted; + + @Column(name = "last_payment_date") + private Date lastPaymentDate; + + @Column(name = "next_payment_date") + private Date nextPaymentDate; + + @Nullable + public UUID getFunixProdUserId() { + if (funixProdUserId == null) { + return null; + } else { + return UUID.fromString(funixProdUserId); + } + } + + public void setFunixProdUserId(@Nullable UUID funixProdUserId) { + if (funixProdUserId == null) { + this.funixProdUserId = null; + } else { + this.funixProdUserId = funixProdUserId.toString(); + } + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/mappers/PaypalPlanMapper.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/mappers/PaypalPlanMapper.java new file mode 100644 index 00000000..7804eb05 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/mappers/PaypalPlanMapper.java @@ -0,0 +1,10 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.mappers; + +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalPlan; +import com.funixproductions.core.crud.mappers.ApiMapper; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface PaypalPlanMapper extends ApiMapper { +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/mappers/PaypalSubscriptionMapper.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/mappers/PaypalSubscriptionMapper.java new file mode 100644 index 00000000..ff5204d0 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/mappers/PaypalSubscriptionMapper.java @@ -0,0 +1,34 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.mappers; + +import com.funixproductions.api.payment.paypal.client.dtos.requests.paypal.PaypalCreateSubscriptionDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalSubscription; +import com.funixproductions.core.crud.mappers.ApiMapper; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = PaypalPlanMapper.class) +public interface PaypalSubscriptionMapper extends ApiMapper { + + @Mapping(target = "active", constant = "true") + @Mapping(target = "cyclesCompleted", constant = "0") + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "approveLink", ignore = true) + @Mapping(target = "lastPaymentDate", ignore = true) + @Mapping(target = "nextPaymentDate", ignore = true) + @Mapping(target = "subscriptionId", ignore = true) + @Mapping(target = "id", ignore = true) + PaypalSubscriptionDTO toDTOFromCreation(PaypalCreateSubscriptionDTO request); + + @Override + @Mapping(target = "id", ignore = true) + @Mapping(target = "uuid", source = "id") + PaypalSubscription toEntity(PaypalSubscriptionDTO dto); + + @Override + @Mapping(target = "id", source = "uuid") + @Mapping(target = "approveLink", ignore = true) + PaypalSubscriptionDTO toDto(PaypalSubscription entity); + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/repositories/PaypalPlanRepository.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/repositories/PaypalPlanRepository.java new file mode 100644 index 00000000..ba1ee40d --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/repositories/PaypalPlanRepository.java @@ -0,0 +1,10 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.repositories; + +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalPlan; +import com.funixproductions.core.crud.repositories.ApiRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PaypalPlanRepository extends ApiRepository { + boolean existsByNameAndProjectName(String name, String projectName); +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/repositories/PaypalSubscriptionRepository.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/repositories/PaypalSubscriptionRepository.java new file mode 100644 index 00000000..c879b7c7 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/repositories/PaypalSubscriptionRepository.java @@ -0,0 +1,9 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.repositories; + +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalSubscription; +import com.funixproductions.core.crud.repositories.ApiRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PaypalSubscriptionRepository extends ApiRepository { +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/resources/PaypalPlanResource.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/resources/PaypalPlanResource.java new file mode 100644 index 00000000..2987faa8 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/resources/PaypalPlanResource.java @@ -0,0 +1,32 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.resources; + +import com.funixproductions.api.payment.paypal.client.clients.PaypalPlanClient; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.services.PaypalPlanService; +import com.funixproductions.core.crud.dtos.PageDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/paypal/plans") +@RequiredArgsConstructor +public class PaypalPlanResource implements PaypalPlanClient { + + private final PaypalPlanService paypalPlanService; + + @Override + public PaypalPlanDTO create(PaypalPlanDTO paypalPlanDTO) { + return this.paypalPlanService.create(paypalPlanDTO); + } + + @Override + public PaypalPlanDTO getPlanById(String id) { + return this.paypalPlanService.getPlanById(id); + } + + @Override + public PageDTO getAll(String page, String elemsPerPage, String search, String sort) { + return this.paypalPlanService.getAll(page, elemsPerPage, search, sort); + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/resources/PaypalSubscriptionResource.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/resources/PaypalSubscriptionResource.java new file mode 100644 index 00000000..1b1463f5 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/resources/PaypalSubscriptionResource.java @@ -0,0 +1,49 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.resources; + +import com.funixproductions.api.payment.paypal.client.clients.PaypalSubscriptionClient; +import com.funixproductions.api.payment.paypal.client.dtos.requests.paypal.PaypalCreateSubscriptionDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.services.PaypalSubscriptionService; +import com.funixproductions.core.crud.dtos.PageDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/paypal/subscriptions") +@RequiredArgsConstructor +public class PaypalSubscriptionResource implements PaypalSubscriptionClient { + + private final PaypalSubscriptionService paypalSubscriptionService; + + @Override + public PaypalSubscriptionDTO subscribe(PaypalCreateSubscriptionDTO request) { + return this.paypalSubscriptionService.subscribe(request); + } + + @Override + public PaypalSubscriptionDTO getSubscriptionById(String id) { + return this.paypalSubscriptionService.getSubscriptionById(id); + } + + @Override + public PaypalSubscriptionDTO pauseSubscription(String id) { + return this.paypalSubscriptionService.pauseSubscription(id); + } + + @Override + public PaypalSubscriptionDTO activateSubscription(String id) { + return this.paypalSubscriptionService.activateSubscription(id); + } + + @Override + public void cancelSubscription(String id) { + this.paypalSubscriptionService.cancelSubscription(id); + } + + @Override + public PageDTO getAll(String page, String elemsPerPage, String search, String sort) { + return this.paypalSubscriptionService.getAll(page, elemsPerPage, search, sort); + } + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalPlanCrudService.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalPlanCrudService.java new file mode 100644 index 00000000..9b7afce1 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalPlanCrudService.java @@ -0,0 +1,27 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.services; + +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalPlan; +import com.funixproductions.api.payment.paypal.service.subscriptions.mappers.PaypalPlanMapper; +import com.funixproductions.api.payment.paypal.service.subscriptions.repositories.PaypalPlanRepository; +import com.funixproductions.core.crud.services.ApiService; +import com.funixproductions.core.exceptions.ApiBadRequestException; +import lombok.NonNull; +import org.springframework.stereotype.Service; + +@Service +public class PaypalPlanCrudService extends ApiService { + + public PaypalPlanCrudService(PaypalPlanRepository repository, PaypalPlanMapper mapper) { + super(repository, mapper); + } + + @Override + public void beforeSavingEntity(@NonNull Iterable entity) { + for (PaypalPlan paypalPlan : entity) { + if (paypalPlan.getId() == null && super.getRepository().existsByNameAndProjectName(paypalPlan.getName(), paypalPlan.getProjectName())) { + throw new ApiBadRequestException("Le plan existe déjà pour ce projet."); + } + } + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalPlanService.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalPlanService.java new file mode 100644 index 00000000..31861efe --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalPlanService.java @@ -0,0 +1,86 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.services; + +import com.funixproductions.api.payment.paypal.client.clients.PaypalPlanClient; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServicePlansClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServiceProductsClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalPlanRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalProductRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalPlanResponse; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalProductResponse; +import com.funixproductions.core.crud.dtos.PageDTO; +import com.funixproductions.core.exceptions.ApiException; +import jakarta.transaction.Transactional; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j(topic = "PaypalPlanService") +@RequiredArgsConstructor +public class PaypalPlanService implements PaypalPlanClient { + + private final PaypalServiceProductsClient paypalServiceProductsClient; + private final PaypalServicePlansClient paypalServicePlansClient; + private final PaypalPlanCrudService paypalPlanCrudService; + + @Override + @Transactional + public PaypalPlanDTO create(PaypalPlanDTO paypalPlanDTO) { + final PaypalPlanDTO createdPlan = this.paypalPlanCrudService.create(paypalPlanDTO); + final PaypalProductResponse productResponse = this.createProduct(createdPlan); + final PaypalPlanResponse planResponse = this.createPlan(createdPlan, productResponse); + + log.info("Created plan -> paypal-id: {} plan-name: {}", planResponse, paypalPlanDTO.getName()); + createdPlan.setPlanId(planResponse.getId()); + return this.paypalPlanCrudService.update(createdPlan); + } + + @Override + public PaypalPlanDTO getPlanById(String id) { + return this.paypalPlanCrudService.findById(id); + } + + @Override + public PageDTO getAll(String page, String elemsPerPage, String search, String sort) { + return this.paypalPlanCrudService.getAll( + page, + elemsPerPage, + search, + sort + ); + } + + @NonNull + private PaypalProductResponse createProduct(final PaypalPlanDTO paypalPlanDTO) throws ApiException { + try { + if (paypalPlanDTO.getId() == null) { + throw new ApiException("L'id du plan Paypal est null. Vous devez d'abord créer le dto en bdd."); + } + + return this.paypalServiceProductsClient.createProduct( + paypalPlanDTO.getId().toString(), + new CreatePaypalProductRequest(paypalPlanDTO) + ); + } catch (Exception e) { + throw new ApiException("Erreur lors de la création du produit sur Paypal.", e); + } + } + + @NonNull + private PaypalPlanResponse createPlan(final PaypalPlanDTO paypalPlanDTO, final PaypalProductResponse productResponse) throws ApiException { + try { + if (paypalPlanDTO.getId() == null) { + throw new ApiException("L'id du plan Paypal est null. Vous devez d'abord créer le dto en bdd."); + } + + return this.paypalServicePlansClient.createPlan( + paypalPlanDTO.getId().toString(), + new CreatePaypalPlanRequest(paypalPlanDTO, productResponse) + ); + } catch (Exception e) { + throw new ApiException("Erreur lors de la création du plan sur Paypal.", e); + } + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalSubscriptionCrudService.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalSubscriptionCrudService.java new file mode 100644 index 00000000..ee16fd28 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalSubscriptionCrudService.java @@ -0,0 +1,53 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.services; + +import com.funixproductions.api.payment.paypal.client.dtos.requests.paypal.PaypalCreateSubscriptionDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalPlan; +import com.funixproductions.api.payment.paypal.service.subscriptions.entities.PaypalSubscription; +import com.funixproductions.api.payment.paypal.service.subscriptions.mappers.PaypalSubscriptionMapper; +import com.funixproductions.api.payment.paypal.service.subscriptions.repositories.PaypalPlanRepository; +import com.funixproductions.api.payment.paypal.service.subscriptions.repositories.PaypalSubscriptionRepository; +import com.funixproductions.core.crud.services.ApiService; +import com.funixproductions.core.exceptions.ApiBadRequestException; +import com.funixproductions.core.exceptions.ApiNotFoundException; +import jakarta.transaction.Transactional; +import lombok.NonNull; +import org.springframework.stereotype.Service; + +@Service +public class PaypalSubscriptionCrudService extends ApiService { + + private final PaypalPlanRepository paypalPlanRepository; + + public PaypalSubscriptionCrudService( + PaypalSubscriptionRepository repository, + PaypalPlanRepository paypalPlanRepository, + PaypalSubscriptionMapper mapper + ) { + super(repository, mapper); + this.paypalPlanRepository = paypalPlanRepository; + } + + @Transactional + protected PaypalSubscriptionDTO createFromRequest(final PaypalCreateSubscriptionDTO paypalCreateSubscriptionDTO) { + return this.create( + super.getMapper().toDTOFromCreation(paypalCreateSubscriptionDTO) + ); + } + + @Override + public void afterMapperCall(@NonNull PaypalSubscriptionDTO dto, @NonNull PaypalSubscription entity) { + if (dto.getPlan().getId() == null) { + throw new ApiBadRequestException("Le plan id est obligatoire."); + } + + entity.setPlan( + this.getPlan(dto.getPlan().getId().toString()) + ); + } + + @NonNull + private PaypalPlan getPlan(@NonNull String planId) { + return paypalPlanRepository.findByUuid(planId).orElseThrow(() -> new ApiNotFoundException("Plan " + planId + " not found")); + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalSubscriptionService.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalSubscriptionService.java new file mode 100644 index 00000000..6eb65cfc --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/subscriptions/services/PaypalSubscriptionService.java @@ -0,0 +1,187 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions.services; + +import com.funixproductions.api.payment.paypal.client.clients.PaypalSubscriptionClient; +import com.funixproductions.api.payment.paypal.client.dtos.requests.paypal.PaypalCreateSubscriptionDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServiceSubscriptionsClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalSubscriptionRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalSubscriptionResponse; +import com.funixproductions.api.user.client.clients.InternalUserCrudClient; +import com.funixproductions.api.user.client.dtos.UserDTO; +import com.funixproductions.core.crud.dtos.PageDTO; +import com.funixproductions.core.crud.enums.SearchOperation; +import com.funixproductions.core.exceptions.ApiException; +import feign.FeignException; +import jakarta.transaction.Transactional; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PaypalSubscriptionService implements PaypalSubscriptionClient { + + private static final String PAUSE_SUBSCRIPTION_REASON = "La pause de l'abonnement a été demandée par l'utilisateur."; + private static final String CANCEL_SUBSCRIPTION_REASON = "L'annulation de l'abonnement a été demandée par l'utilisateur."; + + private final PaypalServiceSubscriptionsClient paypalServiceSubscriptionsClient; + private final PaypalSubscriptionCrudService subscriptionCrudService; + private final InternalUserCrudClient internalUserCrudClient; + + @Override + @Transactional + public PaypalSubscriptionDTO subscribe(PaypalCreateSubscriptionDTO request) { + final PaypalSubscriptionDTO subscriptionDTO = this.subscriptionCrudService.createFromRequest(request); + final PaypalSubscriptionResponse subscriptionResponse = this.createSubscriptionFromPaypalAPI(request, subscriptionDTO); + + mapDtoWithPaypalEntity(subscriptionDTO, subscriptionResponse); + final PaypalSubscriptionDTO res = this.subscriptionCrudService.updatePut(subscriptionDTO); + + res.setApproveLink(subscriptionDTO.getApproveLink()); + return res; + } + + @Override + @Transactional + public PaypalSubscriptionDTO getSubscriptionById(String id) { + final PaypalSubscriptionDTO subscriptionDTO = this.subscriptionCrudService.findById(id); + final PaypalSubscriptionResponse subscriptionResponse = this.paypalServiceSubscriptionsClient.getSubscription(subscriptionDTO.getSubscriptionId()); + + mapDtoWithPaypalEntity(subscriptionDTO, subscriptionResponse); + final PaypalSubscriptionDTO res = this.subscriptionCrudService.updatePut(subscriptionDTO); + + res.setApproveLink(subscriptionDTO.getApproveLink()); + return res; + } + + @Override + @Transactional + public PaypalSubscriptionDTO pauseSubscription(String id) { + final PaypalSubscriptionDTO paypalSubscriptionDTO = this.getSubscriptionById(id); + + try { + this.paypalServiceSubscriptionsClient.pauseSubscription(paypalSubscriptionDTO.getSubscriptionId(), PAUSE_SUBSCRIPTION_REASON); + paypalSubscriptionDTO.setActive(false); + + final PaypalSubscriptionDTO res = this.subscriptionCrudService.updatePut(paypalSubscriptionDTO); + res.setApproveLink(paypalSubscriptionDTO.getApproveLink()); + return res; + } catch (Exception e) { + throw new ApiException("Une erreur est survenue lors de la mise en pause de l'abonnement PayPal.", e); + } + } + + @Override + @Transactional + public PaypalSubscriptionDTO activateSubscription(String id) { + final PaypalSubscriptionDTO paypalSubscriptionDTO = this.getSubscriptionById(id); + + try { + this.paypalServiceSubscriptionsClient.activateSubscription(paypalSubscriptionDTO.getSubscriptionId()); + paypalSubscriptionDTO.setActive(true); + + final PaypalSubscriptionDTO res = this.subscriptionCrudService.updatePut(paypalSubscriptionDTO); + res.setApproveLink(paypalSubscriptionDTO.getApproveLink()); + return res; + } catch (Exception e) { + throw new ApiException("Une erreur est survenue lors de l'activation de l'abonnement PayPal.", e); + } + } + + @Override + @Transactional + public void cancelSubscription(String id) { + final PaypalSubscriptionDTO paypalSubscriptionDTO = this.getSubscriptionById(id); + + try { + this.paypalServiceSubscriptionsClient.cancelSubscription(paypalSubscriptionDTO.getSubscriptionId(), CANCEL_SUBSCRIPTION_REASON); + this.subscriptionCrudService.delete(paypalSubscriptionDTO.getId().toString()); + } catch (Exception e) { + throw new ApiException("Une erreur est survenue lors de l'annulation de l'abonnement PayPal.", e); + } + } + + @Override + public PageDTO getAll(String page, String elemsPerPage, String search, String sort) { + return this.subscriptionCrudService.getAll(page, elemsPerPage, search, sort); + } + + @Nullable + @Transactional + public PaypalSubscriptionDTO getSubscriptionByPaypalId(String paypalId) { + final PageDTO page = this.subscriptionCrudService.getAll( + "0", + "1", + String.format( + "subscriptionId:%s:%s", + SearchOperation.EQUALS.getOperation(), + paypalId + ), + "" + ); + + try { + final PaypalSubscriptionDTO paypalSubscriptionDTO = page.getContent().getFirst(); + final PaypalSubscriptionResponse subscriptionResponse = this.paypalServiceSubscriptionsClient.getSubscription(paypalSubscriptionDTO.getSubscriptionId()); + + mapDtoWithPaypalEntity(paypalSubscriptionDTO, subscriptionResponse); + return this.subscriptionCrudService.updatePut(paypalSubscriptionDTO); + } catch (NoSuchElementException e) { + return null; + } catch (Exception e) { + throw new ApiException("Une erreur est survenue lors de la récupération de l'abonnement PayPal.", e); + } + } + + @NonNull + private PaypalSubscriptionResponse createSubscriptionFromPaypalAPI(PaypalCreateSubscriptionDTO request, PaypalSubscriptionDTO dto) throws ApiException { + if (dto.getId() == null) { + throw new ApiException("L'identifiant unique de l'abonnement est obligatoire."); + } + + final CreatePaypalSubscriptionRequest paypalSubscriptionRequest = new CreatePaypalSubscriptionRequest( + dto.getPlan().getPlanId(), + this.getCurrentUser(request.getFunixProdUserId()), + request.getBrandName(), + request.getReturnUrl(), + request.getCancelUrl() + ); + + try { + return this.paypalServiceSubscriptionsClient.createSubscription( + dto.getId().toString(), + paypalSubscriptionRequest + ); + } catch (Exception e) { + throw new ApiException("Une erreur est survenue lors de la création de l'abonnement PayPal.", e); + } + } + + @NonNull + public UserDTO getCurrentUser(final UUID funixProdUserId) throws ApiException { + try { + return this.internalUserCrudClient.findById(funixProdUserId.toString()); + } catch (FeignException e) { + if (e.status() == 404) { + throw new ApiException("L'utilisateur id: " + funixProdUserId + " n'existe pas."); + } else { + throw new ApiException("Une erreur est survenue lors de la récupération de l'utilisateur id: " + funixProdUserId + ".", e); + } + } catch (Exception e) { + throw new ApiException("Une erreur est survenue lors de la récupération de l'utilisateur id: " + funixProdUserId + ".", e); + } + } + + private static void mapDtoWithPaypalEntity(PaypalSubscriptionDTO subscriptionDTO, PaypalSubscriptionResponse subscriptionResponse) { + subscriptionDTO.setSubscriptionId(subscriptionResponse.getId()); + subscriptionDTO.setCyclesCompleted(subscriptionResponse.getCyclesCompleted()); + subscriptionDTO.setNextPaymentDate(subscriptionResponse.getNextBillingDate()); + subscriptionDTO.setLastPaymentDate(subscriptionResponse.getLastPaymentDate()); + subscriptionDTO.setApproveLink(subscriptionResponse.getApproveLink()); + subscriptionDTO.setActive(subscriptionResponse.isActive() && !subscriptionResponse.isPaused()); + } +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/clients/PacifistaInternalPaymentCallbackClient.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/clients/PacifistaInternalPaymentCallbackClient.java new file mode 100644 index 00000000..7afefcfa --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/clients/PacifistaInternalPaymentCallbackClient.java @@ -0,0 +1,18 @@ +package com.funixproductions.api.payment.paypal.service.webhooks.clients; + +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient( + name = "PacifistaInternalPaymentCallbackClient", + url = "http://web-shop-service", + path = "/kubeinternal/web/shop/cb" +) +public interface PacifistaInternalPaymentCallbackClient { + + @PostMapping("/paypal-subscription") + void sendPaymentCallback(@RequestBody PaypalSubscriptionDTO subscriptionDTO); + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/resources/PaypalWebhookResource.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/resources/PaypalWebhookResource.java new file mode 100644 index 00000000..260f1444 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/resources/PaypalWebhookResource.java @@ -0,0 +1,121 @@ +package com.funixproductions.api.payment.paypal.service.webhooks.resources; + +import com.funixproductions.api.payment.paypal.service.config.PaypalConfig; +import com.funixproductions.api.payment.paypal.service.webhooks.services.PaypalWebhookService; +import com.funixproductions.api.payment.paypal.service.webhooks.services.SubscriptionsWebhookPaypalService; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.zip.CRC32; + +@RestController +@RequestMapping("/paypal/webhooks") +@Slf4j(topic = "PaypalWebhookResource") +public class PaypalWebhookResource { + + private static final String PAYPAL_CERT_URL = "https://api.paypal.com"; + private final Iterable webhookServices; + private final String webhookId; + + public PaypalWebhookResource( + PaypalConfig paypalConfig, + SubscriptionsWebhookPaypalService saleCompleteWebhookService + ) { + this.webhookId = paypalConfig.getWebhookId(); + this.webhookServices = List.of( + saleCompleteWebhookService + ); + } + + @PostMapping("cb") + public ResponseEntity handleWebhook(@RequestBody String event, @RequestHeader HttpHeaders headers) { + if (!this.verifySignature(event, headers)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid signature"); + } + + final JsonObject payload = JsonParser.parseString(event).getAsJsonObject(); + final String eventType = payload.get("event_type").getAsString(); + final JsonObject ressource = payload.getAsJsonObject("resource"); + + for (PaypalWebhookService webhookService : webhookServices) { + if (webhookService.getEventType().equals(eventType)) { + webhookService.handleWebhookEvent(ressource); + } + } + + return ResponseEntity.ok().build(); + } + + private boolean verifySignature(String event, HttpHeaders headers) { + try { + final String transmissionId = headers.getFirst("paypal-transmission-id"); + final String timestamp = headers.getFirst("paypal-transmission-time"); + final String signature = headers.getFirst("paypal-transmission-sig"); + final String certUrl = headers.getFirst("paypal-cert-url"); + + if (!isValidCertUrl(certUrl)) { + return false; + } + + final int crc = crc32(event); + final String message = transmissionId + "|" + timestamp + "|" + webhookId + "|" + crc; + + final String certPem = downloadCert(certUrl); + final PublicKey publicKey = getPublicKeyFromPem(certPem); + + return verifySignatureWithPublicKey(message, signature, publicKey); + } catch (Exception e) { + log.error("Error verifying signature: {}", e.getMessage(), e); + return false; + } + } + + private int crc32(String input) { + CRC32 crc = new CRC32(); + + crc.update(input.getBytes(StandardCharsets.UTF_8)); + return (int) crc.getValue(); + } + + private String downloadCert(String certUrl) { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(certUrl, String.class); + } + + private boolean isValidCertUrl(String certUrl) { + return certUrl != null && certUrl.startsWith(PAYPAL_CERT_URL); + } + + private PublicKey getPublicKeyFromPem(String pem) throws Exception { + String publicKeyPEM = pem.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.getDecoder().decode(publicKeyPEM); + X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(spec); + } + + private boolean verifySignatureWithPublicKey(String message, String signature, PublicKey publicKey) throws Exception { + final byte[] signatureBytes = Base64.getDecoder().decode(signature); + final Signature sig = Signature.getInstance("SHA256withRSA"); + + sig.initVerify(publicKey); + sig.update(message.getBytes(StandardCharsets.UTF_8)); + return sig.verify(signatureBytes); + } + +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/services/PaypalWebhookService.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/services/PaypalWebhookService.java new file mode 100644 index 00000000..f4aee945 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/services/PaypalWebhookService.java @@ -0,0 +1,8 @@ +package com.funixproductions.api.payment.paypal.service.webhooks.services; + +import com.google.gson.JsonObject; + +public interface PaypalWebhookService { + void handleWebhookEvent(JsonObject resource); + String getEventType(); +} diff --git a/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/services/SubscriptionsWebhookPaypalService.java b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/services/SubscriptionsWebhookPaypalService.java new file mode 100644 index 00000000..28c18f08 --- /dev/null +++ b/modules/payment/paypal/service/src/main/java/com/funixproductions/api/payment/paypal/service/webhooks/services/SubscriptionsWebhookPaypalService.java @@ -0,0 +1,119 @@ +package com.funixproductions.api.payment.paypal.service.webhooks.services; + +import com.funixproductions.api.payment.billing.client.clients.BillingFeignInternalClient; +import com.funixproductions.api.payment.billing.client.dtos.BillingDTO; +import com.funixproductions.api.payment.billing.client.dtos.BillingObjectDTO; +import com.funixproductions.api.payment.billing.client.enums.PaymentOrigin; +import com.funixproductions.api.payment.billing.client.enums.PaymentType; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.services.PaypalSubscriptionService; +import com.funixproductions.api.payment.paypal.service.webhooks.clients.PacifistaInternalPaymentCallbackClient; +import com.funixproductions.api.user.client.dtos.UserDTO; +import com.funixproductions.core.exceptions.ApiException; +import com.funixproductions.core.tools.pdf.tools.VATInformation; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j(topic = "SaleCompleteWebhookService") +public class SubscriptionsWebhookPaypalService implements PaypalWebhookService { + + private static final String WEBHOOK_ID = "PAYMENT.SALE.COMPLETED"; + + private final BillingFeignInternalClient billingClient; + private final PaypalSubscriptionService subscriptionService; + private final PacifistaInternalPaymentCallbackClient pacifistaInternalPaymentCallbackClient; + + @Override + public void handleWebhookEvent(JsonObject resource) { + final JsonElement jsonSubscriptionId = resource.get("billing_agreement_id"); + if (jsonSubscriptionId.isJsonNull()) return; + + final PaypalSubscriptionDTO subscription = this.subscriptionService.getSubscriptionByPaypalId(jsonSubscriptionId.getAsString()); + if (subscription == null) return; + + this.sendBillingInvoice(subscription); + + try { + final String projectName = subscription.getPlan().getProjectName().toLowerCase(); + + if (projectName.contains("pacifista")) { + this.pacifistaInternalPaymentCallbackClient.sendPaymentCallback(subscription); + } + } catch (Exception e) { + throw new ApiException("Le callback vers le service Pacifista web a échoué.", e); + } + } + + @Override + public String getEventType() { + return WEBHOOK_ID; + } + + private void sendBillingInvoice(final PaypalSubscriptionDTO subscription) { + final UserDTO user = this.subscriptionService.getCurrentUser(subscription.getFunixProdUserId()); + final VATInformation vatInformation = VATInformation.getVATInformation(user.getCountry().getCountryCode2Chars()); + final BillingDTO billingDTO = new BillingDTO(); + + billingDTO.setBillingDescription(String.format( + "Facture générée suite à un achat de l'abonnement %s. Merci pour votre soutien. Paiement via PayPal.", + subscription.getPlan().getName() + )); + billingDTO.setPaymentType(PaymentType.PAYPAL); + billingDTO.setBilledEntity(this.createBilledDTO(user)); + billingDTO.setPaymentOrigin(this.getPaymentOrigin(subscription)); + billingDTO.setAmountTotal(new BillingDTO.Price( + subscription.getPlan().getPrice(), + subscription.getPlan().getPrice() * (vatInformation == null ? 0.0 : vatInformation.getVatRate() / 100) + )); + billingDTO.setVatInformation(vatInformation); + billingDTO.setBillingObjects(List.of( + new BillingObjectDTO( + subscription.getPlan().getName(), + subscription.getPlan().getDescription(), + 1, + subscription.getPlan().getPrice() + ) + )); + + try { + this.billingClient.create(billingDTO); + } catch (Exception e) { + throw new ApiException(String.format("Erreur interne lors de l'envoi de la facture pour le paypalsub id: %s, subName: %s.", subscription.getId(), subscription.getPlan().getName()), e); + } + } + + private BillingDTO.BilledEntity createBilledDTO(final UserDTO user) { + return new BillingDTO.BilledEntity( + user.getUsername(), + null, + null, + null, + null, + user.getEmail(), + null, + null, + null, + user.getId().toString() + ); + } + + private PaymentOrigin getPaymentOrigin(final PaypalSubscriptionDTO subscription) { + final String project = subscription.getPlan().getProjectName().toLowerCase(); + + if (project.contains("pacifista")) { + return PaymentOrigin.PACIFISTA; + } else if (project.contains("funixgaming")) { + return PaymentOrigin.FUNIXGAMING; + } else { + return PaymentOrigin.OTHER; + } + } + +} diff --git a/modules/payment/paypal/service/src/main/resources/application.properties b/modules/payment/paypal/service/src/main/resources/application.properties index 9be8b3f8..c247604a 100644 --- a/modules/payment/paypal/service/src/main/resources/application.properties +++ b/modules/payment/paypal/service/src/main/resources/application.properties @@ -18,6 +18,7 @@ spring.jackson.time-zone=Europe/Paris #PaypalConfig paypal.client-id=${PAYPAL_CLIENT_ID} paypal.client-secret=${PAYPAL_CLIENT_SECRET} +paypal.webhook-id=${PAYPAL_WEBHOOK_ID} paypal.paypal-domain=https://api-m.paypal.com paypal.paypal-owner-email=contact@funixproductions.com @@ -26,9 +27,10 @@ management.endpoints.web.exposure.include=health management.endpoint.health.show-details=always funixproductions.api.payment.billing.app-domain-url=http://payment-billing +funixproductions.api.user.app-domain-url=http://user #Sentry config sentry.dsn=${SENTRY_DSN} sentry.environment=production sentry.release=funixproductions-payment-paypal@1.3.4.0 -sentry.application-packages=com.funixproductions.api.payment.paypal \ No newline at end of file +sentry.application-packages=com.funixproductions.api.payment.paypal diff --git a/modules/payment/paypal/service/src/test/java/com/funixproductions/api/payment/paypal/service/subscriptions/PaypalPlanResourceTest.java b/modules/payment/paypal/service/src/test/java/com/funixproductions/api/payment/paypal/service/subscriptions/PaypalPlanResourceTest.java new file mode 100644 index 00000000..2c02313f --- /dev/null +++ b/modules/payment/paypal/service/src/test/java/com/funixproductions/api/payment/paypal/service/subscriptions/PaypalPlanResourceTest.java @@ -0,0 +1,300 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions; + +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServicePlansClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServiceProductsClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalPlanRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalProductRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalPlanResponse; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalProductResponse; +import com.funixproductions.api.payment.paypal.service.subscriptions.repositories.PaypalPlanRepository; +import com.funixproductions.core.crud.dtos.PageDTO; +import com.funixproductions.core.crud.enums.SearchOperation; +import com.funixproductions.core.test.beans.JsonHelper; +import com.google.gson.reflect.TypeToken; +import org.apache.logging.log4j.util.Strings; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.lang.reflect.Type; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class PaypalPlanResourceTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JsonHelper jsonHelper; + + @Autowired + private PaypalPlanRepository paypalPlanRepository; + + @MockitoBean + private PaypalServicePlansClient paypalServicePlansClient; + + @MockitoBean + private PaypalServiceProductsClient paypalServiceProductsClient; + + @BeforeEach + void resetDb() { + this.paypalPlanRepository.deleteAll(); + } + + @Test + void createNewPaypalPlan() throws Exception { + final PaypalPlanDTO request = new PaypalPlanDTO( + "TestSubscriptionPlan", + "Test Subscription Plan", + "https://www.test.com/image.jpg", + "https://www.test.com", + 10.0, + "testfunixproductions" + ); + + final PaypalPlanResponse paypalMockResponse = new PaypalPlanResponse(UUID.randomUUID().toString()); + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(paypalMockResponse); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + MvcResult result = this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + final PaypalPlanDTO response = this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalPlanDTO.class); + + assertNotNull(response); + assertEquals(paypalMockResponse.getId(), response.getPlanId()); + assertEquals(request.getName(), response.getName()); + assertEquals(request.getDescription(), response.getDescription()); + assertEquals(request.getImageUrl(), response.getImageUrl()); + assertEquals(request.getHomeUrl(), response.getHomeUrl()); + assertEquals(request.getPrice(), response.getPrice()); + assertEquals(request.getProjectName(), response.getProjectName()); + assertNotEquals(request, response); + assertNotEquals(request.hashCode(), response.hashCode()); + } + + @Test + void createNewPaypalPlanWithNameAlreadyExistsShouldFail() throws Exception { + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + final PaypalPlanDTO request = new PaypalPlanDTO( + "TestSubscriptionPlan", + "Test Subscription Plan", + "https://www.test.com/image.jpg", + "https://www.test.com", + 10.0, + "testfunixproductions" + ); + + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isBadRequest()); + + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + request.setProjectName("testfunixproductions2"); + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + request.setProjectName("testfunixproductions"); + request.setName("dd"); + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + } + + @Test + void testGetPlanById() throws Exception { + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + MvcResult result = this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(new PaypalPlanDTO( + "TestSubscriptionPlan", + "Test Subscription Plan", + "https://www.test.com/image.jpg", + "https://www.test.com", + 10.0, + "testfunixproductions" + ))) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + final PaypalPlanDTO response = this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalPlanDTO.class); + + this.mockMvc.perform( + get("/paypal/plans/" + response.getPlanId()) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isNotFound()); + + this.mockMvc.perform( + get("/paypal/plans/" + UUID.randomUUID()) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isNotFound()); + + result = this.mockMvc.perform( + get("/paypal/plans/" + response.getId()) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + final PaypalPlanDTO responseFromGet = this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalPlanDTO.class); + + assertEquals(response, responseFromGet); + } + + @Test + void testGetPlansBySearch() throws Exception { + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(new PaypalPlanDTO( + "TestSubscriptionPlan", + "Test Subscription Plan", + "https://www.test.com/image.jpg", + "https://www.test.com", + 10.0, + "testfunixproductions" + ))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(new PaypalPlanDTO( + "TestSubscriptionPlan2", + "Test Subscription Plan", + "https://www.test.com/image.jpg", + "https://www.test.com", + 10.0, + "testfunixproductions" + ))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(new PaypalPlanResponse(UUID.randomUUID().toString())); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(new PaypalPlanDTO( + "TestSubscriptionPlan", + "Test Subscription Plan", + "https://www.test.com/image.jpg", + "https://www.test.com", + 10.0, + "testpacifista" + ))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + + PageDTO page = this.getSearch(null, null); + assertEquals(3, page.getContent().size()); + + page = this.getSearch("TestSubscriptionPlan", null); + assertEquals(2, page.getContent().size()); + + page = this.getSearch(null, "testfunixproductions"); + assertEquals(2, page.getContent().size()); + + page = this.getSearch("TestSubscriptionPlan", "testfunixproductions"); + assertEquals(1, page.getContent().size()); + + page = this.getSearch("TestSubscriptionPlan", "testpacifista"); + assertEquals(1, page.getContent().size()); + + page = this.getSearch("TestSubscriptionPlan2", "testpacifista"); + assertEquals(0, page.getContent().size()); + + page = this.getSearch("TestSubscriptionPlan2", "testfunixproductions"); + assertEquals(1, page.getContent().size()); + } + + private PageDTO getSearch(@Nullable final String planName, @Nullable final String projectName) throws Exception { + final String query; + + if (!Strings.isEmpty(planName) && !Strings.isEmpty(projectName)) { + query = String.format( + "name:%s:%s,projectName:%s:%s", + SearchOperation.EQUALS.getOperation(), + planName, + SearchOperation.EQUALS.getOperation(), + projectName + ); + } else if (!Strings.isEmpty(planName)) { + query = String.format( + "name:%s:%s", + SearchOperation.EQUALS.getOperation(), + planName + ); + } else if (!Strings.isEmpty(projectName)) { + query = String.format( + "projectName:%s:%s", + SearchOperation.EQUALS.getOperation(), + projectName + ); + } else { + query = ""; + } + + final MvcResult mvcResult = this.mockMvc.perform( + get("/paypal/plans") + .param("search", query) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + + final Type pageType = new TypeToken>() {}.getType(); + return this.jsonHelper.fromJson(mvcResult.getResponse().getContentAsString(), pageType); + } + +} diff --git a/modules/payment/paypal/service/src/test/java/com/funixproductions/api/payment/paypal/service/subscriptions/PaypalSubscriptionResourceTest.java b/modules/payment/paypal/service/src/test/java/com/funixproductions/api/payment/paypal/service/subscriptions/PaypalSubscriptionResourceTest.java new file mode 100644 index 00000000..8990c089 --- /dev/null +++ b/modules/payment/paypal/service/src/test/java/com/funixproductions/api/payment/paypal/service/subscriptions/PaypalSubscriptionResourceTest.java @@ -0,0 +1,281 @@ +package com.funixproductions.api.payment.paypal.service.subscriptions; + +import com.funixproductions.api.payment.paypal.client.dtos.requests.paypal.PaypalCreateSubscriptionDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalPlanDTO; +import com.funixproductions.api.payment.paypal.client.dtos.responses.PaypalSubscriptionDTO; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServicePlansClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServiceProductsClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.clients.PaypalServiceSubscriptionsClient; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalPlanRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalProductRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.requests.CreatePaypalSubscriptionRequest; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalPlanResponse; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalProductResponse; +import com.funixproductions.api.payment.paypal.service.subscriptions.dtos.responses.PaypalSubscriptionResponse; +import com.funixproductions.api.payment.paypal.service.subscriptions.repositories.PaypalPlanRepository; +import com.funixproductions.api.payment.paypal.service.subscriptions.repositories.PaypalSubscriptionRepository; +import com.funixproductions.api.user.client.clients.InternalUserCrudClient; +import com.funixproductions.api.user.client.dtos.UserDTO; +import com.funixproductions.core.test.beans.JsonHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class PaypalSubscriptionResourceTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JsonHelper jsonHelper; + + @Autowired + private PaypalSubscriptionRepository subscriptionRepository; + + @Autowired + private PaypalPlanRepository paypalPlanRepository; + + @MockitoBean + private PaypalServiceSubscriptionsClient paypalServiceSubscriptionsClient; + + @MockitoBean + private InternalUserCrudClient internalUserCrudClient; + + @BeforeEach + void cleanDb() { + this.subscriptionRepository.deleteAll(); + this.paypalPlanRepository.deleteAll(); + } + + @Test + void createSubscription() throws Exception { + final UserDTO user = this.createUserDTO(UUID.randomUUID()); + final PaypalPlanDTO paypalPlanDTO = this.createPlanDTO(); + final PaypalCreateSubscriptionDTO request = new PaypalCreateSubscriptionDTO( + paypalPlanDTO, + user.getId(), + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + "testfunixprod" + ); + + generateFakeSubscriptionPaypal(paypalPlanDTO); + + MvcResult result = this.mockMvc.perform( + post("/paypal/subscriptions") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + final PaypalSubscriptionDTO response = this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalSubscriptionDTO.class); + + assertNotNull(response); + assertNotNull(response.getPlan()); + assertNotNull(response.getPlan().getPlanId()); + assertNotNull(response.getSubscriptionId()); + assertEquals(user.getId(), response.getFunixProdUserId()); + assertTrue(response.getActive()); + assertEquals(1, response.getCyclesCompleted()); + assertNotNull(response.getLastPaymentDate()); + assertNotNull(response.getNextPaymentDate()); + assertNotNull(response.getApproveLink()); + } + + @Test + void testGetSubscriptionById() throws Exception { + final PaypalSubscriptionDTO responseCreate = this.createSub(); + + MvcResult result = this.mockMvc.perform( + get("/paypal/subscriptions/" + responseCreate.getId()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + final PaypalSubscriptionDTO response = this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalSubscriptionDTO.class); + + assertNotNull(response); + assertNotNull(response.getPlan()); + assertNotNull(response.getPlan().getPlanId()); + assertNotNull(response.getSubscriptionId()); + assertTrue(response.getActive()); + assertEquals(1, response.getCyclesCompleted()); + assertNotNull(response.getLastPaymentDate()); + assertNotNull(response.getNextPaymentDate()); + assertNotNull(response.getApproveLink()); + } + + @Test + void testPauseSubscription() throws Exception { + final PaypalSubscriptionDTO responseCreate = this.createSub(); + + generateFakePauseSubscriptionPaypal(responseCreate); + + this.mockMvc.perform( + post("/paypal/subscriptions/" + responseCreate.getId() + "/pause") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + } + + @Test + void testActivateSubscription() throws Exception { + final PaypalSubscriptionDTO responseCreate = this.createSub(); + + generateFakeActivateSubscriptionPaypal(responseCreate); + + this.mockMvc.perform( + post("/paypal/subscriptions/" + responseCreate.getId() + "/activate") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + } + + @Test + void testCancelSubscription() throws Exception { + final PaypalSubscriptionDTO responseCreate = this.createSub(); + + generateFakeCancelSubscriptionPaypal(responseCreate); + + this.mockMvc.perform( + post("/paypal/subscriptions/" + responseCreate.getId() + "/cancel") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + } + + @Test + void testGetAllSubs() throws Exception { + this.createSub(); + + this.mockMvc.perform( + get("/paypal/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()); + } + + @MockitoBean + private PaypalServicePlansClient paypalServicePlansClient; + + @MockitoBean + private PaypalServiceProductsClient paypalServiceProductsClient; + + private PaypalPlanDTO createPlanDTO() throws Exception { + final Random random = new Random(); + + final PaypalPlanDTO paypalPlanDTO = new PaypalPlanDTO( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + random.nextDouble(1000) + 1, + UUID.randomUUID().toString() + ); + + final PaypalPlanResponse paypalMockResponse = new PaypalPlanResponse(UUID.randomUUID().toString()); + when(this.paypalServicePlansClient.createPlan(anyString(), any(CreatePaypalPlanRequest.class))).thenReturn(paypalMockResponse); + when(this.paypalServiceProductsClient.createProduct(anyString(), any(CreatePaypalProductRequest.class))).thenReturn(new PaypalProductResponse(UUID.randomUUID().toString())); + + MvcResult result = this.mockMvc.perform( + post("/paypal/plans") + .content(this.jsonHelper.toJson(paypalPlanDTO)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + return this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalPlanDTO.class); + } + + private UserDTO createUserDTO(UUID userId) { + final UserDTO userDTO = UserDTO.generateFakeDataForTestingPurposes(); + userDTO.setId(userId); + + when(this.internalUserCrudClient.findById(userId.toString())).thenReturn(userDTO); + return userDTO; + } + + private void generateFakeSubscriptionPaypal(PaypalPlanDTO paypalPlanDTO) { + final PaypalSubscriptionResponse subscriptionResponse = new PaypalSubscriptionResponse( + UUID.randomUUID().toString(), + paypalPlanDTO.getPlanId(), + "ACTIVE", + new PaypalSubscriptionResponse.BillingInfo( + List.of( + new PaypalSubscriptionResponse.BillingInfo.CycleExecution( + 1 + ) + ), + new PaypalSubscriptionResponse.BillingInfo.LastPayment( + "COMPLETED", + Instant.now().toString() + ), + Instant.now().plus(30, ChronoUnit.DAYS).toString() + ), + List.of( + new PaypalSubscriptionResponse.Link( + UUID.randomUUID().toString(), + "approve", + "GET" + ) + ) + ); + + when(this.paypalServiceSubscriptionsClient.createSubscription(anyString(), any(CreatePaypalSubscriptionRequest.class))).thenReturn(subscriptionResponse); + when(this.paypalServiceSubscriptionsClient.getSubscription(anyString())).thenReturn(subscriptionResponse); + } + + private void generateFakePauseSubscriptionPaypal(final PaypalSubscriptionDTO subscriptionDTO) { + doNothing().when(this.paypalServiceSubscriptionsClient).pauseSubscription(eq(subscriptionDTO.getSubscriptionId()), anyString()); + } + + private void generateFakeActivateSubscriptionPaypal(final PaypalSubscriptionDTO subscriptionDTO) { + doNothing().when(this.paypalServiceSubscriptionsClient).activateSubscription(eq(subscriptionDTO.getSubscriptionId())); + } + + private void generateFakeCancelSubscriptionPaypal(final PaypalSubscriptionDTO subscriptionDTO) { + doNothing().when(this.paypalServiceSubscriptionsClient).cancelSubscription(eq(subscriptionDTO.getSubscriptionId()), anyString()); + } + + private PaypalSubscriptionDTO createSub() throws Exception { + final UserDTO user = this.createUserDTO(UUID.randomUUID()); + final PaypalPlanDTO paypalPlanDTO = this.createPlanDTO(); + final PaypalCreateSubscriptionDTO request = new PaypalCreateSubscriptionDTO( + paypalPlanDTO, + user.getId(), + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + "testfunixprod" + ); + + generateFakeSubscriptionPaypal(paypalPlanDTO); + + MvcResult result = this.mockMvc.perform( + post("/paypal/subscriptions") + .content(this.jsonHelper.toJson(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + return this.jsonHelper.fromJson(result.getResponse().getContentAsString(), PaypalSubscriptionDTO.class); + } + +} diff --git a/modules/payment/paypal/service/src/test/resources/application.properties b/modules/payment/paypal/service/src/test/resources/application.properties index b8e9eb59..ee6c2973 100644 --- a/modules/payment/paypal/service/src/test/resources/application.properties +++ b/modules/payment/paypal/service/src/test/resources/application.properties @@ -14,5 +14,7 @@ paypal.client-id=client-id paypal.client-secret=client-secret paypal.paypal-domain=https://api-m.paypal.com paypal.paypal-owner-email=contact@funixproductions.com +paypal.webhook-id=webhook-id-fake -funixproductions.api.payment.billing.app-domain-url=http://localhost:9191 \ No newline at end of file +funixproductions.api.payment.billing.app-domain-url=http://localhost:9191 +funixproductions.api.user.app-domain-url=http://localhost:9192 diff --git a/modules/user/service/src/main/resources/db/migration/V13__paypal_subscription_service.sql b/modules/user/service/src/main/resources/db/migration/V13__paypal_subscription_service.sql new file mode 100644 index 00000000..853c7e34 --- /dev/null +++ b/modules/user/service/src/main/resources/db/migration/V13__paypal_subscription_service.sql @@ -0,0 +1,29 @@ +create table paypal_plans +( + id bigint generated by default as identity primary key, + created_at timestamp not null, + updated_at timestamp, + uuid varchar(255) not null constraint UK_paypal_plan_public_id unique, + plan_id varchar(400), + name varchar(255) not null, + description varchar(1000) not null, + image_url varchar(500) not null, + home_url varchar(500) not null, + price double precision not null, + project_name varchar(255) not null +); + +create table paypal_subscriptions +( + id bigint generated by default as identity primary key, + created_at timestamp not null, + updated_at timestamp, + uuid varchar(255) not null constraint UK_paypal_subscription_public_id unique, + plan_id bigint not null constraint paypal_subscription_link_plan references paypal_plans, + funix_prod_user_id varchar(300) not null, + subscription_id varchar(400), + active boolean not null, + cycles_completed int not null, + last_payment_date timestamp, + next_payment_date timestamp +);