Implement contracts

This commit is contained in:
Paul-Henri Froidmont 2025-10-06 18:30:22 +02:00
parent 31014d1a0c
commit efdc50eb1d
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
33 changed files with 879 additions and 173 deletions

View file

@ -1,9 +1,10 @@
package lu.foyer
import zio.*
import java.util.UUID
import zio.schema.Schema
import java.util.UUID
final case class Entity[T](entityId: UUID, data: T, version: Long)
final case class Event[T](entityId: UUID, data: T, eventId: UUID)

View file

@ -2,9 +2,17 @@ package lu.foyer
import zio.*
import zio.schema.*
import zio.schema.annotation.fieldName
import java.util.UUID
final case class Page(number: Option[Int], size: Option[Int], totals: Option[Boolean])
final case class Page(
@fieldName("page[number]")
number: Option[Int],
@fieldName("page[size]")
size: Option[Int],
@fieldName("page[totals]")
totals: Option[Boolean])
derives Schema
final case class Paged[T](items: List[T], totals: Option[Long])

View file

@ -2,13 +2,17 @@ package lu.foyer
package clients
import zio.schema.*
import java.time.LocalDate
import zio.schema.annotation.caseName
import zio.schema.annotation.discriminatorName
import java.time.LocalDate
@discriminatorName("eventType")
enum ClientEvent derives Schema:
case Created(
sealed trait ClientEvent derives Schema
object ClientEvent:
@caseName("created")
final case class Created(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
@ -16,7 +20,10 @@ enum ClientEvent derives Schema:
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Updated(
extends ClientEvent derives Schema
@caseName("updated")
final case class Updated(
lastName: Option[ClientLastName],
firstName: Option[ClientFirstName],
birthDate: Option[ClientBirthDate],
@ -24,9 +31,7 @@ enum ClientEvent derives Schema:
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Disabled(reason: ClientDisabledReason)
extends ClientEvent derives Schema
object ClientEvent:
given Schema[ClientEvent.Created] = DeriveSchema.gen
given Schema[ClientEvent.Updated] = DeriveSchema.gen
given Schema[ClientEvent.Disabled] = DeriveSchema.gen
@caseName("disabled")
final case class Disabled(reason: ClientDisabledReason) extends ClientEvent derives Schema

View file

@ -2,30 +2,59 @@ package lu.foyer
package clients
import zio.schema.*
import zio.schema.annotation.caseName
import zio.schema.annotation.discriminatorName
import java.time.LocalDate
@discriminatorName("statusType")
enum ClientState derives Schema:
case Actif(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Inactif(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
sealed trait ClientState derives Schema
object ClientState:
@caseName("actif")
final case class Actif(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
extends ClientState derives Schema:
def update(e: ClientEvent.Updated) =
ClientState.Actif(
e.lastName.getOrElse(lastName),
e.firstName.getOrElse(firstName),
e.birthDate.getOrElse(birthDate),
e.drivingLicenseDate.orElse(drivingLicenseDate),
e.phoneNumber.orElse(phoneNumber),
e.email.orElse(email),
e.address.orElse(address)
)
def disable(e: ClientEvent.Disabled) =
ClientState.Inactif(
lastName,
firstName,
birthDate,
drivingLicenseDate,
phoneNumber,
email,
address
)
end Actif
@caseName("inactif")
final case class Inactif(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
extends ClientState derives Schema
def create(event: ClientEvent.Created) =
ClientState.Actif(
event.lastName,
@ -36,25 +65,4 @@ object ClientState:
event.email,
event.address
)
extension (s: ClientState.Actif)
def update(e: ClientEvent.Updated) =
ClientState.Actif(
e.lastName.getOrElse(s.lastName),
e.firstName.getOrElse(s.firstName),
e.birthDate.getOrElse(s.birthDate),
e.drivingLicenseDate.orElse(s.drivingLicenseDate),
e.phoneNumber.orElse(s.phoneNumber),
e.email.orElse(s.email),
e.address.orElse(s.address)
)
def disable(e: ClientEvent.Disabled) =
ClientState.Inactif(
s.lastName,
s.firstName,
s.birthDate,
s.drivingLicenseDate,
s.phoneNumber,
s.email,
s.address
)
end ClientState

View file

@ -0,0 +1,28 @@
package lu.foyer
package contracts
import zio.schema.*
import java.time.LocalDate
import java.util.UUID
enum ContractCommand derives Schema:
case Subscribe(
product: ProductType,
holder: ClientEntityId,
vehicle: Vehicle,
formula: FormulaType)
case Amend(
product: Option[ProductType],
vehicle: Option[Vehicle],
formula: Option[FormulaType])
case Approve()
case Reject(comment: String)
case Terminate(reason: TerminationReasonType)
object ContractCommand:
given Schema[ContractCommand.Subscribe] = DeriveSchema.gen
given Schema[ContractCommand.Amend] = DeriveSchema.gen
given Schema[ContractCommand.Approve] = DeriveSchema.gen
given Schema[ContractCommand.Reject] = DeriveSchema.gen
given Schema[ContractCommand.Terminate] = DeriveSchema.gen

View file

@ -0,0 +1,39 @@
package lu.foyer
package contracts
import zio.schema.*
import zio.schema.annotation.caseName
import zio.schema.annotation.discriminatorName
import java.time.LocalDate
@discriminatorName("eventType")
sealed trait ContractEvent derives Schema
object ContractEvent:
@caseName("created")
final case class Subscribed(
product: ProductType,
holder: ClientEntityId,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount)
extends ContractEvent derives Schema
@caseName("amended")
final case class Amended(
product: ProductType,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount)
extends ContractEvent derives Schema
@caseName("approved")
final case class Approved(approvedBy: Employee) extends ContractEvent derives Schema
@caseName("rejected")
final case class Rejected(rejectedBy: Employee, comment: String) extends ContractEvent
derives Schema
@caseName("terminated")
final case class Terminated(reason: TerminationReasonType) extends ContractEvent derives Schema

View file

@ -0,0 +1,137 @@
package lu.foyer
package contracts
import lu.foyer.clients.*
import zio.*
import java.util.UUID
object ContractHandlers:
val layer =
ZLayer.fromFunction(
(
clientStateRepo: StateRepository[ClientState],
premiumService: PremiumService,
employeeService: EmployeeService
) =>
List(
SubscribeHandler(clientStateRepo, premiumService),
AmendHandler(clientStateRepo, premiumService),
ApproveHandler(employeeService),
RejectHandler(employeeService),
TerminateHandler()
)
)
class SubscribeHandler(
clientStateRepo: StateRepository[ClientState],
premiumService: PremiumService)
extends CommandHandlerCreate[ContractCommand.Subscribe, ContractEvent.Subscribed]:
val name = "create"
def onCommand(entityId: UUID, command: ContractCommand.Subscribe)
: Task[ContractEvent.Subscribed] =
for
holder <- clientStateRepo.fetchOne(command.holder).someOrFailException
residentialAddress <-
holder.data match
case ClientState.Actif(_, _, _, _, _, _, Some(address)) =>
ZIO.succeed(address)
case _ =>
ZIO.fail(new IllegalArgumentException(s"No active holder found for ${command.holder}"))
premium = premiumService.computePremium(command.formula, command.vehicle, residentialAddress)
premium <-
ZIO
.fromEither(
premiumService.computePremium(command.formula, command.vehicle, residentialAddress)
)
.mapError(new IllegalArgumentException(_))
yield ContractEvent.Subscribed(
command.product,
command.holder,
command.vehicle,
command.formula,
premium
)
end SubscribeHandler
class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService: PremiumService)
extends CommandHandlerUpdate[ContractCommand.Amend, ContractEvent.Amended, ContractState]:
val name = "amend"
def onCommand(entityId: UUID, state: ContractState, command: ContractCommand.Amend)
: Task[ContractEvent.Amended] =
for
holder <- clientStateRepo.fetchOne(state.holder).someOrFailException
residentialAddress <-
holder.data match
case ClientState.Actif(_, _, _, _, _, _, Some(address)) =>
ZIO.succeed(address)
case _ =>
ZIO.fail(new IllegalArgumentException(s"No active holder found for ${state.holder}"))
premium <- ZIO
.fromEither(
premiumService.computePremium(
command.formula.getOrElse(state.formula),
command.vehicle.getOrElse(state.vehicle),
residentialAddress
)
)
.mapError(new IllegalArgumentException(_))
yield ContractEvent.Amended(
command.product.getOrElse(state.product),
command.vehicle.getOrElse(state.vehicle),
command.formula.getOrElse(state.formula),
premium
)
end AmendHandler
class ApproveHandler(employeeService: EmployeeService)
extends CommandHandlerUpdate[
ContractCommand.Approve,
ContractEvent.Approved,
ContractState.Pending
]:
val name = "approve"
def onCommand(entityId: UUID, state: ContractState.Pending, command: ContractCommand.Approve)
: Task[ContractEvent.Approved] =
for
user <- ZIO.succeed("") // TODO current user
employee <- ZIO
.fromEither(employeeService.fetchOne(user))
.mapError(new IllegalArgumentException(_))
yield ContractEvent.Approved(employee)
class RejectHandler(employeeService: EmployeeService)
extends CommandHandlerUpdate[
ContractCommand.Reject,
ContractEvent.Rejected,
ContractState.Pending
]:
val name = "reject"
def onCommand(entityId: UUID, state: ContractState.Pending, command: ContractCommand.Reject)
: Task[ContractEvent.Rejected] =
for
user <- ZIO.succeed("") // TODO current user
employee <- ZIO
.fromEither(employeeService.fetchOne(user))
.mapError(new IllegalArgumentException(_))
yield ContractEvent.Rejected(employee, command.comment)
class TerminateHandler()
extends CommandHandlerUpdate[
ContractCommand.Terminate,
ContractEvent.Terminated,
ContractState
]:
val name = "reject"
def onCommand(entityId: UUID, state: ContractState, command: ContractCommand.Terminate)
: Task[ContractEvent.Terminated] =
ZIO.succeed(ContractEvent.Terminated(command.reason))

View file

@ -0,0 +1,26 @@
package lu.foyer
package contracts
import zio.*
class ContractReducer() extends Reducer[ContractEvent, ContractState]:
override val fromEmpty =
case e: ContractEvent.Subscribed => ContractState.subscribe(e)
override val fromState =
case (s: ContractState.Actif, e: ContractEvent.Amended) => s.amend(e)
case (s: ContractState.Actif, e: ContractEvent.Terminated) => s.terminate(e)
case (s: ContractState.PendingSubscription, e: ContractEvent.Amended) => s.amend(e)
case (s: ContractState.PendingSubscription, e: ContractEvent.Approved) => s.approve(e)
case (s: ContractState.PendingSubscription, e: ContractEvent.Rejected) => s.reject(e)
case (s: ContractState.PendingSubscription, e: ContractEvent.Terminated) => s.terminate(e)
case (s: ContractState.PendingAmendment, e: ContractEvent.Amended) => s.amend(e)
case (s: ContractState.PendingAmendment, e: ContractEvent.Approved) => s.approve(e)
case (s: ContractState.PendingAmendment, e: ContractEvent.Rejected) => s.reject(e)
case (s: ContractState.PendingAmendment, e: ContractEvent.Terminated) => s.terminate(e)
object ContractReducer:
val layer: ULayer[Reducer[ContractEvent, ContractState]] = ZLayer.succeed(ContractReducer())

View file

@ -0,0 +1,106 @@
package lu.foyer
package contracts
import zio.schema.*
import zio.schema.annotation.caseName
import zio.schema.annotation.discriminatorName
import java.time.LocalDate
@discriminatorName("statusType")
sealed trait ContractState derives Schema:
def product: ProductType
def holder: ClientEntityId
def vehicle: Vehicle
def formula: FormulaType
def premium: Amount
object ContractState:
def subscribe(e: ContractEvent.Subscribed) =
PendingSubscription(e.product, e.holder, e.vehicle, e.formula, e.premium)
@caseName("actif")
final case class Actif(
product: ProductType,
holder: ClientEntityId,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount)
extends ContractState derives Schema:
def amend(e: ContractEvent.Amended) =
Actif(e.product, holder, e.vehicle, e.formula, e.premium)
def terminate(e: ContractEvent.Terminated) =
Terminated(product, holder, vehicle, formula, premium)
@discriminatorName("statusType")
sealed trait Pending extends ContractState
@caseName("pending-subscription")
final case class PendingSubscription(
product: ProductType,
holder: ClientEntityId,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount)
extends Pending derives Schema:
def amend(e: ContractEvent.Amended) =
PendingSubscription(e.product, holder, e.vehicle, e.formula, e.premium)
def approve(e: ContractEvent.Approved) =
Actif(product, holder, vehicle, formula, premium)
def reject(e: ContractEvent.Rejected) =
Terminated(product, holder, vehicle, formula, premium)
def terminate(e: ContractEvent.Terminated) =
Terminated(product, holder, vehicle, formula, premium)
final case class PendingChanges(
product: ProductType,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount)
derives Schema
@caseName("pending-amendment")
final case class PendingAmendment(
product: ProductType,
holder: ClientEntityId,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount,
pendingChanges: PendingChanges)
extends Pending derives Schema:
def amend(e: ContractEvent.Amended) =
copy(pendingChanges = PendingChanges(e.product, e.vehicle, e.formula, e.premium))
def approve(e: ContractEvent.Approved) =
Actif(
pendingChanges.product,
holder,
pendingChanges.vehicle,
pendingChanges.formula,
pendingChanges.premium
)
def reject(e: ContractEvent.Rejected) =
Actif(product, holder, vehicle, formula, premium)
def terminate(e: ContractEvent.Terminated) =
ContractState.Terminated(product, holder, vehicle, formula, premium)
@caseName("terminated")
final case class Terminated(
product: ProductType,
holder: ClientEntityId,
vehicle: Vehicle,
formula: FormulaType,
premium: Amount)
extends ContractState derives Schema
end ContractState

View file

@ -0,0 +1,7 @@
package lu.foyer
package contracts
import lu.foyer.clients.Address
trait EmployeeService:
def fetchOne(subject: String): Either[String, Employee]

View file

@ -0,0 +1,8 @@
package lu.foyer
package contracts
import lu.foyer.clients.Address
trait PremiumService:
def computePremium(formula: FormulaType, vehicle: Vehicle, residentialAddress: Address)
: Either[String, Amount]