Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ Running the tests:
```shell
cd <project_root>
docker compose up database -d
gradle flywayMigrate
gradle clean build
docs at -> http://localhost:8080/swagger-ui/index.html
./gradlew flywayMigrate
./gradlew clean build
```
docs at -> http://localhost:8080/swagger-ui/index.html

Work has been started but not done yet to containerise the kotlin service.

The service runs in the host right now. Feel free to fix that if it makes your life easier
3 changes: 3 additions & 0 deletions src/main/kotlin/io/billie/orders/data/OrderAlreadyExists.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package io.billie.orders.data

class OrderAlreadyExists(val externalId: String) : RuntimeException()
68 changes: 68 additions & 0 deletions src/main/kotlin/io/billie/orders/data/OrderRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.billie.orders.data

import io.billie.orders.model.OrderRequest
import io.billie.orders.model.OrderState
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.ResultSetExtractor
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.Timestamp
import java.util.*


@Repository
class OrderRepository {

@Autowired
lateinit var jdbcTemplate: JdbcTemplate

@Transactional
fun create(order: OrderRequest): UUID {
if (orderExists(order)) {
throw OrderAlreadyExists(order.externalId)
}
return createOrder(order)
}

private fun createOrder(order: OrderRequest): UUID {
val keyHolder: KeyHolder = GeneratedKeyHolder()
jdbcTemplate.update(
{ connection ->
val ps = connection.prepareStatement(
"INSERT INTO organisations_schema.orders (" +
"external_id, " +
"created_time, " +
"organisation_id, " +
"state " +
") VALUES (?, ?, ?, ?)",
arrayOf("id")
)
ps.setString(1, order.externalId)
ps.setTimestamp(2, Timestamp.valueOf(order.createdTime))
ps.setObject(3, order.organisationId)
ps.setString(4, OrderState.NEW.name)
ps
}, keyHolder
)
return keyHolder.getKeyAs(UUID::class.java)!!
}

private fun orderExists(order: OrderRequest): Boolean {
val reply: Int? = jdbcTemplate.query(
"select count(o.id) from organisations_schema.orders o " +
"WHERE o.external_id = ? " +
"AND o.organisation_id = ? ",
ResultSetExtractor {
it.next()
it.getInt(1)
},
order.externalId,
order.organisationId
)
return (reply != null) && (reply > 0)
}

}
18 changes: 18 additions & 0 deletions src/main/kotlin/io/billie/orders/model/OrderRequest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.billie.orders.model

import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.annotation.JsonProperty
import org.springframework.data.relational.core.mapping.Table
import java.time.LocalDateTime
import java.util.*
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull


data class OrderRequest(
@field:NotNull @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonProperty("created_time") val createdTime: LocalDateTime,
@field:NotNull @JsonProperty("organisation_id") val organisationId: UUID,
@field:NotBlank @JsonProperty("external_id") val externalId: String,
@JsonProperty("state") val state: String, //TODO proper enum validator
// TODO data fields needed for invoicing
)
10 changes: 10 additions & 0 deletions src/main/kotlin/io/billie/orders/model/OrderState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.billie.orders.model

enum class OrderState {
NEW, //announced
SHIPPED, //shipped by merchant
MERCHANT_PAID,
INVOICED, //the buyer is invoiced
COMPLETED, //the buyer paid the invoice, final state
CANCELED //the order was cancelled (for example, was never shipped)
}
68 changes: 68 additions & 0 deletions src/main/kotlin/io/billie/orders/resource/OrderResource.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.billie.orders.resource

import io.billie.orders.data.OrderAlreadyExists
import io.billie.orders.model.OrderRequest
import io.billie.orders.service.OrderService
import io.billie.shared.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.BAD_REQUEST
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import java.util.*
import javax.validation.Valid


@RestController
@RequestMapping("orders")
class OrderResource(val service: OrderService) {

@PostMapping
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "Accepted the new order",
content = [
(Content(
mediaType = "application/json",
array = (ArraySchema(schema = Schema(implementation = Entity::class)))
))]
),
ApiResponse(responseCode = "409", description = "Conflict", content = [Content()])]
)
fun post(@Valid @RequestBody order: OrderRequest): Entity {
try {
val id = service.createOrder(order)
return Entity(id)
} catch (e: OrderAlreadyExists) {
throw ResponseStatusException(CONFLICT, e.message)
}
}

@PatchMapping
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "The order was updated",
content = [
(Content(
mediaType = "application/json",
array = (ArraySchema(schema = Schema(implementation = Entity::class)))
))]
),
ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])]
)
fun patch(@Valid @RequestBody order: OrderRequest) { // TODO add return type
try {
// TODO use for notifying the order was shipped by updating the state
} catch (e: OrderAlreadyExists) {
throw ResponseStatusException(BAD_REQUEST, e.message)
}
}
}
22 changes: 22 additions & 0 deletions src/main/kotlin/io/billie/orders/service/OrderService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.billie.orders.service

import io.billie.orders.data.OrderRepository
import io.billie.orders.model.OrderRequest
import io.billie.organisations.data.OrganisationRepository
import io.billie.organisations.data.UnableToFindCountry
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*

@Service
class OrderService(val orderRepository: OrderRepository) {
@Transactional
fun createOrder(order: OrderRequest): UUID {
//TODO validateOrder:
// createdTime is in the past; organisation exists

return orderRepository.create(order);
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package io.billie.organisations.resource
import io.billie.organisations.data.UnableToFindCountry
import io.billie.organisations.service.OrganisationService
import io.billie.organisations.viewmodel.*
import io.billie.shared.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.HttpStatus.BAD_REQUEST
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.billie.organisations.viewmodel
package io.billie.shared.model

import java.util.*

Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/db/migration/V9__add_orders_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS organisations_schema.orders
(
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
external_id VARCHAR NOT NULL,
created_time TIMESTAMP NOT NULL,
organisation_id UUID NOT NULL,
state VARCHAR(30) NOT NULL
);
85 changes: 85 additions & 0 deletions src/test/kotlin/io/billie/functional/CanCreateOrdersTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.billie.functional

import io.billie.functional.data.Fixtures.orderRequestCreateOrder
import io.billie.functional.data.Fixtures.orderRequestCreateOrderWithoutCreatedTime
import io.billie.functional.data.Fixtures.orderRequestCreateOrderWithoutOrganisationId
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.util.*


@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = DEFINED_PORT)
class CanCreateOrdersTest {

@LocalServerPort
private val port = 8080

@Autowired
private lateinit var mockMvc: MockMvc

private val ORGANISATION_ID = "a5bd8429-7d48-4219-8cb0-5941c8f8821e"
private val VALID_TIME = "2023-06-19T09:34:57"

@Test
fun createOrder() {

val content = orderRequestCreateOrder(UUID.randomUUID().toString(), ORGANISATION_ID, VALID_TIME)
mockMvc.perform(
post("/orders").contentType(APPLICATION_JSON).content(content)
)
.andExpect(status().isOk)
}

@Test
fun cannotCreateOrderWhenExternalIdIsBlank() {
val content = orderRequestCreateOrder("", ORGANISATION_ID, VALID_TIME)
mockMvc.perform(
post("/orders").contentType(APPLICATION_JSON).content(content)
)
.andExpect(status().isBadRequest)
}

@Test
fun cannotCreateOrderWithSameExternalIdAndOrganisation() {
val content = orderRequestCreateOrder(UUID.randomUUID().toString(), ORGANISATION_ID, VALID_TIME)
mockMvc.perform(
post("/orders").contentType(APPLICATION_JSON).content(content)
)
.andExpect(status().isOk)

mockMvc.perform(
post("/orders").contentType(APPLICATION_JSON).content(content)
)
.andExpect(status().isConflict)
}

@Test
fun cannotCreateOrderWithoutCreatedTime() {
val content = orderRequestCreateOrderWithoutCreatedTime(UUID.randomUUID().toString(), ORGANISATION_ID)
mockMvc.perform(
post("/orders").contentType(APPLICATION_JSON).content(content)
)
.andExpect(status().isBadRequest)
}


@Test
fun cannotCreateOrderWithoutOrganisationId() {
val content = orderRequestCreateOrderWithoutOrganisationId(UUID.randomUUID().toString(), VALID_TIME)
mockMvc.perform(
post("/orders").contentType(APPLICATION_JSON).content(content)
)
.andExpect(status().isBadRequest)
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -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.shared.model.Entity
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.core.IsEqual.equalTo
import org.junit.jupiter.api.Test
Expand Down
25 changes: 25 additions & 0 deletions src/test/kotlin/io/billie/functional/data/Fixtures.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ object Fixtures {
"}"
}

fun orderRequestCreateOrder(externalId: String, organisationId: String, createdTime: String): String {
return "{\n" +
" \"created_time\": \"" + createdTime + "\",\n" +
" \"organisation_id\": \"" + organisationId +"\",\n" +
" \"external_id\": \"" + externalId +"\"\n" +
"}"

}

fun orderRequestCreateOrderWithoutOrganisationId(externalId: String, createdTime: String): String {
return "{\n" +
" \"created_time\": \"" + createdTime + "\",\n" +
" \"external_id\": \"" + externalId +"\"\n" +
"}"

}

fun orderRequestCreateOrderWithoutCreatedTime(externalId: String, organisationId: String): String {
return "{\n" +
" \"organisation_id\": \"" + organisationId +"\",\n" +
" \"external_id\": \"" + externalId +"\"\n" +
"}"

}

fun bbcFixture(id: UUID): Map<String, Any> {
val data = HashMap<String, Any>()
data["id"] = id
Expand Down