diff --git a/.DS_Store b/.DS_Store index 058273b..ca2ea60 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000..3dda1f7 --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,102 @@ +Solution for Billie Pair Programming Exercise +============= +### Requirements: + +* Issuing an invoice for the buyer once the merchant notifies us the goods have been shipped + +### Steps to test +1- open the swagger ui: +http://localhost:8080/swagger-ui/index.html + +2- find the list of available merchants or add a new one: + +``` +[ + { + "id": "59267c88-a561-4e07-ab5d-75b64bed6a7c", + "name": "amazon.com" + }, + { + "id": "a177028a-fcde-4b56-95aa-08c0fa9fca2e", + "name": "ebay.com" + } +] +``` + +3- find the list of available customers or create a new one: +``` +[ + { + "id": "25fca542-cb2f-4c5d-81e1-cbe2f4a6fd83", + "name": "John Smith", + "address_details": "5703 Louetta Rd, Texas" + }, + { + "id": "2867689a-f3b1-45ed-a1ad-7adf3b40a0ab", + "name": "Josh Long", + "address_details": "9069 Holman Rd NW, Seattle" + }, + { + "id": "1325673e-ea2e-4b0b-822c-051204ecc7e8", + "name": "Esfandiyar Talebi", + "address_details": "Yavux su, burhabine, Istanbul" + } +] +``` + +4- find the list of available Products or add new ones: +``` +[ + { + "id": "126ff1c1-1e28-4355-a5d2-1aab3f3c48b8", + "name": "iPhone 15 SE", + "price": 999 + }, + { + "id": "93e77e2e-30ea-40d2-b505-b46c00f15680", + "name": "iPhone 15 Max", + "price": 1299 + } +] +``` +5- Create an Order for one of the Customers with available product uid: +``` +{ + "amount": 100.00, + "customerId": "25fca542-cb2f-4c5d-81e1-cbe2f4a6fd83" +} +``` +and note down the order uid: +``` +{ + "id": "94449231-63de-441a-bb9b-d70e802ab957" +} +``` +6- Notify the shipment of the order by using merchant resource + +``` +{ + "shipmentUId": "94449231-63de-441a-bb9b-d70e802ab955", + "orderUId": "94449231-63de-441a-bb9b-d70e802ab957", + "customerUId": "25fca542-cb2f-4c5d-81e1-cbe2f4a6fd83" +} +``` +as a result, invoice will be generated for the customer. + +7- find the newly created Invoice within the list of invoices + +### work in progress +1- Handling the deduplication of shipment event +2- Adding more integration tests for DAO classes + + +#### Prerequisites + +Running the tests: +```shell +cd +docker compose up database -d +gradle flywayMigrate +gradle clean build +docs at -> http://localhost:8080/swagger-ui/index.html +``` diff --git a/build.gradle.kts b/build.gradle.kts index 209c614..149bdde 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,8 @@ repositories { mavenCentral() } +extra["testcontainersVersion"] = "1.18.3" + dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") @@ -39,10 +41,21 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") runtimeOnly("org.postgresql:postgresql") + implementation ("org.flywaydb:flyway-core") + + // adding test container support + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") } +dependencyManagement { + imports { + mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}") + } +} + flyway { url = dbConf.getProperty("DATABASE_URL") user = dbConf.getProperty("POSTGRES_USER") diff --git a/src/main/kotlin/io/billie/countries/data/CountryRepository.kt b/src/main/kotlin/io/billie/countries/data/CountryRepository.kt index e68c6f0..75efd68 100644 --- a/src/main/kotlin/io/billie/countries/data/CountryRepository.kt +++ b/src/main/kotlin/io/billie/countries/data/CountryRepository.kt @@ -15,13 +15,21 @@ class CountryRepository { @Autowired lateinit var jdbcTemplate: JdbcTemplate - @Transactional(readOnly=true) + @Transactional(readOnly = true) fun findCountries(): List { - val query = jdbcTemplate.query( - "select id, name, country_code from organisations_schema.countries", - countryResponseMapper() + return jdbcTemplate.query( + "select id, name, country_code from organisations_schema.countries", + countryResponseMapper() ) - return query + } + + @Transactional + fun findCountryByCode (countryCode: String): Optional{ + return jdbcTemplate.query( + "select id, name, country_code from organisations_schema.countries where country_code=?", + countryResponseMapper(), + countryCode + ).stream().findFirst() } private fun countryResponseMapper() = RowMapper { it: ResultSet, _: Int -> diff --git a/src/main/kotlin/io/billie/countries/exception/CountryNotFoundException.kt b/src/main/kotlin/io/billie/countries/exception/CountryNotFoundException.kt new file mode 100644 index 0000000..09e7f2d --- /dev/null +++ b/src/main/kotlin/io/billie/countries/exception/CountryNotFoundException.kt @@ -0,0 +1,4 @@ +package io.billie.countries.exception + +class CountryNotFoundException (message: String): RuntimeException(message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/countries/service/CountryService.kt b/src/main/kotlin/io/billie/countries/service/CountryService.kt index 4dd33a5..ccdcacd 100644 --- a/src/main/kotlin/io/billie/countries/service/CountryService.kt +++ b/src/main/kotlin/io/billie/countries/service/CountryService.kt @@ -2,6 +2,7 @@ package io.billie.countries.service import io.billie.countries.data.CityRepository import io.billie.countries.data.CountryRepository +import io.billie.countries.exception.CountryNotFoundException import io.billie.countries.model.CityResponse import io.billie.countries.model.CountryResponse import org.springframework.stereotype.Service @@ -14,4 +15,7 @@ class CountryService(val dbCountry: CountryRepository, val dbCity: CityRepositor } fun findCities(countryCode: String): List = dbCity.findByCountryCode(countryCode) + fun findCountryByCode (countryCode: String): CountryResponse { + return dbCountry.findCountryByCode(countryCode).orElseThrow { CountryNotFoundException("country with code: $countryCode was not found!") } + } } diff --git a/src/main/kotlin/io/billie/customers/data/CustomerRepository.kt b/src/main/kotlin/io/billie/customers/data/CustomerRepository.kt new file mode 100644 index 0000000..0bf58d7 --- /dev/null +++ b/src/main/kotlin/io/billie/customers/data/CustomerRepository.kt @@ -0,0 +1,65 @@ +package io.billie.customers.data + +import io.billie.customers.dto.CustomerRequest +import io.billie.customers.model.Customer +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.sql.ResultSet +import java.util.* + +@Repository +class CustomerRepository { + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Transactional(readOnly=true) + fun findCustomerById(id: UUID): Optional { + return jdbcTemplate.query( + "select id, name, address_details from organisations_schema.customers where id= ?", + customerResponseMapper(), + id + ).stream().findFirst(); + } + + private fun customerResponseMapper() = RowMapper { it: ResultSet, _: Int -> + Customer( + it.getObject("id", UUID::class.java), + it.getString("name"), + it.getString("address_details") + ) + } + + @Transactional + fun findCustomers(): List { + return jdbcTemplate.query( + "select id, name, address_details from organisations_schema.customers", + customerResponseMapper(), + ) + } + + @Transactional + fun createCustomer(customerRequest: CustomerRequest): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + """ + INSERT INTO organisations_schema.customers + (name, address_details) + VALUES (?, ?) + """.trimIndent(), + arrayOf("id") + ) + ps.setString(1, customerRequest.name) + ps.setString(2, customerRequest.address) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/customers/dto/CustomerRequest.kt b/src/main/kotlin/io/billie/customers/dto/CustomerRequest.kt new file mode 100644 index 0000000..d2d7364 --- /dev/null +++ b/src/main/kotlin/io/billie/customers/dto/CustomerRequest.kt @@ -0,0 +1,10 @@ +package io.billie.customers.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import javax.validation.constraints.NotBlank + +data class CustomerRequest ( + @field:NotBlank val name: String, + @field:NotBlank @JsonProperty("address_details") + val address: String +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/customers/dto/CustomerResponse.kt b/src/main/kotlin/io/billie/customers/dto/CustomerResponse.kt new file mode 100644 index 0000000..09e6043 --- /dev/null +++ b/src/main/kotlin/io/billie/customers/dto/CustomerResponse.kt @@ -0,0 +1,14 @@ +package io.billie.customers.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import java.util.UUID +import javax.validation.constraints.NotBlank + +data class CustomerResponse ( + val id: UUID, + + @field:NotBlank val name: String, + + @field:NotBlank @JsonProperty("address_details") + val address: String +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/customers/exception/CustomerNotFoundException.kt b/src/main/kotlin/io/billie/customers/exception/CustomerNotFoundException.kt new file mode 100644 index 0000000..aa60d8d --- /dev/null +++ b/src/main/kotlin/io/billie/customers/exception/CustomerNotFoundException.kt @@ -0,0 +1,4 @@ +package io.billie.customers.exception + +class CustomerNotFoundException (message: String): RuntimeException (message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/customers/mapper/CustomerResponseMapper.kt b/src/main/kotlin/io/billie/customers/mapper/CustomerResponseMapper.kt new file mode 100644 index 0000000..c7ab2c3 --- /dev/null +++ b/src/main/kotlin/io/billie/customers/mapper/CustomerResponseMapper.kt @@ -0,0 +1,12 @@ +package io.billie.customers.mapper + +import io.billie.customers.dto.CustomerResponse +import io.billie.customers.model.Customer + + +fun Customer.toCustomerResponse(): CustomerResponse = + CustomerResponse( + id = this.id, + name = this.name, + address = this.address + ) diff --git a/src/main/kotlin/io/billie/customers/model/Customer.kt b/src/main/kotlin/io/billie/customers/model/Customer.kt new file mode 100644 index 0000000..8b11d23 --- /dev/null +++ b/src/main/kotlin/io/billie/customers/model/Customer.kt @@ -0,0 +1,14 @@ +package io.billie.customers.model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.util.UUID +import javax.validation.constraints.NotBlank + +data class Customer ( + val id: UUID, + + @field:NotBlank val name: String, + + // TODO: shall be replaced with AddressDetail later + @field:NotBlank @JsonProperty("address_details") val address: String +) diff --git a/src/main/kotlin/io/billie/customers/resource/CustomerResource.kt b/src/main/kotlin/io/billie/customers/resource/CustomerResource.kt new file mode 100644 index 0000000..a0b380a --- /dev/null +++ b/src/main/kotlin/io/billie/customers/resource/CustomerResource.kt @@ -0,0 +1,65 @@ +package io.billie.customers.resource + +import io.billie.customers.dto.CustomerRequest +import io.billie.customers.dto.CustomerResponse +import io.billie.customers.mapper.toCustomerResponse +import io.billie.customers.service.CustomerService +import io.billie.orders.dto.OrderResponse +import io.billie.organisations.model.Entity +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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 kotlin.streams.toList + +@RestController +@RequestMapping ("/customers") +class CustomerResource (val customerService: CustomerService) { + @GetMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "returns all customers ", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = CustomerResponse::class))) + ))] + ) + ] + ) + fun findCustomers (): ResponseEntity>{ + return ResponseEntity.ok(customerService.findCustomers() + .stream().map { it.toCustomerResponse() } + .toList()) + } + + @PostMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "Accepted the new customer", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun createCustomer(@RequestBody customerRequest: CustomerRequest): ResponseEntity{ + val customerId = customerService.createCustomer(customerRequest) + + return ResponseEntity.status(HttpStatus.CREATED).body(Entity(customerId)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/customers/service/CustomerService.kt b/src/main/kotlin/io/billie/customers/service/CustomerService.kt new file mode 100644 index 0000000..e9e3349 --- /dev/null +++ b/src/main/kotlin/io/billie/customers/service/CustomerService.kt @@ -0,0 +1,11 @@ +package io.billie.customers.service + +import io.billie.customers.dto.CustomerRequest +import io.billie.customers.model.Customer +import java.util.UUID + +interface CustomerService { + fun findCustomers(): List + fun findCustomerByUid (uid: UUID): Customer + fun createCustomer(customerRequest: CustomerRequest): UUID +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/customers/service/CustomerServiceImpl.kt b/src/main/kotlin/io/billie/customers/service/CustomerServiceImpl.kt new file mode 100644 index 0000000..7118dda --- /dev/null +++ b/src/main/kotlin/io/billie/customers/service/CustomerServiceImpl.kt @@ -0,0 +1,24 @@ +package io.billie.customers.service + +import io.billie.customers.data.CustomerRepository +import io.billie.customers.dto.CustomerRequest +import io.billie.customers.exception.CustomerNotFoundException +import io.billie.customers.model.Customer +import org.springframework.stereotype.Service +import java.util.* + +@Service +class CustomerServiceImpl (val customerRepository: CustomerRepository): CustomerService { + override fun findCustomerByUid(uid: UUID): Customer { + return customerRepository.findCustomerById(uid) + .orElseThrow {CustomerNotFoundException("customer with id: $uid was not found!")} + } + + override fun findCustomers(): List { + return customerRepository.findCustomers() + } + + override fun createCustomer(customerRequest: CustomerRequest): UUID { + return customerRepository.createCustomer(customerRequest) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/data/InvoiceRepository.kt b/src/main/kotlin/io/billie/invoicing/data/InvoiceRepository.kt new file mode 100644 index 0000000..0981841 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/data/InvoiceRepository.kt @@ -0,0 +1,157 @@ +package io.billie.invoicing.data + +import io.billie.invoicing.dto.InstallmentRequest +import io.billie.invoicing.dto.InvoiceRequest +import io.billie.invoicing.model.Installment +import io.billie.invoicing.model.Invoice +import io.billie.invoicing.model.InvoiceStatus +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.sql.ResultSet +import java.sql.Timestamp +import java.time.LocalDateTime +import java.util.* +import kotlin.streams.toList + +@Repository +class InvoiceRepository { + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Transactional(readOnly = false) + fun createCustomerInvoices(invoice: InvoiceRequest): UUID { + val invoiceUid = createGrandInvoice(invoice) + + invoice.installmentRequests.stream() + .forEach { createInstallmentInvoice(it, invoiceUid) } + + return invoiceUid + } + + private fun createInstallmentInvoice(installment: InstallmentRequest, grandInvoiceUid: UUID):UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + """ + INSERT INTO organisations_schema.installments + (grand_invoice_uid,order_uid, installment_amount, status,customer_uid,merchant_uid, date_created, due_date, last_update) + VALUES (?,?,?,?,?,?,?,?,?) + """.trimIndent(), + arrayOf("id") + ) + ps.setObject(1, grandInvoiceUid) + ps.setObject(2, installment.orderUid) + ps.setBigDecimal(3, installment.installmentAmount) + ps.setString(4, installment.status.name) + ps.setObject(5, installment.customerUid) + ps.setObject(6, installment.merchantUid) + ps.setTimestamp(7, Timestamp.valueOf(LocalDateTime.now())) + ps.setTimestamp(8, Timestamp.valueOf(installment.dueDate)) + ps.setTimestamp(9, Timestamp.valueOf(LocalDateTime.now())) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } + + private fun createGrandInvoice(invoice: InvoiceRequest): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + """ + INSERT INTO organisations_schema.invoices + (order_uid, order_amount, overall_status,customer_uid,merchant_uid, date_created, last_update) + VALUES (?,?,?,?,?,?,?) + """.trimIndent(), + arrayOf("id") + ) + ps.setObject(1, invoice.orderUid) + ps.setBigDecimal(2, invoice.orderAmount) + ps.setString(3, invoice.status.name) + ps.setObject(4, invoice.customerUid) + ps.setObject(5, invoice.merchantUid) + ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now())) + ps.setTimestamp(7, Timestamp.valueOf(LocalDateTime.now())) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } + + @Transactional + fun findCustomerInvoiceByUid (invoiceUid: UUID): Optional { + val invoiceOptional = jdbcTemplate.query( + "SELECT * FROM organisations_schema.invoices WHERE id =?", + invoiceDataMapper(), + invoiceUid + ).stream().findFirst() + + if (invoiceOptional.isEmpty) return Optional.empty() + + val invoice = invoiceOptional.get() + + val list = jdbcTemplate.query( + "SELECT * FROM organisations_schema.installments where grand_invoice_uid =?", + installmentDataMapper(invoice), + invoiceUid + ) + + invoice.installments.addAll(list) + + return Optional.of(invoice) + } + + @Transactional + fun findInvoices(): List { + return jdbcTemplate.query( + "SELECT * FROM organisations_schema.invoices", + invoiceDataMapper(), + ).stream() + .map { invoice -> + val list = jdbcTemplate.query( + "SELECT * FROM organisations_schema.installments where grand_invoice_uid =?", + installmentDataMapper(invoice), + invoice.id + ) + + invoice.installments.addAll(list) + invoice + }.toList() + } + + private fun invoiceDataMapper() = RowMapper { it: ResultSet, _: Int -> + Invoice( + it.getObject("id", UUID::class.java), + it.getObject("order_uid", UUID::class.java), + it.getBigDecimal("order_amount"), + InvoiceStatus.valueOf(it.getString("overall_status")), + it.getObject("customer_uid", UUID::class.java), + it.getObject("merchant_uid", UUID::class.java), + mutableListOf(), + it.getTimestamp("date_created").toLocalDateTime(), + it.getTimestamp("last_update").toLocalDateTime(), + ) + } + + private fun installmentDataMapper(invoice:Invoice) = RowMapper { it: ResultSet, _: Int -> + Installment( + it.getObject("id", UUID::class.java), + invoice, + it.getObject("order_uid", UUID::class.java), + it.getBigDecimal("installment_amount"), + InvoiceStatus.valueOf(it.getString("status")), + it.getObject("customer_uid", UUID::class.java), + it.getObject("merchant_uid", UUID::class.java), + it.getTimestamp("date_created").toLocalDateTime(), + it.getTimestamp("last_update").toLocalDateTime(), + it.getTimestamp("due_date").toLocalDateTime() + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/dto/InstallmentRequest.kt b/src/main/kotlin/io/billie/invoicing/dto/InstallmentRequest.kt new file mode 100644 index 0000000..52c2829 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/dto/InstallmentRequest.kt @@ -0,0 +1,34 @@ +package io.billie.invoicing.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.invoicing.model.InvoiceStatus +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank + +data class InstallmentRequest( + @JsonProperty("order_uid") + @field:NotBlank val orderUid: UUID, + + @JsonProperty("installment_amount") + @field:NotBlank val installmentAmount: BigDecimal, + + @JsonProperty("status") + @field:NotBlank val status: InvoiceStatus, + + @JsonProperty("customer_uid") + @field:NotBlank val customerUid: UUID, + + @JsonProperty("merchant_uid") + @field:NotBlank val merchantUid: UUID, + + @JsonProperty("date_created") + val createdDate: LocalDateTime, + + @JsonProperty("last_update") + val lastUpdate: LocalDateTime, + + @JsonProperty("due_date") + val dueDate: LocalDateTime +) diff --git a/src/main/kotlin/io/billie/invoicing/dto/InstallmentResponse.kt b/src/main/kotlin/io/billie/invoicing/dto/InstallmentResponse.kt new file mode 100644 index 0000000..9aedb11 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/dto/InstallmentResponse.kt @@ -0,0 +1,20 @@ +package io.billie.invoicing.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.invoicing.model.Invoice +import io.billie.invoicing.model.InvoiceStatus +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank + +data class InstallmentResponse ( + @JsonProperty("installment_amount") + @field:NotBlank val installmentAmount: BigDecimal, + + @JsonProperty("status") + @field:NotBlank val status: InvoiceStatus, + + @JsonProperty("due_date") + val dueDate: LocalDateTime +) diff --git a/src/main/kotlin/io/billie/invoicing/dto/InvoiceRequest.kt b/src/main/kotlin/io/billie/invoicing/dto/InvoiceRequest.kt new file mode 100644 index 0000000..1b73823 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/dto/InvoiceRequest.kt @@ -0,0 +1,34 @@ +package io.billie.invoicing.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.invoicing.model.Installment +import io.billie.invoicing.model.InvoiceStatus +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank + +data class InvoiceRequest( + @JsonProperty("order_uid") + @field:NotBlank val orderUid: UUID, + + @JsonProperty("order_amount") + @field:NotBlank val orderAmount: BigDecimal, + + @JsonProperty("overall_status") + @field:NotBlank val status: InvoiceStatus, + + @JsonProperty("customer_uid") + @field:NotBlank val customerUid: UUID, + + @JsonProperty("merchant_uid") + @field:NotBlank val merchantUid: UUID, + + val installmentRequests: MutableList, + + @JsonProperty("date_created") + val createdDate: LocalDateTime, + + @JsonProperty("last_update") + val lastUpdate: LocalDateTime +) diff --git a/src/main/kotlin/io/billie/invoicing/dto/InvoiceResponse.kt b/src/main/kotlin/io/billie/invoicing/dto/InvoiceResponse.kt new file mode 100644 index 0000000..2e5fe44 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/dto/InvoiceResponse.kt @@ -0,0 +1,30 @@ +package io.billie.invoicing.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.invoicing.model.Installment +import io.billie.invoicing.model.InvoiceStatus +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank + +data class InvoiceResponse ( + val id: UUID, + + @JsonProperty("order_uid") + @field:NotBlank val orderUid: UUID, + + @JsonProperty("order_amount") + @field:NotBlank val orderAmount: BigDecimal, + + @JsonProperty("overall_status") + @field:NotBlank val status: InvoiceStatus, + + @JsonProperty("customer_uid") + @field:NotBlank val customerUid: UUID, //TODO: to be replaced with Customer + + @JsonProperty("merchant_uid") + @field:NotBlank val merchantUid: UUID, //TODO: to be replaced with Merchant + + val installments: List +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/events/ShipmentEvent.kt b/src/main/kotlin/io/billie/invoicing/events/ShipmentEvent.kt new file mode 100644 index 0000000..694212d --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/events/ShipmentEvent.kt @@ -0,0 +1,13 @@ +package io.billie.invoicing.events + +import io.billie.customers.model.Customer +import io.billie.merchants.model.Merchant +import io.billie.orders.model.Order +import java.time.LocalDateTime + +data class ShipmentEvent( + val merchant: Merchant, + val customer: Customer, + val order: Order, + val shipmentTimestamp: LocalDateTime +) diff --git a/src/main/kotlin/io/billie/invoicing/exception/InvoiceNotFoundException.kt b/src/main/kotlin/io/billie/invoicing/exception/InvoiceNotFoundException.kt new file mode 100644 index 0000000..cc2abec --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/exception/InvoiceNotFoundException.kt @@ -0,0 +1,4 @@ +package io.billie.invoicing.exception + +class InvoiceNotFoundException (message: String): RuntimeException (message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/mapper/InvoiceResponseMapper.kt b/src/main/kotlin/io/billie/invoicing/mapper/InvoiceResponseMapper.kt new file mode 100644 index 0000000..788e540 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/mapper/InvoiceResponseMapper.kt @@ -0,0 +1,30 @@ +package io.billie.invoicing.mapper + +import io.billie.invoicing.dto.InstallmentResponse +import io.billie.invoicing.dto.InvoiceResponse +import io.billie.invoicing.model.Installment +import io.billie.invoicing.model.Invoice +import kotlin.streams.toList + +fun Invoice.toInvoiceResponse(): InvoiceResponse { + val installmentResponses = this.installments.stream() + .map { it.toInstallmentResponse() } + .toList() + + return InvoiceResponse( + id = this.id, + orderUid = this.orderUid, + orderAmount = this.orderAmount, + status = this.status, + customerUid = this.customerUid, + merchantUid = this.merchantUid, + installments = installmentResponses + ) +} + +fun Installment.toInstallmentResponse(): InstallmentResponse = + InstallmentResponse( + installmentAmount = this.installmentAmount, + status = this.status, + dueDate =this.dueDate + ) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/model/Installment.kt b/src/main/kotlin/io/billie/invoicing/model/Installment.kt new file mode 100644 index 0000000..f3f4c8a --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/model/Installment.kt @@ -0,0 +1,38 @@ +package io.billie.invoicing.model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank + +data class Installment ( + val id: UUID, + + @JsonProperty("invoice_uid") + val invoice: Invoice, + + @JsonProperty("order_uid") + @field:NotBlank val orderUid: UUID, + + @JsonProperty("installment_amount") + @field:NotBlank val installmentAmount: BigDecimal, + + @JsonProperty("status") + @field:NotBlank val status: InvoiceStatus, + + @JsonProperty("customer_uid") + @field:NotBlank val customerUid: UUID, + + @JsonProperty("merchant_uid") + @field:NotBlank val merchantUid: UUID, + + @JsonProperty("date_created") + val createdDate: LocalDateTime, + + @JsonProperty("last_update") + val lastUpdate: LocalDateTime, + + @JsonProperty("due_date") + val dueDate: LocalDateTime +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/model/Invoice.kt b/src/main/kotlin/io/billie/invoicing/model/Invoice.kt new file mode 100644 index 0000000..10150c8 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/model/Invoice.kt @@ -0,0 +1,36 @@ +package io.billie.invoicing.model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank + +// TODO: To be further discussed in the meeting +data class Invoice ( + val id: UUID, + + @JsonProperty("order_uid") + @field:NotBlank val orderUid: UUID, + + @JsonProperty("order_amount") + @field:NotBlank val orderAmount: BigDecimal, + + @JsonProperty("overall_status") + @field:NotBlank val status: InvoiceStatus, + + @JsonProperty("customer_uid") + @field:NotBlank val customerUid: UUID, + + @JsonProperty("merchant_uid") + @field:NotBlank val merchantUid: UUID, + + val installments: MutableList, + + @JsonProperty("date_created") + val createdDate: LocalDateTime, + + @JsonProperty("last_update") + val lastUpdate: LocalDateTime, + +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/model/InvoiceStatus.kt b/src/main/kotlin/io/billie/invoicing/model/InvoiceStatus.kt new file mode 100644 index 0000000..1bcd06d --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/model/InvoiceStatus.kt @@ -0,0 +1,7 @@ +package io.billie.invoicing.model + +enum class InvoiceStatus { + CREATED, + PAID, + CANCELED +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/resource/InvoiceResource.kt b/src/main/kotlin/io/billie/invoicing/resource/InvoiceResource.kt new file mode 100644 index 0000000..6c5baf7 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/resource/InvoiceResource.kt @@ -0,0 +1,43 @@ +package io.billie.invoicing.resource + +import io.billie.invoicing.dto.InvoiceResponse +import io.billie.invoicing.mapper.toInvoiceResponse +import io.billie.invoicing.service.InvoiceService +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +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 kotlin.streams.toList + +@RestController +@RequestMapping ("/invoices") +class InvoiceResource (val invoiceService: InvoiceService) { + + @GetMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "returns all invoices ", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = InvoiceResponse::class))) + ))] + ) + ] + ) + fun findInvoices (): ResponseEntity> { + val invoices = invoiceService.findInvoices() + .stream() + .map { it.toInvoiceResponse() } + .toList() + + return ResponseEntity.ok(invoices) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/service/InstallmentService.kt b/src/main/kotlin/io/billie/invoicing/service/InstallmentService.kt new file mode 100644 index 0000000..b803fb4 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/service/InstallmentService.kt @@ -0,0 +1,13 @@ +package io.billie.invoicing.service + +import io.billie.customers.model.Customer +import org.springframework.stereotype.Service + +@Service +class InstallmentService { + + // TODO: This should be aligned with the installment strategy based on customer profile + fun getInstallmentsForCustomer (customer: Customer): Long { + return 2L + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/service/InvoiceService.kt b/src/main/kotlin/io/billie/invoicing/service/InvoiceService.kt new file mode 100644 index 0000000..3db1fdf --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/service/InvoiceService.kt @@ -0,0 +1,17 @@ +package io.billie.invoicing.service + +import io.billie.invoicing.events.ShipmentEvent +import io.billie.invoicing.dto.InvoiceRequest +import io.billie.invoicing.model.Invoice +import java.util.UUID + +interface InvoiceService { + + fun findInvoices(): List + + fun findInvoiceByUid (invoiceUid: UUID): Invoice + + fun generateInvoiceForCustomer (shipmentEvent: ShipmentEvent): InvoiceRequest + + fun saveCustomerInvoiceRequest (invoice: InvoiceRequest): UUID +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/invoicing/service/InvoiceServiceImpl.kt b/src/main/kotlin/io/billie/invoicing/service/InvoiceServiceImpl.kt new file mode 100644 index 0000000..9d19f24 --- /dev/null +++ b/src/main/kotlin/io/billie/invoicing/service/InvoiceServiceImpl.kt @@ -0,0 +1,71 @@ +package io.billie.invoicing.service + +import io.billie.invoicing.events.ShipmentEvent +import io.billie.invoicing.data.InvoiceRepository +import io.billie.invoicing.dto.InstallmentRequest +import io.billie.invoicing.dto.InvoiceRequest +import io.billie.invoicing.exception.InvoiceNotFoundException +import io.billie.invoicing.model.Invoice +import io.billie.invoicing.model.InvoiceStatus +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.* +import java.util.stream.IntStream +import kotlin.streams.toList + +@Service +class InvoiceServiceImpl ( + val invoiceRepository: InvoiceRepository, + val installmentService: InstallmentService + ): InvoiceService { + + override fun generateInvoiceForCustomer(shipmentEvent: ShipmentEvent): InvoiceRequest { + val noOfInstallments = installmentService.getInstallmentsForCustomer(shipmentEvent.customer) + + val installmentAmount = shipmentEvent.order.amount.divide(BigDecimal.valueOf(noOfInstallments)) + + val grandInvoice = InvoiceRequest ( + shipmentEvent.order.id, + shipmentEvent.order.amount, + InvoiceStatus.CREATED, + shipmentEvent.customer.id, + shipmentEvent.merchant.id, + mutableListOf(), + LocalDateTime.now(), + LocalDateTime.now() + ) + + //TODO: due date shall be adapted according to installment strategy + val installments = IntStream.rangeClosed(1, noOfInstallments.toInt()) + .mapToObj { it -> + InstallmentRequest ( + shipmentEvent.order.id, + installmentAmount, + InvoiceStatus.CREATED, + shipmentEvent.customer.id, + shipmentEvent.merchant.id, + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now().plusMonths(it.toLong()) + ) + }.toList() + + grandInvoice.installmentRequests.addAll(installments) + + return grandInvoice + } + + override fun saveCustomerInvoiceRequest(invoice: InvoiceRequest): UUID { + return invoiceRepository.createCustomerInvoices(invoice) + } + + override fun findInvoices(): List { + return invoiceRepository.findInvoices() + } + + override fun findInvoiceByUid(invoiceUid: UUID): Invoice { + return invoiceRepository.findCustomerInvoiceByUid(invoiceUid) + .orElseThrow { InvoiceNotFoundException ("invoice with uid: $invoiceUid was not found!") } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/data/MerchantRepository.kt b/src/main/kotlin/io/billie/merchants/data/MerchantRepository.kt new file mode 100644 index 0000000..e92b5f0 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/data/MerchantRepository.kt @@ -0,0 +1,63 @@ +package io.billie.merchants.data + +import io.billie.merchants.dto.MerchantRequest +import io.billie.merchants.model.Merchant +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.sql.ResultSet +import java.util.* + +@Repository +class MerchantRepository (){ + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Transactional(readOnly=true) + fun findMerchantById(id: UUID): Optional { + return jdbcTemplate.query( + "select id, name from organisations_schema.merchants where id= ?", + merchantResponseMapper(), + id + ).stream().findFirst(); + } + + @Transactional + fun findMerchants(): List{ + return jdbcTemplate.query( + "select id, name from organisations_schema.merchants", + merchantResponseMapper() + ) + } + + fun merchantResponseMapper() = RowMapper { it: ResultSet, _: Int -> + Merchant( + it.getObject("id", UUID::class.java), + it.getString("name") + ) + } + + @Transactional + fun createMerchant(merchantRequest: MerchantRequest): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + """ + INSERT INTO organisations_schema.merchants + (name) + VALUES (?) + """.trimIndent(), + arrayOf("id") + ) + ps.setString(1, merchantRequest.name) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/dto/MerchantRequest.kt b/src/main/kotlin/io/billie/merchants/dto/MerchantRequest.kt new file mode 100644 index 0000000..79b1579 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/dto/MerchantRequest.kt @@ -0,0 +1,7 @@ +package io.billie.merchants.dto + +import javax.validation.constraints.NotBlank + +data class MerchantRequest ( + @field:NotBlank val name: String, +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/dto/MerchantResponse.kt b/src/main/kotlin/io/billie/merchants/dto/MerchantResponse.kt new file mode 100644 index 0000000..ec39061 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/dto/MerchantResponse.kt @@ -0,0 +1,11 @@ +package io.billie.merchants.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.organisations.dto.OrganisationResponse +import java.util.* +import javax.validation.constraints.NotBlank + +data class MerchantResponse ( + val id: UUID, + @field:NotBlank val name: String +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/dto/ShipmentNotification.kt b/src/main/kotlin/io/billie/merchants/dto/ShipmentNotification.kt new file mode 100644 index 0000000..f038bad --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/dto/ShipmentNotification.kt @@ -0,0 +1,10 @@ +package io.billie.merchants.dto + +import java.util.* +import javax.validation.constraints.NotBlank + +data class ShipmentNotification( + @field:NotBlank val shipmentUid: UUID, + @field:NotBlank val orderUId: UUID, + @field:NotBlank val customerUId: UUID, +) diff --git a/src/main/kotlin/io/billie/merchants/exception/InvalidMerchantRequestException.kt b/src/main/kotlin/io/billie/merchants/exception/InvalidMerchantRequestException.kt new file mode 100644 index 0000000..c62ba78 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/exception/InvalidMerchantRequestException.kt @@ -0,0 +1,4 @@ +package io.billie.merchants.exception + +class InvalidMerchantRequestException (message: String): RuntimeException (message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/exception/MerchantErrorHandler.kt b/src/main/kotlin/io/billie/merchants/exception/MerchantErrorHandler.kt new file mode 100644 index 0000000..9b13cfc --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/exception/MerchantErrorHandler.kt @@ -0,0 +1,40 @@ +package io.billie.merchants.exception + +import io.billie.customers.exception.CustomerNotFoundException +import io.billie.orders.execption.OrderNotFoundException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus + +@ControllerAdvice +class MerchantErrorHandler { + + @ResponseStatus (HttpStatus.NOT_FOUND) + @ExceptionHandler (MerchantNotFoundException::class) + fun handleMerchantNotFoundException(e: MerchantNotFoundException): ResponseEntity { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(e.message) + } + + @ResponseStatus (HttpStatus.BAD_REQUEST) + @ExceptionHandler (InvalidMerchantRequestException::class) + fun handleInvalidMerchantRequestException(e: InvalidMerchantRequestException): ResponseEntity { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(e.message) + } + + @ResponseStatus (HttpStatus.BAD_REQUEST) + @ExceptionHandler (CustomerNotFoundException::class) + fun handleCustomerNotFoundException(e: CustomerNotFoundException): ResponseEntity { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(e.message) + } + @ResponseStatus (HttpStatus.BAD_REQUEST) + @ExceptionHandler (OrderNotFoundException::class) + fun handleOrderNotFoundException(e: OrderNotFoundException): ResponseEntity { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(e.message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/exception/MerchantNotFoundException.kt b/src/main/kotlin/io/billie/merchants/exception/MerchantNotFoundException.kt new file mode 100644 index 0000000..b8461eb --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/exception/MerchantNotFoundException.kt @@ -0,0 +1,4 @@ +package io.billie.merchants.exception + +class MerchantNotFoundException (message: String): RuntimeException(message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/mapper/MerchantResponseMapper.kt b/src/main/kotlin/io/billie/merchants/mapper/MerchantResponseMapper.kt new file mode 100644 index 0000000..270c438 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/mapper/MerchantResponseMapper.kt @@ -0,0 +1,10 @@ +package io.billie.merchants.mapper + +import io.billie.merchants.dto.MerchantResponse +import io.billie.merchants.model.Merchant + +fun Merchant.toMerchantResponse(): MerchantResponse = + MerchantResponse( + id = this.id, + name = this.name + ) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/model/Merchant.kt b/src/main/kotlin/io/billie/merchants/model/Merchant.kt new file mode 100644 index 0000000..5e733d9 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/model/Merchant.kt @@ -0,0 +1,12 @@ +package io.billie.merchants.model + +import java.util.* +import javax.validation.constraints.NotBlank + +data class Merchant( + val id: UUID, + + @field:NotBlank val name: String, + + //TODO: more properties shall be added +) diff --git a/src/main/kotlin/io/billie/merchants/resource/MerchantResource.kt b/src/main/kotlin/io/billie/merchants/resource/MerchantResource.kt new file mode 100644 index 0000000..158745e --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/resource/MerchantResource.kt @@ -0,0 +1,135 @@ +package io.billie.merchants.resource + +import io.billie.invoicing.events.ShipmentEvent +import io.billie.customers.service.CustomerService +import io.billie.merchants.dto.MerchantRequest +import io.billie.merchants.dto.MerchantResponse +import io.billie.merchants.dto.ShipmentNotification +import io.billie.merchants.mapper.toMerchantResponse +import io.billie.merchants.service.MerchantService +import io.billie.merchants.service.ShipmentProcessor +import io.billie.merchants.validation.MerchantValidator +import io.billie.orders.service.OrderService +import io.billie.organisations.model.Entity +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.time.LocalDateTime +import java.util.* +import java.util.logging.Logger +import kotlin.streams.toList + +@RestController +@RequestMapping ("/merchants") +class MerchantResource ( + val merchantService: MerchantService, + val merchantValidator: MerchantValidator, + val customerService: CustomerService, + val orderService: OrderService, + val shipmentProcessor: ShipmentProcessor) { + + val logger: Logger = Logger.getLogger(MerchantResource::javaClass.name) + + @GetMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "returns all merchants ", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = MerchantResponse::class))) + ))] + ) + ] + ) + fun getAllMerchants(): ResponseEntity>{ + return ResponseEntity.ok( + merchantService.findAllMerchants() + .stream() + .map { it.toMerchantResponse() } + .toList() + ) + } + + // TODO: To be discussed in the meeting + @PostMapping ("/{merchantUid}/notifyShipment") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "merchant notifies about the shipment of an order", + content = [Content()] + ), + ApiResponse(responseCode = "404", description = "Merchant/Customer/Order could not found", content = [Content()])] + ) + fun notifyShipment (@PathVariable (name = "merchantUid") merchantUid: UUID, + @RequestBody shipmentNotification: ShipmentNotification) + :ResponseEntity { + logger.info("shipment was notified by merchant: $merchantUid for: $shipmentNotification") + + val merchant = merchantService.findMerchantByUid(merchantUid) + val customer = customerService.findCustomerByUid(shipmentNotification.customerUId) + val order = orderService.findOrderByUid(shipmentNotification.orderUId) + + // TODO: more data enrichment can be done here + val shipmentEvent = ShipmentEvent (merchant = merchant, customer = customer, order = order, shipmentTimestamp = LocalDateTime.now()) + + val invoiceUid = shipmentProcessor.processShipmentEvent(shipmentEvent) + + logger.info("order was created for customer: ${customer.name} with uid: $invoiceUid") + + return ResponseEntity.ok(Entity(invoiceUid)) + } + + @GetMapping("/{merchantUid}") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "return merchant information for given merchant uuid", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = MerchantResponse::class))) + ))] + ), + ApiResponse(responseCode = "404", description = "Merchant not found", content = [Content()])] + ) + fun getMerchantInfo (@PathVariable (name = "merchantUid") merchantUid: String): ResponseEntity { + return ResponseEntity.ok( + merchantService.findMerchantByUid(UUID.fromString(merchantUid)) + .toMerchantResponse()) + } + + @PostMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "Accepted the new merchant", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun registerMerchant (@RequestBody merchantRequest: MerchantRequest): ResponseEntity { + + merchantValidator.validateMerchantCreationRequest(merchantRequest) + + val merchantUid: UUID= merchantService.createMerchant (merchantRequest) + + logger.info("merchant was created successfully with uid: $merchantUid") + + return ResponseEntity.status(HttpStatus.CREATED).body(Entity(merchantUid)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/service/MerchantService.kt b/src/main/kotlin/io/billie/merchants/service/MerchantService.kt new file mode 100644 index 0000000..10a6bf5 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/service/MerchantService.kt @@ -0,0 +1,11 @@ +package io.billie.merchants.service + +import io.billie.merchants.dto.MerchantRequest +import io.billie.merchants.model.Merchant +import java.util.* + +interface MerchantService { + fun findAllMerchants(): List + fun findMerchantByUid (uid: UUID): Merchant + fun createMerchant(merchantRequest: MerchantRequest): UUID +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/service/MerchantServiceImpl.kt b/src/main/kotlin/io/billie/merchants/service/MerchantServiceImpl.kt new file mode 100644 index 0000000..410ba41 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/service/MerchantServiceImpl.kt @@ -0,0 +1,24 @@ +package io.billie.merchants.service + +import io.billie.merchants.data.MerchantRepository +import io.billie.merchants.dto.MerchantRequest +import io.billie.merchants.exception.MerchantNotFoundException +import io.billie.merchants.model.Merchant +import org.springframework.stereotype.Service +import java.util.* + +@Service +class MerchantServiceImpl (private val merchantRepository: MerchantRepository): MerchantService { + override fun findAllMerchants(): List { + return merchantRepository.findMerchants() + } + + override fun findMerchantByUid(uid: UUID): Merchant { + return merchantRepository.findMerchantById(uid) + .orElseThrow { MerchantNotFoundException("merchant with id:$uid was not found!") } + } + + override fun createMerchant(merchantRequest: MerchantRequest): UUID { + return merchantRepository.createMerchant (merchantRequest) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/service/ShipmentProcessor.kt b/src/main/kotlin/io/billie/merchants/service/ShipmentProcessor.kt new file mode 100644 index 0000000..478c2c8 --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/service/ShipmentProcessor.kt @@ -0,0 +1,23 @@ +package io.billie.merchants.service + +import io.billie.invoicing.events.ShipmentEvent +import io.billie.invoicing.service.InvoiceService +import org.springframework.stereotype.Service +import java.util.UUID + +/** + * TODO: better to send these events to a Kafka Topic to be processed by respected services + * TODO: we need to perform the deduplication logic before processing + */ +@Service +class ShipmentProcessor (val invoiceService: InvoiceService) { + + fun processShipmentEvent (shipmentEvent: ShipmentEvent): UUID { + // For the simplicity we call the invoicing service directly + // TODO: call invoicing service to create invoice for the customer + + val invoice = invoiceService.generateInvoiceForCustomer(shipmentEvent) + + return invoiceService.saveCustomerInvoiceRequest(invoice) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/merchants/validation/MerchantValidator.kt b/src/main/kotlin/io/billie/merchants/validation/MerchantValidator.kt new file mode 100644 index 0000000..17d404b --- /dev/null +++ b/src/main/kotlin/io/billie/merchants/validation/MerchantValidator.kt @@ -0,0 +1,15 @@ +package io.billie.merchants.validation + +import io.billie.merchants.dto.MerchantRequest +import io.billie.merchants.exception.InvalidMerchantRequestException +import org.springframework.stereotype.Component + +@Component +class MerchantValidator() { + + fun validateMerchantCreationRequest(merchantRequest: MerchantRequest) { + if (merchantRequest.name.isEmpty()) + throw InvalidMerchantRequestException ("merchant name is invalid") + } + +} diff --git a/src/main/kotlin/io/billie/orders/data/OrderRepository.kt b/src/main/kotlin/io/billie/orders/data/OrderRepository.kt new file mode 100644 index 0000000..c05a407 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/data/OrderRepository.kt @@ -0,0 +1,76 @@ +package io.billie.orders.data + +import io.billie.orders.dto.OrderRequest +import io.billie.orders.model.Order +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.sql.ResultSet +import java.util.* + +@Repository +class OrderRepository { + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Transactional(readOnly=true) + fun findOrderByUid(uid: UUID): Optional { + return jdbcTemplate.query( + "SELECT id , order_amount, customer_uid FROM organisations_schema.customer_orders where id= ?", + orderDataMapper(), + uid + ).stream().findFirst(); + } + + @Transactional + fun findOrders (): List { + return jdbcTemplate.query( + " SELECT id , order_amount, customer_uid FROM organisations_schema.customer_orders", + orderDataMapper() + ) + } + + @Transactional + fun createOrder (orderRequest: OrderRequest): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + """ + INSERT INTO organisations_schema.customer_orders + (order_amount, customer_uid) + VALUES (?, ?) + """.trimIndent(), + arrayOf("id") + ) + ps.setBigDecimal(1, orderRequest.amount) + ps.setObject(2, orderRequest.customerId) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } + + private fun orderDataMapper() = RowMapper { it: ResultSet, _: Int -> + Order( + it.getObject("id", UUID::class.java), + it.getBigDecimal("order_amount"), + it.getObject("customer_uid", UUID::class.java) + ) + } + + private fun findOrdersQuery(whereClause: String)= + """ + SELECT + id , order_amount, customer_uid + FROM + organisations_schema.customer_orders + $whereClause + + """.trimIndent() + +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/dto/OrderRequest.kt b/src/main/kotlin/io/billie/orders/dto/OrderRequest.kt new file mode 100644 index 0000000..9e6e726 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/dto/OrderRequest.kt @@ -0,0 +1,11 @@ +package io.billie.orders.dto + +import java.math.BigDecimal +import java.util.* +import javax.validation.constraints.NotBlank + +data class OrderRequest( + @field:NotBlank val amount: BigDecimal, + + @field:NotBlank val customerId: UUID +) diff --git a/src/main/kotlin/io/billie/orders/dto/OrderResponse.kt b/src/main/kotlin/io/billie/orders/dto/OrderResponse.kt new file mode 100644 index 0000000..ce1bd82 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/dto/OrderResponse.kt @@ -0,0 +1,14 @@ +package io.billie.orders.dto + +import java.math.BigDecimal +import java.util.* +import javax.validation.constraints.NotBlank + +data class OrderResponse ( + val id: UUID, + + @field:NotBlank val amount: BigDecimal, + + // TODO: to be replaced with Customer entity + @field:NotBlank val customerId: UUID +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/execption/InvalidOrderAmountException.kt b/src/main/kotlin/io/billie/orders/execption/InvalidOrderAmountException.kt new file mode 100644 index 0000000..4bec9d1 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/execption/InvalidOrderAmountException.kt @@ -0,0 +1,4 @@ +package io.billie.orders.execption + +class InvalidOrderAmountException (message: String): RuntimeException(message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/execption/OrderErrorHandler.kt b/src/main/kotlin/io/billie/orders/execption/OrderErrorHandler.kt new file mode 100644 index 0000000..86db0f0 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/execption/OrderErrorHandler.kt @@ -0,0 +1,25 @@ +package io.billie.orders.execption + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus + +@ControllerAdvice +class OrderErrorHandler { + + @ResponseStatus (HttpStatus.BAD_REQUEST) + @ExceptionHandler (InvalidOrderAmountException::class) + fun handleInvalidMerchantRequestException(e: InvalidOrderAmountException): ResponseEntity { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(e.message) + } + + @ResponseStatus (HttpStatus.NOT_FOUND) + @ExceptionHandler (OrderNotFoundException::class) + fun handleOrderNotFoundException(e: OrderNotFoundException): ResponseEntity { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(e.message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/execption/OrderNotFoundException.kt b/src/main/kotlin/io/billie/orders/execption/OrderNotFoundException.kt new file mode 100644 index 0000000..51d8482 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/execption/OrderNotFoundException.kt @@ -0,0 +1,3 @@ +package io.billie.orders.execption + +class OrderNotFoundException (message: String): RuntimeException (message) {} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/mapper/OrderResponseMapper.kt b/src/main/kotlin/io/billie/orders/mapper/OrderResponseMapper.kt new file mode 100644 index 0000000..54b7d96 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/mapper/OrderResponseMapper.kt @@ -0,0 +1,12 @@ +package io.billie.orders.mapper + +import io.billie.orders.dto.OrderResponse +import io.billie.orders.model.Order + + +fun Order.toOrderResponse(): OrderResponse = + OrderResponse( + id = this.id, + amount = this.amount, + customerId = this.customerId + ) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/model/Order.kt b/src/main/kotlin/io/billie/orders/model/Order.kt new file mode 100644 index 0000000..ebac038 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/model/Order.kt @@ -0,0 +1,16 @@ +package io.billie.orders.model + +import java.math.BigDecimal +import java.util.* +import javax.validation.constraints.NotBlank + +data class Order( + val id: UUID, + + @field:NotBlank val amount: BigDecimal, + + // TODO: to be replaced with Customer entity + @field:NotBlank val customerId: UUID + + //TODO: to be implemented later during the session +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/model/OrderLine.kt b/src/main/kotlin/io/billie/orders/model/OrderLine.kt new file mode 100644 index 0000000..500ed13 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/model/OrderLine.kt @@ -0,0 +1,11 @@ +package io.billie.orders.model + +import io.billie.products.model.Product +import java.util.UUID + +// TODO: related dao classes should be implemented along with association with order +data class OrderLine ( + val id: UUID, + val quantity: Int, + val product: Product +) diff --git a/src/main/kotlin/io/billie/orders/resource/OrderResource.kt b/src/main/kotlin/io/billie/orders/resource/OrderResource.kt new file mode 100644 index 0000000..e6c73b9 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/resource/OrderResource.kt @@ -0,0 +1,77 @@ +package io.billie.orders.resource + +import io.billie.customers.service.CustomerService +import io.billie.orders.dto.OrderRequest +import io.billie.orders.dto.OrderResponse +import io.billie.orders.mapper.toOrderResponse +import io.billie.orders.service.OrderService +import io.billie.orders.validation.OrderRequestValidator +import io.billie.organisations.model.Entity +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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 kotlin.streams.toList + +@RestController +@RequestMapping ("/orders") +class OrderResource (val orderService: OrderService, + val customerService: CustomerService, + val orderRequestValidator: OrderRequestValidator) { + + @GetMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "returns all orders ", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = OrderResponse::class))) + ))] + ) + ] + ) + fun findOrders (): ResponseEntity> { + return ResponseEntity.ok( + orderService.findOrders().stream() + .map { it.toOrderResponse() } + .toList() + ) + } + + @PostMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "Accepted the new order", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun createOrder (@RequestBody orderRequest: OrderRequest): ResponseEntity { + + // TODO: it's used for validating the customerId but will be used later + val customer = customerService.findCustomerByUid(orderRequest.customerId) + + orderRequestValidator.validateOrderRequest(orderRequest) + + val orderUid = orderService.createOrder(orderRequest) + + return ResponseEntity.status(HttpStatus.CREATED).body(Entity(orderUid)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/service/OrderService.kt b/src/main/kotlin/io/billie/orders/service/OrderService.kt new file mode 100644 index 0000000..29f703c --- /dev/null +++ b/src/main/kotlin/io/billie/orders/service/OrderService.kt @@ -0,0 +1,11 @@ +package io.billie.orders.service + +import io.billie.orders.dto.OrderRequest +import io.billie.orders.model.Order +import java.util.* + +interface OrderService { + fun createOrder(orderRequest: OrderRequest): UUID + fun findOrders(): List + fun findOrderByUid (orderUid: UUID): Order +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/service/OrderServiceImpl.kt b/src/main/kotlin/io/billie/orders/service/OrderServiceImpl.kt new file mode 100644 index 0000000..878f1de --- /dev/null +++ b/src/main/kotlin/io/billie/orders/service/OrderServiceImpl.kt @@ -0,0 +1,24 @@ +package io.billie.orders.service + +import io.billie.orders.data.OrderRepository +import io.billie.orders.dto.OrderRequest +import io.billie.orders.execption.OrderNotFoundException +import io.billie.orders.model.Order +import org.springframework.stereotype.Service +import java.util.* + +@Service +class OrderServiceImpl (val orderRepository: OrderRepository) : OrderService { + override fun findOrderByUid(orderUid: UUID): Order { + return orderRepository.findOrderByUid(orderUid) + .orElseThrow { OrderNotFoundException ("order with Uid: $orderUid was not found!") } + } + + override fun findOrders(): List { + return orderRepository.findOrders() + } + + override fun createOrder(orderRequest: OrderRequest): UUID { + return orderRepository.createOrder(orderRequest) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/orders/validation/OrderRequestValidator.kt b/src/main/kotlin/io/billie/orders/validation/OrderRequestValidator.kt new file mode 100644 index 0000000..9ab40c6 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/validation/OrderRequestValidator.kt @@ -0,0 +1,17 @@ +package io.billie.orders.validation + +import io.billie.orders.dto.OrderRequest +import io.billie.orders.execption.InvalidOrderAmountException +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +class OrderRequestValidator { + + fun validateOrderRequest(orderRequest: OrderRequest) { + if (orderRequest.amount <= BigDecimal.valueOf(0)) + throw InvalidOrderAmountException ("order amount is not valid!") + + //TODO: further validation can be placed here + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/data/OrganisationRepository.kt b/src/main/kotlin/io/billie/organisations/data/OrganisationRepository.kt index 8c0026b..7e0fc02 100644 --- a/src/main/kotlin/io/billie/organisations/data/OrganisationRepository.kt +++ b/src/main/kotlin/io/billie/organisations/data/OrganisationRepository.kt @@ -1,7 +1,9 @@ package io.billie.organisations.data -import io.billie.countries.model.CountryResponse -import io.billie.organisations.viewmodel.* +import io.billie.organisations.dto.ContactDetailsRequest +import io.billie.organisations.dto.OrganisationRequest +import io.billie.organisations.dto.OrganisationResponse +import io.billie.organisations.mapper.mapOrganisation import org.springframework.beans.factory.annotation.Autowired import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.ResultSetExtractor @@ -23,30 +25,21 @@ class OrganisationRepository { @Transactional(readOnly = true) fun findOrganisations(): List { - return jdbcTemplate.query(organisationQuery(), organisationMapper()) + return jdbcTemplate.query(organisationQuery(), + organisationResponseMapper() + ) + } + + private fun organisationResponseMapper() = RowMapper { it: ResultSet, _: Int -> + mapOrganisation(it) } @Transactional fun create(organisation: OrganisationRequest): UUID { - if(!valuesValid(organisation)) { - throw UnableToFindCountry(organisation.countryCode) - } val id: UUID = createContactDetails(organisation.contactDetails) return createOrganisation(organisation, id) } - private fun valuesValid(organisation: OrganisationRequest): Boolean { - val reply: Int? = jdbcTemplate.query( - "select count(country_code) from organisations_schema.countries c WHERE c.country_code = ?", - ResultSetExtractor { - it.next() - it.getInt(1) - }, - organisation.countryCode - ) - return (reply != null) && (reply > 0) - } - private fun createOrganisation(org: OrganisationRequest, contactDetailsId: UUID): UUID { val keyHolder: KeyHolder = GeneratedKeyHolder() jdbcTemplate.update( @@ -99,53 +92,27 @@ class OrganisationRepository { return keyHolder.getKeyAs(UUID::class.java)!! } - private fun organisationQuery() = "select " + - "o.id as id, " + - "o.name as name, " + - "o.date_founded as date_founded, " + - "o.country_code as country_code, " + - "c.id as country_id, " + - "c.name as country_name, " + - "o.VAT_number as VAT_number, " + - "o.registration_number as registration_number," + - "o.legal_entity_type as legal_entity_type," + - "o.contact_details_id as contact_details_id, " + - "cd.phone_number as phone_number, " + - "cd.fax as fax, " + - "cd.email as email " + - "from " + - "organisations_schema.organisations o " + - "INNER JOIN organisations_schema.contact_details cd on o.contact_details_id::uuid = cd.id::uuid " + - "INNER JOIN organisations_schema.countries c on o.country_code = c.country_code " - - private fun organisationMapper() = RowMapper { it: ResultSet, _: Int -> - OrganisationResponse( - it.getObject("id", UUID::class.java), - it.getString("name"), - Date(it.getDate("date_founded").time).toLocalDate(), - mapCountry(it), - it.getString("vat_number"), - it.getString("registration_number"), - LegalEntityType.valueOf(it.getString("legal_entity_type")), - mapContactDetails(it) - ) - } - - private fun mapContactDetails(it: ResultSet): ContactDetails { - return ContactDetails( - UUID.fromString(it.getString("contact_details_id")), - it.getString("phone_number"), - it.getString("fax"), - it.getString("email") - ) - } - - private fun mapCountry(it: ResultSet): CountryResponse { - return CountryResponse( - it.getObject("country_id", UUID::class.java), - it.getString("country_name"), - it.getString("country_code") - ) - } - + private fun organisationQuery() = """ + SELECT + o.id as organisation_id, + o.name as organisation_name, + o.date_founded as organisation_date_founded, + o.country_code as organisation_country_code, + c.id as country_id, + c.name as country_name, + c.country_code as country_code, + o.VAT_number as organisation_vat_number, + o.registration_number as organisation_registration_number, + o.legal_entity_type as organisation_legal_entity_type, + o.contact_details_id as organisation_contact_details_id, + cd.phone_number as cd_phone_number, + cd.fax as cd_fax, + cd.email as cd_email + FROM + organisations_schema.organisations o + INNER JOIN + organisations_schema.contact_details cd ON o.contact_details_id::uuid = cd.id::uuid + INNER JOIN + organisations_schema.countries c ON o.country_code = c.country_code + """.trimIndent() } diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/ContactDetailsRequest.kt b/src/main/kotlin/io/billie/organisations/dto/ContactDetailsRequest.kt similarity index 83% rename from src/main/kotlin/io/billie/organisations/viewmodel/ContactDetailsRequest.kt rename to src/main/kotlin/io/billie/organisations/dto/ContactDetailsRequest.kt index 6fbd39a..8f7b970 100644 --- a/src/main/kotlin/io/billie/organisations/viewmodel/ContactDetailsRequest.kt +++ b/src/main/kotlin/io/billie/organisations/dto/ContactDetailsRequest.kt @@ -1,4 +1,4 @@ -package io.billie.organisations.viewmodel +package io.billie.organisations.dto import com.fasterxml.jackson.annotation.JsonProperty import java.util.* diff --git a/src/main/kotlin/io/billie/organisations/dto/OrganisationRequest.kt b/src/main/kotlin/io/billie/organisations/dto/OrganisationRequest.kt new file mode 100644 index 0000000..9965008 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/dto/OrganisationRequest.kt @@ -0,0 +1,20 @@ +package io.billie.organisations.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.organisations.model.LegalEntityType +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDate +import java.util.* +import javax.validation.constraints.NotBlank + +@Table("ORGANISATIONS") +data class OrganisationRequest( + @field:NotBlank val name: String, + @JsonFormat(pattern = "dd/MM/yyyy") @JsonProperty("date_founded") val dateFounded: LocalDate, + @field:NotBlank @JsonProperty("country_code") val countryCode: String, + @JsonProperty("vat_number") val VATNumber: String?, + @JsonProperty("registration_number") val registrationNumber: String?, + @JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType, + @JsonProperty("contact_details") val contactDetails: ContactDetailsRequest, +) diff --git a/src/main/kotlin/io/billie/organisations/dto/OrganisationResponse.kt b/src/main/kotlin/io/billie/organisations/dto/OrganisationResponse.kt new file mode 100644 index 0000000..0482e65 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/dto/OrganisationResponse.kt @@ -0,0 +1,22 @@ +package io.billie.organisations.dto + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.countries.model.CountryResponse +import io.billie.organisations.model.ContactDetails +import io.billie.organisations.model.LegalEntityType +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDate +import java.util.* + +@Table("ORGANISATIONS") +data class OrganisationResponse( + val id: UUID, + val name: String, + @JsonFormat(pattern = "dd/MM/yyyy") @JsonProperty("date_founded") val dateFounded: LocalDate, + val country: CountryResponse, + @JsonProperty("vat_number") val VATNumber: String?, + @JsonProperty("registration_number") val registrationNumber: String?, + @JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType, + @JsonProperty("contact_details") val contactDetails: ContactDetails, +) diff --git a/src/main/kotlin/io/billie/organisations/mapper/OrganisationDataMapper.kt b/src/main/kotlin/io/billie/organisations/mapper/OrganisationDataMapper.kt new file mode 100644 index 0000000..5bba3de --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/mapper/OrganisationDataMapper.kt @@ -0,0 +1,38 @@ +package io.billie.organisations.mapper + +import io.billie.countries.model.CountryResponse +import io.billie.organisations.dto.OrganisationResponse +import io.billie.organisations.model.ContactDetails +import io.billie.organisations.model.LegalEntityType +import org.springframework.jdbc.core.RowMapper +import java.sql.Date +import java.sql.ResultSet +import java.util.* + + fun mapOrganisation(it: ResultSet) = + OrganisationResponse( + it.getObject("organisation_id", UUID::class.java), + it.getString("organisation_name"), + Date(it.getDate("organisation_date_founded").time).toLocalDate(), + mapCountry(it), + it.getString("organisation_vat_number"), + it.getString("organisation_registration_number"), + LegalEntityType.valueOf(it.getString("organisation_legal_entity_type")), + mapContactDetails(it) + ) + + fun mapContactDetails(it: ResultSet): ContactDetails { + return ContactDetails( + UUID.fromString(it.getString("organisation_contact_details_id")), + it.getString("cd_phone_number"), + it.getString("cd_fax"), + it.getString("cd_email") + ) +} + fun mapCountry(it: ResultSet): CountryResponse { + return CountryResponse( + it.getObject("country_id", UUID::class.java), + it.getString("country_name"), + it.getString("country_code") + ) +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/mapper/OrganisationResponseMapper.kt b/src/main/kotlin/io/billie/organisations/mapper/OrganisationResponseMapper.kt new file mode 100644 index 0000000..3060746 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/mapper/OrganisationResponseMapper.kt @@ -0,0 +1,16 @@ +package io.billie.organisations.mapper + +import io.billie.organisations.dto.OrganisationResponse +import io.billie.organisations.model.Organisation + +fun Organisation.toOrganisationResponse(): OrganisationResponse = + OrganisationResponse( + id = this.id, + name = this.name, + dateFounded = this.dateFounded, + country = this.country, + VATNumber = this.VATNumber, + registrationNumber = this.registrationNumber, + legalEntityType = this.legalEntityType, + contactDetails = this.contactDetails + ) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/model/AddressDetails.kt b/src/main/kotlin/io/billie/organisations/model/AddressDetails.kt new file mode 100644 index 0000000..2a634dc --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/model/AddressDetails.kt @@ -0,0 +1,15 @@ +package io.billie.organisations.model + +import io.billie.countries.model.CityResponse +import io.billie.countries.model.CountryResponse +import java.util.UUID + +// TODO: To be implemented later +data class AddressDetails( + val id: UUID, + val countryResponse: CountryResponse, + val cityResponse: CityResponse, + val zipCode: String, + val addressLine1: String, + val addressLine2: String +) diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/ContactDetails.kt b/src/main/kotlin/io/billie/organisations/model/ContactDetails.kt similarity index 84% rename from src/main/kotlin/io/billie/organisations/viewmodel/ContactDetails.kt rename to src/main/kotlin/io/billie/organisations/model/ContactDetails.kt index 03f3b02..034d0ea 100644 --- a/src/main/kotlin/io/billie/organisations/viewmodel/ContactDetails.kt +++ b/src/main/kotlin/io/billie/organisations/model/ContactDetails.kt @@ -1,4 +1,4 @@ -package io.billie.organisations.viewmodel +package io.billie.organisations.model import com.fasterxml.jackson.annotation.JsonProperty import java.util.* diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/Entity.kt b/src/main/kotlin/io/billie/organisations/model/Entity.kt similarity index 55% rename from src/main/kotlin/io/billie/organisations/viewmodel/Entity.kt rename to src/main/kotlin/io/billie/organisations/model/Entity.kt index d35b67b..7bfc343 100644 --- a/src/main/kotlin/io/billie/organisations/viewmodel/Entity.kt +++ b/src/main/kotlin/io/billie/organisations/model/Entity.kt @@ -1,4 +1,4 @@ -package io.billie.organisations.viewmodel +package io.billie.organisations.model import java.util.* diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/LegalEntityType.kt b/src/main/kotlin/io/billie/organisations/model/LegalEntityType.kt similarity index 89% rename from src/main/kotlin/io/billie/organisations/viewmodel/LegalEntityType.kt rename to src/main/kotlin/io/billie/organisations/model/LegalEntityType.kt index 97680d3..b1d7401 100644 --- a/src/main/kotlin/io/billie/organisations/viewmodel/LegalEntityType.kt +++ b/src/main/kotlin/io/billie/organisations/model/LegalEntityType.kt @@ -1,4 +1,4 @@ -package io.billie.organisations.viewmodel +package io.billie.organisations.model enum class LegalEntityType { SOLE_PROPRIETORSHIP, diff --git a/src/main/kotlin/io/billie/organisations/model/Organisation.kt b/src/main/kotlin/io/billie/organisations/model/Organisation.kt new file mode 100644 index 0000000..43b04ad --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/model/Organisation.kt @@ -0,0 +1,23 @@ +package io.billie.organisations.model + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.countries.model.CountryResponse +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDate +import java.util.* + +@Table("ORGANISATIONS") +data class Organisation( + val id: UUID, + val name: String, + + @JsonFormat(pattern = "dd/MM/yyyy") @JsonProperty("date_founded") + val dateFounded: LocalDate, + + val country: CountryResponse, + @JsonProperty("vat_number") val VATNumber: String?, + @JsonProperty("registration_number") val registrationNumber: String?, + @JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType, + @JsonProperty("contact_details") val contactDetails: ContactDetails, +) diff --git a/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt b/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt index b108a1f..c12b088 100644 --- a/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt +++ b/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt @@ -1,14 +1,17 @@ package io.billie.organisations.resource import io.billie.organisations.data.UnableToFindCountry +import io.billie.organisations.dto.OrganisationRequest +import io.billie.organisations.dto.OrganisationResponse +import io.billie.organisations.model.Entity import io.billie.organisations.service.OrganisationService -import io.billie.organisations.viewmodel.* +import io.billie.organisations.validation.OrganisationValidator import io.swagger.v3.oas.annotations.media.ArraySchema import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses -import org.springframework.http.HttpStatus +import org.jboss.logging.Logger import org.springframework.http.HttpStatus.BAD_REQUEST import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException @@ -18,7 +21,11 @@ import javax.validation.Valid @RestController @RequestMapping("organisations") -class OrganisationResource(val service: OrganisationService) { +class OrganisationResource( + val service: OrganisationService, + val organisationValidator: OrganisationValidator) { + + val logger: Logger = Logger.getLogger(OrganisationResource::class.java) @GetMapping fun index(): List = service.findOrganisations() @@ -39,7 +46,11 @@ class OrganisationResource(val service: OrganisationService) { ) fun post(@Valid @RequestBody organisation: OrganisationRequest): Entity { try { + organisationValidator.validateOrganisationCreationRequest(organisation) + val id = service.createOrganisation(organisation) + logger.info("organization created successfully with id: $id") + return Entity(id) } catch (e: UnableToFindCountry) { throw ResponseStatusException(BAD_REQUEST, e.message) diff --git a/src/main/kotlin/io/billie/organisations/service/OrganisationService.kt b/src/main/kotlin/io/billie/organisations/service/OrganisationService.kt index d029521..30640b4 100644 --- a/src/main/kotlin/io/billie/organisations/service/OrganisationService.kt +++ b/src/main/kotlin/io/billie/organisations/service/OrganisationService.kt @@ -1,8 +1,8 @@ package io.billie.organisations.service import io.billie.organisations.data.OrganisationRepository -import io.billie.organisations.viewmodel.OrganisationRequest -import io.billie.organisations.viewmodel.OrganisationResponse +import io.billie.organisations.dto.OrganisationRequest +import io.billie.organisations.dto.OrganisationResponse import org.springframework.stereotype.Service import java.util.* diff --git a/src/main/kotlin/io/billie/organisations/validation/OrganisationValidator.kt b/src/main/kotlin/io/billie/organisations/validation/OrganisationValidator.kt new file mode 100644 index 0000000..669aa52 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/validation/OrganisationValidator.kt @@ -0,0 +1,15 @@ +package io.billie.organisations.validation + +import io.billie.countries.data.CountryRepository +import io.billie.organisations.data.UnableToFindCountry +import io.billie.organisations.dto.OrganisationRequest +import org.springframework.stereotype.Component + +@Component +class OrganisationValidator (val countryRepository: CountryRepository) { + + fun validateOrganisationCreationRequest (organisationRequest: OrganisationRequest){ + if (countryRepository.findCountryByCode(organisationRequest.countryCode).isEmpty) + throw UnableToFindCountry (organisationRequest.countryCode) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/OrganisationRequest.kt b/src/main/kotlin/io/billie/organisations/viewmodel/OrganisationRequest.kt deleted file mode 100644 index 2e31f21..0000000 --- a/src/main/kotlin/io/billie/organisations/viewmodel/OrganisationRequest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.billie.organisations.viewmodel - -import com.fasterxml.jackson.annotation.JsonFormat -import com.fasterxml.jackson.annotation.JsonProperty -import org.springframework.data.relational.core.mapping.Table -import java.time.LocalDate -import java.util.* -import javax.validation.constraints.NotBlank - -@Table("ORGANISATIONS") -data class OrganisationRequest( - @field:NotBlank val name: String, - @JsonFormat(pattern = "dd/MM/yyyy") @JsonProperty("date_founded") val dateFounded: LocalDate, - @field:NotBlank @JsonProperty("country_code") val countryCode: String, - @JsonProperty("vat_number") val VATNumber: String?, - @JsonProperty("registration_number") val registrationNumber: String?, - @JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType, - @JsonProperty("contact_details") val contactDetails: ContactDetailsRequest, -) diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/OrganisationResponse.kt b/src/main/kotlin/io/billie/organisations/viewmodel/OrganisationResponse.kt deleted file mode 100644 index d0fec75..0000000 --- a/src/main/kotlin/io/billie/organisations/viewmodel/OrganisationResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.billie.organisations.viewmodel - -import com.fasterxml.jackson.annotation.JsonFormat -import com.fasterxml.jackson.annotation.JsonProperty -import io.billie.countries.model.CountryResponse -import org.springframework.data.relational.core.mapping.Table -import java.time.LocalDate -import java.util.* - -@Table("ORGANISATIONS") -data class OrganisationResponse( - val id: UUID, - val name: String, - @JsonFormat(pattern = "dd/MM/yyyy") @JsonProperty("date_founded") val dateFounded: LocalDate, - val country: CountryResponse, - @JsonProperty("vat_number") val VATNumber: String?, - @JsonProperty("registration_number") val registrationNumber: String?, - @JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType, - @JsonProperty("contact_details") val contactDetails: ContactDetails, -) diff --git a/src/main/kotlin/io/billie/products/data/ProductRepository.kt b/src/main/kotlin/io/billie/products/data/ProductRepository.kt new file mode 100644 index 0000000..3f3f138 --- /dev/null +++ b/src/main/kotlin/io/billie/products/data/ProductRepository.kt @@ -0,0 +1,65 @@ +package io.billie.products.data + +import io.billie.products.dto.ProductRequest +import io.billie.products.model.Product +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.sql.ResultSet +import java.util.* + +@Repository +class ProductRepository { + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Transactional(readOnly=true) + fun findProductById(id: UUID): Optional { + return jdbcTemplate.query( + "select id, name, price from organisations_schema.products where id= ?", + productResponseMapper(), + id + ).stream().findFirst(); + } + + @Transactional + fun findProducts(): List { + return jdbcTemplate.query( + "select id, name, price from organisations_schema.products", + productResponseMapper() + ) + } + + private fun productResponseMapper() = RowMapper { it: ResultSet, _: Int -> + Product( + it.getObject("id", UUID::class.java), + it.getString("name"), + it.getDouble("price") + ) + } + + @Transactional + fun createProduct (productRequest: ProductRequest): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + """ + INSERT INTO organisations_schema.products + (name, price) + VALUES (?, ?) + """.trimIndent(), + arrayOf("id") + ) + ps.setString(1, productRequest.name) + ps.setBigDecimal(2, productRequest.price) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/dto/ProductRequest.kt b/src/main/kotlin/io/billie/products/dto/ProductRequest.kt new file mode 100644 index 0000000..0f7aa0d --- /dev/null +++ b/src/main/kotlin/io/billie/products/dto/ProductRequest.kt @@ -0,0 +1,9 @@ +package io.billie.products.dto + +import java.math.BigDecimal +import javax.validation.constraints.NotBlank + +data class ProductRequest ( + @field:NotBlank val name: String, + @field:NotBlank val price: BigDecimal +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/dto/ProductResponse.kt b/src/main/kotlin/io/billie/products/dto/ProductResponse.kt new file mode 100644 index 0000000..83af1de --- /dev/null +++ b/src/main/kotlin/io/billie/products/dto/ProductResponse.kt @@ -0,0 +1,10 @@ +package io.billie.products.dto + +import java.util.* +import javax.validation.constraints.NotBlank + +data class ProductResponse ( + val id: UUID, + @field:NotBlank val name: String, + @field:NotBlank val price: Double +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/exception/ProductNotFoundException.kt b/src/main/kotlin/io/billie/products/exception/ProductNotFoundException.kt new file mode 100644 index 0000000..4285041 --- /dev/null +++ b/src/main/kotlin/io/billie/products/exception/ProductNotFoundException.kt @@ -0,0 +1,4 @@ +package io.billie.products.exception + +class ProductNotFoundException (message: String): RuntimeException (message) { +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/mapper/ProductResponseMapper.kt b/src/main/kotlin/io/billie/products/mapper/ProductResponseMapper.kt new file mode 100644 index 0000000..eb2cf7d --- /dev/null +++ b/src/main/kotlin/io/billie/products/mapper/ProductResponseMapper.kt @@ -0,0 +1,11 @@ +package io.billie.products.mapper + +import io.billie.products.dto.ProductResponse +import io.billie.products.model.Product + +fun Product.toProductResponse(): ProductResponse = + ProductResponse( + id = this.id, + name = this.name, + price = this.price + ) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/model/Product.kt b/src/main/kotlin/io/billie/products/model/Product.kt new file mode 100644 index 0000000..331d47f --- /dev/null +++ b/src/main/kotlin/io/billie/products/model/Product.kt @@ -0,0 +1,11 @@ +package io.billie.products.model + +import java.util.* +import javax.validation.constraints.NotBlank + +data class Product( + val id: UUID, + @field:NotBlank val name: String, + @field:NotBlank val price: Double +) + diff --git a/src/main/kotlin/io/billie/products/resource/ProductResource.kt b/src/main/kotlin/io/billie/products/resource/ProductResource.kt new file mode 100644 index 0000000..c9b4aec --- /dev/null +++ b/src/main/kotlin/io/billie/products/resource/ProductResource.kt @@ -0,0 +1,66 @@ +package io.billie.products.resource + +import io.billie.organisations.model.Entity +import io.billie.products.dto.ProductRequest +import io.billie.products.dto.ProductResponse +import io.billie.products.mapper.toProductResponse +import io.billie.products.service.ProductService +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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 kotlin.streams.toList + +@RestController +@RequestMapping ("/products") +class ProductResource (val productService: ProductService){ + + @GetMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "returns all products ", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = ProductResponse::class))) + ))] + ) + ] + ) + fun findProducts (): ResponseEntity> { + return ResponseEntity.ok( + productService.findProducts() + .stream().map { it.toProductResponse() } + .toList()) + } + + @PostMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "Accepted the new product", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun createProduct (@RequestBody productRequest: ProductRequest): ResponseEntity{ + val productUid = productService.createProduct(productRequest) + + return ResponseEntity.status(HttpStatus.CREATED).body(Entity(productUid)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/service/ProductService.kt b/src/main/kotlin/io/billie/products/service/ProductService.kt new file mode 100644 index 0000000..f5722ba --- /dev/null +++ b/src/main/kotlin/io/billie/products/service/ProductService.kt @@ -0,0 +1,11 @@ +package io.billie.products.service + +import io.billie.products.dto.ProductRequest +import io.billie.products.model.Product +import java.util.* + +interface ProductService { + fun createProduct (productRequest: ProductRequest): UUID + fun findProducts(): List + fun findByProductId (productUid: UUID): Product +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/products/service/ProductServiceImpl.kt b/src/main/kotlin/io/billie/products/service/ProductServiceImpl.kt new file mode 100644 index 0000000..8a0b840 --- /dev/null +++ b/src/main/kotlin/io/billie/products/service/ProductServiceImpl.kt @@ -0,0 +1,24 @@ +package io.billie.products.service + +import io.billie.products.data.ProductRepository +import io.billie.products.dto.ProductRequest +import io.billie.products.exception.ProductNotFoundException +import io.billie.products.model.Product +import org.springframework.stereotype.Service +import java.util.* + +@Service +class ProductServiceImpl (private val productRepository: ProductRepository) : ProductService { + override fun findByProductId(productUid: UUID): Product { + return productRepository.findProductById(productUid) + .orElseThrow { ProductNotFoundException("product with uuid: $productUid was not found!") } + } + + override fun findProducts(): List { + return productRepository.findProducts() + } + + override fun createProduct(productRequest: ProductRequest): UUID { + return productRepository.createProduct(productRequest) + } +} \ No newline at end of file diff --git a/src/main/resources/application-test-containers.yml b/src/main/resources/application-test-containers.yml new file mode 100644 index 0000000..f5482e0 --- /dev/null +++ b/src/main/resources/application-test-containers.yml @@ -0,0 +1,10 @@ +spring: + datasource: + url: jdbc:tc:postgresql:12:///test_database + username: user + password: password + jpa: + hibernate: + ddl-auto: create + flyway: + enabled: true \ No newline at end of file diff --git a/src/main/resources/db/migration/V10__add_customers_table.sql b/src/main/resources/db/migration/V10__add_customers_table.sql new file mode 100644 index 0000000..5eac732 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_customers_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.customers +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + name VARCHAR(256) NOT NULL, + address_details VARCHAR(256) NOT NULL +); diff --git a/src/main/resources/db/migration/V11__add_products_table.sql b/src/main/resources/db/migration/V11__add_products_table.sql new file mode 100644 index 0000000..e6a8b43 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_products_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.products +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + name VARCHAR(256) NOT NULL, + price DECIMAL(12, 2) NOT NULL +); diff --git a/src/main/resources/db/migration/V12__add_orders_table.sql b/src/main/resources/db/migration/V12__add_orders_table.sql new file mode 100644 index 0000000..1b6dd2c --- /dev/null +++ b/src/main/resources/db/migration/V12__add_orders_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.customer_orders +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + order_amount DECIMAL(12, 2) NOT NULL, + customer_uid UUID NOT NULL +); diff --git a/src/main/resources/db/migration/V13__Add_customers.sql b/src/main/resources/db/migration/V13__Add_customers.sql new file mode 100644 index 0000000..23a6b09 --- /dev/null +++ b/src/main/resources/db/migration/V13__Add_customers.sql @@ -0,0 +1,3 @@ +insert into organisations_schema.customers(name, address_details) VALUES ('John Smith','5703 Louetta Rd, Texas'); +insert into organisations_schema.customers(name, address_details) VALUES ('Josh Long','9069 Holman Rd NW, Seattle'); +insert into organisations_schema.customers(name, address_details) VALUES ('Esfandiyar Talebi','Yavux su, burhabine, Istanbul'); diff --git a/src/main/resources/db/migration/V14__Add_products.sql b/src/main/resources/db/migration/V14__Add_products.sql new file mode 100644 index 0000000..c96dfb3 --- /dev/null +++ b/src/main/resources/db/migration/V14__Add_products.sql @@ -0,0 +1,2 @@ +insert into organisations_schema.products(name, price) VALUES ('iPhone 15 SE', 999.00); +insert into organisations_schema.products(name, price) VALUES ('iPhone 15 Max',1299.00); diff --git a/src/main/resources/db/migration/V15__add_invoices_table.sql b/src/main/resources/db/migration/V15__add_invoices_table.sql new file mode 100644 index 0000000..8a83674 --- /dev/null +++ b/src/main/resources/db/migration/V15__add_invoices_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.invoices +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + order_uid UUID NOT NULL, + order_amount DECIMAL(12, 2) NOT NULL, + overall_status VARCHAR (100) NOT NULL DEFAULT 'CREATED', + customer_uid UUID NOT NULL, + merchant_uid UUID NOT NULL, + date_created TIMESTAMP NOT NULL, + last_update TIMESTAMP +); diff --git a/src/main/resources/db/migration/V16__add_installments_table.sql b/src/main/resources/db/migration/V16__add_installments_table.sql new file mode 100644 index 0000000..2ef6b36 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_installments_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.installments +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + grand_invoice_uid UUID NOT NULL, + order_uid UUID NOT NULL, + installment_amount DECIMAL(12, 2) NOT NULL, + status VARCHAR (100) NOT NULL DEFAULT 'CREATED', + customer_uid UUID NOT NULL, + merchant_uid UUID NOT NULL, + date_created TIMESTAMP NOT NULL, + due_date TIMESTAMP, + last_update TIMESTAMP +); diff --git a/src/main/resources/db/migration/V17__Add_merchants.sql b/src/main/resources/db/migration/V17__Add_merchants.sql new file mode 100644 index 0000000..33370e1 --- /dev/null +++ b/src/main/resources/db/migration/V17__Add_merchants.sql @@ -0,0 +1,2 @@ +insert into organisations_schema.merchants(name) VALUES ('amazon.com'); +insert into organisations_schema.merchants(name) VALUES ('ebay.com'); diff --git a/src/main/resources/db/migration/V9__add_merchants_table.sql b/src/main/resources/db/migration/V9__add_merchants_table.sql new file mode 100644 index 0000000..a113890 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_merchants_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.merchants +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + name VARCHAR(200) NOT NULL +); diff --git a/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt b/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt index 91782d7..4a37d8c 100644 --- a/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt +++ b/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt @@ -8,6 +8,7 @@ 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.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.test.web.servlet.MockMvc @@ -18,7 +19,7 @@ import java.util.* @AutoConfigureMockMvc -@SpringBootTest(webEnvironment = DEFINED_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT) class CanReadLocationsTest { @LocalServerPort diff --git a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt b/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt index 2d57630..b1852e7 100644 --- a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt +++ b/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt @@ -11,7 +11,7 @@ import io.billie.functional.data.Fixtures.orgRequestJsonNameBlank import io.billie.functional.data.Fixtures.orgRequestJsonNoContactDetails import io.billie.functional.data.Fixtures.orgRequestJsonNoCountryCode import io.billie.functional.data.Fixtures.orgRequestJsonNoLegalEntityType -import io.billie.organisations.viewmodel.Entity +import io.billie.organisations.model.Entity import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.IsEqual.equalTo import org.junit.jupiter.api.Test @@ -19,6 +19,7 @@ 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.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.jdbc.core.JdbcTemplate @@ -30,7 +31,7 @@ import java.util.* @AutoConfigureMockMvc -@SpringBootTest(webEnvironment = DEFINED_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT) class CanStoreAndReadOrganisationTest { @LocalServerPort diff --git a/src/test/kotlin/io/billie/functional/common/BaseIntegrationTest.kt b/src/test/kotlin/io/billie/functional/common/BaseIntegrationTest.kt new file mode 100644 index 0000000..20ffbcd --- /dev/null +++ b/src/test/kotlin/io/billie/functional/common/BaseIntegrationTest.kt @@ -0,0 +1,34 @@ +package io.billie.functional.common + +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +@ActiveProfiles("test-containers") +@DirtiesContext (methodMode = DirtiesContext.MethodMode.BEFORE_METHOD) +class BaseIntegrationTest { + + companion object { + @Container + val container = PostgreSQLContainer("postgres:12").apply { + withDatabaseName("testdb") + withUsername("duke") + withPassword("s3crEt") + } + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url", container::getJdbcUrl); + registry.add("spring.datasource.password", container::getPassword); + registry.add("spring.datasource.username", container::getUsername); + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/customers/CustomerRepositoryTest.kt b/src/test/kotlin/io/billie/functional/customers/CustomerRepositoryTest.kt new file mode 100644 index 0000000..cbf3a93 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/customers/CustomerRepositoryTest.kt @@ -0,0 +1,63 @@ +package io.billie.functional.customers + +import io.billie.customers.data.CustomerRepository +import io.billie.functional.common.BaseIntegrationTest +import io.billie.functional.data.generateCustomerRequest +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import java.util.UUID +import java.util.stream.IntStream + +class CustomerRepositoryTest: BaseIntegrationTest() { + + @Autowired + private lateinit var customerRepository: CustomerRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun resetDatabase(){ + jdbcTemplate.execute("delete from organisations_schema.customers") + } + + @Test + fun `should be able to create a new customer`(){ + val customerId = generateCustomer() + + Assertions.assertThat(customerId).isNotNull + } + + private fun generateCustomer(): UUID{ + val customerRequest = generateCustomerRequest() + return customerRepository.createCustomer(customerRequest) + } + + @Test + fun `should be able to find a customer by uid`(){ + // generating a customer and storing in db + val customerId = generateCustomer() + + val customerCheck = customerRepository.findCustomerById(customerId) + + Assertions.assertThat(customerCheck.isPresent).isEqualTo(true) + + val customerFound = customerCheck.get() + + Assertions.assertThat(customerFound.id).isEqualTo(customerId) + } + + @Test + fun `should be able to find all customers`(){ + + // generating some customers and storing in db + IntStream.rangeClosed(0, 2).forEach { generateCustomer() } + + val customers = customerRepository.findCustomers() + + Assertions.assertThat(customers.size).isEqualTo(3) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/customers/CustomerResourceTest.kt b/src/test/kotlin/io/billie/functional/customers/CustomerResourceTest.kt new file mode 100644 index 0000000..85e63ae --- /dev/null +++ b/src/test/kotlin/io/billie/functional/customers/CustomerResourceTest.kt @@ -0,0 +1,103 @@ +package io.billie.functional.customers + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.customers.data.CustomerRepository +import io.billie.customers.dto.CustomerRequest +import io.billie.customers.dto.CustomerResponse +import io.billie.functional.common.BaseIntegrationTest +import io.billie.functional.data.generateCustomerRequest +import io.billie.organisations.model.Entity +import org.assertj.core.api.Assertions +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.json.AutoConfigureJsonTesters +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.json.JacksonTester +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* +import java.util.stream.IntStream + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@AutoConfigureJsonTesters +class CustomerResourceTest { + + @Autowired + private lateinit var mocMvc: MockMvc + + @Autowired + private lateinit var customerRepo: CustomerRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var jacksonTester: JacksonTester + + @BeforeEach + fun resetDatabase() { + jdbcTemplate.execute("delete from organisations_schema.customers") + } + + @Test + fun `should return empty when there is no customers in database`() { + val findCustomers = mocMvc.perform( + get("/customers") + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk) + .andReturn() + + val customerResponses = objectMapper.readValue(findCustomers.response.contentAsString, Array::class.java) + + Assertions.assertThat(customerResponses.size).isZero + } + + @Test + fun `should return all customers from database`() { + IntStream.rangeClosed(0, 2) + .forEach { generateCustomer() } + + val findAllCustomers = + mocMvc.perform( + get("/customers") + ) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + + val customerResponses = objectMapper.readValue(findAllCustomers.response.contentAsString, Array::class.java) + Assertions.assertThat(customerResponses.size).isEqualTo(3) + } + + @Test + fun `create new customer should return customer uuid`() { + val customerRequest = generateCustomerRequest() + + val createCustomerCall = mocMvc.perform( + post("/customers") + .content(jacksonTester.write(customerRequest).json) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated) + .andReturn() + + val createResult = objectMapper.readValue(createCustomerCall.response.contentAsString, Entity::class.java) + + Assertions.assertThat(createResult.id).isNotNull + } + + private fun generateCustomer(): UUID { + return customerRepo.createCustomer(generateCustomerRequest()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/CustomerFixtures.kt b/src/test/kotlin/io/billie/functional/data/CustomerFixtures.kt new file mode 100644 index 0000000..c694f8f --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/CustomerFixtures.kt @@ -0,0 +1,9 @@ +package io.billie.functional.data + +import io.billie.customers.dto.CustomerRequest + +fun generateCustomerRequest(): CustomerRequest = + CustomerRequest( + name ="demoCustomer", + address = "demo address" + ) \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/InvoiceFixtures.kt b/src/test/kotlin/io/billie/functional/data/InvoiceFixtures.kt new file mode 100644 index 0000000..dba905e --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/InvoiceFixtures.kt @@ -0,0 +1,41 @@ +package io.billie.functional.data + +import io.billie.invoicing.dto.InstallmentRequest +import io.billie.invoicing.dto.InvoiceRequest +import io.billie.invoicing.model.InvoiceStatus +import java.math.BigDecimal +import java.time.LocalDateTime +import java.util.UUID + +fun createInvoiceRequest(orderId: UUID, customerId: UUID, merchantId: UUID): InvoiceRequest = + InvoiceRequest( + orderUid = orderId, + orderAmount = BigDecimal.valueOf(200.00), + status = InvoiceStatus.CREATED, + customerUid = customerId, + merchantUid = merchantId, + createInstallments(orderId, customerId,merchantId), + LocalDateTime.now(), + LocalDateTime.now() + ) + +fun createInstallments(orderId: UUID, customerId: UUID, merchantId: UUID): MutableList { + return mutableListOf( + createInstallmentRequest(orderId, customerId,merchantId), + createInstallmentRequest(orderId, customerId,merchantId), + ) +} + +fun createInstallmentRequest(orderId: UUID, customerId: UUID, merchantId: UUID): InstallmentRequest = + InstallmentRequest( + orderUid = orderId, + installmentAmount = BigDecimal.valueOf(100.00), + status = InvoiceStatus.CREATED, + customerUid = customerId, + merchantUid = merchantId, + createdDate = LocalDateTime.now(), + lastUpdate = LocalDateTime.now(), + dueDate = LocalDateTime.now().plusMonths(1) + ) + + diff --git a/src/test/kotlin/io/billie/functional/data/MerchantFixtures.kt b/src/test/kotlin/io/billie/functional/data/MerchantFixtures.kt new file mode 100644 index 0000000..dd0912d --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/MerchantFixtures.kt @@ -0,0 +1,26 @@ +package io.billie.functional.data + +import io.billie.merchants.dto.MerchantRequest + +class MerchantFixtures { + fun orgRequestJsonNameBlank(): String { + return "{\n" + + " \"name\": \"\",\n" + + " \"date_founded\": \"18/10/1922\",\n" + + " \"country_code\": \"GB\",\n" + + " \"vat_number\": \"333289454\",\n" + + " \"registration_number\": \"3686147\",\n" + + " \"legal_entity_type\": \"NONPROFIT_ORGANIZATION\",\n" + + " \"contact_details\": {\n" + + " \"phone_number\": \"+443700100222\",\n" + + " \"fax\": \"\",\n" + + " \"email\": \"yourquestions@bbc.co.uk\"\n" + + " }\n" + + "}" + } +} + +fun generateMerchantRequest(merchantName: String): MerchantRequest = + MerchantRequest( + name = merchantName + ) \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/OrderTextures.kt b/src/test/kotlin/io/billie/functional/data/OrderTextures.kt new file mode 100644 index 0000000..6038b68 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/OrderTextures.kt @@ -0,0 +1,10 @@ +package io.billie.functional.data + +import io.billie.orders.dto.OrderRequest +import java.math.BigDecimal +import java.util.UUID + +fun generateOrderRequest(customerId: UUID, amount: BigDecimal): OrderRequest = + OrderRequest( + amount = amount, customerId = customerId + ) \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/ProductFixtures.kt b/src/test/kotlin/io/billie/functional/data/ProductFixtures.kt new file mode 100644 index 0000000..77eeb10 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/ProductFixtures.kt @@ -0,0 +1,11 @@ +package io.billie.functional.data + +import io.billie.products.dto.ProductRequest +import java.math.BigDecimal + + +fun generateProductRequest(): ProductRequest = + ProductRequest( + name = "iphone", + price = BigDecimal.valueOf(1299.00) + ) \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/invoicing/InvoiceResourceTest.kt b/src/test/kotlin/io/billie/functional/invoicing/InvoiceResourceTest.kt new file mode 100644 index 0000000..9d10ade --- /dev/null +++ b/src/test/kotlin/io/billie/functional/invoicing/InvoiceResourceTest.kt @@ -0,0 +1,124 @@ +package io.billie.functional.invoicing + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.customers.data.CustomerRepository +import io.billie.functional.data.* +import io.billie.invoicing.data.InvoiceRepository +import io.billie.invoicing.dto.InvoiceResponse +import io.billie.invoicing.model.InvoiceStatus +import io.billie.invoicing.resource.InvoiceResource +import io.billie.invoicing.service.InvoiceService +import io.billie.merchants.data.MerchantRepository +import io.billie.orders.data.OrderRepository +import io.billie.products.data.ProductRepository +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.json.JacksonTester +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.math.BigDecimal +import java.util.* + +@SpringBootTest (webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@AutoConfigureJsonTesters +class InvoiceResourceTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var mapper: ObjectMapper + + @Autowired + private lateinit var invoiceService: InvoiceService + + @Autowired + private lateinit var merchantRepository: MerchantRepository + + @Autowired + private lateinit var customerRepository: CustomerRepository + + @Autowired + private lateinit var productRepository: ProductRepository + + @Autowired + private lateinit var orderRepository: OrderRepository + + @Autowired + private lateinit var invoiceRepository: InvoiceRepository + + @Autowired + private lateinit var template: JdbcTemplate + + @Autowired + private lateinit var jacksonTester: JacksonTester + + @AfterEach + fun cleanTablesBeforeTest() { + template.execute("DELETE FROM organisations_schema.merchants") + template.execute("DELETE FROM organisations_schema.customer_orders") + template.execute("DELETE FROM organisations_schema.customers") + template.execute("DELETE FROM organisations_schema.products") + template.execute("DELETE FROM organisations_schema.invoices") + } + + fun generateMerchant(): UUID { + return merchantRepository.createMerchant(generateMerchantRequest("demo")) + } + + fun generateCustomer(): UUID { + return customerRepository.createCustomer(generateCustomerRequest()) + } + + fun generateOrder(customerId: UUID): UUID { + val orderRequest = generateOrderRequest(customerId, BigDecimal.valueOf(120.00)) + return orderRepository.createOrder(orderRequest) + } + + fun generateInvoice(customerId: UUID, orderId: UUID, merchantId: UUID): UUID{ + return invoiceRepository.createCustomerInvoices(createInvoiceRequest(orderId, customerId, merchantId)) + } + + @Test + fun `findAll should return all invoices`() { + val merchantId = generateMerchant() + val customerId = generateCustomer() + val orderId = generateOrder(customerId) + val invoiceUid = generateInvoice(customerId, orderId, merchantId) + + val findAllInvoices = mockMvc + .perform(get("/invoices")) + .andExpect(status().isOk) + .andReturn() + + val invoiceResponses = mapper.readValue(findAllInvoices.response.contentAsString, Array::class.java) + Assertions.assertEquals(1,invoiceResponses.size) + + // verifying the results + val firstInvoice = invoiceResponses[0] + Assertions.assertEquals(invoiceUid,firstInvoice.id) + Assertions.assertEquals(merchantId, firstInvoice.merchantUid) + Assertions.assertEquals(orderId, firstInvoice.orderUid) + Assertions.assertEquals(InvoiceStatus.CREATED, firstInvoice.status) + Assertions.assertEquals(2, firstInvoice.installments.size) + + } + + @Test + fun `should return empty when no invoices exists`() { + val findMerchant = mockMvc + .perform(get("/invoices")) + .andExpect(status().isOk) + .andReturn() + + val invoiceResponses = mapper.readValue(findMerchant.response.contentAsString, Array::class.java) + Assertions.assertEquals(0,invoiceResponses.size) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/merchants/MerchantResourceTest.kt b/src/test/kotlin/io/billie/functional/merchants/MerchantResourceTest.kt new file mode 100644 index 0000000..cdaf345 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/merchants/MerchantResourceTest.kt @@ -0,0 +1,134 @@ +package io.billie.functional.merchants + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.functional.data.generateMerchantRequest +import io.billie.merchants.data.MerchantRepository +import io.billie.merchants.dto.MerchantRequest +import io.billie.merchants.dto.MerchantResponse +import io.billie.organisations.model.Entity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.json.JacksonTester +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* + +@AutoConfigureMockMvc +@AutoConfigureJsonTesters +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MerchantResourceTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var mapper: ObjectMapper + + @Autowired + private lateinit var template: JdbcTemplate + + @Autowired + private lateinit var merchantRepository: MerchantRepository + + @Autowired + private lateinit var jacksonTester: JacksonTester + + @AfterEach + fun cleanTablesBeforeTest() { + template.execute("DELETE FROM organisations_schema.merchants") + } + + private fun createMerchants(name: String): UUID{ + return merchantRepository.createMerchant(generateMerchantRequest(name)) + } + + @Test + fun `should return all merchants`() { + val merchantId1 = createMerchants("demo1") + val merchantId2 = createMerchants("demo2") + + val findMerchant = mockMvc + .perform(get("/merchants")) + .andExpect(status().isOk) + .andReturn() + + val merchantResponses = mapper.readValue(findMerchant.response.contentAsString, Array::class.java) + Assertions.assertEquals(merchantResponses.size, 2) + + // verify results + val firstMerchant = merchantResponses[0] + Assertions.assertEquals("demo1",firstMerchant.name) + Assertions.assertEquals(merchantId1, firstMerchant.id) + + val secondMerchant = merchantResponses[1] + Assertions.assertEquals("demo2",secondMerchant.name) + Assertions.assertEquals(merchantId2, secondMerchant.id) + } + + @Test + fun `should return empty when no merchants exists`() { + val findMerchant = mockMvc + .perform(get("/merchants")) + .andExpect(status().isOk) + .andReturn() + + val merchantResponses = mapper.readValue(findMerchant.response.contentAsString, Array::class.java) + Assertions.assertEquals(merchantResponses.size, 0) + } + + @Test + fun `should find the merchant with uuid when id exists`() { + val merchantId1 = createMerchants("demo1") + + val findMerchant = mockMvc + .perform(get("/merchants/{merchantUid}", merchantId1)) + .andExpect(status().isOk) + .andReturn() + + val merchantResponse = mapper.readValue(findMerchant.response.contentAsString, MerchantResponse::class.java) + + // validate the response + Assertions.assertEquals("demo1",merchantResponse.name) + Assertions.assertEquals(merchantId1, merchantResponse.id) + } + + @Test + fun `find merchant by Uid should return 404 when uid does not exists`() { + val merchantId = UUID.randomUUID() + mockMvc + .perform(get("/merchants/{merchantUid}", merchantId)) + .andExpect(status().isNotFound) + .andReturn() + } + + @Test + fun `create merchant should return uuid of new merchant`(){ + val merchantRequest = generateMerchantRequest("demo") + + val createMerchantResult = mockMvc + .perform(postMerchantRequest(merchantRequest)) + .andExpect(status().isCreated) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + + val merchantEntity = mapper.readValue(createMerchantResult.response.contentAsString, Entity::class.java) + + assertThat (merchantEntity.id).isNotNull + assertThat(merchantEntity.id.toString().length).isEqualTo(36) + } + + private fun postMerchantRequest(request: MerchantRequest) = + post("/merchants") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTester.write(request).json) +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/merchants/MerchantShipmentTest.kt b/src/test/kotlin/io/billie/functional/merchants/MerchantShipmentTest.kt new file mode 100644 index 0000000..ac06780 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/merchants/MerchantShipmentTest.kt @@ -0,0 +1,190 @@ +package io.billie.functional.merchants + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.customers.data.CustomerRepository +import io.billie.functional.data.generateCustomerRequest +import io.billie.functional.data.generateMerchantRequest +import io.billie.functional.data.generateOrderRequest +import io.billie.invoicing.data.InvoiceRepository +import io.billie.invoicing.model.InvoiceStatus +import io.billie.merchants.data.MerchantRepository +import io.billie.merchants.dto.ShipmentNotification +import io.billie.orders.data.OrderRepository +import io.billie.organisations.model.Entity +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.json.JacksonTester +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.math.BigDecimal +import java.util.UUID + +@SpringBootTest (webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureJsonTesters +@AutoConfigureMockMvc +class MerchantShipmentTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var mapper: ObjectMapper + + @Autowired + private lateinit var template: JdbcTemplate + + @Autowired + private lateinit var merchantRepository: MerchantRepository + + @Autowired + private lateinit var customerRepository: CustomerRepository + + @Autowired + private lateinit var orderRepository: OrderRepository + + @Autowired + private lateinit var invoiceRepository: InvoiceRepository + + @Autowired + private lateinit var jacksonTester: JacksonTester + + private final val noOfInstallments = 2L + + @AfterEach + fun cleanTablesBeforeTest() { + template.execute("DELETE FROM organisations_schema.merchants") + template.execute("DELETE FROM organisations_schema.customer_orders") + template.execute("DELETE FROM organisations_schema.customers") + template.execute("DELETE FROM organisations_schema.products") + template.execute("DELETE FROM organisations_schema.invoices") + } + + fun generateMerchant(): UUID { + return merchantRepository.createMerchant(generateMerchantRequest("demo")) + } + + fun generateCustomer(): UUID { + return customerRepository.createCustomer(generateCustomerRequest()) + } + + fun generateOrder(customerId: UUID, amount: BigDecimal): UUID { + val orderRequest = generateOrderRequest(customerId, amount) + return orderRepository.createOrder(orderRequest) + } + + @Test + fun `notifying of shipment should generate invoices for that customer with installments`() { + val merchantId = generateMerchant() + val customerId = generateCustomer() + val orderId = generateOrder(customerId, BigDecimal.valueOf(100.00)) + + val shipmentNotification = ShipmentNotification( + shipmentUid = UUID.randomUUID(), + orderUId = orderId, + customerUId = customerId + ) + + val shipmentNotificationResult = mockMvc + .perform(postShipmentNotification(merchantId, shipmentNotification)) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + + val invoiceEntity = mapper.readValue(shipmentNotificationResult.response.contentAsString, Entity::class.java) + + Assertions.assertThat(invoiceEntity.id).isNotNull + Assertions.assertThat(invoiceEntity.id.toString().length).isEqualTo(36) + + val invoiceCheck = invoiceRepository.findCustomerInvoiceByUid(invoiceEntity.id) + Assertions.assertThat(invoiceCheck.isPresent).isTrue + + val invoice = invoiceCheck.get() + Assertions.assertThat(invoice.id).isEqualTo(invoiceEntity.id) + Assertions.assertThat(invoice.customerUid).isEqualTo(customerId) + Assertions.assertThat(invoice.orderUid).isEqualTo(orderId) + Assertions.assertThat(invoice.status).isEqualTo(InvoiceStatus.CREATED) + Assertions.assertThat(invoice.installments.size).isEqualTo(noOfInstallments) + + val installmentAmount = invoice.orderAmount.divide(BigDecimal.valueOf(noOfInstallments)) + + val installment1 = invoice.installments[0] + Assertions.assertThat(installment1.customerUid).isEqualTo(customerId) + Assertions.assertThat(installment1.orderUid).isEqualTo(orderId) + Assertions.assertThat(installment1.status).isEqualTo(InvoiceStatus.CREATED) + Assertions.assertThat(installment1.installmentAmount).isEqualTo(installmentAmount) + + val installment2 = invoice.installments[1] + Assertions.assertThat(installment2.customerUid).isEqualTo(customerId) + Assertions.assertThat(installment2.orderUid).isEqualTo(orderId) + Assertions.assertThat(installment2.status).isEqualTo(InvoiceStatus.CREATED) + Assertions.assertThat(installment2.installmentAmount).isEqualTo(installmentAmount) + } + + private fun postShipmentNotification(merchantUid: UUID, shipmentNotification: ShipmentNotification) = + post("/merchants/{merchantUid}/notifyShipment", merchantUid) + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTester.write(shipmentNotification).json) + + + @Test + fun `notifying of shipment should return 404 when merchant id doesn't exists `() { + val merchantId = UUID.randomUUID() + val customerId = generateCustomer() + val orderId = generateOrder(customerId, BigDecimal.valueOf(100.00)) + + val shipmentNotification = ShipmentNotification( + shipmentUid = UUID.randomUUID(), + orderUId = orderId, + customerUId = customerId + ) + + mockMvc + .perform(postShipmentNotification(merchantId, shipmentNotification)) + .andExpect(status().isNotFound) + } + + @Test + fun `notifying of shipment should return 400 when customer id doesn't exists `() { + val merchantId = generateMerchant() + val customerId = UUID.randomUUID() + val orderId = generateOrder(customerId, BigDecimal.valueOf(120.00)) + + val shipmentNotification = ShipmentNotification( + shipmentUid = UUID.randomUUID(), + orderUId = orderId, + customerUId = customerId + ) + + mockMvc + .perform(postShipmentNotification(merchantId, shipmentNotification)) + .andExpect(status().isBadRequest) + } + + @Test + fun `notifying of shipment should return 400 when order id doesn't exists `() { + val merchantId = generateMerchant() + val customerId = generateCustomer() + val orderId = UUID.randomUUID() + + val shipmentNotification = ShipmentNotification( + shipmentUid = UUID.randomUUID(), + orderUId = orderId, + customerUId = customerId + ) + + mockMvc + .perform(postShipmentNotification(merchantId, shipmentNotification)) + .andExpect(status().isBadRequest) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/orders/OrderResourceTest.kt b/src/test/kotlin/io/billie/functional/orders/OrderResourceTest.kt new file mode 100644 index 0000000..525cfd7 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/orders/OrderResourceTest.kt @@ -0,0 +1,150 @@ +package io.billie.functional.orders + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.customers.data.CustomerRepository +import io.billie.functional.data.generateCustomerRequest +import io.billie.functional.data.generateOrderRequest +import io.billie.orders.data.OrderRepository +import io.billie.orders.dto.OrderRequest +import io.billie.orders.dto.OrderResponse +import io.billie.organisations.model.Entity +import org.assertj.core.api.Assertions +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.json.AutoConfigureJsonTesters +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.json.JacksonTester +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.math.BigDecimal +import java.util.* +import java.util.stream.IntStream + +@SpringBootTest (webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@AutoConfigureJsonTesters +class OrderResourceTest { + + @Autowired + private lateinit var mocMvc: MockMvc + + @Autowired + private lateinit var orderRepository: OrderRepository + + @Autowired + private lateinit var customerRepository: CustomerRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var jacksonTester: JacksonTester + + @BeforeEach + fun resetDatabase() { + jdbcTemplate.execute("delete from organisations_schema.customer_orders") + jdbcTemplate.execute("delete from organisations_schema.customers") + } + + @Test + fun `should return empty when there is no orders in database`() { + val findOrdersCall = mocMvc.perform( + MockMvcRequestBuilders.get("/orders") + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk) + .andReturn() + + val customerResponses = objectMapper.readValue(findOrdersCall.response.contentAsString, Array::class.java) + + Assertions.assertThat(customerResponses.size).isZero + } + + @Test + fun `should return all orders from database`() { + val orderAmount = BigDecimal.valueOf(100.00) + IntStream.rangeClosed(0, 2) + .forEach { generateOrder(orderAmount) } + + val findAllOrdersCall = + mocMvc.perform( + MockMvcRequestBuilders.get("/orders") + ) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andReturn() + + val customerResponses = objectMapper.readValue(findAllOrdersCall.response.contentAsString, Array::class.java) + Assertions.assertThat(customerResponses.size).isEqualTo(3) + + val firstOrder = customerResponses[0] + Assertions.assertThat(firstOrder.amount.toDouble()).isEqualTo(orderAmount.toDouble()) + } + + @Test + fun `create new order should return order uuid`() { + val customerId = generateCustomer() + val orderAmount = BigDecimal.valueOf(100.00) + val orderRequest = generateOrderRequest(customerId, orderAmount) + + val createOrderCall = mocMvc.perform( + post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTester.write(orderRequest).json) + ) + .andExpect(status().isCreated) + .andReturn() + + val createResult = objectMapper.readValue(createOrderCall.response.contentAsString, Entity::class.java) + + Assertions.assertThat(createResult.id).isNotNull + } + + @Test + fun `create new order with amount zero should return 400`() { + val customerId = generateCustomer() + val orderAmount = BigDecimal.valueOf(0.00) + val orderRequest = generateOrderRequest(customerId, orderAmount) + + mocMvc.perform( + post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTester.write(orderRequest).json) + ) + .andExpect(status().isBadRequest) + .andReturn() + } + + @Test + fun `create new order with amount negative should return 400`() { + val customerId = generateCustomer() + val orderAmount = BigDecimal.valueOf(-10.00) + val orderRequest = generateOrderRequest(customerId, orderAmount) + + mocMvc.perform( + post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(jacksonTester.write(orderRequest).json) + ) + .andExpect(status().isBadRequest) + .andReturn() + } + + private fun generateOrder(amount: BigDecimal): UUID { + val customerId = generateCustomer() + return orderRepository.createOrder(generateOrderRequest(customerId, amount)) + } + + private fun generateCustomer(): UUID = + customerRepository.createCustomer(generateCustomerRequest()) + +} \ No newline at end of file