diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml index 1d537df..f8d0047 100644 --- a/api-gateway/src/main/resources/application.yml +++ b/api-gateway/src/main/resources/application.yml @@ -12,5 +12,15 @@ spring: - Path=/api/smartbank/** filters: - StripPrefix=2 + - id: easypay-service + uri: lb://EASYPAY-SERVICE + predicates: + - Path=/api/easypay/** + filters: + - StripPrefix=2 + - id: fraudetect-service + uri: lb://FRAUDETECT-SERVICE + predicates: + - Path=/api/fraudetect/** # refresh: # enabled: false # AOT / Native Image does not support Spring Cloud Refresh Scope \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f6d211d --- /dev/null +++ b/compose.yml @@ -0,0 +1,66 @@ +services: + postgres-easypay: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: easypay + POSTGRES_USER: easypay + POSTGRES_PASSWORD: easypay + + postgres-smartbank: + image: postgres:16 + ports: + - "5433:5432" + environment: + POSTGRES_DB: smartbank + POSTGRES_USER: smartbank + POSTGRES_PASSWORD: smartbank + + postgres-fraudetect: + image: postgres:16 + ports: + - "5434:5432" + environment: + POSTGRES_DB: fraudetect + POSTGRES_USER: fraudetect + POSTGRES_PASSWORD: fraudetect + + postgres-merchantbo: + image: postgres:16 + ports: + - "5435:5432" + environment: + POSTGRES_DB: merchantbo + POSTGRES_USER: merchantbo + POSTGRES_PASSWORD: merchantbo + + kafka: + image: docker.redpanda.com/redpandadata/redpanda:v24.1.3 + command: + - redpanda + - start + - --kafka-addr internal://0.0.0.0:9092,external://0.0.0.0:19092 + # Address the broker advertises to clients that connect to the Kafka API. + # Use the internal addresses to connect to the Redpanda brokers' + # from inside the same Docker network. + # Use the external addresses to connect to the Redpanda brokers' + # from outside the Docker network. + - --advertise-kafka-addr internal://kafka:9092,external://localhost:19092 + - --pandaproxy-addr internal://0.0.0.0:8082,external://0.0.0.0:18082 + # Address the broker advertises to clients that connect to the HTTP Proxy. + - --advertise-pandaproxy-addr internal://kafka:8082,external://localhost:18082 + - --schema-registry-addr internal://0.0.0.0:8081,external://0.0.0.0:18081 + # Redpanda brokers use the RPC API to communicate with each other internally. + - --rpc-addr kafka:33145 + - --advertise-rpc-addr kafka:33145 + # Mode dev-container uses well-known configuration properties for development in containers. + - --mode dev-container + # Tells Seastar (the framework Redpanda uses under the hood) to use 1 core on the system. + - --smp 1 + - --default-log-level=info + ports: + - 18081:18081 + - 18082:18082 + - 19092:19092 + - 19644:9644 \ No newline at end of file diff --git a/config-server/src/main/resources/config-server/application.properties b/config-server/src/main/resources/config-server/application.properties index 61a8115..65ebc8d 100644 --- a/config-server/src/main/resources/config-server/application.properties +++ b/config-server/src/main/resources/config-server/application.properties @@ -1,6 +1,8 @@ server.port=0 server.shutdown=graceful +server.tomcat.accesslog.enabled=true + spring.cloud.config.allow-override=true spring.cloud.config.override-none=true @@ -13,4 +15,6 @@ management.endpoint.web.exposure.include="*" logging.level.org.springframework=INFO # AOT / Native Image does not support Spring Cloud Refresh Scope -#spring.cloud.refresh.enabled=false \ No newline at end of file +#spring.cloud.refresh.enabled=false + +eureka.client.registryFetchIntervalSeconds=5 \ No newline at end of file diff --git a/config-server/src/main/resources/config-server/easypay-service.yml b/config-server/src/main/resources/config-server/easypay-service.yml new file mode 100644 index 0000000..6fd7e93 --- /dev/null +++ b/config-server/src/main/resources/config-server/easypay-service.yml @@ -0,0 +1,23 @@ +spring: + config: + activate: + on-profile: default +eureka: + instance: + instance-id: ${spring.application.name}:${random.uuid} + +--- +spring: + config: + activate: + on-profile: default + datasource: + url: jdbc:postgresql://localhost:5432/easypay + username: easypay + password: easypay + driverClassName: org.postgresql.Driver + sql: + init: + schema-locations: classpath*:db/postgresql/schema.sql + data-locations: classpath*:db/postgresql/data.sql + mode: ALWAYS diff --git a/config-server/src/main/resources/config-server/fraudetect-service.yml b/config-server/src/main/resources/config-server/fraudetect-service.yml new file mode 100644 index 0000000..9485071 --- /dev/null +++ b/config-server/src/main/resources/config-server/fraudetect-service.yml @@ -0,0 +1,23 @@ +spring: + config: + activate: + on-profile: default +eureka: + instance: + instance-id: ${spring.application.name}:${random.uuid} + +--- +spring: + config: + activate: + on-profile: default + datasource: + url: jdbc:postgresql://localhost:5434/fraudetect + username: fraudetect + password: fraudetect + driverClassName: org.postgresql.Driver + sql: + init: + schema-locations: optional:classpath*:db/postgresql/schema.sql + data-locations: optional:classpath*:db/postgresql/data.sql + mode: ALWAYS diff --git a/config-server/src/main/resources/config-server/merchant-backoffice.yml b/config-server/src/main/resources/config-server/merchant-backoffice.yml new file mode 100644 index 0000000..349d3bb --- /dev/null +++ b/config-server/src/main/resources/config-server/merchant-backoffice.yml @@ -0,0 +1,23 @@ +spring: + config: + activate: + on-profile: default +eureka: + instance: + instance-id: ${spring.application.name}:${random.uuid} + +--- +spring: + config: + activate: + on-profile: default + datasource: + url: jdbc:postgresql://localhost:5435/merchantbo + username: merchantbo + password: merchantbo + driverClassName: org.postgresql.Driver + sql: + init: + schema-locations: optional:classpath*:db/postgresql/schema.sql + data-locations: optional:classpath*:db/postgresql/data.sql + mode: ALWAYS diff --git a/config-server/src/main/resources/config-server/smartbank-gateway.yml b/config-server/src/main/resources/config-server/smartbank-gateway.yml index 77c6c57..34baa08 100644 --- a/config-server/src/main/resources/config-server/smartbank-gateway.yml +++ b/config-server/src/main/resources/config-server/smartbank-gateway.yml @@ -4,4 +4,21 @@ spring: on-profile: default eureka: instance: - instance-id: ${spring.application.name}:${random.uuid} \ No newline at end of file + instance-id: ${spring.application.name}:${random.uuid} + +--- + +spring: + config: + activate: + on-profile: default + datasource: + url: jdbc:postgresql://localhost:5433/smartbank + username: smartbank + password: smartbank + driverClassName: org.postgresql.Driver + sql: + init: + schema-locations: optional:classpath*:db/postgresql/schema.sql + data-locations: optional:classpath*:db/postgresql/data.sql + mode: ALWAYS \ No newline at end of file diff --git a/easypay-service/.gitignore b/easypay-service/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/easypay-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/easypay-service/build.gradle.kts b/easypay-service/build.gradle.kts new file mode 100644 index 0000000..3723fca --- /dev/null +++ b/easypay-service/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + java + id("org.springframework.boot") version "3.2.5" + id("io.spring.dependency-management") version "1.1.4" +// id("org.graalvm.buildtools.native") version "0.9.28" +} + +group = "com.worldline.easypay" +version = "0.0.1-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_21 +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2023.0.1" +extra["springDocVersion"] = "2.5.0" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + // implementation("io.github.danielliu1123:httpexchange-spring-boot-starter:3.2.5") + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") + + implementation("org.springframework.cloud:spring-cloud-stream") + implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") + implementation("org.springframework.kafka:spring-kafka") + + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("springDocVersion")}") + developmentOnly("org.springframework.boot:spring-boot-devtools") + runtimeOnly("com.h2database:h2") + runtimeOnly("org.postgresql:postgresql") + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/easypay-service/create.sql b/easypay-service/create.sql new file mode 100644 index 0000000..d77bbc3 --- /dev/null +++ b/easypay-service/create.sql @@ -0,0 +1,9 @@ +create table card_ref (blacklisted boolean, id bigserial not null, card_number varchar(255) unique, card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), primary key (id)); +create table payment (amount integer, authorized boolean, bank_called boolean, date_time timestamp(6), response_time bigint, authorization_id uuid, id uuid not null, card_number varchar(255), card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), expiry_date varchar(255), pos_id varchar(255), processing_mode varchar(255) check (processing_mode in ('STANDARD','FALLBACK')), response_code varchar(255) check (response_code in ('ACCEPTED','INACTIVE_POS','INVALID_CARD_NUMBER','BLACK_LISTED_CARD_NUMBER','UNKNWON_CARD_TYPE','AUTHORIZATION_DENIED','AMOUNT_EXCEEDED')), primary key (id)); +create table pos_ref (active boolean, id bigserial not null, location varchar(255), pos_id varchar(255) unique, primary key (id)); +create table card_ref (blacklisted boolean, id bigserial not null, card_number varchar(255) unique, card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), primary key (id)); +create table payment (amount integer, authorized boolean, bank_called boolean, date_time timestamp(6), response_time bigint, authorization_id uuid, id uuid not null, card_number varchar(255), card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), expiry_date varchar(255), pos_id varchar(255), processing_mode varchar(255) check (processing_mode in ('STANDARD','FALLBACK')), response_code varchar(255) check (response_code in ('ACCEPTED','INACTIVE_POS','INVALID_CARD_NUMBER','BLACK_LISTED_CARD_NUMBER','UNKNWON_CARD_TYPE','AUTHORIZATION_DENIED','AMOUNT_EXCEEDED')), primary key (id)); +create table pos_ref (active boolean, id bigserial not null, location varchar(255), pos_id varchar(255) unique, primary key (id)); +create table card_ref (blacklisted boolean, id bigserial not null, card_number varchar(255) unique, card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), primary key (id)); +create table payment (amount integer, authorized boolean, bank_called boolean, date_time timestamp(6), response_time bigint, authorization_id uuid, id uuid not null, card_number varchar(255), card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), expiry_date varchar(255), pos_id varchar(255), processing_mode varchar(255) check (processing_mode in ('STANDARD','FALLBACK')), response_code varchar(255) check (response_code in ('ACCEPTED','INACTIVE_POS','INVALID_CARD_NUMBER','BLACK_LISTED_CARD_NUMBER','UNKNWON_CARD_TYPE','AUTHORIZATION_DENIED','AMOUNT_EXCEEDED')), primary key (id)); +create table pos_ref (active boolean, id bigserial not null, location varchar(255), pos_id varchar(255) unique, primary key (id)); diff --git a/easypay-service/src/main/java/com/worldline/easypay/EasypayServiceApplication.java b/easypay-service/src/main/java/com/worldline/easypay/EasypayServiceApplication.java new file mode 100644 index 0000000..74761cb --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/EasypayServiceApplication.java @@ -0,0 +1,20 @@ +package com.worldline.easypay; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; + +import com.worldline.easypay.payment.control.bank.BankAuthorClient; + + +@SpringBootApplication +@EnableDiscoveryClient +@EnableFeignClients +public class EasypayServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(EasypayServiceApplication.class, args); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/cardref/boundary/CardRefResource.java b/easypay-service/src/main/java/com/worldline/easypay/cardref/boundary/CardRefResource.java new file mode 100644 index 0000000..de65a12 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/cardref/boundary/CardRefResource.java @@ -0,0 +1,31 @@ +package com.worldline.easypay.cardref.boundary; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.worldline.easypay.cardref.control.CardService; + +import io.swagger.v3.oas.annotations.Operation; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +@RestController +@RequestMapping("/cards") +public class CardRefResource { + + CardService cardService; + + public CardRefResource(CardService cardService) { + this.cardService = cardService; + } + + @GetMapping + @Operation(description = "List all cards declared as reference data in the system", summary = "List all cards") + public ResponseEntity> findAll() { + return ResponseEntity.ok(cardService.listAll()); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/cardref/boundary/CardRefResponse.java b/easypay-service/src/main/java/com/worldline/easypay/cardref/boundary/CardRefResponse.java new file mode 100644 index 0000000..5517d55 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/cardref/boundary/CardRefResponse.java @@ -0,0 +1,8 @@ +package com.worldline.easypay.cardref.boundary; + +public record CardRefResponse( + String cardNumber, + String cardType, + Boolean blackListed) { + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/cardref/control/CardService.java b/easypay-service/src/main/java/com/worldline/easypay/cardref/control/CardService.java new file mode 100644 index 0000000..b5c89a4 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/cardref/control/CardService.java @@ -0,0 +1,27 @@ +package com.worldline.easypay.cardref.control; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.worldline.easypay.cardref.boundary.CardRefResponse; +import com.worldline.easypay.cardref.entity.CardRef; +import com.worldline.easypay.cardref.entity.CardRefRepository; + +@Service +public class CardService { + + CardRefRepository repository; + + public CardService(CardRefRepository repository) { + this.repository = repository; + } + + public List listAll() { + return repository.findAll().stream().map(this::toResponse).toList(); + } + + private CardRefResponse toResponse(CardRef cardRef) { + return new CardRefResponse(cardRef.cardNumber, cardRef.cardType.toString(), cardRef.blackListed); + } +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/cardref/control/CardType.java b/easypay-service/src/main/java/com/worldline/easypay/cardref/control/CardType.java new file mode 100644 index 0000000..c3ff0cd --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/cardref/control/CardType.java @@ -0,0 +1,46 @@ +package com.worldline.easypay.cardref.control; + +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public enum CardType { + + UNKNOWN, + VISA("^4[0-9]{12}(?:[0-9]{3}){0,2}$"), // 13 or 16 digits, starting with 4 + MASTERCARD("^(?:5[1-5]|2(?!2([01]|20)|7(2[1-9]|3))[2-7])\\d{14}$"), // 16 digits, starting with 51 through 55. + AMERICAN_EXPRESS("^3[47][0-9]{13}$"), // 15 digits, starting with 34 or 37 + DINERS_CLUB("^3(?:0[0-5]|[68][0-9])[0-9]{11}$"), // 14 digits, starting with 300 through 305, 36, or 38 + DISCOVER("^6(?:011|[45][0-9]{2})[0-9]{12}$"), // 16 digits, starting with 6011 or 65 + JCB("^(?:2131|1800|35\\d{3})\\d{11}$"), // 15 digits, starting with 2131 or 1800, or 16 digits starting with 35 + CHINA_UNION_PAY("^62[0-9]{14,17}$"); // start with 62 and is 16-19 digit long + + private static final Logger log = LoggerFactory.getLogger(CardType.class); + + private Pattern pattern; + + CardType() { + this.pattern = null; + } + + CardType(String pattern) { + this.pattern = Pattern.compile(pattern); + } + + public static CardType detect(String cardNumber) { + + for (CardType cardType : CardType.values()) { + if (null == cardType.pattern) { + continue; + } + if (cardType.pattern.matcher(cardNumber).matches()) { + return cardType; + } + } + + log.warn("card number {} has an unknown card type", cardNumber); + return UNKNOWN; + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/cardref/entity/CardRef.java b/easypay-service/src/main/java/com/worldline/easypay/cardref/entity/CardRef.java new file mode 100644 index 0000000..b46ae9c --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/cardref/entity/CardRef.java @@ -0,0 +1,32 @@ +package com.worldline.easypay.cardref.entity; + +import com.worldline.easypay.cardref.control.CardType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "card_ref") +public class CardRef { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + public Long id; + + @Column(name="card_number", unique = true) + public String cardNumber; + + @Enumerated(EnumType.STRING) + @Column(name="card_type") + public CardType cardType; + + @Column(name="blacklisted") + public Boolean blackListed; + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/cardref/entity/CardRefRepository.java b/easypay-service/src/main/java/com/worldline/easypay/cardref/entity/CardRefRepository.java new file mode 100644 index 0000000..65714d4 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/cardref/entity/CardRefRepository.java @@ -0,0 +1,7 @@ +package com.worldline.easypay.cardref.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CardRefRepository extends JpaRepository { + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentRequest.java b/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentRequest.java new file mode 100644 index 0000000..e06e60a --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentRequest.java @@ -0,0 +1,11 @@ +package com.worldline.easypay.payment.boundary; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record PaymentRequest( + @NotNull String posId, + @NotNull String cardNumber, + @NotNull String expiryDate, + @Min(10) Integer amount) { +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentResource.java b/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentResource.java new file mode 100644 index 0000000..713c58d --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentResource.java @@ -0,0 +1,82 @@ +package com.worldline.easypay.payment.boundary; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import com.worldline.easypay.payment.control.PaymentProcessingContext; +import com.worldline.easypay.payment.control.PaymentService; +import com.worldline.easypay.payment.entity.Payment; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +@RestController +@RequestMapping("/payments") +public class PaymentResource { + + PaymentService paymentService; + + public PaymentResource(PaymentService paymentService) { + this.paymentService = paymentService; + } + + @GetMapping + @Operation(description = "List all payments that have been processed", summary = "List all payments") + public ResponseEntity> findAll() { + return ResponseEntity.ok(paymentService.findAll()); + } + + @GetMapping("count") + @Operation(description = "Count all payments", summary = "Count payments") + public ResponseEntity count() { + return ResponseEntity.ok(paymentService.count()); + // return Json.createObjectBuilder().add("count", + // paymentService.count()).build(); + } + + @GetMapping("{id}") + @Operation(description = "Retrieve a given payment with its id", summary = "Retrieve a payment with its id") + @ApiResponse(responseCode = "200", description = "Payment found", content = @Content(mediaType = "application/json")) + @ApiResponse(responseCode = "204", description = "Payment not found", content = @Content(mediaType = "text/plain")) + public ResponseEntity findById( + @Parameter(description = "The payment id to be retrieved", required = true) @PathVariable("id") String paymentId) { + UUID id = UUID.fromString(paymentId); + var payment = paymentService.findById(id); + if (payment.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(payment.get()); + } + + @PostMapping + @Operation(description = "Process a payment: can be accepted or denied", summary = "Process a payment") + @ApiResponse(responseCode = "201", description = "Payment processed", content = @Content(mediaType = "application/json")) + public ResponseEntity processPayment( + @Parameter(description = "The payment to be processed", required = true) @Valid @NotNull @RequestBody PaymentRequest paymentRequest) { + + PaymentProcessingContext paymentContext = new PaymentProcessingContext(paymentRequest); + + paymentService.accept(paymentContext); + + PaymentResponse response = paymentContext.generateResponse(); + + URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") + .buildAndExpand(response.paymentId()).toUri(); + return ResponseEntity.created(location).body(response); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentResponse.java b/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentResponse.java new file mode 100644 index 0000000..7ebdbf3 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/boundary/PaymentResponse.java @@ -0,0 +1,25 @@ +package com.worldline.easypay.payment.boundary; + +import java.util.Optional; +import java.util.UUID; + +import com.worldline.easypay.cardref.control.CardType; +import com.worldline.easypay.payment.control.PaymentResponseCode; +import com.worldline.easypay.payment.control.ProcessingMode; + +public record PaymentResponse( + String posId, + String cardNumber, + String expiryDate, + Integer amount, + + UUID paymentId, + PaymentResponseCode responseCode, + Optional authorId, + CardType cardType, + Boolean bankCalled, + Boolean authorized, + Long responseTime, + ProcessingMode processingMode) { + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/CardValidator.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/CardValidator.java new file mode 100644 index 0000000..3a25ce6 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/CardValidator.java @@ -0,0 +1,106 @@ +package com.worldline.easypay.payment.control; + +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Example; +import org.springframework.stereotype.Component; + +import com.worldline.easypay.cardref.control.CardType; +import com.worldline.easypay.cardref.entity.CardRef; +import com.worldline.easypay.cardref.entity.CardRefRepository; + +@Component +public class CardValidator { + + private static final Logger log = LoggerFactory.getLogger(CardValidator.class); + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("MM/yy") + .withZone(ZoneId.of("UTC")); + + CardRefRepository cardRepository; + + public CardValidator(CardRefRepository cardRepository) { + this.cardRepository = cardRepository; + } + + public boolean checkExpiryDate(String expiryDate) { + + try { + YearMonth expiration = YearMonth.parse(expiryDate, DATE_TIME_FORMATTER); + YearMonth today = YearMonth.now(); + boolean rc = expiration.equals(today) || expiration.isAfter(today); + if (!rc) + log.warn("checkExpiryDate NOK: outdated: {}", expiryDate); + return rc; + } catch (DateTimeParseException e) { + log.warn("checkExpiryDate NOK: bad format: {}", expiryDate); + return false; + } + + } + + private static boolean checkLuhnKey(String cardNumber) { + int[] ints = new int[cardNumber.length()]; + for (int i = 0; i < cardNumber.length(); i++) { + ints[i] = Integer.parseInt(cardNumber.substring(i, i + 1)); + } + for (int i = ints.length - 2; i >= 0; i = i - 2) { + int j = ints[i]; + j = j * 2; + if (j > 9) { + j = j % 10 + 1; + } + ints[i] = j; + } + int sum = 0; + for (int i = 0; i < ints.length; i++) { + sum += ints[i]; + } + + if (sum % 10 != 0) { + log.warn("checkLuhnKey NOK {}", cardNumber); + return false; + } + + return true; + } + + public boolean isBlackListed(String cardNumber) { + + CardRef probe = new CardRef(); + probe.cardNumber = cardNumber; + probe.blackListed = true; + long count = cardRepository.count(Example.of(probe)); + + // long count = em.createNamedQuery("CardRef.isBlackListed", + // Long.class).setParameter("cardNumber", cardNumber).getSingleResult(); + if (count != 0) { + log.warn("cardNumber {} is black listed", cardNumber); + return true; + } + + return false; + } + + public boolean checkCardNumber(String cardNumber) { + + // Check format + String card = cardNumber.replaceAll("[^0-9]+", ""); // remove all non-numerics + if ((card == null) || (card.length() < 13) || (card.length() > 19)) { + return false; + } + + // Check Luhn key + return checkLuhnKey(card); + } + + public CardType checkCardType(String cardNumber) { + return CardType.detect(cardNumber); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentBoundaryControl.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentBoundaryControl.java new file mode 100644 index 0000000..88344e3 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentBoundaryControl.java @@ -0,0 +1,5 @@ +package com.worldline.easypay.payment.control; + +public class PaymentBoundaryControl { + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentProcessingContext.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentProcessingContext.java new file mode 100644 index 0000000..ebe29d4 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentProcessingContext.java @@ -0,0 +1,63 @@ +package com.worldline.easypay.payment.control; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import com.worldline.easypay.cardref.control.CardType; +import com.worldline.easypay.payment.boundary.PaymentRequest; +import com.worldline.easypay.payment.boundary.PaymentResponse; + +public class PaymentProcessingContext { + // Data coming from the request + public final String posId; + public final String cardNumber; + public final String expiryDate; + public final int amount; + + // Data generated by the processing + public UUID id; + public PaymentResponseCode responseCode; + public CardType cardType; + public ProcessingMode processingMode; + public long responseTime; + public LocalDateTime dateTime; + public boolean bankCalled; + public boolean authorized; + public Optional authorId; + + // Initializes the processing context from the request + public PaymentProcessingContext(PaymentRequest request) { + this.posId = request.posId(); + this.dateTime = LocalDateTime.now(); + this.responseTime = System.currentTimeMillis(); + this.cardNumber = request.cardNumber(); + this.expiryDate = request.expiryDate(); + this.amount = request.amount(); + + // Default values + this.responseCode = PaymentResponseCode.ACCEPTED; + this.processingMode = ProcessingMode.STANDARD; + this.authorized = false; + this.bankCalled = false; + this.authorId = Optional.empty(); + } + + // Generates the response from the processing context + public PaymentResponse generateResponse() { + return new PaymentResponse( + posId, + cardNumber, + expiryDate, + amount, + id, + responseCode, + authorId, + cardType, + bankCalled, + authorized, + responseTime, + processingMode); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentResponseCode.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentResponseCode.java new file mode 100644 index 0000000..ba5bf06 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentResponseCode.java @@ -0,0 +1,5 @@ +package com.worldline.easypay.payment.control; + +public enum PaymentResponseCode { + ACCEPTED, INACTIVE_POS, INVALID_CARD_NUMBER, BLACK_LISTED_CARD_NUMBER, UNKNWON_CARD_TYPE, AUTHORIZATION_DENIED, AMOUNT_EXCEEDED +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentService.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentService.java new file mode 100644 index 0000000..5695abd --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PaymentService.java @@ -0,0 +1,120 @@ +package com.worldline.easypay.payment.control; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.worldline.easypay.cardref.control.CardType; +import com.worldline.easypay.payment.control.bank.BankAuthorService; +import com.worldline.easypay.payment.control.track.PaymentTracker; +import com.worldline.easypay.payment.entity.Payment; +import com.worldline.easypay.payment.entity.PaymentRepository; + +import jakarta.transaction.Transactional; + +@Service +public class PaymentService { + + private CardValidator cardValidator; + private PosValidator posValidator; + private PaymentRepository paymentRepository; + private BankAuthorService bankAuthorService; + private PaymentTracker paymentTracker; + + @Value("${payment.author.threshold:10000}") + Integer authorThreshold; + + public PaymentService(CardValidator cardValidator, PosValidator posValidator, + PaymentRepository paymentRepository, BankAuthorService bankAuthorService, PaymentTracker paymentTracker) { + this.cardValidator = cardValidator; + this.posValidator = posValidator; + this.paymentRepository = paymentRepository; + this.bankAuthorService = bankAuthorService; + this.paymentTracker = paymentTracker; + } + + private void process(PaymentProcessingContext context) { + + if (!posValidator.isActive(context.posId)) { + context.responseCode = PaymentResponseCode.INACTIVE_POS; + return; + } + + if (!cardValidator.checkCardNumber(context.cardNumber)) { + context.responseCode = PaymentResponseCode.INVALID_CARD_NUMBER; + return; + } + + CardType cardType = cardValidator.checkCardType(context.cardNumber); + if (cardType == CardType.UNKNOWN) { + context.responseCode = PaymentResponseCode.UNKNWON_CARD_TYPE; + return; + } + context.cardType = cardType; + + if (cardValidator.isBlackListed(context.cardNumber)) { + context.responseCode = PaymentResponseCode.BLACK_LISTED_CARD_NUMBER; + return; + } + + if (context.amount > authorThreshold) { + if (!bankAuthorService.authorize(context)) { + // Authorization refused: locally (AMOUNT_EXCEEDED) or remotely by the Bank + // (AUTHORIZATION_DENIED)? + // log.info("JJS => refused authorization, context=" + context); + context.responseCode = context.processingMode.equals(ProcessingMode.STANDARD) + ? PaymentResponseCode.AUTHORIZATION_DENIED + : PaymentResponseCode.AMOUNT_EXCEEDED; + } + } + + } + + private void store(PaymentProcessingContext context) { + Payment payment = new Payment(); + + payment.amount = context.amount; + payment.cardNumber = context.cardNumber; + payment.expiryDate = context.expiryDate; + payment.responseCode = context.responseCode; + payment.processingMode = context.processingMode; + payment.cardType = context.cardType; + payment.posId = context.posId; + payment.dateTime = context.dateTime; + payment.responseTime = System.currentTimeMillis() - context.responseTime; + context.responseTime = payment.responseTime; + payment.bankCalled = context.bankCalled; + payment.authorized = context.authorized; + if (context.authorId.isPresent()) { + payment.authorId = context.authorId.get(); + } + + paymentRepository.saveAndFlush(payment); + + context.id = payment.id; + } + + @Transactional(Transactional.TxType.REQUIRED) + // @TrackedPayment + public void accept(PaymentProcessingContext paymentContext) { + process(paymentContext); + store(paymentContext); + paymentTracker.track(paymentContext); + } + + public Optional findById(UUID id) { + return paymentRepository.findById(id); + } + + public List findAll() { + return paymentRepository.findAll(); + } + + public long count() { + return paymentRepository.count(); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/PosValidator.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PosValidator.java new file mode 100644 index 0000000..f6c7760 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/PosValidator.java @@ -0,0 +1,43 @@ +package com.worldline.easypay.payment.control; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Example; +import org.springframework.stereotype.Controller; + +import com.worldline.easypay.posref.entity.PosRef; +import com.worldline.easypay.posref.entity.PosRefRepository; + +@Controller +public class PosValidator { + + private static final Logger log = LoggerFactory.getLogger(PosValidator.class); + + private PosRefRepository posRefRepository; + + public PosValidator(PosRefRepository posRefRepository) { + this.posRefRepository = posRefRepository; + } + + public boolean isActive(String posId) { + PosRef probe = new PosRef(); + probe.posId = posId; + List posList = posRefRepository.findAll(Example.of(probe)); + + if (posList.isEmpty()) { + log.warn( "checkPosStatus NOK, unknown posId {}", posId); + return false; + } + + boolean result = posList.get(0).active; + + if (!result) { + log.warn( "checkPosStatus NOK, inactive posId {}", posId); + } + + return result; + + } +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/ProcessingMode.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/ProcessingMode.java new file mode 100644 index 0000000..02e2b25 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/ProcessingMode.java @@ -0,0 +1,5 @@ +package com.worldline.easypay.payment.control; + +public enum ProcessingMode { + STANDARD, FALLBACK +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorClient.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorClient.java new file mode 100644 index 0000000..9374b19 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorClient.java @@ -0,0 +1,16 @@ +package com.worldline.easypay.payment.control.bank; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient("SMARTBANK-GATEWAY") +public interface BankAuthorClient { + + @PostMapping("/authors/authorize") + BankAuthorResponse authorize(@RequestBody BankAuthorRequest request); + + @GetMapping("/authors/count") + long count(); +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorConfig.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorConfig.java new file mode 100644 index 0000000..acef6cd --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorConfig.java @@ -0,0 +1,27 @@ +package com.worldline.easypay.payment.control.bank; + +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +// @Configuration +public class BankAuthorConfig { + + //@Bean + //@LoadBalanced + public RestClient.Builder restClient() { + return RestClient.builder(); + } + + + // @Bean + BankAuthorClient produceBankAuthorClient(RestClient.Builder restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient.build()); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(BankAuthorClient.class); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorRequest.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorRequest.java new file mode 100644 index 0000000..39d6bed --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorRequest.java @@ -0,0 +1,9 @@ +package com.worldline.easypay.payment.control.bank; + +public record BankAuthorRequest( + String merchantId, + String cardNumber, + String expiryDate, + Integer amount +) { +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorResponse.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorResponse.java new file mode 100644 index 0000000..40cf33d --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorResponse.java @@ -0,0 +1,12 @@ +package com.worldline.easypay.payment.control.bank; + +import java.util.UUID; + +public record BankAuthorResponse( + String cardNumber, + String expiryDate, + Integer amount, + UUID authorId, + Boolean authorized +) { +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorService.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorService.java new file mode 100644 index 0000000..f10939b --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/bank/BankAuthorService.java @@ -0,0 +1,64 @@ +package com.worldline.easypay.payment.control.bank; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.worldline.easypay.payment.control.PaymentProcessingContext; +import com.worldline.easypay.payment.control.ProcessingMode; + +import io.github.resilience4j.retry.annotation.Retry; + +@Service +public class BankAuthorService { + + private static final Logger log = LoggerFactory.getLogger(BankAuthorService.class); + + @Value("${payment.author.merchantId:RivieraDev Swag Store}") + String merchantId; + + @Value("${payment.max.amount.fallback:20000}") + Long maxAmountFallback; + + + @Autowired + private BankAuthorClient client; + + public BankAuthorService(/*BankAuthorClient client*/) { + //this.client = client; + } + + private BankAuthorRequest initRequest(PaymentProcessingContext context) { + return new BankAuthorRequest( + merchantId, + context.cardNumber, + context.expiryDate, + context.amount); + } + + @Retry(name = "BankAuthorService", fallbackMethod = "acceptByDelegation") + public boolean authorize(PaymentProcessingContext context) { + log.info("Authorize payment for {}", context); + try { + var response = client.authorize(initRequest(context)); + context.bankCalled = true; + context.authorId = Optional.of(response.authorId()); + context.authorized = response.authorized(); + return context.authorized; + } catch (Exception e) { + log.warn("Should retry or fallback: {}", e.getMessage()); + throw e; + } + } + + public boolean acceptByDelegation(PaymentProcessingContext context, Throwable throwable) { + log.debug("Accept by delegation. Error was: {}", throwable.getMessage()); + context.bankCalled = false; + context.processingMode = ProcessingMode.FALLBACK; + return context.amount < maxAmountFallback; + } +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/track/PaymentProcessedEvent.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/track/PaymentProcessedEvent.java new file mode 100644 index 0000000..b5ee4be --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/track/PaymentProcessedEvent.java @@ -0,0 +1,29 @@ +package com.worldline.easypay.payment.control.track; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import com.worldline.easypay.cardref.control.CardType; +import com.worldline.easypay.payment.control.PaymentResponseCode; +import com.worldline.easypay.payment.control.ProcessingMode; + +public record PaymentProcessedEvent( + // Internal data coming from PaymentProcessingContext + String posId, + String cardNumber, + String expiryDate, + int amount, + UUID paymentId, + PaymentResponseCode responseCode, + CardType cardType, + ProcessingMode processingMode, + long responseTime, + LocalDateTime dateTime, + boolean bankCalled, + boolean authorized, + Optional authorId, + + // Additional configuration data + String paymentServerId) { +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/control/track/PaymentTracker.java b/easypay-service/src/main/java/com/worldline/easypay/payment/control/track/PaymentTracker.java new file mode 100644 index 0000000..ecde77e --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/control/track/PaymentTracker.java @@ -0,0 +1,41 @@ +package com.worldline.easypay.payment.control.track; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.stereotype.Service; + +import com.worldline.easypay.payment.control.PaymentProcessingContext; + +@Service +public class PaymentTracker { + + StreamBridge streamBridge; + + String paymentServerId; + + + public PaymentTracker(StreamBridge streamBridge, @Value("${payment.server.id:unknown}") String paymentServerId) { + this.streamBridge = streamBridge; + this.paymentServerId = paymentServerId; + } + + public void track(PaymentProcessingContext context) { + var event = new PaymentProcessedEvent( + context.posId, + context.cardNumber, + context.expiryDate, + context.amount, + context.id, + context.responseCode, + context.cardType, + context.processingMode, + context.responseTime, + context.dateTime, + context.bankCalled, + context.authorized, + context.authorId, + "unknox" + ); + streamBridge.send("payment", event); + } +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/entity/Payment.java b/easypay-service/src/main/java/com/worldline/easypay/payment/entity/Payment.java new file mode 100644 index 0000000..80fe193 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/entity/Payment.java @@ -0,0 +1,65 @@ +package com.worldline.easypay.payment.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.worldline.easypay.cardref.control.CardType; +import com.worldline.easypay.payment.control.PaymentResponseCode; +import com.worldline.easypay.payment.control.ProcessingMode; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "payment") +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + public UUID id; + + @Column(name = "date_time") + public LocalDateTime dateTime; + + @Column(name = "response_time") + public Long responseTime; + + @Column(name = "pos_id") + public String posId; + + @Column(name = "card_number") + public String cardNumber; + + @Column(name = "expiry_date") + public String expiryDate; + + @Column(name = "amount") + public Integer amount; + + @Column(name = "card_type") + @Enumerated(EnumType.STRING) + public CardType cardType; + + @Column(name = "response_code") + @Enumerated(EnumType.STRING) + public PaymentResponseCode responseCode; + + @Column(name = "processing_mode") + @Enumerated(EnumType.STRING) + public ProcessingMode processingMode; + + @Column(name = "bank_called") + public Boolean bankCalled; + + @Column(name = "authorized") + public Boolean authorized; + + @Column(name = "authorization_id") + public UUID authorId; +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/payment/entity/PaymentRepository.java b/easypay-service/src/main/java/com/worldline/easypay/payment/entity/PaymentRepository.java new file mode 100644 index 0000000..e7a736e --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/payment/entity/PaymentRepository.java @@ -0,0 +1,9 @@ +package com.worldline.easypay.payment.entity; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentRepository extends JpaRepository { + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/posref/boundary/PosRefResource.java b/easypay-service/src/main/java/com/worldline/easypay/posref/boundary/PosRefResource.java new file mode 100644 index 0000000..db6398a --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/posref/boundary/PosRefResource.java @@ -0,0 +1,31 @@ +package com.worldline.easypay.posref.boundary; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.worldline.easypay.posref.control.PosService; + +import io.swagger.v3.oas.annotations.Operation; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +@RestController +@RequestMapping("/pos") +public class PosRefResource { + + PosService posService; + + public PosRefResource(PosService posService) { + this.posService = posService; + } + + @GetMapping + @Operation(description = "List all Point of Sales declared in the system", summary = "List Point of Sales") + public ResponseEntity> findAll() { + return ResponseEntity.ok(posService.findAll()); + } + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/posref/boundary/PosRefResponse.java b/easypay-service/src/main/java/com/worldline/easypay/posref/boundary/PosRefResponse.java new file mode 100644 index 0000000..d049808 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/posref/boundary/PosRefResponse.java @@ -0,0 +1,8 @@ +package com.worldline.easypay.posref.boundary; + +public record PosRefResponse( + String posId, + String location, + Boolean active) { + +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/posref/control/PosService.java b/easypay-service/src/main/java/com/worldline/easypay/posref/control/PosService.java new file mode 100644 index 0000000..a9521b6 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/posref/control/PosService.java @@ -0,0 +1,27 @@ +package com.worldline.easypay.posref.control; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.worldline.easypay.posref.boundary.PosRefResponse; +import com.worldline.easypay.posref.entity.PosRef; +import com.worldline.easypay.posref.entity.PosRefRepository; + +@Service +public class PosService { + + private PosRefRepository repository; + + public PosService(PosRefRepository repository) { + this.repository = repository; + } + + public List findAll() { + return repository.findAll().stream().map(this::toResponse).toList(); + } + + private PosRefResponse toResponse(PosRef posRef) { + return new PosRefResponse(posRef.posId, posRef.location, posRef.active); + } +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/posref/entity/PosRef.java b/easypay-service/src/main/java/com/worldline/easypay/posref/entity/PosRef.java new file mode 100644 index 0000000..7f7523c --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/posref/entity/PosRef.java @@ -0,0 +1,26 @@ +package com.worldline.easypay.posref.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "pos_ref") +public class PosRef { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + public Long id; + + @Column(name = "pos_id", unique = true) + public String posId; + + @Column(name = "location") + public String location; + + @Column(name = "active") + public Boolean active; +} diff --git a/easypay-service/src/main/java/com/worldline/easypay/posref/entity/PosRefRepository.java b/easypay-service/src/main/java/com/worldline/easypay/posref/entity/PosRefRepository.java new file mode 100644 index 0000000..ba4aa03 --- /dev/null +++ b/easypay-service/src/main/java/com/worldline/easypay/posref/entity/PosRefRepository.java @@ -0,0 +1,7 @@ +package com.worldline.easypay.posref.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PosRefRepository extends JpaRepository { + +} diff --git a/easypay-service/src/main/resources/application.properties b/easypay-service/src/main/resources/application.properties new file mode 100644 index 0000000..4cc3bcc --- /dev/null +++ b/easypay-service/src/main/resources/application.properties @@ -0,0 +1,18 @@ +spring.application.name=easypay-service + +spring.config.import=optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888} + +# AOT / Native Image does not support Spring Cloud Refresh Scope +# spring.cloud.refresh.enabled=false + +management.endpoints.web.exposure.include=* + +spring.cloud.stream.kafka.binder.defaultBrokerPort=19092 + +spring.cloud.stream.bindings.payment.destination=payment-topic + +resilience4j.retry.instances.BankAuthorService.max-attempts=3 +resilience4j.retry.instances.BankAuthorService.wait-duration=1s +resilience4j.retry.instances.BankAuthorService.enable-exponential-backoff=true +resilience4j.retry.instances.BankAuthorService.exponential-backoff-multiplier=2 +resilience4j.retry.instances.BankAuthorService.retry-exceptions=feign.RetryableException \ No newline at end of file diff --git a/easypay-service/src/main/resources/db/postgresql/data.sql b/easypay-service/src/main/resources/db/postgresql/data.sql new file mode 100644 index 0000000..dd92abc --- /dev/null +++ b/easypay-service/src/main/resources/db/postgresql/data.sql @@ -0,0 +1,11 @@ +INSERT INTO pos_ref(id, pos_id, location, active) VALUES (1, 'POS-01', 'Vallauris France', true) ON CONFLICT DO NOTHING; +INSERT INTO pos_ref(id, pos_id, location, active) VALUES (2, 'POS-02', 'Blois France', true) ON CONFLICT DO NOTHING; +INSERT INTO pos_ref(id, pos_id, location, active) VALUES (3, 'POS-03', 'Paris France', false) ON CONFLICT DO NOTHING; +INSERT INTO pos_ref(id, pos_id, location, active) VALUES (4, 'POS-04', 'San Francisco US', true) ON CONFLICT DO NOTHING; + +INSERT INTO card_ref(id, card_number, card_type, blacklisted) VALUES (1, '4485248221242242', 'VISA', true) ON CONFLICT DO NOTHING; +INSERT INTO card_ref(id, card_number, card_type, blacklisted) VALUES (2, '5261749597812879', 'MASTERCARD', true) ON CONFLICT DO NOTHING; +INSERT INTO card_ref(id, card_number, card_type, blacklisted) VALUES (3, '6011191990123805', 'DISCOVER', true) ON CONFLICT DO NOTHING; +INSERT INTO card_ref(id, card_number, card_type, blacklisted) VALUES (4, '343506189778618', 'AMERICAN_EXPRESS', true) ON CONFLICT DO NOTHING; +INSERT INTO card_ref(id, card_number, card_type, blacklisted) VALUES (5, '36031319313683', 'DINERS_CLUB', true) ON CONFLICT DO NOTHING; +INSERT INTO card_ref(id, card_number, card_type, blacklisted) VALUES (6, '6289193933258511', 'CHINA_UNION_PAY', true) ON CONFLICT DO NOTHING; diff --git a/easypay-service/src/main/resources/db/postgresql/schema.sql b/easypay-service/src/main/resources/db/postgresql/schema.sql new file mode 100644 index 0000000..7826cde --- /dev/null +++ b/easypay-service/src/main/resources/db/postgresql/schema.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS card_ref ( + blacklisted boolean, + id bigserial not null, + card_number varchar(255) unique, + card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), + primary key (id) +); + +CREATE TABLE IF NOT EXISTS payment ( + amount integer, + authorized boolean, + bank_called boolean, + date_time timestamp(6), + response_time bigint, + authorization_id uuid, + id uuid not null, + card_number varchar(255), + card_type varchar(255) check (card_type in ('UNKNOWN','VISA','MASTERCARD','AMERICAN_EXPRESS','DINERS_CLUB','DISCOVER','JCB','CHINA_UNION_PAY')), + expiry_date varchar(255), + pos_id varchar(255), + processing_mode varchar(255) check (processing_mode in ('STANDARD','FALLBACK')), + response_code varchar(255) check (response_code in ('ACCEPTED','INACTIVE_POS','INVALID_CARD_NUMBER','BLACK_LISTED_CARD_NUMBER','UNKNWON_CARD_TYPE','AUTHORIZATION_DENIED','AMOUNT_EXCEEDED')), + primary key (id) +); + +CREATE TABLE IF NOT EXISTS pos_ref ( + active boolean, + id bigserial not null, + location varchar(255), + pos_id varchar(255) unique, + primary key (id) +); \ No newline at end of file diff --git a/easypay-service/src/test/java/com/worldline/easypay/easypayservice/EasypayServiceApplicationTests.java b/easypay-service/src/test/java/com/worldline/easypay/easypayservice/EasypayServiceApplicationTests.java new file mode 100644 index 0000000..f1f4081 --- /dev/null +++ b/easypay-service/src/test/java/com/worldline/easypay/easypayservice/EasypayServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.worldline.easypay.easypayservice; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class EasypayServiceApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/fraudetect-service/.gitignore b/fraudetect-service/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/fraudetect-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/fraudetect-service/build.gradle.kts b/fraudetect-service/build.gradle.kts new file mode 100644 index 0000000..16f859c --- /dev/null +++ b/fraudetect-service/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + java + id("org.springframework.boot") version "3.3.0" + id("io.spring.dependency-management") version "1.1.5" +} + +group = "com.worldline.easypay" +version = "0.0.1-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_21 +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2023.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation("org.springframework.cloud:spring-cloud-stream") + implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") + implementation("org.springframework.kafka:spring-kafka") + developmentOnly("org.springframework.boot:spring-boot-devtools") + // developmentOnly("org.springframework.boot:spring-boot-docker-compose") + runtimeOnly("org.postgresql:postgresql") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.cloud:spring-cloud-stream-test-binder") + testImplementation("org.springframework.kafka:spring-kafka-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/fraudetect-service/create.sql b/fraudetect-service/create.sql new file mode 100644 index 0000000..370209d --- /dev/null +++ b/fraudetect-service/create.sql @@ -0,0 +1,5 @@ +create table fraud (count bigint, id bigint generated by default as identity, card_number varchar(255) unique, status varchar(255) check (status in ('GREEN','ORANGE','RED')), primary key (id)); +create table fraud (count bigint, id bigint generated by default as identity, card_number varchar(255) unique, status varchar(255) check (status in ('GREEN','ORANGE','RED')), primary key (id)); +create table fraud (count bigint, id bigint generated by default as identity, card_number varchar(255) unique, status varchar(255) check (status in ('GREEN','ORANGE','RED')), primary key (id)); +create table fraud (count bigint, id bigint generated by default as identity, card_number varchar(255) unique, status varchar(255) check (status in ('GREEN','ORANGE','RED')), primary key (id)); +create table fraud (count bigint, id bigint generated by default as identity, card_number varchar(255) unique, status varchar(255) check (status in ('GREEN','ORANGE','RED')), primary key (id)); diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/FraudetectServiceApplication.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/FraudetectServiceApplication.java new file mode 100644 index 0000000..30637d1 --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/FraudetectServiceApplication.java @@ -0,0 +1,15 @@ +package com.worldline.easypay.fraudetect; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class FraudetectServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(FraudetectServiceApplication.class, args); + } + +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/FraudResource.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/FraudResource.java new file mode 100644 index 0000000..362480c --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/FraudResource.java @@ -0,0 +1,37 @@ +package com.worldline.easypay.fraudetect.fraud.boundary; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.worldline.easypay.fraudetect.fraud.entity.Fraud; +import com.worldline.easypay.fraudetect.fraud.entity.FraudRepository; + +@RestController +@RequestMapping("/fraud") +public class FraudResource { + + FraudRepository fraudRepository; + + public FraudResource(FraudRepository fraudRepository) { + this.fraudRepository = fraudRepository; + } + + @GetMapping + // @Operation(description = "List all fraud records processed by the system", + // summary="List fraud records") + public ResponseEntity> findAll() { + return ResponseEntity.ok().body(fraudRepository.findAll()); + } + + @GetMapping("/count") + // @Operation(description = "Count all fraud records processed by the system", + // summary="Count fraud records") + public ResponseEntity count() { + return ResponseEntity.ok().body(fraudRepository.count()); + } + +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/FraudSubscriber.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/FraudSubscriber.java new file mode 100644 index 0000000..aa05cba --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/FraudSubscriber.java @@ -0,0 +1,17 @@ +package com.worldline.easypay.fraudetect.fraud.boundary; + +import java.util.function.Consumer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.worldline.easypay.fraudetect.fraud.control.FraudEngine; + +@Configuration +public class FraudSubscriber { + + @Bean + public Consumer fraudConsumer(FraudEngine fraudEngine) { + return event -> fraudEngine.detect(event); + } +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/PaymentEvent.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/PaymentEvent.java new file mode 100644 index 0000000..00d0719 --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/boundary/PaymentEvent.java @@ -0,0 +1,5 @@ +package com.worldline.easypay.fraudetect.fraud.boundary; + +public record PaymentEvent(String cardNumber) { + +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/control/FraudEngine.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/control/FraudEngine.java new file mode 100644 index 0000000..b7eabbc --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/control/FraudEngine.java @@ -0,0 +1,64 @@ +package com.worldline.easypay.fraudetect.fraud.control; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Example; +import org.springframework.stereotype.Service; + +import com.worldline.easypay.fraudetect.fraud.boundary.PaymentEvent; +import com.worldline.easypay.fraudetect.fraud.entity.Fraud; +import com.worldline.easypay.fraudetect.fraud.entity.FraudRepository; + +import jakarta.transaction.Transactional; + +@Service +public class FraudEngine { + + private static final Logger log = LoggerFactory.getLogger(FraudEngine.class); + + FraudRepository fraudRepository; + Long countThreshold; + + public FraudEngine(FraudRepository fraudRepository, @Value("${fraud.count.threshold:2}") Long countThreshold) { + this.fraudRepository = fraudRepository; + this.countThreshold = countThreshold; + } + + @Transactional + public void detect(PaymentEvent event) { + var probe = new Fraud(); + probe.cardNumber = event.cardNumber(); + + Optional dbRecord = fraudRepository.findOne(Example.of(probe)); + Fraud record; + + if (dbRecord.isPresent()) { + record = dbRecord.get(); + record.count += 1L; + } else { + record = new Fraud(); + record.cardNumber = event.cardNumber(); + record.count = 1L; + record.status = FraudStatus.GREEN; + record = fraudRepository.saveAndFlush(record); + } + + if (record.status == FraudStatus.GREEN) { + if (record.count.longValue() >= countThreshold) { + record.status = FraudStatus.ORANGE; + } + } else if (record.status == FraudStatus.ORANGE) { + if (record.count.longValue() >= (2 * countThreshold)) { + record.status = FraudStatus.RED; + } + } + + fraudRepository.save(record); + + log.warn("FraudEngine => {}", record); + } + +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/control/FraudStatus.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/control/FraudStatus.java new file mode 100644 index 0000000..0db693d --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/control/FraudStatus.java @@ -0,0 +1,5 @@ +package com.worldline.easypay.fraudetect.fraud.control; + +public enum FraudStatus { + GREEN, ORANGE, RED +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/entity/Fraud.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/entity/Fraud.java new file mode 100644 index 0000000..0be70cd --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/entity/Fraud.java @@ -0,0 +1,41 @@ +package com.worldline.easypay.fraudetect.fraud.entity; + +import com.worldline.easypay.fraudetect.fraud.control.FraudStatus; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "fraud") +public class Fraud { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + public Long id; + + @Column(name = "card_number", unique = true) + public String cardNumber; + + @Column(name = "count") + public Long count; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + public FraudStatus status; + + @Override + public String toString() { + return "Fraud{" + + "id=" + id + + ", cardNumber='" + cardNumber + '\'' + + ", count=" + count + + ", status=" + status + + '}'; + } +} diff --git a/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/entity/FraudRepository.java b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/entity/FraudRepository.java new file mode 100644 index 0000000..e7adeb6 --- /dev/null +++ b/fraudetect-service/src/main/java/com/worldline/easypay/fraudetect/fraud/entity/FraudRepository.java @@ -0,0 +1,7 @@ +package com.worldline.easypay.fraudetect.fraud.entity; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FraudRepository extends JpaRepository { + +} diff --git a/fraudetect-service/src/main/resources/application.properties b/fraudetect-service/src/main/resources/application.properties new file mode 100644 index 0000000..649744c --- /dev/null +++ b/fraudetect-service/src/main/resources/application.properties @@ -0,0 +1,17 @@ +spring.application.name=fraudetect-service + +spring.config.import=optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888} + +# AOT / Native Image does not support Spring Cloud Refresh Scope +# spring.cloud.refresh.enabled=false + +management.endpoints.web.exposure.include=* + +spring.cloud.stream.kafka.binder.defaultBrokerPort=19092 + +spring.cloud.stream.bindings.fraudConsumer-in-0.destination=payment-topic +spring.cloud.stream.bindings.fraudConsumer-in-0.group=fraudetect + +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.action=create +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-target=create.sql +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-source=metadata \ No newline at end of file diff --git a/fraudetect-service/src/main/resources/db/postgresql/schema.sql b/fraudetect-service/src/main/resources/db/postgresql/schema.sql new file mode 100644 index 0000000..e388e08 --- /dev/null +++ b/fraudetect-service/src/main/resources/db/postgresql/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS fraud ( + count bigint, + id bigint generated by default as identity, + card_number varchar(255) unique, + status varchar(255) check (status in ('GREEN','ORANGE','RED')), + primary key (id) +); diff --git a/fraudetect-service/src/test/java/com/worldline/easypay/fraudetect_service/FraudetectServiceApplicationTests.java b/fraudetect-service/src/test/java/com/worldline/easypay/fraudetect_service/FraudetectServiceApplicationTests.java new file mode 100644 index 0000000..96a5336 --- /dev/null +++ b/fraudetect-service/src/test/java/com/worldline/easypay/fraudetect_service/FraudetectServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.worldline.easypay.fraudetect_service; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class FraudetectServiceApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/merchant-backoffice/.gitignore b/merchant-backoffice/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/merchant-backoffice/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/merchant-backoffice/build.gradle.kts b/merchant-backoffice/build.gradle.kts new file mode 100644 index 0000000..537f0b7 --- /dev/null +++ b/merchant-backoffice/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + java + id("org.springframework.boot") version "3.3.0" + id("io.spring.dependency-management") version "1.1.5" +} + +group = "com.worldline.easypay" +version = "0.0.1-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_21 +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2023.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-graphql") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.apache.kafka:kafka-streams") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation("org.springframework.cloud:spring-cloud-stream") + implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") + implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka-streams") + implementation("org.springframework.kafka:spring-kafka") + developmentOnly("org.springframework.boot:spring-boot-devtools") + runtimeOnly("org.postgresql:postgresql") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework:spring-webflux") + testImplementation("org.springframework.cloud:spring-cloud-stream-test-binder") + testImplementation("org.springframework.graphql:spring-graphql-test") + testImplementation("org.springframework.kafka:spring-kafka-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/merchant-backoffice/gradle/wrapper/gradle-wrapper.properties b/merchant-backoffice/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/merchant-backoffice/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/merchant-backoffice/src/main/java/com/worldline/easypay/merchantbo/MerchantBackofficeApplication.java b/merchant-backoffice/src/main/java/com/worldline/easypay/merchantbo/MerchantBackofficeApplication.java new file mode 100644 index 0000000..d048177 --- /dev/null +++ b/merchant-backoffice/src/main/java/com/worldline/easypay/merchantbo/MerchantBackofficeApplication.java @@ -0,0 +1,15 @@ +package com.worldline.easypay.merchantbo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class MerchantBackofficeApplication { + + public static void main(String[] args) { + SpringApplication.run(MerchantBackofficeApplication.class, args); + } + +} diff --git a/merchant-backoffice/src/main/java/com/worldline/easypay/merchantbo/payment/boundary/PaymentSubscriber.java b/merchant-backoffice/src/main/java/com/worldline/easypay/merchantbo/payment/boundary/PaymentSubscriber.java new file mode 100644 index 0000000..ebbf8df --- /dev/null +++ b/merchant-backoffice/src/main/java/com/worldline/easypay/merchantbo/payment/boundary/PaymentSubscriber.java @@ -0,0 +1,16 @@ +package com.worldline.easypay.merchantbo.payment.boundary; + +import java.util.function.Consumer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PaymentSubscriber { + + @Bean + public Consumer paymentConsumer() { + return s -> System.out.println(s); + } + +} diff --git a/merchant-backoffice/src/main/resources/application.properties b/merchant-backoffice/src/main/resources/application.properties new file mode 100644 index 0000000..cd8751e --- /dev/null +++ b/merchant-backoffice/src/main/resources/application.properties @@ -0,0 +1,19 @@ +spring.application.name=merchant-backoffice + +spring.config.import=optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888} + +# AOT / Native Image does not support Spring Cloud Refresh Scope +# spring.cloud.refresh.enabled=false + +management.endpoints.web.exposure.include=* + +spring.cloud.stream.kafka.binder.defaultBrokerPort=19092 + +spring.cloud.function.definition=paymentConsumer + +spring.cloud.stream.bindings.paymentConsumer-in-0.destination=payment-topic +spring.cloud.stream.bindings.paymentConsumer-in-0.group=merchant-bo + +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.action=create +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-target=create.sql +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-source=metadata \ No newline at end of file diff --git a/merchant-backoffice/src/test/java/com/worldline/easypay/merchantbo/MerchantBackofficeApplicationTests.java b/merchant-backoffice/src/test/java/com/worldline/easypay/merchantbo/MerchantBackofficeApplicationTests.java new file mode 100644 index 0000000..3f33bcd --- /dev/null +++ b/merchant-backoffice/src/test/java/com/worldline/easypay/merchantbo/MerchantBackofficeApplicationTests.java @@ -0,0 +1,13 @@ +package com.worldline.easypay.merchantbo; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MerchantBackofficeApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e372f0b..2ca79ce 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,4 +2,7 @@ rootProject.name = "observability-workshop" include("api-gateway") include("config-server") include("discovery-server") +include("easypay-service") +include("fraudetect-service") +include("merchant-backoffice") include("smartbank-gateway") \ No newline at end of file diff --git a/smartbank-gateway/build.gradle.kts b/smartbank-gateway/build.gradle.kts index 2c50dfc..cbecde4 100644 --- a/smartbank-gateway/build.gradle.kts +++ b/smartbank-gateway/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("springDocVersion")}") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") + runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") } diff --git a/smartbank-gateway/create.sql b/smartbank-gateway/create.sql new file mode 100644 index 0000000..e4bfa9d --- /dev/null +++ b/smartbank-gateway/create.sql @@ -0,0 +1,5 @@ +create table bank_author (amount integer, authorized boolean, date_time timestamp(6), id uuid not null, card_number varchar(255), expiry_date varchar(255), merchant_id varchar(255), primary key (id)); +create table bank_author (amount integer, authorized boolean, date_time timestamp(6), id uuid not null, card_number varchar(255), expiry_date varchar(255), merchant_id varchar(255), primary key (id)); +create table bank_author (amount integer, authorized boolean, date_time timestamp(6), id uuid not null, card_number varchar(255), expiry_date varchar(255), merchant_id varchar(255), primary key (id)); +create table bank_author (amount integer, authorized boolean, date_time timestamp(6), id uuid not null, card_number varchar(255), expiry_date varchar(255), merchant_id varchar(255), primary key (id)); +create table bank_author (amount integer, authorized boolean, date_time timestamp(6), id uuid not null, card_number varchar(255), expiry_date varchar(255), merchant_id varchar(255), primary key (id)); diff --git a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorCountResponse.java b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorCountResponse.java new file mode 100644 index 0000000..748b983 --- /dev/null +++ b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorCountResponse.java @@ -0,0 +1,6 @@ +package com.worldline.easypay.smartbank.bankauthor.boundary; + +public record BankAuthorCountResponse( + Long count +) { +} diff --git a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorResource.java b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorResource.java index ca1b672..9c12065 100644 --- a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorResource.java +++ b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/boundary/BankAuthorResource.java @@ -1,10 +1,12 @@ package com.worldline.easypay.smartbank.bankauthor.boundary; import java.net.URI; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -22,18 +24,34 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; + @RestController @RequestMapping("/authors") public class BankAuthorResource { - BankAuthorBoundaryControl boundaryControl; + BankAuthorBoundaryControl bankAuthors; AuthorizationService authorizationService; public BankAuthorResource(BankAuthorBoundaryControl boundaryControl, AuthorizationService validationService) { - this.boundaryControl = boundaryControl; + this.bankAuthors = boundaryControl; this.authorizationService = validationService; } + @GetMapping("/count") + @Operation(summary = "") + public ResponseEntity count() { + return ResponseEntity.ok().body(new BankAuthorCountResponse(this.bankAuthors.count())); + } + + + @GetMapping + @Operation(summary = "Get all payment authorizations", description = "Get all payment authorizations") + @ApiResponse(responseCode = "200", description = "List of payment authorizations found") + public ResponseEntity> findAll() { + return ResponseEntity.ok().body(this.bankAuthors.findAll()); + } + + @GetMapping(value = "/{id}") @Operation(summary = "Get a payment authorization", description = "Get a payment authorization by its ID") @ApiResponse(responseCode = "200", description = "Payment authorization found") @@ -41,7 +59,7 @@ public ResponseEntity findById( @Parameter(description = "The ID of the payment authorization to retrieve") @PathVariable("id") String id) { try { var authorizationId = UUID.fromString(id); - Optional response = this.boundaryControl.findById(authorizationId); + Optional response = this.bankAuthors.findById(authorizationId); if (response.isEmpty()) { return ResponseEntity.notFound().build(); } @@ -54,6 +72,7 @@ public ResponseEntity findById( @PostMapping(value = "/authorize") @Operation(summary = "Process a payment authorization", description = "Deliver (or refuse) a payment authorization request") @ApiResponse(responseCode = "201", description = "Payment authorization request processed successfully") + @Transactional ResponseEntity authorize(@Valid @NotNull @RequestBody BankAuthorRequest request) { var authorized = this.authorizationService.authorize(request); var response = new BankAuthorResponse(UUID.randomUUID(), request.merchantId(), request.cardNumber(), diff --git a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/AuthorizationService.java b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/AuthorizationService.java index d4df58f..2d3ba6b 100644 --- a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/AuthorizationService.java +++ b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/AuthorizationService.java @@ -18,7 +18,7 @@ public class AuthorizationService { private Integer maxAmount; - public AuthorizationService(BankAuthorRepository repository, @Value("${smartbank.bankauthor.validation.maxAmount:4000}") Integer maxAmount) { + public AuthorizationService(BankAuthorRepository repository, @Value("${smartbank.bankauthor.validation.maxAmount:40000}") Integer maxAmount) { this.repository = repository; this.maxAmount = maxAmount; } diff --git a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/BankAuthorBoundaryControl.java b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/BankAuthorBoundaryControl.java index 4aa204e..3fedeac 100644 --- a/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/BankAuthorBoundaryControl.java +++ b/smartbank-gateway/src/main/java/com/worldline/easypay/smartbank/bankauthor/control/BankAuthorBoundaryControl.java @@ -1,7 +1,9 @@ package com.worldline.easypay.smartbank.bankauthor.control; +import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -11,13 +13,21 @@ @Service public class BankAuthorBoundaryControl { - + private BankAuthorRepository repository; public BankAuthorBoundaryControl(BankAuthorRepository repository) { this.repository = repository; } + public Long count() { + return this.repository.count(); + } + + public List findAll() { + return this.repository.findAll().stream().map(this::toResponse).collect(Collectors.toList()); + } + public Optional findById(UUID uuid) { Optional author = this.repository.findById(uuid); @@ -29,6 +39,7 @@ public Optional findById(UUID uuid) { } private BankAuthorResponse toResponse(BankAuthor author) { - return new BankAuthorResponse(author.id, author.merchantId,author.cardNumber,author.expiryDate, author.amount, author.authorized); + return new BankAuthorResponse(author.id, author.merchantId, author.cardNumber, author.expiryDate, author.amount, + author.authorized); } } diff --git a/smartbank-gateway/src/main/resources/application.properties b/smartbank-gateway/src/main/resources/application.properties index a2d7354..d26a26c 100644 --- a/smartbank-gateway/src/main/resources/application.properties +++ b/smartbank-gateway/src/main/resources/application.properties @@ -2,13 +2,17 @@ spring.application.name=smartbank-gateway spring.config.import=optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888} -spring.datasource.url=jdbc:h2:mem:smartbankdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=smartbank -spring.datasource.password=password -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +# spring.datasource.url=jdbc:h2:mem:smartbankdb +# spring.datasource.driverClassName=org.h2.Driver +# spring.datasource.username=smartbank +# spring.datasource.password=password +# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect # AOT / Native Image does not support Spring Cloud Refresh Scope # spring.cloud.refresh.enabled=false -management.endpoints.web.exposure.include=* \ No newline at end of file +management.endpoints.web.exposure.include=* + +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.action=create +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-target=create.sql +spring.jpa.properties.jakarta.persistence.schema-generation.scripts.create-source=metadata \ No newline at end of file diff --git a/smartbank-gateway/src/main/resources/db/postgresql/schema.sql b/smartbank-gateway/src/main/resources/db/postgresql/schema.sql new file mode 100644 index 0000000..f17fb62 --- /dev/null +++ b/smartbank-gateway/src/main/resources/db/postgresql/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS bank_author ( + amount integer, + authorized boolean, + date_time timestamp(6), + id uuid not null, + card_number varchar(255), + expiry_date varchar(255), + merchant_id varchar(255), + primary key (id) +);