├── project ├── build.properties └── plugins.sbt ├── domain ├── shared │ └── src │ │ └── main │ │ └── scala │ │ └── ru │ │ └── pavkin │ │ ├── utils │ │ ├── option.scala │ │ ├── map.scala │ │ └── enum.scala │ │ └── ihavemoney │ │ └── domain │ │ ├── package.scala │ │ └── fortune │ │ └── Currency.scala └── jvm │ └── src │ └── main │ └── scala │ └── ru │ └── pavkin │ └── ihavemoney │ └── domain │ ├── CommandEnvelope.scala │ ├── query │ ├── Query.scala │ └── QueryResult.scala │ ├── errors │ └── DomainError.scala │ └── fortune │ ├── Worth.scala │ ├── FortuneProtocol.scala │ └── Fortune.scala ├── docker-postgres ├── Dockerfile └── setup.sh ├── js-app └── src │ └── main │ ├── scala │ ├── ru │ │ └── pavkin │ │ │ └── ihavemoney │ │ │ └── frontend │ │ │ ├── Route.scala │ │ │ ├── styles │ │ │ └── Global.scala │ │ │ ├── components │ │ │ ├── Nav.scala │ │ │ ├── BalanceViewComponent.scala │ │ │ └── AddTransactionsComponent.scala │ │ │ └── api.scala │ └── IHaveMoneyApp.scala │ └── resources │ └── index.html ├── read-backend └── src │ └── main │ ├── resources │ ├── db │ │ └── migrations │ │ │ └── V1_0__ReadTables.sql │ └── application.conf │ └── scala │ └── ru │ └── pavkin │ └── ihavemoney │ └── readback │ ├── MoneyViewRepository.scala │ ├── InterfaceActor.scala │ ├── MoneyViewProjection.scala │ ├── db │ └── Money.scala │ ├── FortuneTagEventSourceProvider.scala │ ├── ReadBackend.scala │ ├── DatabaseMoneyViewRepository.scala │ └── JournalPuller.scala ├── .gitignore ├── docker-all-local.sh ├── docker-all-vm.sh ├── docker-writefront.sh ├── docker-readfront.sh ├── serialization └── src │ └── main │ ├── scala │ └── ru │ │ └── pavkin │ │ └── ihavemoney │ │ └── serialization │ │ ├── adapters │ │ ├── DomainEventTagAdapter.scala │ │ └── FortuneEventAdapter.scala │ │ ├── Serializers.scala │ │ ├── MetadataSerialization.scala │ │ ├── ProtobufSuite.scala │ │ ├── ProtobufSerializer.scala │ │ └── implicits.scala │ └── protobuf │ ├── results.proto │ ├── commands.proto │ └── events.proto ├── docker-writeback.sh ├── docker-postgres.sh ├── write-backend └── src │ └── main │ ├── scala │ └── ru │ │ └── pavkin │ │ └── ihavemoney │ │ └── writeback │ │ ├── CommandMessageExtractor.scala │ │ ├── InterfaceActor.scala │ │ ├── FortuneOffice.scala │ │ └── WriteBackend.scala │ └── resources │ ├── db │ └── migrations │ │ └── V1_0__Journals.sql │ └── application.conf ├── read-frontend └── src │ └── main │ ├── resources │ ├── index.html │ ├── application.conf │ └── bootstrap.min.css │ └── scala │ └── ru │ └── pavkin │ └── ihavemoney │ └── readfront │ ├── conversions.scala │ ├── ReadBackClient.scala │ └── ReadFrontend.scala ├── frontend-protocol └── shared │ └── src │ └── main │ └── scala │ └── ru │ └── pavkin │ └── ihavemoney │ └── protocol │ ├── SharedProtocol.scala │ ├── readfront.scala │ └── writefront.scala ├── docker-readback.sh ├── tests └── src │ ├── test │ └── scala │ │ └── ru │ │ └── pavkin │ │ └── ihavemoney │ │ └── domain │ │ ├── FortuneSpec.scala │ │ ├── IHaveMoneySpec.scala │ │ └── FortuneProtocolSpec.scala │ └── main │ └── scala │ └── ru │ └── pavkin │ └── ihavemoney │ └── domain │ └── InMemoryMoneyViewRepository.scala ├── write-frontend └── src │ └── main │ ├── resources │ └── application.conf │ └── scala │ └── ru │ └── pavkin │ └── ihavemoney │ └── writefront │ ├── WriteBackClusterClient.scala │ └── WriteFrontend.scala └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 0.13.11 2 | -------------------------------------------------------------------------------- /domain/shared/src/main/scala/ru/pavkin/utils/option.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.utils 2 | 3 | object option { 4 | def notEmpty(s: String): Option[String] = if (s.nonEmpty) Some(s) else None 5 | } 6 | -------------------------------------------------------------------------------- /docker-postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:9.4 2 | COPY setup.sh /docker-entrypoint-initdb.d/setup.sh 3 | COPY V1_0__ReadTables.sql V1_0__ReadTables.sql 4 | COPY V1_0__Journals.sql V1_0__Journals.sql 5 | -------------------------------------------------------------------------------- /domain/shared/src/main/scala/ru/pavkin/ihavemoney/domain/package.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney 2 | 3 | package object domain { 4 | def unexpected = throw new Exception("unexpected") 5 | } 6 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/CommandEnvelope.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain 2 | 3 | import io.funcqrs.DomainCommand 4 | 5 | case class CommandEnvelope(aggregateId: String, command: DomainCommand) 6 | -------------------------------------------------------------------------------- /js-app/src/main/scala/ru/pavkin/ihavemoney/frontend/Route.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.frontend 2 | 3 | sealed trait Route 4 | 5 | object Route { 6 | case object AddTransactions extends Route 7 | case object BalanceView extends Route 8 | } 9 | -------------------------------------------------------------------------------- /read-backend/src/main/resources/db/migrations/V1_0__ReadTables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS public.money ( 2 | fortune_id VARCHAR(255) NOT NULL, 3 | currency VARCHAR(255) NOT NULL, 4 | amount NUMERIC NOT NULL, 5 | PRIMARY KEY(fortune_id, currency) 6 | ); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docker-postgres/*.sql 2 | *.class 3 | *.log 4 | nohup.out 5 | 6 | # sbt specific 7 | .cache 8 | .history 9 | .lib/ 10 | dist/* 11 | target/ 12 | lib_managed/ 13 | src_managed/ 14 | project/boot/ 15 | project/plugins/project/ 16 | 17 | .idea 18 | 19 | sbt-*.sh 20 | -------------------------------------------------------------------------------- /docker-all-local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export HOST_IP='127.0.0.1' 4 | 5 | docker rm -f writeback writefront readback readfront 6 | nohup ./docker-writeback.sh & 7 | nohup ./docker-readback.sh & 8 | nohup ./docker-readfront.sh & 9 | nohup ./docker-writefront.sh & 10 | -------------------------------------------------------------------------------- /docker-all-vm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export HOST_IP=$(docker-machine ip default) 4 | 5 | docker rm -f writeback writefront readback readfront 6 | nohup ./docker-writeback.sh & 7 | nohup ./docker-readback.sh & 8 | nohup ./docker-readfront.sh & 9 | nohup ./docker-writefront.sh & 10 | -------------------------------------------------------------------------------- /docker-writefront.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker run -i --net=host \ 3 | -e ihavemoney_writeback_host=$HOST_IP \ 4 | -e ihavemoney_writeback_port=9101 \ 5 | -e ihavemoney_writefront_host=$HOST_IP \ 6 | -e ihavemoney_writefront_http_port=8101 \ 7 | -e ihavemoney_writefront_tcp_port=10101 \ 8 | --name writefront -a stdin ihavemoney/write-frontend 9 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/query/Query.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.query 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.FortuneId 4 | 5 | case class QueryId(value: String) 6 | 7 | sealed trait Query { 8 | val id: QueryId 9 | } 10 | 11 | case class MoneyBalance(id: QueryId, fortuneId: FortuneId) extends Query 12 | -------------------------------------------------------------------------------- /docker-readfront.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker run -i --net=host \ 3 | -e ihavemoney_readback_host=$HOST_IP \ 4 | -e ihavemoney_readback_port=9201 \ 5 | -e ihavemoney_readfront_host=$HOST_IP \ 6 | -e ihavemoney_readfront_http_port=8201 \ 7 | -e ihavemoney_readfront_tcp_port=10201 \ 8 | -e ihavemoney_writefront_host=$HOST_IP \ 9 | -e ihavemoney_writefront_port=8101 \ 10 | --name readfront -a stdin ihavemoney/read-frontend 11 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/adapters/DomainEventTagAdapter.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization.adapters 2 | 3 | import akka.persistence.journal.Tagged 4 | import io.funcqrs.Metadata 5 | 6 | trait DomainEventTagAdapter { 7 | 8 | def tag(event: Any, metadata: Metadata): Any = 9 | if (metadata.tags.nonEmpty) 10 | Tagged(event, metadata.tags.map(_.value)) 11 | else event 12 | } 13 | -------------------------------------------------------------------------------- /docker-writeback.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker run -i --net=host \ 3 | -e ihavemoney_writeback_db_user=admin \ 4 | -e ihavemoney_writeback_db_password=changeit \ 5 | -e ihavemoney_writeback_db_host=$HOST_IP \ 6 | -e ihavemoney_writeback_db_port=5432 \ 7 | -e ihavemoney_writeback_db_name=ihavemoney-write \ 8 | -e ihavemoney_writeback_host=$HOST_IP \ 9 | -e ihavemoney_writeback_port=9101 \ 10 | --name writeback -a stdin ihavemoney/write-backend 11 | -------------------------------------------------------------------------------- /domain/shared/src/main/scala/ru/pavkin/utils/map.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.utils 2 | 3 | object map { 4 | def adjust[K, V](m: Map[K, V])(key: K, adjuster: Option[V] ⇒ V): Map[K, V] = 5 | m.updated(key, adjuster(m.get(key))) 6 | 7 | object syntax { 8 | implicit class MapUtilsOps[K, V](m: Map[K, V]) { 9 | def adjust(key: K, adjuster: Option[V] ⇒ V): Map[K, V] = 10 | ru.pavkin.utils.map.adjust(m)(key, adjuster) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docker-postgres.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cp ./read-backend/src/main/resources/db/migrations/V1_0__ReadTables.sql ./docker-postgres/V1_0__ReadTables.sql 3 | cp ./write-backend/src/main/resources/db/migrations/V1_0__Journals.sql ./docker-postgres/V1_0__Journals.sql 4 | docker build -t ihavemoney/postgres ./docker-postgres && \ 5 | docker run --net=host \ 6 | -e POSTGRES_USER=admin \ 7 | -e POSTGRES_PASSWORD=changeit \ 8 | --name ihavemoney-postgres -d ihavemoney/postgres 9 | -------------------------------------------------------------------------------- /serialization/src/main/protobuf/results.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option java_package = "ru.pavkin.ihavemoney.proto"; 3 | option optimize_for = SPEED; 4 | 5 | message CommandSuccess { 6 | string id = 1; 7 | } 8 | 9 | message InvalidCommand { 10 | string id = 1; 11 | string reason = 2; 12 | } 13 | 14 | message UnknownCommand { 15 | string commandType = 1; 16 | } 17 | 18 | message UnexpectedFailure { 19 | string id = 1; 20 | string reason = 2; 21 | } 22 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers += "Flyway" at "https://flywaydb.org/repo" 2 | 3 | addSbtPlugin("org.flywaydb" % "flyway-sbt" % "4.0") 4 | addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.1") 5 | addSbtPlugin("com.trueaccord.scalapb" % "sbt-scalapb" % "0.5.24") 6 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3") 7 | 8 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.8") 9 | 10 | libraryDependencies ++= Seq( 11 | "com.github.os72" % "protoc-jar" % "3.0.0-b2.1" 12 | ) 13 | -------------------------------------------------------------------------------- /write-backend/src/main/scala/ru/pavkin/ihavemoney/writeback/CommandMessageExtractor.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.writeback 2 | 3 | import akka.cluster.sharding.ShardRegion 4 | import ru.pavkin.ihavemoney.domain.CommandEnvelope 5 | 6 | class CommandMessageExtractor(shardsNumber: Int) extends ShardRegion.HashCodeMessageExtractor(shardsNumber) { 7 | def entityId(message: Any): String = message match { 8 | case CommandEnvelope(id, _) ⇒ id 9 | case _ ⇒ null 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/query/QueryResult.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.query 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 4 | 5 | sealed trait QueryResult 6 | 7 | case class MoneyBalanceQueryResult(id: FortuneId, balance: Map[Currency, BigDecimal]) extends QueryResult 8 | 9 | case class EntityNotFound(id: QueryId, error: String) extends QueryResult 10 | case class QueryFailed(id: QueryId, error: String) extends QueryResult 11 | -------------------------------------------------------------------------------- /js-app/src/main/scala/ru/pavkin/ihavemoney/frontend/styles/Global.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.frontend.styles 2 | 3 | import scalacss.Defaults._ 4 | import scala.language.postfixOps 5 | 6 | object Global extends StyleSheet.Standalone { 7 | 8 | import dsl._ 9 | 10 | "body" - ( 11 | paddingTop(80 px) 12 | ) 13 | 14 | ".form-group" -( 15 | &("button") - ( 16 | marginRight(15 px) 17 | ), 18 | &(".form-control") - ( 19 | marginBottom(10 px) 20 | ) 21 | ) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /write-backend/src/main/scala/ru/pavkin/ihavemoney/writeback/InterfaceActor.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.writeback 2 | 3 | import akka.actor.{Actor, ActorRef} 4 | import akka.pattern.ask 5 | import akka.util.Timeout 6 | 7 | import scala.concurrent.ExecutionContext 8 | 9 | class InterfaceActor(shardRegion: ActorRef)(implicit val timeout: Timeout) extends Actor { 10 | implicit val dispatcher: ExecutionContext = context.system.dispatcher 11 | 12 | def receive: Receive = { 13 | case a ⇒ 14 | val origin = sender 15 | (shardRegion ? a).foreach(origin ! _) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /serialization/src/main/protobuf/commands.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option java_package = "ru.pavkin.ihavemoney.proto"; 3 | option optimize_for = SPEED; 4 | 5 | message PBCommandEnvelope { 6 | string aggregateId = 1; 7 | oneof command { 8 | PBReceiveIncome command1 = 2; 9 | PBSpend command2 = 3; 10 | } 11 | } 12 | 13 | message PBReceiveIncome { 14 | string amount = 1; 15 | string currency = 2; 16 | string category = 3; 17 | string comment = 15; 18 | } 19 | 20 | message PBSpend { 21 | string amount = 1; 22 | string currency = 2; 23 | string category = 3; 24 | string comment = 15; 25 | } 26 | -------------------------------------------------------------------------------- /read-frontend/src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | I Have Money 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend-protocol/shared/src/main/scala/ru/pavkin/ihavemoney/protocol/SharedProtocol.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.protocol 2 | 3 | import cats.data.Xor 4 | import io.circe.{Decoder, Encoder, Json} 5 | import ru.pavkin.ihavemoney.domain.fortune.Currency 6 | 7 | trait SharedProtocol { 8 | 9 | implicit val decodeCurrency: Decoder[Currency] = 10 | Decoder.decodeString.emap(s ⇒ Currency.fromCode(s) match { 11 | case Some(c) ⇒ Xor.Right(c) 12 | case None ⇒ Xor.Left(s"$s is not a valid currency") 13 | }) 14 | 15 | implicit val encodeCurrency: Encoder[Currency] = 16 | Encoder.instance(c ⇒ Json.string(c.code)) 17 | 18 | } 19 | -------------------------------------------------------------------------------- /docker-readback.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker run -i --net=host \ 3 | -e ihavemoney_writeback_db_user=admin \ 4 | -e ihavemoney_writeback_db_password=changeit \ 5 | -e ihavemoney_writeback_db_host=$HOST_IP \ 6 | -e ihavemoney_writeback_db_port=5432 \ 7 | -e ihavemoney_writeback_db_name=ihavemoney-write \ 8 | -e ihavemoney_readback_db_user=admin \ 9 | -e ihavemoney_readback_db_password=changeit \ 10 | -e ihavemoney_readback_db_host=$HOST_IP \ 11 | -e ihavemoney_readback_db_port=5432 \ 12 | -e ihavemoney_readback_db_name=ihavemoney-read \ 13 | -e ihavemoney_readback_host=$HOST_IP \ 14 | -e ihavemoney_readback_port=9201 \ 15 | --name readback -a stdin ihavemoney/read-backend 16 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/Serializers.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization 2 | 3 | import ru.pavkin.ihavemoney.domain.CommandEnvelope 4 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol._ 5 | import ru.pavkin.ihavemoney.proto.commands.{PBCommandEnvelope, PBReceiveIncome, PBSpend} 6 | import ru.pavkin.ihavemoney.serialization.implicits._ 7 | 8 | class CommandEnvelopeSerializer extends ProtobufSerializer[CommandEnvelope, PBCommandEnvelope](100) 9 | class ReceiveIncomeSerializer extends ProtobufSerializer[ReceiveIncome, PBReceiveIncome](101) 10 | class SpendSerializer extends ProtobufSerializer[Spend, PBSpend](102) 11 | -------------------------------------------------------------------------------- /tests/src/test/scala/ru/pavkin/ihavemoney/domain/FortuneSpec.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.{Fortune, Worth} 4 | 5 | class FortuneSpec extends IHaveMoneySpec { 6 | 7 | test("Fortune.increase increases the amount of money") { 8 | forAll { (f: Fortune, w: Worth) ⇒ 9 | f.increase(w).amount(w.currency) shouldBe (f.amount(w.currency) + w.amount) 10 | } 11 | } 12 | 13 | test("Fortune.decrease unconditionally decreases the amount of money") { 14 | forAll { (f: Fortune, w: Worth) ⇒ 15 | f.decrease(w).amount(w.currency) shouldBe (f.amount(w.currency) - w.amount) 16 | } 17 | } 18 | 19 | 20 | } 21 | -------------------------------------------------------------------------------- /serialization/src/main/protobuf/events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option java_package = "ru.pavkin.ihavemoney.proto"; 3 | option optimize_for = SPEED; 4 | 5 | message PBMetadata { 6 | string aggregate_id = 1; 7 | string command_id = 2; 8 | string event_id = 3; 9 | string timestamp = 4; 10 | repeated string tags = 5; 11 | } 12 | 13 | message PBFortuneIncreased { 14 | string amount = 1; 15 | string currency = 2; 16 | string category = 3; 17 | PBMetadata metadata = 4; 18 | string comment = 15; 19 | } 20 | 21 | message PBFortuneSpent { 22 | string amount = 1; 23 | string currency = 2; 24 | string category = 3; 25 | PBMetadata metadata = 4; 26 | string comment = 15; 27 | } 28 | -------------------------------------------------------------------------------- /read-frontend/src/main/scala/ru/pavkin/ihavemoney/readfront/conversions.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readfront 2 | 3 | import ru.pavkin.ihavemoney.domain.query.{EntityNotFound, MoneyBalanceQueryResult, QueryFailed, QueryResult} 4 | import ru.pavkin.ihavemoney.protocol.readfront._ 5 | 6 | object conversions { 7 | 8 | def toFrontendFormat(qr: QueryResult): FrontendQueryResult = qr match { 9 | case MoneyBalanceQueryResult(id, balance) => 10 | FrontendMoneyBalance(id.value, balance.map(kv ⇒ kv._1.code → kv._2)) 11 | case EntityNotFound(id, error) => 12 | FrontendEntityNotFound(id.value, error) 13 | case QueryFailed(id, error) => 14 | FrontendQueryFailed(id.value, error) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/errors/DomainError.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.errors 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.Currency 4 | 5 | sealed trait DomainError extends Throwable { 6 | def message: String 7 | override def getMessage: String = message 8 | } 9 | case object NegativeWorth extends DomainError { 10 | def message = "Asset can't have negative worth" 11 | } 12 | case class BalanceIsNotEnough(amount: BigDecimal, currency: Currency) extends DomainError { 13 | def message = s"Your balance ($amount ${currency.code}) is not enough for this operation" 14 | } 15 | case object UnsupportedCommand extends DomainError{ 16 | def message = s"Command is not supported" 17 | } 18 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/MoneyViewRepository.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 4 | 5 | import scala.concurrent.{ExecutionContext, Future} 6 | 7 | trait MoneyViewRepository { 8 | 9 | def findAll(id: FortuneId)(implicit ec: ExecutionContext): Future[Map[Currency, BigDecimal]] 10 | def find(id: FortuneId, currency: Currency)(implicit ec: ExecutionContext): Future[Option[BigDecimal]] 11 | def updateById(id: FortuneId, currency: Currency, newAmount: BigDecimal)(implicit ec: ExecutionContext): Future[Unit] 12 | def insert(id: FortuneId, currency: Currency, amount: BigDecimal)(implicit ec: ExecutionContext): Future[Unit] 13 | } 14 | -------------------------------------------------------------------------------- /write-backend/src/main/resources/db/migrations/V1_0__Journals.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS public.journal ( 2 | persistence_id VARCHAR(255) NOT NULL, 3 | sequence_number BIGINT NOT NULL, 4 | created BIGINT NOT NULL, 5 | tags VARCHAR(255) DEFAULT NULL, 6 | message BYTEA NOT NULL, 7 | PRIMARY KEY(persistence_id, sequence_number) 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS public.deleted_to ( 11 | persistence_id VARCHAR(255) NOT NULL, 12 | deleted_to BIGINT NOT NULL 13 | ); 14 | 15 | CREATE TABLE IF NOT EXISTS public.snapshot ( 16 | persistence_id VARCHAR(255) NOT NULL, 17 | sequence_number BIGINT NOT NULL, 18 | created BIGINT NOT NULL, 19 | snapshot BYTEA NOT NULL, 20 | PRIMARY KEY(persistence_id, sequence_number) 21 | ); 22 | -------------------------------------------------------------------------------- /docker-postgres/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 5 | CREATE DATABASE "ihavemoney-read" 6 | WITH OWNER = admin 7 | ENCODING = 'UTF8' 8 | TABLESPACE = pg_default 9 | LC_COLLATE = 'en_US.utf8' 10 | LC_CTYPE = 'en_US.utf8' 11 | CONNECTION LIMIT = -1; 12 | CREATE DATABASE "ihavemoney-write" 13 | WITH OWNER = admin 14 | ENCODING = 'UTF8' 15 | TABLESPACE = pg_default 16 | LC_COLLATE = 'en_US.utf8' 17 | LC_CTYPE = 'en_US.utf8' 18 | CONNECTION LIMIT = -1; 19 | EOSQL 20 | 21 | psql -v ON_ERROR_STOP=1 -d ihavemoney-read --username "$POSTGRES_USER" -f V1_0__ReadTables.sql 22 | psql -v ON_ERROR_STOP=1 -d ihavemoney-write --username "$POSTGRES_USER" -f V1_0__Journals.sql 23 | -------------------------------------------------------------------------------- /frontend-protocol/shared/src/main/scala/ru/pavkin/ihavemoney/protocol/readfront.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.protocol 2 | 3 | import io.circe._ 4 | import io.circe.generic.semiauto._ 5 | 6 | object readfront extends SharedProtocol { 7 | sealed trait FrontendQueryResult 8 | case class FrontendMoneyBalance(fortuneId: String, balances: Map[String, BigDecimal]) extends FrontendQueryResult 9 | case class FrontendQueryFailed(id: String, error: String) extends FrontendQueryResult 10 | case class FrontendEntityNotFound(entityId: String, error: String) extends FrontendQueryResult 11 | 12 | implicit val fqEncoder: Encoder[FrontendQueryResult] = deriveEncoder[FrontendQueryResult] 13 | implicit val fqDecoder: Decoder[FrontendQueryResult] = deriveDecoder[FrontendQueryResult] 14 | } 15 | -------------------------------------------------------------------------------- /js-app/src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | I Have Money 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /domain/shared/src/main/scala/ru/pavkin/ihavemoney/domain/fortune/Currency.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.fortune 2 | 3 | import cats.Eq 4 | import ru.pavkin.ihavemoney.domain._ 5 | import ru.pavkin.utils.enum._ 6 | 7 | sealed trait Currency { 8 | self ⇒ 9 | def code: String = self.toString 10 | } 11 | 12 | object Currency { 13 | 14 | case object USD extends Currency 15 | case object RUR extends Currency 16 | case object EUR extends Currency 17 | 18 | val values: Set[Currency] = Values 19 | 20 | def isCurrency(code: String) = fromCode(code).isDefined 21 | def unsafeFromCode(code: String): Currency = fromCode(code).getOrElse(unexpected) 22 | def fromCode(code: String): Option[Currency] = values.find(_.code == code) 23 | 24 | def unapply(arg: Currency): Option[String] = Some(arg.code) 25 | 26 | implicit val eq: Eq[Currency] = new Eq[Currency] { 27 | def eqv(x: Currency, y: Currency): Boolean = x == y 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/InterfaceActor.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import akka.actor.Actor 4 | import ru.pavkin.ihavemoney.domain.query._ 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | import scala.util.{Failure, Success} 8 | 9 | class InterfaceActor(moneyRepo: MoneyViewRepository) extends Actor { 10 | implicit val dispatcher: ExecutionContext = context.system.dispatcher 11 | 12 | def receive: Receive = { 13 | case q: Query ⇒ 14 | val origin = sender 15 | val queryFuture: Future[QueryResult] = q match { 16 | case MoneyBalance(_, fortuneId) => 17 | moneyRepo.findAll(fortuneId).map { 18 | case m if m.isEmpty ⇒ EntityNotFound(q.id, s"Fortune $fortuneId not found") 19 | case m ⇒ MoneyBalanceQueryResult(fortuneId, m) 20 | } 21 | } 22 | queryFuture.onComplete { 23 | case Success(r) ⇒ origin ! r 24 | case Failure(ex) ⇒ origin ! QueryFailed(q.id, ex.getMessage) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /read-frontend/src/main/scala/ru/pavkin/ihavemoney/readfront/ReadBackClient.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readfront 2 | 3 | import akka.actor.{ActorSelection, ActorSystem} 4 | import akka.http.scaladsl.model.StatusCode 5 | import akka.http.scaladsl.model.StatusCodes._ 6 | import akka.pattern.ask 7 | import akka.util.Timeout 8 | import ru.pavkin.ihavemoney.domain.query.{EntityNotFound, Query, QueryFailed, QueryResult} 9 | 10 | import scala.concurrent.{ExecutionContext, Future} 11 | 12 | class ReadBackClient(system: ActorSystem, interfaceAddress: String) { 13 | 14 | val writeBackendClient: ActorSelection = system.actorSelection(interfaceAddress) 15 | 16 | def query(query: Query) 17 | (implicit ec: ExecutionContext, timeout: Timeout): Future[(StatusCode, QueryResult)] = 18 | (writeBackendClient ? query).mapTo[QueryResult] 19 | .map { 20 | case e: EntityNotFound ⇒ 21 | NotFound → e 22 | case e: QueryFailed ⇒ 23 | InternalServerError → e 24 | case q ⇒ 25 | OK → q 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/adapters/FortuneEventAdapter.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization.adapters 2 | 3 | import akka.persistence.journal.{EventAdapter, EventSeq} 4 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol._ 5 | import ru.pavkin.ihavemoney.proto.events._ 6 | import ru.pavkin.ihavemoney.serialization.ProtobufSuite.syntax._ 7 | import ru.pavkin.ihavemoney.serialization.implicits._ 8 | 9 | class FortuneEventAdapter extends EventAdapter with DomainEventTagAdapter { 10 | override def manifest(event: Any): String = "" 11 | 12 | override def toJournal(event: Any): Any = event match { 13 | case m: FortuneIncreased ⇒ tag(m.encode, m.metadata) 14 | case m: FortuneSpent ⇒ tag(m.encode, m.metadata) 15 | case _ ⇒ event 16 | } 17 | 18 | override def fromJournal(event: Any, manifest: String): EventSeq = event match { 19 | case p: PBFortuneIncreased ⇒ EventSeq.single(p.decode) 20 | case p: PBFortuneSpent ⇒ EventSeq.single(p.decode) 21 | case _ ⇒ EventSeq.single(event) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/MetadataSerialization.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization 2 | 3 | import java.util.UUID 4 | 5 | import io.funcqrs._ 6 | import ru.pavkin.ihavemoney.proto.events.PBMetadata 7 | 8 | object MetadataSerialization { 9 | def serialize(m: Metadata with JavaTime): PBMetadata = PBMetadata( 10 | m.aggregateId.value, 11 | m.commandId.value.toString, 12 | m.eventId.value.toString, 13 | m.date.toString, 14 | m.tags.map(_.value).toSeq 15 | ) 16 | 17 | def deserialize[M <: Metadata, Id](constructor: (Id, CommandId, EventId, M#DateTime, Set[Tag]) ⇒ M, 18 | idConstructor: String ⇒ Id, 19 | dateConstructor: String ⇒ M#DateTime) 20 | (m: PBMetadata): M = constructor( 21 | idConstructor(m.aggregateId), 22 | CommandId(UUID.fromString(m.commandId)), 23 | EventId(UUID.fromString(m.eventId)), 24 | dateConstructor(m.timestamp), 25 | m.tags.toSet.map(Tags.aggregateTag) 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/ProtobufSuite.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization 2 | 3 | import com.trueaccord.scalapb.{GeneratedMessage, GeneratedMessageCompanion, Message} 4 | 5 | trait ProtobufSuite[M, PB <: GeneratedMessage with Message[PB]] { 6 | def encode(m: M): PB 7 | def decode(p: PB): M 8 | def companion: GeneratedMessageCompanion[PB] 9 | def protobufFromBytes(bytes: Array[Byte]): PB = companion.parseFrom(bytes) 10 | def fromBytes(bytes: Array[Byte]): M = decode(protobufFromBytes(bytes)) 11 | def toBytes(message: M): Array[Byte] = encode(message).toByteArray 12 | } 13 | 14 | object ProtobufSuite { 15 | 16 | object syntax { 17 | implicit class MessageOps[M, PB <: GeneratedMessage with Message[PB]](m: M)(implicit ev: ProtobufSuite[M, PB]) { 18 | def encode: PB = ev.encode(m) 19 | def toBytes: Array[Byte] = ev.toBytes(m) 20 | } 21 | 22 | implicit class ProtobufOps[M, PB <: GeneratedMessage with Message[PB]](p: PB)(implicit ev: ProtobufSuite[M, PB]) { 23 | def decode: M = ev.decode(p) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/MoneyViewProjection.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import io.funcqrs.{HandleEvent, Projection} 4 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol._ 5 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 6 | 7 | import scala.concurrent.ExecutionContext.Implicits.global 8 | import scala.concurrent.Future 9 | 10 | class MoneyViewProjection(repo: MoneyViewRepository) extends Projection { 11 | 12 | private def adjustFortune(id: FortuneId, currency: Currency, op: Option[BigDecimal] ⇒ BigDecimal): Future[Unit] = { 13 | repo.find(id, currency).flatMap { 14 | case Some(amount) ⇒ repo.updateById(id, currency, op(Some(amount))) 15 | case None ⇒ repo.insert(id, currency, op(None)) 16 | } 17 | } 18 | 19 | def handleEvent: HandleEvent = { 20 | 21 | case e: FortuneIncreased => 22 | adjustFortune(e.aggregateId, e.currency, _.getOrElse(BigDecimal(0.0)) + e.amount) 23 | case e: FortuneSpent => 24 | adjustFortune(e.aggregateId, e.currency, _.getOrElse(BigDecimal(0.0)) - e.amount) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/ProtobufSerializer.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization 2 | 3 | import akka.serialization.SerializerWithStringManifest 4 | import com.trueaccord.scalapb.{GeneratedMessage, Message} 5 | 6 | import scala.reflect.ClassTag 7 | 8 | 9 | abstract class ProtobufSerializer[M <: AnyRef : ClassTag, PB <: GeneratedMessage with Message[PB]](override val identifier: Int) 10 | (implicit val ev: ProtobufSuite[M, PB]) extends SerializerWithStringManifest { 11 | 12 | final val Manifest = implicitly[ClassTag[M]].runtimeClass.getName 13 | 14 | override def manifest(o: AnyRef): String = o.getClass.getName 15 | 16 | override def fromBinary(bytes: Array[Byte], manifest: String): AnyRef = 17 | if (Manifest == manifest) { 18 | ev.fromBytes(bytes) 19 | } else throw new IllegalArgumentException("Unable to handle manifest: " + manifest) 20 | 21 | override def toBinary(o: AnyRef): Array[Byte] = o match { 22 | case m: M ⇒ ev.toBytes(m) 23 | case _ ⇒ throw new IllegalStateException("Cannot serialize: " + o.getClass.getName) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/src/main/scala/ru/pavkin/ihavemoney/domain/InMemoryMoneyViewRepository.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 4 | import ru.pavkin.ihavemoney.readback.MoneyViewRepository 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | 8 | class InMemoryMoneyViewRepository extends MoneyViewRepository { 9 | 10 | private var repo: Map[FortuneId, Map[Currency, BigDecimal]] = Map.empty 11 | 12 | def findAll(id: FortuneId)(implicit ec: ExecutionContext): Future[Map[Currency, BigDecimal]] = Future.successful { 13 | repo.getOrElse(id, Map.empty) 14 | } 15 | 16 | def find(id: FortuneId, currency: Currency)(implicit ec: ExecutionContext): Future[Option[BigDecimal]] = Future.successful { 17 | repo.get(id).flatMap(_.get(currency)) 18 | } 19 | 20 | def updateById(id: FortuneId, currency: Currency, newAmount: BigDecimal)(implicit ec: ExecutionContext): Future[Unit] = Future.successful { 21 | repo = repo.updated(id, repo.getOrElse(id, Map.empty).updated(currency, newAmount)) 22 | } 23 | 24 | def insert(id: FortuneId, currency: Currency, amount: BigDecimal)(implicit ec: ExecutionContext): Future[Unit] = 25 | updateById(id, currency, amount) 26 | } 27 | -------------------------------------------------------------------------------- /domain/shared/src/main/scala/ru/pavkin/utils/enum.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.utils 2 | 3 | import shapeless.{:+:, CNil, Coproduct, Generic, Witness} 4 | import scala.language.implicitConversions 5 | 6 | object enum { 7 | 8 | object Values { 9 | implicit def conv[T](self: this.type)(implicit v: MkValues[T]): Set[T] = Values[T] 10 | 11 | def apply[T](implicit v: MkValues[T]): Set[T] = v.values.toSet 12 | 13 | trait MkValues[T] { 14 | def values: List[T] 15 | } 16 | 17 | object MkValues { 18 | implicit def values[T, Repr <: Coproduct] 19 | (implicit gen: Generic.Aux[T, Repr], v: Aux[T, Repr]): MkValues[T] = 20 | new MkValues[T] { 21 | def values = v.values 22 | } 23 | 24 | trait Aux[T, Repr] { 25 | def values: List[T] 26 | } 27 | 28 | object Aux { 29 | implicit def cnilAux[A]: Aux[A, CNil] = 30 | new Aux[A, CNil] { 31 | def values = Nil 32 | } 33 | 34 | implicit def cconsAux[T, L <: T, R <: Coproduct] 35 | (implicit l: Witness.Aux[L], r: Aux[T, R]): Aux[T, L :+: R] = 36 | new Aux[T, L :+: R] { 37 | def values = l.value :: r.values 38 | } 39 | } 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /tests/src/test/scala/ru/pavkin/ihavemoney/domain/IHaveMoneySpec.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain 2 | 3 | import org.scalacheck.{Arbitrary, Gen} 4 | import org.scalatest.prop.GeneratorDrivenPropertyChecks 5 | import org.scalatest.{FunSuite, Matchers} 6 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, Fortune, FortuneId, Worth} 7 | 8 | class IHaveMoneySpec extends FunSuite with Matchers with GeneratorDrivenPropertyChecks { 9 | 10 | lazy val currencies = Currency.values.toList 11 | 12 | val generateCurrency: Gen[Currency] = Gen.choose(0, currencies.size - 1).map(currencies(_)) 13 | implicit val arbCurrency: Arbitrary[Currency] = Arbitrary(generateCurrency) 14 | 15 | val generateWorth: Gen[Worth] = for { 16 | c ← generateCurrency 17 | a ← Gen.posNum[Double].map(BigDecimal(_)) 18 | } yield Worth(a, c) 19 | 20 | implicit val arbWorth: Arbitrary[Worth] = Arbitrary(generateWorth) 21 | 22 | val generateFortune: Gen[Fortune] = for { 23 | id ← Gen.uuid.map(_.toString).map(FortuneId(_)) 24 | balances ← Gen.mapOf(generateCurrency.flatMap(c ⇒ Gen.posNum[Double].map(BigDecimal(_)).map(b ⇒ c → b))) 25 | } yield Fortune(id, balances) 26 | 27 | implicit val arbFortune: Arbitrary[Fortune] = Arbitrary(generateFortune) 28 | } 29 | -------------------------------------------------------------------------------- /read-frontend/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | app { 2 | host = "127.0.0.1" 3 | host = ${?ihavemoney_readfront_host} 4 | http-port = 8201 5 | http-port = ${?ihavemoney_readfront_http_port} 6 | port = 10201 7 | port = ${?ihavemoney_readfront_tcp_port} 8 | } 9 | 10 | read-backend { 11 | host = "127.0.0.1" 12 | host = ${?ihavemoney_readback_host} 13 | port = 9201 14 | port = ${?ihavemoney_readback_port} 15 | system = "iHaveMoneyReadBackend" 16 | interface = "akka.tcp://"${read-backend.system}"@"${read-backend.host}":"${read-backend.port}"/user/interface" 17 | } 18 | 19 | write-frontend { 20 | host = "127.0.0.1" 21 | host = ${?ihavemoney_writefront_host} 22 | port = 8101 23 | port = ${?ihavemoney_writefront_port} 24 | } 25 | 26 | akka { 27 | loglevel = "INFO" 28 | 29 | actor { 30 | provider = "akka.remote.RemoteActorRefProvider" 31 | 32 | serializers { 33 | proto = "akka.remote.serialization.ProtobufSerializer" 34 | } 35 | 36 | serialization-bindings { 37 | "com.trueaccord.scalapb.GeneratedMessage" = proto 38 | } 39 | } 40 | 41 | remote { 42 | enabled-transports = ["akka.remote.netty.tcp"] 43 | netty.tcp { 44 | hostname = ${app.host} 45 | port = ${app.port} 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/fortune/Worth.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.fortune 2 | 3 | import cats.Eq 4 | import cats.data.Xor 5 | import ru.pavkin.ihavemoney.domain.errors.NegativeWorth 6 | 7 | case class Worth(amount: BigDecimal, currency: Currency) { 8 | def +(other: Worth): Worth = { 9 | require(other.currency == currency, "Can't combine different currencies") 10 | copy(amount = amount + other.amount) 11 | } 12 | def *(by: BigDecimal): Worth = 13 | copy(amount = amount * by) 14 | 15 | def -(other: Worth): Xor[NegativeWorth.type, Worth] = { 16 | require(other.currency == currency, "Can't combine different currencies") 17 | if (amount < other.amount) 18 | Xor.left(NegativeWorth) 19 | else 20 | Xor.right(copy(amount - other.amount)) 21 | } 22 | 23 | override def toString: String = f"$amount%1.2f ${currency.code}" 24 | } 25 | 26 | object Worth { 27 | implicit val eq: Eq[Worth] = new Eq[Worth] { 28 | def eqv(x: Worth, y: Worth): Boolean = x.amount.equals(y.amount) && x.currency.equals(y.currency) 29 | } 30 | implicit val ord: Ordering[Worth] = Ordering.by(_.amount) 31 | 32 | def unsafeFrom(amount: BigDecimal, currency: String) = Worth(amount, Currency.unsafeFromCode(currency)) 33 | } 34 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/db/Money.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback.db 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 4 | import slick.driver.PostgresDriver.api._ 5 | import slick.jdbc.GetResult 6 | import slick.lifted.{Rep, TableQuery, Tag} 7 | 8 | case class MoneyRow(fortuneId: FortuneId, currency: Currency, amount: BigDecimal) 9 | 10 | class Money(tableTag: Tag) extends Table[MoneyRow](tableTag, "money") { 11 | def * = (fortuneId, currency, amount) <>( 12 | (t: (String, String, BigDecimal)) ⇒ 13 | MoneyRow(FortuneId(t._1), Currency.unsafeFromCode(t._2), t._3), 14 | (m: MoneyRow) ⇒ Some((m.fortuneId.value, m.currency.code, m.amount)) 15 | ) 16 | 17 | val fortuneId: Rep[String] = column[String]("fortune_id") 18 | val currency: Rep[String] = column[String]("currency") 19 | val amount: Rep[BigDecimal] = column[BigDecimal]("amount") 20 | 21 | def pk = primaryKey("money_pkey", (fortuneId, currency)) 22 | } 23 | 24 | object Money { 25 | 26 | implicit def GetResultMoneyRow(implicit 27 | e0: GetResult[String], 28 | e1: GetResult[BigDecimal]): GetResult[MoneyRow] = GetResult { 29 | prs => import prs._ 30 | MoneyRow(FortuneId(<<[String]), Currency.unsafeFromCode(<<[String]), <<[BigDecimal]) 31 | } 32 | 33 | lazy val table = new TableQuery(tag ⇒ new Money(tag)) 34 | } 35 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/FortuneTagEventSourceProvider.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import akka.NotUsed 4 | import akka.actor.{ActorContext, Props} 5 | import akka.persistence.query.EventEnvelope 6 | import akka.stream.scaladsl.Source 7 | import io.funcqrs.Tag 8 | import io.funcqrs.akka.EventsSourceProvider 9 | import ru.pavkin.ihavemoney.proto.events.{PBFortuneIncreased, PBFortuneSpent} 10 | import ru.pavkin.ihavemoney.serialization.ProtobufSuite.syntax._ 11 | import ru.pavkin.ihavemoney.serialization.implicits._ 12 | 13 | class FortuneTagEventSourceProvider(tag: Tag) extends EventsSourceProvider { 14 | 15 | /** 16 | * Resolve inconsistency between FunCQRS and akka-persistence-jdbc: 17 | * 18 | * FunCQRS expects journal plugin to serve events starting from the supplied offset 19 | * 20 | * akka-persistence-jdbc streams events starting from `offset + 1` 21 | */ 22 | def normalize(offset: Long): Long = math.max(offset - 1, 0) 23 | 24 | def source(offset: Long)(implicit context: ActorContext): Source[EventEnvelope, NotUsed] = 25 | Source.actorPublisher[EventEnvelope](Props(new JournalPuller(tag.value, normalize(offset)))) 26 | .mapMaterializedValue(_ ⇒ NotUsed) 27 | .map { 28 | case e: EventEnvelope ⇒ e.event match { 29 | case p: PBFortuneIncreased ⇒ e.copy(event = p.decode) 30 | case p: PBFortuneSpent ⇒ e.copy(event = p.decode) 31 | case p ⇒ e 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/ReadBackend.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import akka.actor.{ActorSystem, Props} 4 | import com.typesafe.config.ConfigFactory 5 | import io.funcqrs.akka.EventsSourceProvider 6 | import io.funcqrs.akka.backend.AkkaBackend 7 | import io.funcqrs.backend.{Query, QueryByTag} 8 | import io.funcqrs.config.api._ 9 | import ru.pavkin.ihavemoney.domain.fortune._ 10 | import slick.driver.PostgresDriver 11 | import slick.driver.PostgresDriver.api._ 12 | 13 | object ReadBackend extends App { 14 | 15 | println("Starting IHaveMoney read backend...") 16 | 17 | val config = ConfigFactory.load() 18 | val system: ActorSystem = ActorSystem(config.getString("app.system")) 19 | 20 | val database: PostgresDriver.Backend#Database = Database.forConfig("read-db") 21 | val moneyRepo = new DatabaseMoneyViewRepository(database) 22 | 23 | val backend = new AkkaBackend { 24 | val actorSystem: ActorSystem = system 25 | def sourceProvider(query: Query): EventsSourceProvider = { 26 | query match { 27 | case QueryByTag(Fortune.tag) => new FortuneTagEventSourceProvider(Fortune.tag) 28 | } 29 | } 30 | }.configure { 31 | projection( 32 | query = QueryByTag(Fortune.tag), 33 | projection = new MoneyViewProjection(moneyRepo), 34 | name = "MoneyViewProjection" 35 | ).withBackendOffsetPersistence() 36 | } 37 | 38 | val interface = system.actorOf(Props(new InterfaceActor(moneyRepo)), "interface") 39 | } 40 | -------------------------------------------------------------------------------- /write-backend/src/main/scala/ru/pavkin/ihavemoney/writeback/FortuneOffice.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.writeback 2 | 3 | import akka.actor.{Actor, ActorLogging} 4 | import akka.util.Timeout 5 | import io.funcqrs.akka.backend.AkkaBackend 6 | import ru.pavkin.ihavemoney.domain.errors.DomainError 7 | import ru.pavkin.ihavemoney.domain._ 8 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol.FortuneCommand 9 | import ru.pavkin.ihavemoney.domain.fortune.{Fortune, FortuneId} 10 | import ru.pavkin.ihavemoney.proto.results.{CommandSuccess, InvalidCommand, UnexpectedFailure, UnknownCommand} 11 | 12 | import scala.concurrent.ExecutionContext 13 | import scala.util.{Failure, Success} 14 | 15 | class FortuneOffice(backend: AkkaBackend)(implicit val timeout: Timeout) extends Actor with ActorLogging { 16 | 17 | implicit val dispatcher: ExecutionContext = context.system.dispatcher 18 | 19 | def receive: Receive = { 20 | case CommandEnvelope(id, command) ⇒ 21 | val origin = sender 22 | command match { 23 | case c: FortuneCommand ⇒ 24 | val aggregate = backend.aggregateRef[Fortune](FortuneId(id)) 25 | (aggregate ? c).onComplete { 26 | case Success(_) ⇒ origin ! CommandSuccess(c.id.value.toString) 27 | case Failure(e: DomainError) ⇒ origin ! InvalidCommand(c.id.value.toString, e.message) 28 | case Failure(e) ⇒ origin ! UnexpectedFailure(c.id.value.toString, e.getMessage) 29 | } 30 | case other ⇒ sender ! UnknownCommand(other.getClass.getName) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/DatabaseMoneyViewRepository.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 4 | import ru.pavkin.ihavemoney.readback.db.{Money, MoneyRow} 5 | import slick.driver.PostgresDriver.api.{Database, _} 6 | 7 | import scala.concurrent.{ExecutionContext, Future} 8 | 9 | class DatabaseMoneyViewRepository(db: Database) extends MoneyViewRepository { 10 | 11 | private def findQuery(id: FortuneId, currency: Currency): Query[Money, MoneyRow, Seq] = 12 | Money.table 13 | .filter(money ⇒ money.fortuneId === id.value && money.currency === currency.code) 14 | 15 | def findAll(id: FortuneId)(implicit ec: ExecutionContext): Future[Map[Currency, BigDecimal]] = db.run { 16 | Money.table.filter(_.fortuneId === id.value).result 17 | }.map(_.map(a ⇒ a.currency → a.amount).toMap) 18 | 19 | def find(id: FortuneId, currency: Currency)(implicit ec: ExecutionContext): Future[Option[BigDecimal]] = db.run { 20 | findQuery(id, currency) 21 | .take(1) 22 | .map(_.amount) 23 | .result 24 | }.map(_.headOption) 25 | 26 | def updateById(id: FortuneId, currency: Currency, newAmount: BigDecimal)(implicit ec: ExecutionContext): Future[Unit] = db.run { 27 | findQuery(id, currency).map(_.amount).update(newAmount) 28 | }.map(_ ⇒ ()) 29 | 30 | def insert(id: FortuneId, currency: Currency, amount: BigDecimal)(implicit ec: ExecutionContext): Future[Unit] = db.run { 31 | Money.table += MoneyRow(id, currency, amount) 32 | }.map(_ ⇒ ()) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /write-backend/src/main/scala/ru/pavkin/ihavemoney/writeback/WriteBackend.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.writeback 2 | 3 | import akka.actor.{ActorSystem, Props} 4 | import akka.cluster.client.ClusterClientReceptionist 5 | import akka.cluster.sharding.{ClusterSharding, ClusterShardingSettings} 6 | import akka.util.Timeout 7 | import com.typesafe.config.ConfigFactory 8 | import io.funcqrs.akka.EventsSourceProvider 9 | import io.funcqrs.akka.backend.AkkaBackend 10 | import io.funcqrs.backend.Query 11 | import io.funcqrs.config.api._ 12 | import ru.pavkin.ihavemoney.domain.fortune._ 13 | 14 | import scala.concurrent.duration._ 15 | 16 | object WriteBackend extends App { 17 | 18 | println("Starting IHaveMoney write backend...") 19 | 20 | val config = ConfigFactory.load() 21 | val system: ActorSystem = ActorSystem(config.getString("app.system")) 22 | 23 | val backend = new AkkaBackend { 24 | val actorSystem: ActorSystem = system 25 | def sourceProvider(query: Query): EventsSourceProvider = null 26 | }.configure { 27 | aggregate[Fortune](Fortune.behavior) 28 | } 29 | 30 | implicit val timeout: Timeout = new Timeout(30.seconds) 31 | 32 | val fortuneRegion = ClusterSharding(system).start( 33 | typeName = "FortuneShard", 34 | entityProps = Props(new FortuneOffice(backend)), 35 | settings = ClusterShardingSettings(system), 36 | messageExtractor = new CommandMessageExtractor(config.getInt("app.number-of-nodes")) 37 | ) 38 | 39 | val interface = system.actorOf(Props(new InterfaceActor(fortuneRegion)), "interface") 40 | ClusterClientReceptionist(system).registerService(interface) 41 | } 42 | -------------------------------------------------------------------------------- /frontend-protocol/shared/src/main/scala/ru/pavkin/ihavemoney/protocol/writefront.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.protocol 2 | 3 | import io.circe._ 4 | import io.circe.generic.semiauto._ 5 | import ru.pavkin.ihavemoney.domain.fortune.Currency 6 | 7 | object writefront extends SharedProtocol { 8 | 9 | sealed trait WriteFrontRequest 10 | case class ReceiveIncomeRequest(amount: BigDecimal, 11 | currency: Currency, 12 | category: String, 13 | comment: Option[String] = None) extends WriteFrontRequest 14 | case class SpendRequest(amount: BigDecimal, 15 | currency: Currency, 16 | category: String, 17 | comment: Option[String] = None) extends WriteFrontRequest 18 | 19 | case class RequestResult(commandId: String, success: Boolean, error: Option[String] = None) 20 | 21 | 22 | implicit val riEncoder: Encoder[ReceiveIncomeRequest] = deriveEncoder[ReceiveIncomeRequest] 23 | implicit val riDecoder: Decoder[ReceiveIncomeRequest] = deriveDecoder[ReceiveIncomeRequest] 24 | 25 | implicit val sEncoder: Encoder[SpendRequest] = deriveEncoder[SpendRequest] 26 | implicit val sDecoder: Decoder[SpendRequest] = deriveDecoder[SpendRequest] 27 | 28 | implicit val reqEncoder: Encoder[WriteFrontRequest] = deriveEncoder[WriteFrontRequest] 29 | implicit val reqDecoder: Decoder[WriteFrontRequest] = deriveDecoder[WriteFrontRequest] 30 | 31 | implicit val resEncoder: Encoder[RequestResult] = deriveEncoder[RequestResult] 32 | implicit val resDecoder: Decoder[RequestResult] = deriveDecoder[RequestResult] 33 | 34 | } 35 | -------------------------------------------------------------------------------- /js-app/src/main/scala/IHaveMoneyApp.scala: -------------------------------------------------------------------------------- 1 | import japgolly.scalajs.react._ 2 | import japgolly.scalajs.react.extra.router.{BaseUrl, Redirect, Resolution, Router, RouterConfigDsl, RouterCtl} 3 | import japgolly.scalajs.react.vdom.all._ 4 | import org.scalajs.dom 5 | import org.scalajs.dom._ 6 | import org.scalajs.dom.raw.HTMLStyleElement 7 | import ru.pavkin.ihavemoney.frontend.{Route, api} 8 | import ru.pavkin.ihavemoney.frontend.Route._ 9 | import ru.pavkin.ihavemoney.frontend.components.{AddTransactionsComponent, BalanceViewComponent, Nav} 10 | import ru.pavkin.ihavemoney.frontend.styles.Global 11 | 12 | import scalacss.Defaults._ 13 | import scalacss.ScalaCssReact._ 14 | import scala.scalajs.js.JSApp 15 | import scala.scalajs.js.annotation.JSExport 16 | 17 | 18 | object IHaveMoneyApp extends JSApp { 19 | 20 | val routerConfig = RouterConfigDsl[Route].buildConfig { dsl => 21 | import dsl._ 22 | 23 | (trimSlashes 24 | | staticRoute(root, AddTransactions) ~> render(AddTransactionsComponent.component()) 25 | | staticRoute("#page2", BalanceView) ~> render(BalanceViewComponent.component())) 26 | .notFound(redirectToPage(AddTransactions)(Redirect.Replace)) 27 | .renderWith(layout) 28 | .verify(AddTransactions, BalanceView) 29 | } 30 | 31 | def layout(c: RouterCtl[Route], r: Resolution[Route]) = div( 32 | Nav.component(c), 33 | div(className := "container", r.render()) 34 | ) 35 | 36 | @JSExport 37 | def main(): Unit = { 38 | dom.document.head appendChild Global.render[HTMLStyleElement] 39 | val router = Router(api.readFrontBaseUrl, routerConfig.logToConsole) 40 | router() render dom.document.getElementById("root") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /write-frontend/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | app { 2 | host = "127.0.0.1" 3 | host = ${?ihavemoney_writefront_host} 4 | http-port = 8101 5 | http-port = ${?ihavemoney_writefront_http_port} 6 | port = 10101 7 | port = ${?ihavemoney_writefront_tcp_port} 8 | } 9 | 10 | write-backend { 11 | host = "127.0.0.1" 12 | host = ${?ihavemoney_writeback_host} 13 | port = 9101 14 | port = ${?ihavemoney_writeback_port} 15 | system = "iHaveMoneyWriteBackend" 16 | } 17 | 18 | akka { 19 | loglevel = "INFO" 20 | 21 | actor { 22 | provider = "akka.remote.RemoteActorRefProvider" 23 | 24 | serializers { 25 | proto = "akka.remote.serialization.ProtobufSerializer" 26 | 27 | receiveIncome = "ru.pavkin.ihavemoney.serialization.ReceiveIncomeSerializer" 28 | spend = "ru.pavkin.ihavemoney.serialization.SpendSerializer" 29 | commandEnvelope = "ru.pavkin.ihavemoney.serialization.CommandEnvelopeSerializer" 30 | } 31 | 32 | serialization-bindings { 33 | "com.trueaccord.scalapb.GeneratedMessage" = proto 34 | 35 | "ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol$ReceiveIncome" = receiveIncome 36 | "ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol$Spend" = spend 37 | "ru.pavkin.ihavemoney.domain.CommandEnvelope" = commandEnvelope 38 | } 39 | } 40 | 41 | remote { 42 | enabled-transports = ["akka.remote.netty.tcp"] 43 | netty.tcp { 44 | hostname = ${app.host} 45 | port = ${app.port} 46 | } 47 | } 48 | 49 | cluster.client { 50 | initial-contacts = [ 51 | "akka.tcp://"${write-backend.system}"@"${write-backend.host}":"${write-backend.port}"/system/receptionist" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /write-frontend/src/main/scala/ru/pavkin/ihavemoney/writefront/WriteBackClusterClient.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.writefront 2 | 3 | import akka.actor.{ActorRef, ActorSystem} 4 | import akka.cluster.client.{ClusterClient, ClusterClientSettings} 5 | import akka.http.scaladsl.model.StatusCode 6 | import akka.http.scaladsl.model.StatusCodes._ 7 | import akka.pattern.ask 8 | import akka.util.Timeout 9 | import io.funcqrs.DomainCommand 10 | import ru.pavkin.ihavemoney.domain.CommandEnvelope 11 | import ru.pavkin.ihavemoney.proto.results.{CommandSuccess, InvalidCommand, UnexpectedFailure, UnknownCommand} 12 | import ru.pavkin.ihavemoney.protocol.writefront._ 13 | 14 | import scala.concurrent.{ExecutionContext, Future} 15 | 16 | class WriteBackClusterClient(system: ActorSystem) { 17 | 18 | val writeBackendClient: ActorRef = system.actorOf( 19 | ClusterClient.props(ClusterClientSettings(system)), 20 | "writeBackendClient" 21 | ) 22 | 23 | def sendCommand(aggregateId: String, command: DomainCommand) 24 | (implicit ec: ExecutionContext, timeout: Timeout): Future[(StatusCode, RequestResult)] = 25 | (writeBackendClient ? ClusterClient.Send("/user/interface", CommandEnvelope(aggregateId, command), localAffinity = true)) 26 | .map { 27 | case CommandSuccess(id) ⇒ 28 | OK → RequestResult(id, success = true) 29 | case UnknownCommand(c) ⇒ 30 | InternalServerError → RequestResult("unassigned", success = false, Some(s"Unknown command $c")) 31 | case InvalidCommand(id, reason) ⇒ 32 | BadRequest → RequestResult(id, success = false, Some(reason)) 33 | case UnexpectedFailure(id, reason) ⇒ 34 | InternalServerError → RequestResult(id, success = false, Some(reason)) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /js-app/src/main/scala/ru/pavkin/ihavemoney/frontend/components/Nav.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.frontend.components 2 | 3 | import japgolly.scalajs.react._ 4 | import japgolly.scalajs.react.extra.router.RouterCtl 5 | import japgolly.scalajs.react.vdom.all._ 6 | import ru.pavkin.ihavemoney.frontend.Route 7 | import ru.pavkin.ihavemoney.frontend.Route._ 8 | import org.querki.jquery._ 9 | 10 | import scala.scalajs.js 11 | 12 | object Nav { 13 | 14 | case class State(token: String) 15 | 16 | class Backend($scope: BackendScope[RouterCtl[Route], State]) { 17 | 18 | def clickCallback(routerCallback: ReactEvent ⇒ Callback)(e: ReactEvent) = 19 | routerCallback(e).map { _ ⇒ 20 | $("#navbar", $scope.getDOMNode()).asInstanceOf[js.Dynamic].collapse("hide") 21 | () 22 | } 23 | 24 | def render(ctl: RouterCtl[Route], s: State) = { 25 | def routeLink(name: String, target: Route) = 26 | li(a(href := ctl.urlFor(target).value, onClick ==> clickCallback(ctl.setEH(target)), name)) 27 | 28 | nav(className := "navbar navbar-default navbar-fixed-top", 29 | div(className := "container", 30 | div(className := "navbar-header", 31 | button(tpe := "button", className := "navbar-toggle collapsed", 32 | "data-toggle".reactAttr := "collapse", 33 | "data-target".reactAttr := "#navbar", 34 | "aria-expanded".reactAttr := "false", 35 | "aria-controls".reactAttr := "navbar", 36 | span(className := "sr-only", "Toggle navigation"), 37 | span(className := "icon-bar"), 38 | span(className := "icon-bar"), 39 | span(className := "icon-bar") 40 | ), 41 | a(className := "navbar-brand", href := "#", "I Have Money") 42 | ), 43 | div(id := "navbar", className := "navbar-collapse collapse", 44 | "aria-expanded".reactAttr := "false", 45 | height := "1px", 46 | ul(className := "nav navbar-nav", 47 | routeLink("Add Transactions", AddTransactions), 48 | routeLink("Balance View", BalanceView) 49 | ) 50 | ) 51 | ) 52 | ) 53 | } 54 | } 55 | 56 | val component = ReactComponentB[RouterCtl[Route]]("Menu") 57 | .initialState(State("")) 58 | .renderBackend[Backend] 59 | .build 60 | } 61 | -------------------------------------------------------------------------------- /write-frontend/src/main/scala/ru/pavkin/ihavemoney/writefront/WriteFrontend.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.writefront 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.Logging 5 | import akka.http.scaladsl.Http 6 | import akka.http.scaladsl.server.Directives._ 7 | import akka.http.scaladsl.server.Route 8 | import akka.stream.ActorMaterializer 9 | import akka.util.Timeout 10 | import com.typesafe.config.ConfigFactory 11 | import de.heikoseeberger.akkahttpcirce.CirceSupport 12 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol.{ExpenseCategory, IncomeCategory, ReceiveIncome, Spend} 13 | import ru.pavkin.ihavemoney.protocol.writefront._ 14 | import ch.megard.akka.http.cors.{CorsDirectives, CorsSettings} 15 | import akka.http.scaladsl.model.StatusCodes._ 16 | import scala.concurrent.duration._ 17 | 18 | object WriteFrontend extends App with CirceSupport with CorsDirectives { 19 | 20 | implicit val system = ActorSystem("IHaveMoneyWriteFront") 21 | implicit val executor = system.dispatcher 22 | implicit val materializer = ActorMaterializer() 23 | implicit val timeout: Timeout = Timeout(30.seconds) 24 | 25 | val config = ConfigFactory.load() 26 | val logger = Logging(system, getClass) 27 | 28 | val writeBack = new WriteBackClusterClient(system) 29 | 30 | val routes: Route = 31 | cors(CorsSettings.defaultSettings.copy(allowCredentials = false)) { 32 | logRequestResult("i-have-money-write-frontend") { 33 | pathPrefix("fortune" / Segment) { fortuneId ⇒ 34 | (path("income") & post & entity(as[ReceiveIncomeRequest])) { req ⇒ 35 | complete { 36 | writeBack.sendCommand(fortuneId, ReceiveIncome( 37 | req.amount, 38 | req.currency, 39 | IncomeCategory(req.category), 40 | req.comment 41 | )) 42 | } 43 | } ~ (path("spend") & post & entity(as[SpendRequest])) { req ⇒ 44 | complete { 45 | writeBack.sendCommand(fortuneId, Spend( 46 | req.amount, 47 | req.currency, 48 | ExpenseCategory(req.category), 49 | req.comment 50 | )) 51 | } 52 | } 53 | } ~ complete(NotFound) 54 | } 55 | } 56 | 57 | Http().bindAndHandle(routes, config.getString("app.host"), config.getInt("app.http-port")) 58 | } 59 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/fortune/FortuneProtocol.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.fortune 2 | 3 | import java.time.OffsetDateTime 4 | import java.util.UUID 5 | 6 | import io.funcqrs._ 7 | 8 | case class FortuneId(value: String) extends AggregateId 9 | object FortuneId { 10 | def fromString(aggregateId: String): FortuneId = FortuneId(aggregateId) 11 | def generate(): FortuneId = FortuneId(UUID.randomUUID().toString) 12 | } 13 | 14 | object FortuneProtocol extends ProtocolLike { 15 | 16 | case class ExpenseCategory(name: String) 17 | case class IncomeCategory(name: String) 18 | 19 | /*-------------------Commands---------------------*/ 20 | sealed trait FortuneCommand extends ProtocolCommand 21 | 22 | sealed trait FortuneLifecycleCommand extends FortuneCommand 23 | case class Spend(amount: BigDecimal, 24 | currency: Currency, 25 | category: ExpenseCategory, 26 | comment: Option[String] = None) extends FortuneLifecycleCommand 27 | case class ReceiveIncome(amount: BigDecimal, 28 | currency: Currency, 29 | category: IncomeCategory, 30 | comment: Option[String] = None) extends FortuneLifecycleCommand 31 | 32 | /*-------------------Events---------------------*/ 33 | sealed trait FortuneEvent extends ProtocolEvent with MetadataFacet[FortuneMetadata] 34 | 35 | case class FortuneIncreased(amount: BigDecimal, 36 | currency: Currency, 37 | category: IncomeCategory, 38 | metadata: FortuneMetadata, 39 | comment: Option[String] = None) extends FortuneEvent 40 | case class FortuneSpent(amount: BigDecimal, 41 | currency: Currency, 42 | category: ExpenseCategory, 43 | metadata: FortuneMetadata, 44 | comment: Option[String] = None) extends FortuneEvent 45 | 46 | /*-------------------Metadata---------------------*/ 47 | case class FortuneMetadata(aggregateId: FortuneId, 48 | commandId: CommandId, 49 | eventId: EventId = EventId(), 50 | date: OffsetDateTime = OffsetDateTime.now(), 51 | tags: Set[Tag] = Set()) extends Metadata with JavaTime { 52 | type Id = FortuneId 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /read-frontend/src/main/scala/ru/pavkin/ihavemoney/readfront/ReadFrontend.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readfront 2 | 3 | import java.util.UUID 4 | 5 | import akka.actor.ActorSystem 6 | import akka.event.Logging 7 | import akka.http.scaladsl.Http 8 | import akka.http.scaladsl.model._ 9 | import akka.http.scaladsl.server.Directives._ 10 | import akka.pattern.AskTimeoutException 11 | import akka.stream.ActorMaterializer 12 | import akka.util.Timeout 13 | import com.typesafe.config.ConfigFactory 14 | import de.heikoseeberger.akkahttpcirce.CirceSupport 15 | import akka.http.scaladsl.model.StatusCodes._ 16 | 17 | import scala.concurrent.duration._ 18 | import ru.pavkin.ihavemoney.domain.fortune.FortuneId 19 | import ru.pavkin.ihavemoney.domain.query.{MoneyBalance, QueryFailed, QueryId} 20 | import ru.pavkin.ihavemoney.protocol.readfront._ 21 | 22 | object ReadFrontend extends App with CirceSupport { 23 | 24 | implicit val system = ActorSystem("IHaveMoneyReadFront") 25 | implicit val executor = system.dispatcher 26 | implicit val materializer = ActorMaterializer() 27 | implicit val timeout: Timeout = Timeout(30.seconds) 28 | 29 | val config = ConfigFactory.load() 30 | val logger = Logging(system, getClass) 31 | 32 | val readBack = new ReadBackClient(system, config.getString("read-backend.interface")) 33 | 34 | val writeFrontURL = s"http://${config.getString("write-frontend.host")}:${config.getString("write-frontend.port")}" 35 | 36 | val routes = { 37 | logRequestResult("i-have-money-read-frontend") { 38 | path("write_front_url") { 39 | get { 40 | complete { 41 | HttpResponse(entity = HttpEntity(ContentType(MediaTypes.`text/plain`, HttpCharsets.`UTF-8`), writeFrontURL)) 42 | } 43 | } 44 | } ~ 45 | pathPrefix("balance" / Segment) { fortuneId ⇒ 46 | get { 47 | complete { 48 | val queryId = QueryId(UUID.randomUUID.toString) 49 | readBack.query(MoneyBalance(queryId, FortuneId(fortuneId))) 50 | .recover { 51 | case timeout: AskTimeoutException ⇒ 52 | RequestTimeout → QueryFailed(queryId, s"Query $queryId timed out") 53 | } 54 | .map(kv ⇒ kv._1 → conversions.toFrontendFormat(kv._2)) 55 | } 56 | } 57 | } ~ 58 | get { 59 | pathSingleSlash { 60 | getFromResource("index.html") 61 | } 62 | } 63 | } ~ getFromResourceDirectory("") 64 | } 65 | Http().bindAndHandle(routes, config.getString("app.host"), config.getInt("app.http-port")) 66 | } 67 | -------------------------------------------------------------------------------- /read-backend/src/main/scala/ru/pavkin/ihavemoney/readback/JournalPuller.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.readback 2 | 3 | import akka.actor.ActorLogging 4 | import akka.persistence.jdbc.query.journal.scaladsl.JdbcReadJournal 5 | import akka.persistence.query.{EventEnvelope, PersistenceQuery} 6 | import akka.stream.ActorMaterializer 7 | import akka.stream.actor.ActorPublisher 8 | import akka.stream.actor.ActorPublisherMessage.{Cancel, Request} 9 | import akka.stream.scaladsl.Sink 10 | import ru.pavkin.ihavemoney.readback.JournalPuller.Pull 11 | 12 | import scala.concurrent.Future 13 | import scala.concurrent.duration._ 14 | 15 | object JournalPuller { 16 | private case object Pull 17 | } 18 | 19 | class JournalPuller(tag: String, 20 | initialOffset: Long, 21 | pullFrequency: FiniteDuration = 1.second, 22 | maxBatchSize: Int = 1000) extends ActorPublisher[EventEnvelope] with ActorLogging { 23 | 24 | implicit val dispatcher = context.system.dispatcher 25 | implicit val materializer = ActorMaterializer() 26 | 27 | private lazy val journal = PersistenceQuery(context.system).readJournalFor[JdbcReadJournal](JdbcReadJournal.Identifier) 28 | 29 | private var buffer = Vector.empty[EventEnvelope] 30 | private var nextOffset: Long = initialOffset 31 | 32 | override def preStart(): Unit = scheduleNextPull 33 | 34 | def receive: Receive = { 35 | case _: Request ⇒ 36 | deliverIfRequested() 37 | 38 | case Pull ⇒ 39 | pull.onComplete { _ ⇒ 40 | scheduleNextPull 41 | deliverIfRequested() 42 | } 43 | 44 | case Cancel ⇒ context.stop(self) 45 | } 46 | 47 | def pull: Future[Unit] = 48 | journal.currentEventsByTag(tag, nextOffset) 49 | .take(maxBatchSize.toLong) 50 | .grouped(maxBatchSize) 51 | .runWith(Sink.foreach[Seq[EventEnvelope]] { seq ⇒ 52 | log.info(s"Appending ${seq.length} events to internal buffer") 53 | buffer ++= seq 54 | nextOffset = seq.last.offset 55 | }) 56 | .map(_ ⇒ ()) 57 | 58 | def scheduleNextPull = context.system.scheduler.scheduleOnce(pullFrequency, self, Pull) 59 | 60 | def deliverIfRequested(): Unit = 61 | if (buffer.nonEmpty && totalDemand > 0) { 62 | if (buffer.size == 1) { 63 | // optimize for this common case 64 | onNext(buffer.head) 65 | log.info("Pushing 1 event to consumer") 66 | buffer = Vector.empty 67 | } else if (totalDemand <= Int.MaxValue) { 68 | val (use, keep) = buffer.splitAt(totalDemand.toInt) 69 | buffer = keep 70 | log.info(s"Pushing ${use.size} events to consumer") 71 | use foreach onNext 72 | } else { 73 | log.info(s"Pushing ${buffer.size} events to consumer") 74 | buffer foreach onNext 75 | buffer = Vector.empty 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /domain/jvm/src/main/scala/ru/pavkin/ihavemoney/domain/fortune/Fortune.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain.fortune 2 | 3 | import io.funcqrs._ 4 | import io.funcqrs.behavior._ 5 | import ru.pavkin.ihavemoney.domain.errors.BalanceIsNotEnough 6 | 7 | case class Fortune(id: FortuneId, balances: Map[Currency, BigDecimal]) extends AggregateLike { 8 | type Id = FortuneId 9 | type Protocol = FortuneProtocol.type 10 | 11 | def increase(worth: Worth): Fortune = 12 | copy(balances = balances + (worth.currency -> (amount(worth.currency) + worth.amount))) 13 | 14 | def decrease(by: Worth): Fortune = 15 | copy(balances = balances + (by.currency -> (amount(by.currency) - by.amount))) 16 | 17 | def worth(currency: Currency): Worth = Worth(amount(currency), currency) 18 | def amount(currency: Currency): BigDecimal = balances.getOrElse(currency, BigDecimal(0.0)) 19 | 20 | import FortuneProtocol._ 21 | 22 | def metadata(cmd: FortuneCommand): FortuneMetadata = 23 | Fortune.metadata(id, cmd) 24 | 25 | def cantHaveNegativeBalance = action[Fortune] 26 | .rejectCommand { 27 | case cmd: Spend if this.amount(cmd.currency) < cmd.amount => 28 | BalanceIsNotEnough(this.amount(cmd.currency), cmd.currency) 29 | } 30 | 31 | def increaseFortune = action[Fortune] 32 | .handleCommand { 33 | cmd: ReceiveIncome => Fortune.handleReceiveIncome(id, cmd) 34 | } 35 | .handleEvent { 36 | evt: FortuneIncreased => this.increase(Worth(evt.amount, evt.currency)) 37 | } 38 | 39 | def decreaseFortune = action[Fortune] 40 | .handleCommand { 41 | cmd: Spend => FortuneSpent( 42 | cmd.amount, 43 | cmd.currency, 44 | cmd.category, 45 | metadata(cmd), 46 | cmd.comment) 47 | } 48 | .handleEvent { 49 | evt: FortuneSpent => this.decrease(Worth(evt.amount, evt.currency)) 50 | } 51 | } 52 | 53 | object Fortune { 54 | 55 | import FortuneProtocol._ 56 | 57 | val tag = Tags.aggregateTag("fortune") 58 | 59 | def metadata(fortuneId: FortuneId, cmd: FortuneCommand) = { 60 | FortuneMetadata(fortuneId, cmd.id, tags = Set(tag)) 61 | } 62 | 63 | def handleReceiveIncome(id: FortuneId, cmd: ReceiveIncome): FortuneIncreased = FortuneIncreased( 64 | cmd.amount, 65 | cmd.currency, 66 | cmd.category, 67 | metadata(id, cmd), 68 | cmd.comment) 69 | 70 | def createFortune(fortuneId: FortuneId) = 71 | actions[Fortune] 72 | .handleCommand { 73 | cmd: ReceiveIncome => Fortune.handleReceiveIncome(fortuneId, cmd) 74 | } 75 | .handleEvent { 76 | evt: FortuneIncreased => Fortune(id = fortuneId, balances = Map(evt.currency -> evt.amount)) 77 | } 78 | 79 | def behavior(fortuneId: FortuneId): Behavior[Fortune] = { 80 | 81 | case Uninitialized(id) => createFortune(id) 82 | 83 | case Initialized(fortune) => 84 | fortune.cantHaveNegativeBalance ++ 85 | fortune.increaseFortune ++ 86 | fortune.decreaseFortune 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /js-app/src/main/scala/ru/pavkin/ihavemoney/frontend/components/BalanceViewComponent.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.frontend.components 2 | 3 | import cats.data.Xor 4 | import japgolly.scalajs.react._ 5 | import japgolly.scalajs.react.vdom.all._ 6 | import org.scalajs.dom.raw.HTMLInputElement 7 | import ru.pavkin.ihavemoney.frontend.api 8 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 9 | 10 | object BalanceViewComponent { 11 | 12 | case class State(fortuneId: String, 13 | balances: Map[String, BigDecimal]) 14 | 15 | class Backend($: BackendScope[Unit, State]) { 16 | 17 | val fortuneIdInput = Ref[HTMLInputElement]("fortuneIdInput") 18 | 19 | def onTextChange(change: (State, String) ⇒ State)(e: ReactEventI) = { 20 | val newValue = e.target.value 21 | $.modState(change(_, newValue)) 22 | } 23 | 24 | def onFormSubmit(e: ReactEventI) = e.preventDefaultCB 25 | 26 | def loadBalances(fortuneId: String) = Callback { 27 | println(s"Refresh balance for $fortuneId") 28 | if (fortuneId.isEmpty) 29 | Callback.alert("Empty id").runNow() 30 | else 31 | api.getBalances( 32 | fortuneId 33 | ).map { 34 | case Xor.Left(error) ⇒ Callback.alert(s"Error: $error").runNow() 35 | case Xor.Right(balances) ⇒ 36 | $.modState(_.copy(balances = balances)).runNow() 37 | } 38 | } 39 | 40 | def render(state: State) = { 41 | div( 42 | form( 43 | className := "form-horizontal", 44 | onSubmit ==> onFormSubmit, 45 | div(className := "form-group", 46 | label(htmlFor := "fortuneIdInput", className := "col-sm-2 control-label", "Fortune ID"), 47 | div(className := "col-sm-8", 48 | input( 49 | ref := fortuneIdInput, 50 | required := true, 51 | tpe := "text", 52 | className := "form-control", 53 | id := "fortuneIdInput", 54 | placeholder := "Fortune Id", 55 | value := state.fortuneId, 56 | onChange ==> onTextChange((s, v) ⇒ s.copy(fortuneId = v)) 57 | ) 58 | ), 59 | div(className := "col-sm-2", 60 | button(tpe := "submit", className := "btn btn-success", disabled := state.fortuneId.isEmpty, 61 | onClick --> loadBalances(state.fortuneId), "Refresh") 62 | ) 63 | ) 64 | ), 65 | if (state.balances.nonEmpty) 66 | div( 67 | table(className := "table table-striped table-hover table-condensed", 68 | thead(tr(th("Currency"), th("Amount"))), 69 | tbody( 70 | state.balances.map { 71 | case (currency, amount) ⇒ tr(td(currency), td(amount.toString)) 72 | } 73 | ) 74 | ) 75 | ) 76 | else 77 | div() 78 | ) 79 | } 80 | 81 | def init = $.state.flatMap(s ⇒ loadBalances(s.fortuneId)) 82 | } 83 | 84 | val component = ReactComponentB[Unit]("AddTransactionsComponent") 85 | .initialState(State("MyFortune", Map.empty)) 86 | .renderBackend[Backend] 87 | .componentDidMount(_.backend.init) 88 | .build 89 | } 90 | -------------------------------------------------------------------------------- /js-app/src/main/scala/ru/pavkin/ihavemoney/frontend/api.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.frontend 2 | 3 | import cats.data.{Xor, XorT} 4 | import cats.std.future._ 5 | import cats.syntax.xor._ 6 | import io.circe.parser._ 7 | import io.circe.syntax._ 8 | import japgolly.scalajs.react.Callback 9 | import japgolly.scalajs.react.extra.router.BaseUrl 10 | import org.scalajs.dom.ext.{Ajax, AjaxException} 11 | import ru.pavkin.ihavemoney.domain.fortune.Currency 12 | import ru.pavkin.ihavemoney.protocol.readfront._ 13 | import ru.pavkin.ihavemoney.protocol.writefront._ 14 | 15 | import scala.concurrent.{ExecutionContext, Future} 16 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 17 | 18 | object api { 19 | 20 | val readFrontBaseUrl = BaseUrl.fromWindowOrigin_/ 21 | var writeFrontBaseUrl: BaseUrl = BaseUrl("") 22 | 23 | get((readFrontBaseUrl / "write_front_url").value).foreach { 24 | case Xor.Right(url) ⇒ writeFrontBaseUrl = BaseUrl(url).endWith_/ 25 | case Xor.Left(error) ⇒ Callback.alert(s"Failed to obtain writeback url: $error").runNow 26 | } 27 | 28 | object routes { 29 | def addIncome(fortuneId: String) = writeFrontBaseUrl / "fortune" / fortuneId / "income" 30 | def addExpense(fortuneId: String) = writeFrontBaseUrl / "fortune" / fortuneId / "spend" 31 | def getBalances(fortuneId: String) = readFrontBaseUrl / "balance" / fortuneId 32 | } 33 | 34 | def addIncome(id: String, 35 | amount: BigDecimal, 36 | currency: Currency, 37 | category: String, 38 | comment: Option[String]) 39 | (implicit ec: ExecutionContext): Future[String Xor Unit] = 40 | postJSON(routes.addIncome(id).value, 41 | ReceiveIncomeRequest(amount, currency, category, comment).asJson.toString()) 42 | .map(_.map(_ ⇒ ())) 43 | 44 | def addExpense(id: String, 45 | amount: BigDecimal, 46 | currency: Currency, 47 | category: String, 48 | comment: Option[String]) 49 | (implicit ec: ExecutionContext): Future[String Xor Unit] = 50 | postJSON(routes.addExpense(id).value, 51 | ReceiveIncomeRequest(amount, currency, category, comment).asJson.toString()) 52 | .map(_.map(_ ⇒ ())) 53 | 54 | def getBalances(id: String)(implicit ec: ExecutionContext): Future[String Xor Map[String, BigDecimal]] = 55 | XorT(get(routes.getBalances(id).value)) 56 | .subflatMap(decode[FrontendQueryResult](_).leftMap(_.getMessage)) 57 | .subflatMap { 58 | case FrontendMoneyBalance(_, balances) ⇒ balances.right 59 | case other ⇒ s"Unexpected responce: $other".left 60 | }.value 61 | 62 | private def recover(f: Future[String Xor String])(implicit ec: ExecutionContext) = f.recover { 63 | case AjaxException(xhr) => (xhr.status match { 64 | case 404 => "NotFound" 65 | case 400 => s"BadRequest: ${xhr.responseText}" 66 | case 503 => "ServiceUnavailable" 67 | case i => s"Other Error: $i" 68 | }).left 69 | } 70 | 71 | private def get(url: String)(implicit ec: ExecutionContext): Future[String Xor String] = recover { 72 | Ajax.get(url).map(xhr => 73 | if (xhr.status == 200) { 74 | xhr.responseText.right 75 | } else 76 | s"Other Error: ${xhr.status}".left 77 | ) 78 | } 79 | 80 | private def postJSON(url: String, body: String)(implicit ec: ExecutionContext): Future[String Xor String] = recover { 81 | Ajax.post( 82 | url, 83 | data = body, 84 | headers = Map("Content-Type" -> "application/json") 85 | ).map(xhr => 86 | if (xhr.status == 200) { 87 | xhr.responseText.right 88 | } else 89 | s"Other Error: ${xhr.status}".left 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /read-backend/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | app { 2 | host = "127.0.0.1" 3 | host = ${?ihavemoney_readback_host} 4 | port = 9201 5 | port = ${?ihavemoney_readback_port} 6 | system = "iHaveMoneyReadBackend" 7 | 8 | write-db { 9 | host = "127.0.0.1" 10 | host = ${?ihavemoney_writeback_db_host} 11 | port = "5432" 12 | port = ${?ihavemoney_writeback_db_port} 13 | name = "ihavemoney-write" 14 | name = ${?ihavemoney_writeback_db_name} 15 | user = "admin" 16 | user = ${?ihavemoney_writeback_db_user} 17 | password = "changeit" 18 | password = ${?ihavemoney_writeback_db_password} 19 | } 20 | 21 | read-db { 22 | host = "127.0.0.1" 23 | host = ${?ihavemoney_readback_db_host} 24 | port = "5432" 25 | port = ${?ihavemoney_readback_db_port} 26 | name = "ihavemoney-read" 27 | name = ${?ihavemoney_readback_db_name} 28 | user = "admin" 29 | user = ${?ihavemoney_readback_db_user} 30 | password = "changeit" 31 | password = ${?ihavemoney_readback_db_password} 32 | } 33 | } 34 | 35 | akka { 36 | 37 | loglevel = "INFO" 38 | 39 | persistence { 40 | journal.plugin = "jdbc-journal" 41 | snapshot-store.plugin = "jdbc-snapshot-store" 42 | } 43 | 44 | actor { 45 | provider = "akka.remote.RemoteActorRefProvider" 46 | 47 | serializers { 48 | proto = "akka.remote.serialization.ProtobufSerializer" 49 | } 50 | 51 | serialization-bindings { 52 | "com.trueaccord.scalapb.GeneratedMessage" = proto 53 | } 54 | } 55 | 56 | remote { 57 | enabled-transports = ["akka.remote.netty.tcp"] 58 | netty.tcp { 59 | hostname = ${app.host} 60 | port = ${app.port} 61 | } 62 | } 63 | 64 | } 65 | 66 | jdbc-journal { 67 | event-adapters { 68 | fortune = "ru.pavkin.ihavemoney.serialization.adapters.FortuneEventAdapter" 69 | } 70 | event-adapter-bindings { 71 | "ru.pavkin.ihavemoney.proto.events.PBFortuneSpent" = fortune 72 | "ru.pavkin.ihavemoney.proto.events.PBFortuneIncreased" = fortune 73 | } 74 | } 75 | 76 | akka-persistence-jdbc { 77 | slick { 78 | driver = "slick.driver.PostgresDriver" 79 | db { 80 | url = "jdbc:postgresql://"${app.write-db.host}":"${app.write-db.port}"/"${app.write-db.name} 81 | user = ${app.write-db.user} 82 | password = ${app.write-db.password} 83 | driver = "org.postgresql.Driver" 84 | keepAliveConnection = on 85 | numThreads = 2 86 | queueSize = 100 87 | } 88 | } 89 | 90 | tables { 91 | journal { 92 | tableName = "journal" 93 | schemaName = "" 94 | columnNames { 95 | persistenceId = "persistence_id" 96 | sequenceNumber = "sequence_number" 97 | created = "created" 98 | tags = "tags" 99 | message = "message" 100 | } 101 | } 102 | 103 | deletedTo { 104 | tableName = "deleted_to" 105 | schemaName = "" 106 | columnNames = { 107 | persistenceId = "persistence_id" 108 | deletedTo = "deleted_to" 109 | } 110 | } 111 | 112 | snapshot { 113 | tableName = "snapshot" 114 | schemaName = "" 115 | columnNames { 116 | persistenceId = "persistence_id" 117 | sequenceNumber = "sequence_number" 118 | created = "created" 119 | snapshot = "snapshot" 120 | } 121 | } 122 | } 123 | 124 | query { 125 | separator = "," 126 | } 127 | } 128 | 129 | read-db { 130 | url = "jdbc:postgresql://"${app.read-db.host}":"${app.read-db.port}"/"${app.read-db.name} 131 | user = ${app.read-db.user} 132 | password = ${app.read-db.password} 133 | driver = "org.postgresql.Driver" 134 | keepAliveConnection = on 135 | numThreads = 2 136 | queueSize = 100 137 | } 138 | -------------------------------------------------------------------------------- /tests/src/test/scala/ru/pavkin/ihavemoney/domain/FortuneProtocolSpec.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.domain 2 | 3 | import io.funcqrs.CommandException 4 | import io.funcqrs.backend.QueryByTag 5 | import io.funcqrs.config.Api._ 6 | import io.funcqrs.test.InMemoryTestSupport 7 | import io.funcqrs.test.backend.InMemoryBackend 8 | import org.scalatest.concurrent.ScalaFutures 9 | import ru.pavkin.ihavemoney.domain.errors.BalanceIsNotEnough 10 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol._ 11 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, Fortune, FortuneId} 12 | import ru.pavkin.ihavemoney.readback.{MoneyViewProjection, MoneyViewRepository} 13 | 14 | import scala.concurrent.ExecutionContext.Implicits.global 15 | 16 | class FortuneProtocolSpec extends IHaveMoneySpec with ScalaFutures { 17 | 18 | class FortuneInMemoryTest extends InMemoryTestSupport { 19 | 20 | val repo = new InMemoryMoneyViewRepository 21 | val id = FortuneId.generate() 22 | 23 | def configure(backend: InMemoryBackend): Unit = 24 | backend.configure { 25 | aggregate[Fortune](Fortune.behavior) 26 | } 27 | .configure { 28 | projection( 29 | query = QueryByTag(Fortune.tag), 30 | projection = new MoneyViewProjection(repo), 31 | name = "MoneyViewProjection" 32 | ) 33 | } 34 | 35 | def ref(id: FortuneId) = aggregateRef[Fortune](id) 36 | } 37 | 38 | 39 | test("Increase fortune") { 40 | 41 | 42 | new FortuneInMemoryTest { 43 | val fortune = ref(id) 44 | 45 | fortune ! ReceiveIncome(BigDecimal(123.12), Currency.USD, IncomeCategory("salary")) 46 | fortune ! ReceiveIncome(BigDecimal(20), Currency.EUR, IncomeCategory("salary")) 47 | fortune ! ReceiveIncome(BigDecimal(30.5), Currency.EUR, IncomeCategory("salary")) 48 | 49 | expectEvent { case FortuneIncreased(amount, Currency.USD, _, _, None) if amount.toDouble == 123.12 => () } 50 | expectEvent { case FortuneIncreased(amount, Currency.EUR, _, _, None) if amount.toDouble == 20.0 => () } 51 | expectEvent { case FortuneIncreased(amount, Currency.EUR, _, _, None) if amount.toDouble == 30.5 => () } 52 | 53 | val view = repo.findAll(id).futureValue 54 | view(Currency.USD) shouldBe BigDecimal(123.12) 55 | view(Currency.EUR) shouldBe BigDecimal(50.5) 56 | } 57 | } 58 | 59 | test("Increase and decrease fortune") { 60 | 61 | new FortuneInMemoryTest { 62 | val fortune = ref(id) 63 | 64 | fortune ! ReceiveIncome(BigDecimal(123.12), Currency.USD, IncomeCategory("salary")) 65 | fortune ! Spend(BigDecimal(20), Currency.USD, ExpenseCategory("food")) 66 | 67 | expectEvent { case FortuneIncreased(amount, Currency.USD, _, _, None) if amount.toDouble == 123.12 => () } 68 | expectEvent { case FortuneSpent(amount, Currency.USD, _, _, None) if amount.toDouble == 20.0 => () } 69 | 70 | val view = repo.findAll(id).futureValue 71 | view(Currency.USD) shouldBe BigDecimal(103.12) 72 | } 73 | } 74 | 75 | test("Spending of not initialized fortune produces an error") { 76 | 77 | new FortuneInMemoryTest { 78 | val fortune = ref(id) 79 | intercept[CommandException] { 80 | fortune ? Spend(BigDecimal(20), Currency.USD, ExpenseCategory("food")) 81 | }.getMessage should startWith("Invalid command Spend") 82 | } 83 | } 84 | 85 | test("Spending more than is available is not allowed") { 86 | new FortuneInMemoryTest { 87 | val fortune = ref(id) 88 | 89 | fortune ? ReceiveIncome(BigDecimal(10), Currency.USD, IncomeCategory("salary")) 90 | 91 | intercept[BalanceIsNotEnough] { 92 | fortune ? Spend(BigDecimal(20), Currency.USD, ExpenseCategory("food")) 93 | }.getMessage shouldBe "Your balance (10 USD) is not enough for this operation" 94 | } 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /write-backend/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | app { 2 | host = "127.0.0.1" 3 | host = ${?ihavemoney_writeback_host} 4 | port = 9101 5 | port = ${?ihavemoney_writeback_port} 6 | system = "iHaveMoneyWriteBackend" 7 | number-of-nodes = 1 8 | 9 | db { 10 | host = "127.0.0.1" 11 | host = ${?ihavemoney_writeback_db_host} 12 | port = "5432" 13 | port = ${?ihavemoney_writeback_db_port} 14 | name = "ihavemoney-write" 15 | name = ${?ihavemoney_writeback_db_name} 16 | user = "admin" 17 | user = ${?ihavemoney_writeback_db_user} 18 | password = "changeit" 19 | password = ${?ihavemoney_writeback_db_password} 20 | } 21 | } 22 | 23 | akka { 24 | 25 | loglevel = "INFO" 26 | 27 | extensions = ["akka.cluster.client.ClusterClientReceptionist", "akka.cluster.ddata.DistributedData"] 28 | 29 | persistence { 30 | journal.plugin = "jdbc-journal" 31 | snapshot-store.plugin = "jdbc-snapshot-store" 32 | } 33 | 34 | actor { 35 | provider = "akka.cluster.ClusterActorRefProvider" 36 | 37 | serializers { 38 | proto = "akka.remote.serialization.ProtobufSerializer" 39 | 40 | receiveIncome = "ru.pavkin.ihavemoney.serialization.ReceiveIncomeSerializer" 41 | spend = "ru.pavkin.ihavemoney.serialization.SpendSerializer" 42 | commandEnvelope = "ru.pavkin.ihavemoney.serialization.CommandEnvelopeSerializer" 43 | } 44 | 45 | serialization-bindings { 46 | "com.trueaccord.scalapb.GeneratedMessage" = proto 47 | 48 | "ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol$ReceiveIncome" = receiveIncome 49 | "ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol$Spend" = spend 50 | "ru.pavkin.ihavemoney.domain.CommandEnvelope" = commandEnvelope 51 | } 52 | } 53 | 54 | remote { 55 | enabled-transports = ["akka.remote.netty.tcp"] 56 | netty.tcp { 57 | hostname = ${app.host} 58 | port = ${app.port} 59 | } 60 | } 61 | 62 | cluster { 63 | seed-nodes = [ 64 | "akka.tcp://"${app.system}"@"${app.host}":9101" 65 | ] 66 | sharding.state-store-mode = ddata 67 | } 68 | } 69 | 70 | jdbc-journal { 71 | event-adapters { 72 | fortune = "ru.pavkin.ihavemoney.serialization.adapters.FortuneEventAdapter" 73 | } 74 | event-adapter-bindings { 75 | "ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol$FortuneIncreased" = fortune 76 | "ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol$FortuneSpent" = fortune 77 | "ru.pavkin.ihavemoney.proto.events.PBFortuneSpent" = fortune 78 | "ru.pavkin.ihavemoney.proto.events.PBFortuneIncreased" = fortune 79 | } 80 | } 81 | 82 | akka-persistence-jdbc { 83 | slick { 84 | driver = "slick.driver.PostgresDriver" 85 | db { 86 | url = "jdbc:postgresql://"${app.db.host}":"${app.db.port}"/"${app.db.name} 87 | user = ${app.db.user} 88 | password = ${app.db.password} 89 | driver = "org.postgresql.Driver" 90 | keepAliveConnection = on 91 | numThreads = 2 92 | queueSize = 100 93 | } 94 | } 95 | 96 | tables { 97 | journal { 98 | tableName = "journal" 99 | schemaName = "" 100 | columnNames { 101 | persistenceId = "persistence_id" 102 | sequenceNumber = "sequence_number" 103 | created = "created" 104 | tags = "tags" 105 | message = "message" 106 | } 107 | } 108 | 109 | deletedTo { 110 | tableName = "deleted_to" 111 | schemaName = "" 112 | columnNames = { 113 | persistenceId = "persistence_id" 114 | deletedTo = "deleted_to" 115 | } 116 | } 117 | 118 | snapshot { 119 | tableName = "snapshot" 120 | schemaName = "" 121 | columnNames { 122 | persistenceId = "persistence_id" 123 | sequenceNumber = "sequence_number" 124 | created = "created" 125 | snapshot = "snapshot" 126 | } 127 | } 128 | } 129 | 130 | query { 131 | separator = "," 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /serialization/src/main/scala/ru/pavkin/ihavemoney/serialization/implicits.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.serialization 2 | 3 | import java.time.OffsetDateTime 4 | 5 | import com.trueaccord.scalapb.GeneratedMessageCompanion 6 | import ru.pavkin.ihavemoney.domain.CommandEnvelope 7 | import ru.pavkin.ihavemoney.domain.fortune.{Currency, FortuneId} 8 | import ru.pavkin.ihavemoney.domain.fortune.FortuneProtocol._ 9 | import ru.pavkin.ihavemoney.proto.commands.{PBCommandEnvelope, PBReceiveIncome, PBSpend} 10 | import ru.pavkin.ihavemoney.proto.events.{PBFortuneIncreased, PBFortuneSpent, PBMetadata} 11 | import ru.pavkin.utils.option._ 12 | import ProtobufSuite.syntax._ 13 | import ru.pavkin.ihavemoney.proto.commands.PBCommandEnvelope.Command.{Command1, Command2, Empty} 14 | 15 | object implicits { 16 | def deserializeFortuneMetadata(m: PBMetadata): FortuneMetadata = 17 | MetadataSerialization.deserialize[FortuneMetadata, FortuneId](FortuneMetadata, FortuneId(_), OffsetDateTime.parse)(m) 18 | 19 | implicit val fortuneIncreasedSuite: ProtobufSuite[FortuneIncreased, PBFortuneIncreased] = 20 | new ProtobufSuite[FortuneIncreased, PBFortuneIncreased] { 21 | def encode(m: FortuneIncreased): PBFortuneIncreased = PBFortuneIncreased( 22 | m.amount.toString, 23 | m.currency.code, 24 | m.category.name, 25 | Some(MetadataSerialization.serialize(m.metadata)), 26 | m.comment.getOrElse("")) 27 | def decode(p: PBFortuneIncreased): FortuneIncreased = FortuneIncreased( 28 | BigDecimal(p.amount), 29 | Currency.unsafeFromCode(p.currency), 30 | IncomeCategory(p.category), 31 | deserializeFortuneMetadata(p.metadata.get), 32 | notEmpty(p.comment) 33 | ) 34 | def companion = PBFortuneIncreased 35 | } 36 | 37 | implicit val fortuneSpentSuite: ProtobufSuite[FortuneSpent, PBFortuneSpent] = 38 | new ProtobufSuite[FortuneSpent, PBFortuneSpent] { 39 | def encode(m: FortuneSpent): PBFortuneSpent = PBFortuneSpent( 40 | m.amount.toString, 41 | m.currency.code, 42 | m.category.name, 43 | Some(MetadataSerialization.serialize(m.metadata)), 44 | m.comment.getOrElse("")) 45 | def decode(p: PBFortuneSpent): FortuneSpent = FortuneSpent( 46 | BigDecimal(p.amount), 47 | Currency.unsafeFromCode(p.currency), 48 | ExpenseCategory(p.category), 49 | deserializeFortuneMetadata(p.metadata.get), 50 | notEmpty(p.comment) 51 | ) 52 | def companion = PBFortuneSpent 53 | } 54 | 55 | implicit val receiveIncomeSuite: ProtobufSuite[ReceiveIncome, PBReceiveIncome] = 56 | new ProtobufSuite[ReceiveIncome, PBReceiveIncome] { 57 | def encode(m: ReceiveIncome): PBReceiveIncome = PBReceiveIncome( 58 | m.amount.toString, 59 | m.currency.code, 60 | m.category.name, 61 | m.comment.getOrElse("") 62 | ) 63 | 64 | def decode(p: PBReceiveIncome): ReceiveIncome = ReceiveIncome( 65 | BigDecimal(p.amount), 66 | Currency.unsafeFromCode(p.currency), 67 | IncomeCategory(p.category), 68 | notEmpty(p.comment) 69 | ) 70 | def companion = PBReceiveIncome 71 | } 72 | 73 | implicit val spendSuite: ProtobufSuite[Spend, PBSpend] = 74 | new ProtobufSuite[Spend, PBSpend] { 75 | def encode(m: Spend): PBSpend = PBSpend( 76 | m.amount.toString, 77 | m.currency.code, 78 | m.category.name, 79 | m.comment.getOrElse("") 80 | ) 81 | 82 | def decode(p: PBSpend): Spend = Spend( 83 | BigDecimal(p.amount), 84 | Currency.unsafeFromCode(p.currency), 85 | ExpenseCategory(p.category), 86 | notEmpty(p.comment) 87 | ) 88 | def companion = PBSpend 89 | } 90 | 91 | implicit val commandEnvelopeSuite: ProtobufSuite[CommandEnvelope, PBCommandEnvelope] = 92 | new ProtobufSuite[CommandEnvelope, PBCommandEnvelope] { 93 | def encode(m: CommandEnvelope): PBCommandEnvelope = PBCommandEnvelope( 94 | m.aggregateId, 95 | m.command match { 96 | case f: FortuneCommand ⇒ f match { 97 | case s: ReceiveIncome ⇒ PBCommandEnvelope.Command.Command1(s.encode) 98 | case s: Spend ⇒ PBCommandEnvelope.Command.Command2(s.encode) 99 | } 100 | case other ⇒ throw new Exception(s"Unknown domain command ${other.getClass.getName}") 101 | } 102 | ) 103 | def decode(p: PBCommandEnvelope): CommandEnvelope = CommandEnvelope( 104 | p.aggregateId, 105 | p.command match { 106 | case Empty => throw new Exception(s"Received empty command envelope") 107 | case Command1(value) => value.decode 108 | case Command2(value) => value.decode 109 | } 110 | ) 111 | def companion: GeneratedMessageCompanion[PBCommandEnvelope] = PBCommandEnvelope 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /js-app/src/main/scala/ru/pavkin/ihavemoney/frontend/components/AddTransactionsComponent.scala: -------------------------------------------------------------------------------- 1 | package ru.pavkin.ihavemoney.frontend.components 2 | 3 | import cats.data.Xor 4 | import japgolly.scalajs.react._ 5 | import japgolly.scalajs.react.vdom.all._ 6 | import org.scalajs.dom.raw.HTMLInputElement 7 | import ru.pavkin.ihavemoney.domain.fortune.Currency 8 | import ru.pavkin.ihavemoney.frontend.api 9 | import ru.pavkin.utils.option._ 10 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 11 | import scala.util.Try 12 | 13 | object AddTransactionsComponent { 14 | 15 | case class State(fortuneId: String, 16 | currency: String, 17 | amount: String, 18 | category: String, 19 | comment: String) 20 | class Backend($: BackendScope[Unit, State]) { 21 | 22 | val fortuneIdInput = Ref[HTMLInputElement]("fortuneIdInput") 23 | val amountInput = Ref[HTMLInputElement]("amountInput") 24 | val currencyInput = Ref[HTMLInputElement]("currencyInput") 25 | val categoryInput = Ref[HTMLInputElement]("categoryInput") 26 | val commentInput = Ref[HTMLInputElement]("commentInput") 27 | 28 | def onTextChange(change: (State, String) ⇒ State)(e: ReactEventI) = { 29 | val newValue = e.target.value 30 | $.modState(change(_, newValue)) 31 | } 32 | 33 | def onFormSubmit(e: ReactEventI) = e.preventDefaultCB 34 | 35 | def onIncomeSubmit(state: State)(e: ReactEventI) = e.preventDefaultCB >> Callback { 36 | if (!isValid(state)) 37 | Callback.alert("Invalid data").runNow() 38 | else 39 | api.addIncome( 40 | state.fortuneId, 41 | BigDecimal(state.amount), 42 | Currency.unsafeFromCode(state.currency), 43 | state.category, 44 | notEmpty(state.comment) 45 | ).map { 46 | case Xor.Left(error) ⇒ Callback.alert(s"Error: $error").runNow() 47 | case _ ⇒ Callback.alert(s"Success") 48 | } 49 | } 50 | 51 | def onExpenseSubmit(state: State)(e: ReactEventI) = e.preventDefaultCB >> Callback { 52 | if (!isValid(state)) 53 | Callback.alert("Invalid data").runNow() 54 | else 55 | api.addExpense( 56 | state.fortuneId, 57 | BigDecimal(state.amount), 58 | Currency.unsafeFromCode(state.currency), 59 | state.category, 60 | notEmpty(state.comment) 61 | ).map { 62 | case Xor.Left(error) ⇒ Callback.alert(s"Error: $error").runNow() 63 | case _ ⇒ Callback.alert(s"Success") 64 | } 65 | } 66 | 67 | 68 | def isValid(s: State) = 69 | s.fortuneId.nonEmpty && 70 | Try(BigDecimal(s.amount)).isSuccess && 71 | Currency.isCurrency(s.currency) && 72 | s.category.nonEmpty 73 | 74 | def render(state: State) = { 75 | val valid = isValid(state) 76 | form( 77 | className := "form-horizontal", 78 | onSubmit ==> onFormSubmit, 79 | div(className := "form-group", 80 | label(htmlFor := "fortuneIdInput", className := "col-sm-2 control-label", "Fortune ID"), 81 | div(className := "col-sm-10", 82 | input( 83 | ref := fortuneIdInput, 84 | required := true, 85 | tpe := "text", 86 | className := "form-control", 87 | id := "fortuneIdInput", 88 | placeholder := "Fortune Id", 89 | value := state.fortuneId, 90 | onChange ==> onTextChange((s, v) ⇒ s.copy(fortuneId = v)) 91 | ) 92 | ) 93 | ), 94 | div(className := "form-group", 95 | label(htmlFor := "amountInput", className := "col-sm-2 control-label", "Amount"), 96 | div(className := "col-sm-8", input( 97 | ref := amountInput, 98 | required := true, 99 | tpe := "number", 100 | min := 0.0, 101 | step := 0.01, 102 | className := "form-control", 103 | id := "amountInput", 104 | placeholder := "Amount", 105 | value := state.amount, 106 | onChange ==> onTextChange((s, v) ⇒ s.copy(amount = v)) 107 | )), 108 | div(className := "col-sm-2", select( 109 | ref := currencyInput, 110 | required := true, 111 | className := "form-control", 112 | id := "currencyInput", 113 | value := state.currency, 114 | onChange ==> onTextChange((s, v) ⇒ s.copy(currency = v)), 115 | List("USD", "EUR", "RUR").map(option(_)) 116 | )) 117 | ), 118 | div(className := "form-group", 119 | label(htmlFor := "categoryInput", className := "col-sm-2 control-label", "Category"), 120 | div(className := "col-sm-10", 121 | input( 122 | ref := categoryInput, 123 | required := true, 124 | tpe := "text", 125 | className := "form-control", 126 | id := "categoryInput", 127 | placeholder := "Category", 128 | value := state.category, 129 | onChange ==> onTextChange((s, v) ⇒ s.copy(category = v)) 130 | ) 131 | ) 132 | ), 133 | div(className := "form-group", 134 | label(htmlFor := "commentInput", className := "col-sm-2 control-label", "Comment"), 135 | div(className := "col-sm-10", 136 | input( 137 | ref := commentInput, 138 | tpe := "text", 139 | className := "form-control", 140 | id := "commentInput", 141 | placeholder := "Comment", 142 | value := state.comment, 143 | onChange ==> onTextChange((s, v) ⇒ s.copy(comment = v)) 144 | ) 145 | ) 146 | ), 147 | div(className := "form-group", 148 | div(className := "col-sm-offset-2 col-sm-10", 149 | button(tpe := "submit", className := "btn btn-success", disabled := (!valid), 150 | onClick ==> onIncomeSubmit(state), "Income"), 151 | button(tpe := "submit", className := "btn btn-danger", disabled := (!valid), 152 | onClick ==> onExpenseSubmit(state), "Expense") 153 | ) 154 | ) 155 | ) 156 | } 157 | } 158 | val component = ReactComponentB[Unit]("AddTransactionsComponent") 159 | .initialState(State("MyFortune", "USD", "1000", "Salary", "")) 160 | .renderBackend[Backend] 161 | .build 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I Have Money 2 | Rich-featured example of event-sourced application with full CQRS support. 3 | 4 | Built with akka, fun-cqrs, circe, shapeless, scalajs, scalajs-react and other libraries. 5 | 6 | ## Domain and Purpose 7 | "I Have Money" is an application for tracking expenses, income and assets with multi-currency support. 8 | 9 | Example version supports only Income and Expense commands on the write-side, and querying current balance from the read-side. 10 | 11 | ## Features 12 | The intent of the example is to solve several problems, that arise for ES/CQRS applications, and get the solutions to work together. From this standpoint "I Have Money" has following features: 13 | 14 | * Totally separated command and query services 15 | * Domain logic encoded with the help of [fun-cqrs](https://github.com/strongtyped/fun-cqrs). 16 | * AggregateId based [cluster sharding](http://doc.akka.io/docs/akka/2.4.3/scala/cluster-sharding.html) for write backend 17 | * Efficient [Protobuf](https://developers.google.com/protocol-buffers/) serialization with the help of [sbt-scalapb](https://github.com/trueaccord/sbt-scalapb) and [protoc-jar](https://github.com/os72/protoc-jar) (command side only). 18 | * Read and write backends are hidden behind respective HTTP API frontends (built with [akka-http](http://doc.akka.io/docs/akka/2.4.3/scala/http/)), that validate requests and forward them to the backends. 19 | * PostgreSQL based journal and query-side databases. 20 | * Schemas defined with [Slick](http://slick.typesafe.com/). 21 | * Migrations are managed with [Flyway](https://flywaydb.org/). 22 | * Akka Persistence and Persistent Query implementations are supplied by [akka-persistence-jdbc](https://github.com/dnvriend/akka-persistence-jdbc). Some gotchas here: 23 | * Manual workaround is needed to handle [differences](https://github.com/strongtyped/fun-cqrs/issues/49) in Persistent Query offset interpretation between fun-cqrs and akka-persistence-jdbc. 24 | * Event stream for persistent query is _live_ only when respective persistent actors [are on the same machine](https://github.com/dnvriend/akka-persistence-jdbc/issues/39). A live stream has to be constructed by hand in case of distributed scenario, when one node is writing the journal and another is polling it for a query side projection. 25 | * Each application can be assembled in a single .jar file and wrapped in a docker container for easier deployment (see [Launching with Docker](#running-the-example)). 26 | * A web UI is served by the Read Frontend application. It's mobile friendly and written with [scalajs-react](https://github.com/japgolly/scalajs-react). 27 | * Single HTTP protocol definition is [cross-compiled](https://www.scala-js.org/doc/project/cross-build.html) and used by both web UI and HTTP frontends. [Circe](http://circe.io) takes care of JSON serialization. 28 | 29 | ## Applications 30 | 31 | Each of listed applications can be launched on separate node, with write backend being able to work on a cluster. 32 | 33 | Single machine deployment is also supported. 34 | 35 | ### Write Backend 36 | Receives domain commands from Write Frontend via cluster recipient, handles them and stores resulting events in a postgreSQL journal. Can be deployed in a sharded cluster. 37 | 38 | ### Read Backend 39 | Polls the event stream from the journal, projects it to the current "state" (active balance in "I Have Money" domain) and stores it into a query-side postgreSQL database. 40 | 41 | Also it handles query messages from Read Frontend and responds with current balance for requested aggregate. 42 | 43 | ### Write Frontend 44 | HTTP API for sending commands to the system. After validating the HTTP request transforms it and sends to the Write Backend cluster. 45 | 46 | Can be also used for circuit breaking (not implemented in this example) 47 | 48 | ### Read Frontend 49 | HTTP API for sending queries. Transforms HTTP requests to Read Backend messages and forwards them there. 50 | 51 | Also serves the web UI, that can be used to send commands and queries in a visual way. 52 | 53 | ## Other modules 54 | 55 | ### Domain 56 | Domain entities and behaviour definitions. Some data classes are cross-compiled to be available for Frontend Protocol module. 57 | 58 | ### Serialization 59 | Contains tools for converting between storage/network and domain message formats. 60 | 61 | Defines protobuf protocols for write-side messages. 62 | 63 | ### Frontend Protocol 64 | Cross-project, that contains message protocol definitions for read-frontend and write-frontend. Is used both by web UI and read/write backends. 65 | 66 | Cross-compilation from single source guarantees protocol implementations consistency at compile time. 67 | 68 | ### JS App (Web UI) 69 | HTML interface written in scala-js on top of scalajs-react framework. Allows for sending commands to Write Frontend and sending queries to Read Frontend. 70 | 71 | ## Running the example 72 | 73 | Default configuration deploys all the apps on the localhost and uses same database server (but different DBs) for write and read sides. 74 | 75 | All configuration required to set up a distributed deployment can be defined through run parameters or environment variables. 76 | 77 | Next we'll see how to run the example on localhost with default configuration. There are two steps to be made, and each has two options. You can choose on your preference or availability. 78 | 79 | ###1. Setting up PostgreSQL database 80 | 81 | #### Docker image 82 | if you don't have Postgres installed, just run `./docker-postgres.sh`. This will run a docker container with PostgreSQL instance that is already configure with all "I Have Money" schemas. 83 | 84 | The container will use port 5432 of your host machine or docker VM. Of course, docker has to be configured for this to run. 85 | 86 | #### Locally installed PostgreSQL 87 | Those, who **have postgreSQL installed locally**: 88 | 89 | * Either add "admin" user with password "changeit", or change the credentials in build.sbt or docker launch scripts (depending on the way you are going to launch the app). 90 | * Run `sbt readBackend/flywayMigrate` and `sbt writeBackend/flywayMigrate` to prepare the schema. 91 | * Ensure that either your Postgres instance is available on localhost:5432 or change the host/port in build.sbt/launch scripts. 92 | 93 | ###2. Running the apps 94 | 95 | #### Running with sbt 96 | In most cases it's enough to do just this: 97 | 98 | ```bash 99 | sbt writeBackend/run 100 | sbt readBackend/run 101 | sbt writeFrontend/run 102 | sbt readFrontend/run 103 | ``` 104 | Everything will work with default settings if you have PostgreSQL on 127.0.0.0:5432 105 | 106 | #### Running with docker 107 | First build all the containers: 108 | 109 | ```bash 110 | sbt docker 111 | ``` 112 | 113 | On Linux run `./docker-all-local.sh`. 114 | On MacOS X run `./docker-all-vm.sh` 115 | 116 | ## Open the web UI 117 | 118 | Go to 127.0.0.1:8201 with your favourite browser. 119 | 120 | Change the IP to Docker VM IP in case running with docker on MacOS X. 121 | -------------------------------------------------------------------------------- /read-frontend/src/main/resources/bootstrap.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.6 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} 6 | /*# sourceMappingURL=bootstrap.min.css.map */ --------------------------------------------------------------------------------