bitcoin-atm

bitcoin atm for pyc inc.

git clone https://9o.is/git/bitcoin-atm.git

commit 4bfeb741370aaa76b9567d3498752d021a075d95
parent 397380c2c636a4d44598792d13544f6d33e06e10
Author: Jul <jul@9o.is>
Date:   Thu,  9 Oct 2014 11:23:13 -0400

update.

Diffstat:
M.gitignore | 6++++--
Abin/installDynamoDbLocal | 30++++++++++++++++++++++++++++++
Abin/runDynamoDbLocal | 10++++++++++
Mproject/Build.scala | 13+++++++------
Mproject/BuildSettings.scala | 15++++-----------
Msrc/main/resources/application.conf | 42+++++++++++++++++++++++++++---------------
Dsrc/main/resources/reference.conf | 5-----
Msrc/main/scala/bootstrap/liftweb/Boot.scala | 67++++++++++++++++++++++++++++---------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/BitcoinService.scala | 41-----------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/BitcoinSystem.scala | 31-------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/PriceTicker.scala | 159-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/Wallet.scala | 169-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BitStamp.scala | 48------------------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BitcoinExchange.scala | 20--------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BitcoinPriceTicker.scala | 33---------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BitcoinServiceActor.scala | 15---------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BitcoinWallet.scala | 220-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BlockChain.scala | 35-----------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/BtcWallet.scala | 26--------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/HttpBitcoinService.scala | 37-------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/JsonRPC.scala | 136-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/bitcoin/service/WsBitcoinService.scala | 201-------------------------------------------------------------------------------
Asrc/main/scala/inc/pyc/chimera/Bitcoin.scala | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Bus.scala | 27+++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Data.scala | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Messages.scala | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Msg.scala | 20++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Network.scala | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Overlord.scala | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/State.scala | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/System.scala | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/Topic.scala | 24++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/User.scala | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/main/scala/inc/pyc/chimera/config/ErrorHandler.scala | 59-----------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/config/Machine.scala | 77-----------------------------------------------------------------------------
Asrc/main/scala/inc/pyc/chimera/ddb/DDB.scala | 31+++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/ddb/Expenditure.scala | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/ddb/PhoneNumber.scala | 16++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/ddb/ReceiptEmail.scala | 16++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/ddb/Transaction.scala | 27+++++++++++++++++++++++++++
Dsrc/main/scala/inc/pyc/chimera/lib/CRC.scala | 65-----------------------------------------------------------------
Asrc/main/scala/inc/pyc/chimera/lib/ErrorHandler.scala | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/main/scala/inc/pyc/chimera/lib/Network.scala | 81-------------------------------------------------------------------------------
Asrc/main/scala/inc/pyc/chimera/lib/Receipt.scala | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/lib/SmtpMailer.scala | 34++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/lib/Support.scala | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/lib/Twilio.scala | 23+++++++++++++++++++++++
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/AcceptorCommands.scala | 55-------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/BillAcceptor.scala | 249-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/Driver.scala | 182-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/DriverCommands.scala | 83-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Commands.scala | 526-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Decoder.scala | 91-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Driver.scala | 196-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Fsm.scala | 314-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/lib/currency/Currency.scala | 13-------------
Dsrc/main/scala/inc/pyc/chimera/lib/currency/USD.scala | 13-------------
Dsrc/main/scala/inc/pyc/chimera/lib/currency/package.scala | 48------------------------------------------------
Msrc/main/scala/inc/pyc/chimera/lycia/Lycia.scala | 16+++++++++++++---
Asrc/main/scala/inc/pyc/chimera/lycia/StateWatcher.scala | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/DDB.scala | 20++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Expenditure.scala | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Factory.scala | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/General.scala | 35+++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Http.scala | 20++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/LocalDB.scala | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Minion.scala | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/PhoneNumber.scala | 31+++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/ReceiptEmail.scala | 31+++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Ticker.scala | 38++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Transaction.scala | 30++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/User.scala | 36++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Verify.scala | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/minions/Wallet.scala | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/model/CompletedTransaction.scala | 39+++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/model/IncompleteTransaction.scala | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/main/scala/inc/pyc/chimera/model/Transaction.scala | 136-------------------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/snippet/BitcoinAddressScanner.scala | 66------------------------------------------------------------------
Dsrc/main/scala/inc/pyc/chimera/snippet/Comet.scala | 85-------------------------------------------------------------------------------
Asrc/main/scala/inc/pyc/chimera/snippet/Logger.scala | 39+++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/snippet/Overlord.scala | 33+++++++++++++++++++++++++++++++++
Asrc/main/scala/inc/pyc/chimera/snippet/PriceTicker.scala | 17+++++++++++++++++
Dsrc/main/scala/inc/pyc/chimera/snippet/RoundTripSnippet.scala | 34----------------------------------
Asrc/main/scala/inc/pyc/chimera/snippet/StateWatcher.scala | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/inc/pyc/chimera/snippet/UtilSnips.scala | 26++++++++------------------
Msrc/main/scala/inc/pyc/lift_akka/ClientActorBridge.scala | 4++--
Dsrc/main/webapp/404.html | 3---
Msrc/main/webapp/app/test/App.spec.js | 64++++------------------------------------------------------------
Dsrc/main/webapp/disconnected.html | 4----
Dsrc/main/webapp/malfunction.html | 4----
Asrc/main/webapp/templates-hidden/email/receipt.html | 660+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/webapp/templates-hidden/email/support.html | 652+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/BaseSpec.scala | 19+++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/MinionSpec.scala | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/MockComet.scala | 21+++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/MockConfig.scala | 28++++++++++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/MockMinion.scala | 25+++++++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/MockOverlord.scala | 25+++++++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/OverlordSpec.scala | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/scala/inc/pyc/chimera/SpecHelper.scala | 36++++++++++++++++++++++++++++++++++++
100 files changed, 5277 insertions(+), 3716 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,6 @@ +# local dynamo db +bin/_* + # use glob syntax. syntax: glob *.swp @@ -71,4 +74,4 @@ sbt-linuxlab.sh # backups and nohup *~ *.out -*.log -\ No newline at end of file +*.log diff --git a/bin/installDynamoDbLocal b/bin/installDynamoDbLocal @@ -0,0 +1,30 @@ +#!/bin/bash + +# **** Only tested on Ubuntu 14.04 **** + +# Install DynamoDB Local unit test dependency +# Usage:installDynamoDbLocal [DIR] +# Where DIR defaults to /opt + +set -e + +if [ $# == 0 ]; then PARENT=/opt; else PARENT="$1"; fi +TMP_DIR=/var/tmp/dynamoDB +TMP_FILE=$TMP_DIR/dynamo.tar.gz +DYNAMO_DIR=DynamoDBLocal +DEST=$PARENT/$DYNAMO_DIR + +mkdir -p $TMP_DIR +wget http://dynamodb-local.s3-website-us-west-2.amazonaws.com/dynamodb_local_latest -O $TMP_FILE +cd $TMP_DIR +if [ -d "$DEST/" ]; then + echo "Replacing $DEST/" + rm -rf $DEST/ +fi +mkdir -p $DEST +tar xzf $TMP_FILE && mv *.jar $DEST/ && mv DynamoDBLocal_lib/ $DEST/ +cd - >/dev/null +rm -rf $TMP_DIR +echo "Installed to $DEST/" + + diff --git a/bin/runDynamoDbLocal b/bin/runDynamoDbLocal @@ -0,0 +1,10 @@ +#!/bin/bash + +# Run DynamoDB Local unit test dependency +# Usage:launchDynamoDbLocal [DIR] +# Where DIR defaults to /opt + +if [ $# == 0 ]; then DEST=/opt; else DEST="$1"; fi +DIR=$DEST/DynamoDBLocal + +java -Djava.library.path="$DIR" -jar $DIR/DynamoDBLocal.jar diff --git a/project/Build.scala b/project/Build.scala @@ -12,15 +12,15 @@ object LiftProjectBuild extends Build { "net.liftweb" %% "lift-webkit" % Ver.lift, "net.liftweb" %% "lift-mapper" % Ver.lift, "net.liftmodules" %% ("extras_"+Ver.lift_edition) % "0.4-SNAPSHOT", - "com.typesafe.akka" %% "akka-actor" % "2.3.3", - "com.typesafe.akka" %% "akka-agent" % "2.3.3", - "com.typesafe.akka" %% "akka-testkit" % "2.3.3", + "com.typesafe.akka" %% "akka-actor" % "2.3.6", "net.databinder.dispatch" %% "dispatch-core" % "0.11.1", "net.databinder.dispatch" %% "dispatch-lift-json" % "0.11.0", - "com.markgoldenstein" %% "bitcoin-akka" % "0.1-SNAPSHOT", - "com.palletops" % "java-websocket" % "1.3.1-SNAPSHOT", - "com.github.jodersky" %% "flow" % "2.0.3", "com.github.jodersky" % "flow-native" % "2.0.3", + "inc.pyc" %% "currency-lib" % "0.1", + "inc.pyc" %% "bitcoin" % "0.1-SNAPSHOT", + "inc.pyc" %% "bill-acceptor-core" % "0.1-SNAPSHOT", + "inc.pyc" %% "bill-acceptor-apex" % "0.1-SNAPSHOT", + "com.github.seratch" %% "awscala" % "0.3.+", //"io.kamon" %% "kamon-core" % "0.3.2", //"io.kamon" %% "kamon-log-reporter" % "0.3.2", //"io.kamon" %% "kamon-system-metrics" % "0.3.2", @@ -28,6 +28,7 @@ object LiftProjectBuild extends Build { "org.eclipse.jetty" % "jetty-webapp" % Ver.jetty % "container", "com.h2database" % "h2" % "1.2.138", "ch.qos.logback" % "logback-classic" % "1.0.13", + "com.typesafe.akka" %% "akka-testkit" % "2.3.6", "org.scalatest" %% "scalatest" % "1.9.2" % "test" ) ) diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala @@ -59,6 +59,7 @@ object BuildSettings { resolvers ++= Seq[Resolver]( "Sonatype Releases" at "http://oss.sonatype.org/content/repositories/releases", "Sonatype Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots", + s3resolver.value("PYC Releases", s3("releases-pyc-inc")), s3resolver.value("PYC Snapshots", s3("snapshots-pyc-inc")), "clojars.org" at "http://clojars.org/repo" ) @@ -92,6 +93,7 @@ object BuildSettings { //compile in Compile <<= (compile in Compile) dependsOn gruntBuild, // (start in container.Configuration) <<= (start in container.Configuration) dependsOn gruntBuild, Keys.`package` <<= (Keys.`package` in Compile) dependsOn gruntDefault, + Keys.`package` <<= (Keys.`package` in Test) dependsOn gruntDefault, test in Test <<= (test in Test) dependsOn gruntTest, // add javascript and css source files to the webapp, for development @@ -113,20 +115,11 @@ object BuildSettings { IO.copyDirectory(gdist, webapp) } }, - - publishTo := Some(s3resolver.value( - "My "+{if (isSnapshot.value) "snapshots-pyc-inc" else "releases-pyc-inc"}+" S3 bucket", - s3(if (isSnapshot.value) "snapshots-pyc-inc" else "releases-pyc-inc"))), - + s3credentials := { Path.userHome / ".ivy2" / ".s3credentials" }, - - s3region := com.amazonaws.services.s3.model.Region.US_Standard, - - publishMavenStyle := true, - publishArtifact in Test := false, - pomIncludeRepository := { _ => false }, + licenses := Seq("Apache 2.0 License" -> url("http://www.apache.org/licenses/LICENSE-2.0.html")), pomExtra := ( <url>https://bitbucket.org/pyd/chimera</url> diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf @@ -1,22 +1,35 @@ chimera { - id = "12345" - name = "test" + guid = "53f5121cd59af03d4de2111a" + secret = "Di1Z&aD7cQ*SQn$GzGo@TVG1Net%$mgU" + name = "Flat128" currency = "USD" + userDb = "http://127.0.0.1:8080" + + aws { + accessKey = "AKIAIWWIYXCAYI4HNBQA" + secretKey = "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" + } + + smtp { + host = "email-smtp.us-east-1.amazonaws.com" + port = 587 + auth = on + email = "noreply@pycbitcoin.com" + } + + techsupport { + phone = "(305) 735-1799" + email = "techsupport@pycbitcoin.com" + } + + twilio { + sid = "ACd274394cab1dc918b9968a7eac6f3a86" + token = "626b39c421774a7facdf4071ed996c32" + phone = "+15675100344" + } } -bill-acceptor { - driver = "ID003" - currency = "USD" - port = "/dev/ttyUSB0" - baud = 9600 - parity = 2 - char-size = 8 - buffer-size = 10 - two-stop-bits = false -} - - bitcoin { btcwallet { @@ -42,7 +55,6 @@ akka { loglevel = "INFO" remote { - # do not log remote messages since we will be sending logs to Lycia log-sent-messages = off log-received-messages = off diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf @@ -1,4 +0,0 @@ -bitcoin { - wallet = "BlockChain" - priceticker = "BitStamp" -} -\ No newline at end of file diff --git a/src/main/scala/bootstrap/liftweb/Boot.scala b/src/main/scala/bootstrap/liftweb/Boot.scala @@ -12,12 +12,8 @@ import net.liftweb.sitemap._ import Loc._ import inc.pyc.chimera._ import model._ -import lycia._ -import config._ import lib._ -import Machine._ -import inc.pyc.bitcoin._ - +import System._ /** * A class that's instantiated early and run. It allows the application @@ -25,28 +21,29 @@ import inc.pyc.bitcoin._ */ class Boot extends Loggable { def boot { - logger.info("Run Mode: "+Props.mode.toString) + + logger.info("Run Mode: " + Props.mode.toString) // where to search snippet LiftRules.addToPackages("inc.pyc.chimera") - + // set up Local DB this.setDB() // set the default htmlProperties - LiftRules.htmlProperties.default.set((r: Req) => new Html5Properties(r.userAgent)) + LiftRules.htmlProperties.default.set( + (r: Req) => new Html5Properties(r.userAgent)) // Build SiteMap LiftRules.setSiteMap(SiteMap(List( - Menu.i("Bitcoin ATM") / "index", - Menu.i("Disconnected") / "disconnected", - Menu.i("Malfunction") / "malfunction", - Menu.i("Error") / "error" >> Hidden, - Menu.i("404") / "404" >> Hidden - ): _*)) + Menu.i("Start") / "index", + Menu.i("Error") / "error" >> Hidden): _*)) // Error handler ErrorHandler.init + + // SMTP Configurations + SmtpMailer.init // 404 handler LiftRules.uriNotFound.prepend(NamedPF("404handler") { @@ -56,49 +53,41 @@ class Boot extends Loggable { // Force the request to be UTF-8 LiftRules.early.append(_.setCharacterEncoding("UTF-8")) - + // set name to generate correct minified js and css files LiftExtras.artifactName.default.set("pyc-0.0.1") // don't include the liftAjax.js code. It's served statically. LiftRules.autoIncludeAjaxCalc.default.set(() => (session: LiftSession) => false) - + // Does the following at system shutdown LiftRules.unloadHooks.append { - () => - system.shutdown() + () => + lycia.Lycia.stateWatcher ! akka.actor.PoisonPill + System.overlord ! akka.actor.PoisonPill + System.system.shutdown() } - - // Initialize price ticker - priceTicker.changeBitcoinService(Lycia.servicePriceTicker) - priceTicker.percentage(Lycia.percentProfit) - - // Initialize bitcoin wallet - wallet.changeBitcoinService(Lycia.serviceWallet) - wallet.initBalance - - // Initialize network monitor - Network(system).ping } - + def setDB(): Unit = { - + // set up connection if (!DB.jndiJdbcConnAvailable_?) { val vendor = - new StandardDBVendor(Props.get("db.driver") openOr "org.h2.Driver", - Props.get("db.url") openOr - "jdbc:h2:lift_proto.db;AUTO_SERVER=TRUE", - Props.get("db.user"), Props.get("db.password")) + new StandardDBVendor( + "org.h2.Driver", + "jdbc:h2:lift_proto.db;AUTO_SERVER=TRUE", + Full(Settings(system).name), + Full(Settings(system).secret)) LiftRules.unloadHooks.append(vendor.closeAllConnections_! _) - DB.defineConnectionManager(util.DefaultConnectionIdentifier, vendor) } - + // setup schemas - Schemifier.schemify(true, Schemifier.infoF _, Transaction) - + Schemifier.schemify(true, Schemifier.infoF _, CompletedTransaction) + Schemifier.schemify(true, Schemifier.infoF _, IncompleteTransaction) + // setup H2 login // H2 Console if (Props.devMode || Props.testMode) { diff --git a/src/main/scala/inc/pyc/bitcoin/BitcoinService.scala b/src/main/scala/inc/pyc/bitcoin/BitcoinService.scala @@ -1,41 +0,0 @@ -package inc.pyc.bitcoin - -import service._ -import akka.actor.Props - -/** - * The different choices for bitcoin services. - */ -object BitcoinService extends Enumeration { - type BitcoinService = Value - val BitStamp, BlockChain, BtcWallet = Value - - def priceTickers: Map[BitcoinService, Props] = Map( - BitStamp -> Props[BitStamp], - BlockChain -> Props[BlockChain] - ) - - def wallets: Map[BitcoinService, Props] = Map( - BtcWallet -> Props[BtcWallet], - BlockChain -> Props[BlockChain] - ) - - def getPriceTicker(s: String) = get(s, priceTickers) - def getPriceTicker(s: Value) = get(s, priceTickers) - def getWallet(s: String) = get(s, wallets) - def getWallet(s: Value) = get(s, wallets) - - private def get(s: Value, m: Map[BitcoinService, Props]) = { - m.filter(_._1 == s).map(_._2).head - } - - private def get(s: String, m: Map[BitcoinService, Props]) = { - m.filter(_._1.toString.toLowerCase == s.toLowerCase).map(_._2).head - } -} - - -/** - * Command to change the bitcoin service provider. - */ -case class ChangeBitcoinService(service: BitcoinService.Value) diff --git a/src/main/scala/inc/pyc/bitcoin/BitcoinSystem.scala b/src/main/scala/inc/pyc/bitcoin/BitcoinSystem.scala @@ -1,30 +0,0 @@ -package inc.pyc.bitcoin - -import akka.actor._ -import akka.event._ -import com.typesafe.config._ -import inc.pyc.lift_akka.LiftActorEventBus - - -/** - * Main bitcoin actor services - */ -object BitcoinSystem { - - /** - * Main config - */ - val config = ConfigFactory.load().getConfig("bitcoin") - - /** - * Event Bus for Bitcoin services. - */ - val bus = new LookupBitcoin -} - -/** - * PubSub for lift actors - */ -class LookupBitcoin extends LiftActorEventBus { - override protected def mapSize: Int = 50 -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/PriceTicker.scala b/src/main/scala/inc/pyc/bitcoin/PriceTicker.scala @@ -1,158 +0,0 @@ -package inc.pyc.bitcoin - -import service._ -import akka.actor._ -import akka.event._ -import akka.util.Timeout -import SupervisorStrategy._ -import akka.pattern.{ask, pipe} -import scala.concurrent._ -import duration._ -import inc.pyc.lift_akka.EventUpdate - - -object PriceTicker extends ExtensionId[PriceTickerImpl] with ExtensionIdProvider { - override def lookup = PriceTicker - override def createExtension(system: ExtendedActorSystem) = new PriceTickerImpl(system) - - /** - * Subscription topics for event bus. - */ - val newPrice = "newPrice" -} - -/** - * Price Ticker API implementation - */ -class PriceTickerImpl(system: ExtendedActorSystem) extends Extension { - import PriceTickerCommands._ - import system.dispatcher - - implicit private val timeout: Timeout = Timeout(5 seconds) - - /** - * The main actor that handles communications. - */ - private val actor = system.actorOf(Props[PriceTicker], "PriceTicker") - - /** - * The ticker that updates the price every 5 minutes. - */ - private val ticker = - system.scheduler.schedule(0 milliseconds, 5 minutes, actor, Tick) - - def price() = (actor ? GetPrice).mapTo[Price] - - def tick(): Unit = actor ! Tick - - def percentage(profit: Double): Unit = actor ! Percentage(profit) - - def gossipPrice(): Unit = actor ! GossipPrice - - def changeBitcoinService(service: BitcoinService.Value) = - actor ! ChangeBitcoinService(service) -} - - -/** - * Main Price Ticker actor that creates child actor and uses the chosen, - * configured service to get the price per bitcoin. - */ -class PriceTicker extends Actor with ActorLogging { - import PriceTickerCommands._ - import context.dispatcher - - override val supervisorStrategy = - OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) { - case _: java.net.ConnectException => Resume - case t => - super.supervisorStrategy.decider.applyOrElse(t, (_: Any) => Escalate) - } - - /** The current service to use to check price. */ - private var service: ActorRef = createServiceActor() - - context.watch(service) - - /** Price per bitcoin */ - private var price: String = "0.00" - - /** Percentage price over market */ - private var percentage: Double = 0 - - def receive = { - case Tick => - service ! Tick - - case GetPrice => - sender ! Price(priceWithPercentage) - - case GossipPrice => - self ! Price(price) - - case Percentage(profit) => - percentage = profit - self ! Price(price) - - case Price(newPrice) => - price = newPrice - BitcoinSystem.bus.publish(EventUpdate(PriceTicker.newPrice, Price(priceWithPercentage))) - - case ChangeBitcoinService(provider) => - if (context.child(provider.toString).isEmpty) { - context.unwatch(service) - context.stop(service) - service = createServiceActor(provider) - context.watch(service) - } - - case _ => - log.warning("Received Unknown Message") - } - - private def priceWithPercentage: String = { - val p = price.toDouble - val withPercentage = p + (p * (percentage * 0.01)) - Price(withPercentage toString) format - } - - private def createServiceActor(service: BitcoinService.Value): ActorRef = createServiceActor(service.toString) - - private def createServiceActor(service: String = BitcoinSystem.config.getString("priceticker")): ActorRef = { - context.actorOf(BitcoinService.getPriceTicker(service), service) - } -} - - -private[bitcoin] object PriceTickerCommands { - /** - * Price per bitcoin. - */ - case class Price(price: String) { - def format: String = "%1.2f" format (price.toDouble) - def double: Double = price.toDouble - } - - /** - * Command to send a broadcast of the buy price. - * Comet will be listening to update the UI. - */ - case object GossipPrice - - /** - * Command to send a unicast of the buy price. - */ - case object GetPrice - - /** - * Command to set a percentage price over market. - * If price over market is 5%, send 5, not 0.05, as the profit value. - */ - case class Percentage(profit: Double) - - /** - * Command to update the price per bitcoin. - * Sent frequently to update the price in the UI. - */ - case object Tick -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/Wallet.scala b/src/main/scala/inc/pyc/bitcoin/Wallet.scala @@ -1,168 +0,0 @@ -package inc.pyc.bitcoin - -import service.BitcoinJsonRPC -import akka.actor._ -import akka.event._ -import akka.util._ -import SupervisorStrategy._ -import akka.pattern.{ask, pipe} -import scala.concurrent._ -import duration._ -import BitcoinJsonRPC._ -import inc.pyc.lift_akka.EventUpdate - - -object Wallet extends ExtensionId[WalletImpl] with ExtensionIdProvider { - override def lookup = Wallet - override def createExtension(system: ExtendedActorSystem) = new WalletImpl(system) - - - /** - * Subscription topics for event bus. - */ - val newBalance = "newBalance" -} - - -/** - * Wallet API implementation - */ -class WalletImpl(system: ExtendedActorSystem) extends Extension { - import WalletCommands._ - - implicit private val timeout: Timeout = Timeout(5 seconds) - - private val actor = system.actorOf(Props[Wallet], "BitcoinWallet") - - def initBalance(): Unit = - actor ! InitBalance - - def gossipBalance(): Unit = - actor ! GossipBalance - - def createRawTransaction(inputs: Seq[(String, BigDecimal)], receivers: Seq[(String, BigDecimal)]) = - (actor ? CreateRawTransaction(inputs, receivers)).mapTo[String] - - def listUnspentTransactions(minConfirmations: BigDecimal = 1, maxConfirmations: BigDecimal = 999999) = - (actor ? ListUnspentTransactions(minConfirmations, maxConfirmations)).mapTo[Seq[UnspentTransaction]] - - def sendRawTransaction(signedTransaction: String) = - (actor ? SendRawTransaction(signedTransaction)).mapTo[String] - - def getBalance(): Future[String] = - (actor ? GetBalance).mapTo[String] - - def getNewAddress(): Future[String] = - (actor ? GetNewAddress).mapTo[String] - - def getRawTransaction(transactionHash: String) = - (actor ? GetRawTransaction(transactionHash)).mapTo[RawTransaction] - - def signRawTransaction(transaction: String): Future[SignedTransaction] = - (actor ? SignRawTransaction(transaction)).mapTo[SignedTransaction] - - def validateAddress(address: String): Future[AddressValidation] = - (actor ? ValidateAddress(address)).mapTo[AddressValidation] - - def changeBitcoinService(service: BitcoinService.Value) = - actor ! ChangeBitcoinService(service) -} - - -/** - * Main Bitcoin Wallet actor that creates child actor and uses the chosen, - * configured service as the bitcoin wallet. - */ -class Wallet extends Actor with ActorLogging { - import WalletCommands._ - import context.dispatcher - - implicit val timeout: Timeout = Timeout(5 seconds) - - override val supervisorStrategy = - OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) { - case _: java.net.ConnectException => Resume - case t => - super.supervisorStrategy.decider.applyOrElse(t, (_: Any) => Escalate) - } - - /** The current service to use to check price. */ - private var service: ActorRef = createServiceActor() - - context.watch(service) - - /** Balance remaining in the wallet */ - private var balance: Double = 0 - - def receive = { - case InitBalance => - val newBalance = (service ? GetBalance).mapTo[String] - newBalance map { newBalance => - balance = newBalance.toDouble - } - - case GetBalance => - sender ! Balance(balance) - - case GossipBalance => - self ! Balance(balance) - - case Balance(newBalance) => - balance = newBalance - BitcoinSystem.bus.publish(EventUpdate(Wallet.newBalance, Balance(balance))) - - case ChangeBitcoinService(provider) => - if (context.child(provider.toString).isEmpty) { - context.unwatch(service) - context.stop(service) - service = createServiceActor(provider) - context.watch(service) - } - - case ValidateAddress(address) => - val extracted = extractFromBitcoinUri(address) - service ? ValidateAddress(extracted) pipeTo sender - - - case _ => - log.warning("Received Unknown Message") - } - - private def createServiceActor(service: BitcoinService.Value): ActorRef = createServiceActor(service.toString) - - private def createServiceActor(service: String = BitcoinSystem.config.getString("wallet")): ActorRef = { - context.actorOf(BitcoinService.getWallet(service), service) - } - - /** - * Extracts bitcoin address from URI. - * - * Note the regex is not what the bitcoin address should exactly be, - * but it works for extraction. - */ - private def extractFromBitcoinUri(uri: String) = { - val r = "(bitcoin:)?([a-zA-Z0-9]{1,60})(/*?.*)?".r - uri match { - case r(_, address, _) => address - case _ => "" - } - } -} - - -private[bitcoin] object WalletCommands { - /** - * Balance remaining in bitcoin wallet. - */ - case class Balance(remaining: Double) - - /** - * Command to send a broadcast of the balance. - */ - case object GossipBalance - - /** - * Initializes the balance in wallet - */ - case object InitBalance -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BitStamp.scala b/src/main/scala/inc/pyc/bitcoin/service/BitStamp.scala @@ -1,47 +0,0 @@ -package inc.pyc.bitcoin -package service - -import dispatch._ -import akka.actor._ -import net.liftweb.json._ - -/** - * BitStamp REST services - * NOTE: Bitstamp is almost useless since it keeps asking for captcha -_- - */ -class BitStamp extends Actor - with ActorLogging - with HttpBitcoinExchange { - - import BitStamp._ - implicit val formats = DefaultFormats - - def receive = priceTicker - - protected val api = :/ ("www.bitstamp.net").secure / "api" - - protected val ticker_api = api / "ticker" / "" - - protected def buyPrice: String = { - (request(ticker_api).extract[BitStampPrices]).last - } -} - -object BitStamp { - /** - * Different prices returned by BitStamp's API. - * - * Note: Currently not configurable. 'last' is default. - * - * last - last BTC price (BitStamp's Current Price) - * high - last 24 hours price high - * low - last 24 hours price low - * vwap - last 24 hours volume weighted average price - * volume - last 24 hours volume - * bid - highest buy order - * ask - lowest sell order - */ - case class BitStampPrices( - last: String, high: String, low: String, vwap: String, volume: String, - bid: String, ask: String, timestamp: String) -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BitcoinExchange.scala b/src/main/scala/inc/pyc/bitcoin/service/BitcoinExchange.scala @@ -1,19 +0,0 @@ -package inc.pyc.bitcoin -package service - -import akka.actor._ - -/** - * Bitcoin Exchange service to buy and sell bitcoin - */ -sealed trait BitcoinExchange - - - -/** - * Bitcoin Exchange service to buy and sell bitcoin via HTTP - */ -trait HttpBitcoinExchange - extends HttpBitcoinService with HttpBitcoinPriceTicker { - this: Actor with ActorLogging => -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BitcoinPriceTicker.scala b/src/main/scala/inc/pyc/bitcoin/service/BitcoinPriceTicker.scala @@ -1,32 +0,0 @@ -package inc.pyc.bitcoin -package service - -import dispatch.Req -import akka.actor._ - -/** - * A Bitcoin service with buy/sell prices. - */ -sealed trait BitcoinPriceTicker - -trait HttpBitcoinPriceTicker extends HttpBitcoinService with BitcoinPriceTicker { - this: Actor with ActorLogging => - - import PriceTickerCommands._ - /** - * Price ticker API - */ - protected val ticker_api: Req - - /** - * Gets the buy price - */ - protected def buyPrice: String - - val priceTicker: Receive = { - case Tick => - val price = Price(buyPrice) - log.info("New Price: {}", price.format) - sender ! price - } -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BitcoinServiceActor.scala b/src/main/scala/inc/pyc/bitcoin/service/BitcoinServiceActor.scala @@ -1,14 +0,0 @@ -package inc.pyc.bitcoin -package service - -import akka.actor._ - -/** - * Any Bitcoin Service Actor must be this. - */ -trait BitcoinServiceActor { - this: Actor with ActorLogging => - - protected val serviceName: String = - this.getClass.getName.split("""\.""").last -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BitcoinWallet.scala b/src/main/scala/inc/pyc/bitcoin/service/BitcoinWallet.scala @@ -1,219 +0,0 @@ -package inc.pyc.bitcoin -package service - -import net.liftweb.json._ -import net.liftweb.util.Helpers.tryo -import JsonAST.JValue -import scala.collection._ -import scala.concurrent._, duration._ -import akka.util.Timeout -import com.typesafe.config.{Config, ConfigFactory} -import akka.actor._ -import JsonRPC._ -import BitcoinJsonRPC._ - -/** - * Actor to handle bitcoin wallet communications with JSON-RPC. - */ -sealed trait BitcoinWallet extends BitcoinServiceActor { - this: Actor with ActorLogging => - - /** Wallet configuration */ - protected val config: Config - protected val walletUri: String - protected val rpcUser: String - protected val rpcPass: String - protected val walletPass: String - - /** - * The wait time for a response. This timeout is also used to set - * the timeout for walletpassphrase command. - */ - protected implicit val timeout: Timeout = 5 seconds - - /** - * Checks wallet json response. Throws exception if - * response is invalid, else it logs the response. - */ - protected def checkWalletResponse(json: JsonResponse, method: String = "") { - json.either.left.map { - case err => - new RuntimeException("Wallet Command '"+method+"' Failed: " + err) - } - - json.either.right.map { - case r => - implicit val formats = DefaultFormats - log.info("\nWallet Command '{}' Success:\n{}", method, pretty(render(Extraction.decompose(r)))) - } - } -} - - -/** - * Actor to handle bitcoin wallet communications with JSON-RPC - * over Http. - */ -trait HttpBitcoinWallet extends HttpBitcoinService with BitcoinWallet { - this: Actor with ActorLogging => - - import dispatch._ - - val bitcoinWallet: Receive = { - case CreateRawTransaction(inputs, receivers) => - val json = JsonMessage.createRawTransaction(inputs, receivers) - sender ! requestExtract(json, _.extract[String]) - - case GetBalance => - val json = JsonMessage.getBalance - sender ! requestExtract(json, _.extract[String]) - - case GetNewAddress => - val json = JsonMessage.getNewAddress - sender ! requestExtract(json, _.extract[String]) - - case GetRawTransaction(transactionHash) => - val json = JsonMessage.getRawTransaction(transactionHash) - sender ! requestExtract(json, _.extract[RawTransaction]) - - case ListUnspentTransactions(minConfirmations, maxConfirmations) => - val json = JsonMessage.listUnspentTransactions(minConfirmations, maxConfirmations) - sender ! requestExtract(json, _.extract[List[UnspentTransaction]]) - - case SendRawTransaction(signedTransaction) => - val json = JsonMessage.sendRawTransaction(signedTransaction) - sender ! requestExtract(json, _.extract[String]) - - case SignRawTransaction(transaction) => - val json = JsonMessage.signRawTransaction(transaction) - sender ! requestExtract(json, _.extract[SignedTransaction]) - - case WalletPassPhrase(walletPass, timeout) => - val json = JsonMessage.walletPassPhrase(walletPass, timeout) - request(post(json)) // fire & forget - - case ValidateAddress(address) => - val json = JsonMessage.validateAddress(address) - sender ! requestExtract(json, _.extract[AddressValidation]) - } - - - private def post(msg: JValue): Req = - url(walletUri) <:< Map("Content-type" -> "application/json-rpc") << - compact(render(msg)) as_!(rpcUser, rpcPass) - - /* All-in-one func: makes request, extracts response, extracts data. */ - private def requestExtract[T](req: JsonRequest, extractor: JValue => T): T = { - val json = request(post(req)).extract[JsonResponse] - checkWalletResponse(json, req.method) - val result = json.result.getOrElse(JNull) - extractor(result) - } -} - - -/** - * Actor to handle bitcoin wallet communications with JSON-RPC - * over WebSocket. - */ -trait WsBitcoinWallet extends WsBitcoinService with BitcoinWallet { - this: Actor with ActorLogging => - - override val api = new java.net.URI(walletUri) - - /** - * Handles notifications incoming from server. - */ - protected def handleNotification: Receive - - - val bitcoinWallet: Receive = { - case CreateRawTransaction(inputs, receivers) => - val json = JsonMessage.createRawTransaction(inputs, receivers) - requestExtract(json.id, json, _.extract[String], "CreateRawTransaction" :: Nil) - - case GetBalance => - val json = JsonMessage.getBalance - requestExtract(json.id, json, _.extract[String], "GetBalance" :: Nil) - - case GetNewAddress => - val json = JsonMessage.getNewAddress - requestExtract(json.id, json, _.extract[String], "GetNewAddress" :: Nil) - - case GetRawTransaction(transactionHash) => - val json = JsonMessage.getRawTransaction(transactionHash) - requestExtract(json.id, json, _.extract[RawTransaction], "GetRawTransaction" :: Nil) - - case ListUnspentTransactions(minConfirmations, maxConfirmations) => - val json = JsonMessage.listUnspentTransactions(minConfirmations, maxConfirmations) - requestExtract(json.id, json, _.extract[List[UnspentTransaction]], "ListUnspentTransactions" :: Nil) - - case SendRawTransaction(signedTransaction) => - val json = JsonMessage.sendRawTransaction(signedTransaction) - requestExtract(json.id, json, _.extract[String], "SendRawTransaction" :: Nil) - - case SignRawTransaction(transaction) => - val json = JsonMessage.signRawTransaction(transaction) - requestExtract(json.id, json, _.extract[SignedTransaction], "SignRawTransaction" :: Nil) - - case WalletPassPhrase(walletPass, timeout) => - val json = JsonMessage.walletPassPhrase(walletPass, timeout) - requestForget(json) - - case ValidateAddress(address) => - val json = JsonMessage.validateAddress(address) - requestExtract(json.id, json, _.extract[AddressValidation], "ValidateAddress" :: Nil) - - case json @ JsonResponse(jsonrpc, id, errorOption, resultOption) => - requests.remove(id).foreach(req => { - val (p, extract, info) = req - checkWalletResponse(json, info.head) - json.either.left.map(p tryFailure new RuntimeException(_)) - json.either.right.map(p trySuccess extract(_)) - }) - - case n: NotificationMessage => - handleNotification.applyOrElse(n, unhandled) - } - - /* - * Handles notifications incoming from server. - */ - private def handleResponseNotification: PartialFunction[JsonNotification, Unit] = { - - /* New Transaction */ - case JsonNotification(_, "newtx", params) => - for { - tx <- tryo(params(1).extract[TransactionNotification]) - if tx.category == "receive" - } self ! ReceivedPayment(tx.txid, tx.address, tx.amount, tx.confirmations) - - case _ => // ignore - } - - override def onMessage(msg: JValue) = () => { - - // messages should be either JsonNotification or JsonResponse - // only notifications have jsonrpc field. - def isNotification: Boolean = - (msg find { - case JField("jsonrpc", _) => true - case _ => false - }).isDefined - - if(isNotification) - tryo(msg.extract[JsonNotification]).map( - handleResponseNotification.applyOrElse(_, unhandled)) - else - tryo(msg.extract[JsonResponse]).map(self ! _) - } -} - - -/** - * Actor to handle bitcoin wallet communications with JSON-RPC - * over Secure WebSocket. - */ -trait WssBitcoinWallet extends WssBitcoinService with WsBitcoinWallet { - this: Actor with ActorLogging => -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BlockChain.scala b/src/main/scala/inc/pyc/bitcoin/service/BlockChain.scala @@ -1,34 +0,0 @@ -package inc.pyc.bitcoin -package service - -import dispatch._ -import net.liftweb.json._ -import akka.actor._ - -/** - * BlockChain REST services - */ -class BlockChain extends Actor - with ActorLogging - with HttpBitcoinPriceTicker - with HttpBitcoinWallet { - - this: Actor with ActorLogging => - - def receive = bitcoinWallet orElse priceTicker - - protected val config = BitcoinSystem.config.getConfig("blockchain") - protected val walletUri = config.getString("wallet-uri") - protected val rpcUser = config.getString("rpc-user") - protected val rpcPass = config.getString("rpc-pass") - protected val walletPass = config.getString("wallet-pass") - - protected val api = :/ ("blockchain.info").secure - protected val ticker_api = api / "ticker" - - protected def buyPrice: String = - for (JField("USD", usd) <- request(ticker_api)) - yield compact(render(usd \ "last")) - - implicit def listStringToString(s: List[String]): String = s.head -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/BtcWallet.scala b/src/main/scala/inc/pyc/bitcoin/service/BtcWallet.scala @@ -1,25 +0,0 @@ -package inc.pyc.bitcoin -package service - -import net.liftweb.json._ -import akka.actor._ - -class BtcWallet extends Actor - with ActorLogging - with WssBitcoinWallet { - - val config = BitcoinSystem.config.getConfig("btcwallet") - val walletUri = config.getString("wallet-uri") - val rpcUser = config.getString("rpc-user") - val rpcPass = config.getString("rpc-pass") - val walletPass = config.getString("wallet-pass") - val keyStoreFile = new java.io.File(config.getString("keystore-file")) - val keyStorePass = config.getString("keystore-pass") - - def receive = bitcoinWallet orElse websocket - - def handleNotification = { - case _ => - } - -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/HttpBitcoinService.scala b/src/main/scala/inc/pyc/bitcoin/service/HttpBitcoinService.scala @@ -1,36 +0,0 @@ -package inc.pyc.bitcoin -package service - -import dispatch._ -import net.liftweb.json.JsonAST.JValue -import akka.actor._ - -/** - * Bitcoin service over HTTP communications. - */ -trait HttpBitcoinService extends BitcoinServiceActor { - this: Actor with ActorLogging => - - /** - * Service's API - */ - protected val api: Req - - /** - * Request headers - */ - protected val headers: Map[String, String] = Map() - - /** - * Pretend to be another browser. - */ - protected val userAgent = "Windows / Chrome 34: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.137 Safari/537.36" - - /** - * Request and get a json response. - */ - protected def request(req: Req): JValue = ({ - import Defaults._ - Http.configure(_.setUserAgent(userAgent))(req <:< headers > as.lift.Json): Future[JValue] - })() -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/JsonRPC.scala b/src/main/scala/inc/pyc/bitcoin/service/JsonRPC.scala @@ -1,135 +0,0 @@ -package inc.pyc.bitcoin -package service - -import net.liftweb._ -import json.JsonAST._ - -sealed trait JsonMessage - -/** - * For more information, visit - * http://json-rpc.org/wiki/specification - */ -object JsonRPC { - implicit val formats = json.DefaultFormats - - // JSON-RPC MESSAGES - case class JsonNotification(jsonrpc: String, method: String, params: JArray) extends JsonMessage - case class JsonRequest(jsonrpc: String, id: String, method: String, params: JArray) extends JsonMessage - case class JsonResponse(jsonrpc: String, id: String, error: Option[JValue], result: Option[JValue]) extends JsonMessage { - def either: Either[String, JValue] = - (result, error) match { - case (Some(result), _) => Right(result) - case (_, Some(error)) => Left((error \ "message").extract[String]) - case _ => Left("Unknown response") - } - } - - // JsonMessage to JValue implicit - implicit def jsonRequestToJValue(r: JsonRequest): JValue = { - JObject(List( - JField("jsonrpc", JString(r.jsonrpc)), - JField("id", JString(r.id)), - JField("method", JString(r.method)), - JField("params", r.params))) - } -} - -trait WalletMessage -trait NotificationMessage extends WalletMessage - -/** - * For more information, visit - * https://en.bitcoin.it/wiki/API_reference_(JSON-RPC) - */ -object BitcoinJsonRPC { - import JsonRPC._ - - // BITCOIN TRANSACTIONS - case class RawTransaction(hex: String, txid: String, version: BigDecimal, locktime: BigDecimal, vin: Seq[VIn], vout: Seq[VOut]) - case class VIn(txid: String, vout: Int, scriptSig: ScriptSig, sequence: BigDecimal) - case class VOut(value: BigDecimal, n: BigDecimal, scriptPubKey: ScriptPubKey) - case class ScriptSig(asm: String, hex: String) - case class ScriptPubKey(asm: String, hex: String, reqSigs: BigDecimal, `type`: String, addresses: Seq[String]) - case class UnspentTransaction(txid: String, account: String, address: String, amount: BigDecimal, confirmations: BigDecimal) - case class SignedTransaction(hex: String, complete: Boolean) - case class TransactionNotification(txid: String, account: String, address: String, - category: String, amount: BigDecimal, confirmations: BigDecimal, timereceived: BigDecimal) - - // OTHER BITCOIN MESSAGES - case class AddressValidation(isvalid: Boolean, address: String, - ismine: Option[Boolean], pubkey: Option[String], iscompressed: Option[Boolean]) - - // ACTOR MESSAGES - // notifications - case class ReceivedPayment(txId: String, address: String, amount: BigDecimal, confirmations: BigDecimal) extends NotificationMessage - - // requests - sealed trait RequestMessage extends WalletMessage - case class CreateRawTransaction(inputs: Seq[(String, BigDecimal)], receivers: Seq[(String, BigDecimal)]) extends RequestMessage - case object GetBalance extends RequestMessage - case object GetNewAddress extends RequestMessage - case class GetRawTransaction(transactionHash: String) extends RequestMessage - case class ListUnspentTransactions(minConfirmations: BigDecimal = 1, maxConfirmations: BigDecimal = 999999) extends RequestMessage - case class SendRawTransaction(signedTransaction: String) extends RequestMessage - case class SignRawTransaction(transaction: String) extends RequestMessage - case class WalletPassPhrase(walletPass: String, timeout: BigDecimal) extends RequestMessage - case class ValidateAddress(address: String) extends RequestMessage - - - /** - * Constructs JSON-RPC messages out of the existing actor messages related - * to Bitcoin's standard rpc-json commands. - */ - object JsonMessage { - def createRawTransaction(inputs: Seq[(String, BigDecimal)], receivers: Seq[(String, BigDecimal)]) = - JsonRequest("1.0", Utils.getUUID, "createrawtransaction", JArray( - inputs.map(i => JObject(List("txid" -> i._1, "vout" -> i._2))).toList :: - receivers.map(r => JObject(List(r._1 -> r._2))).toList)) - - def getBalance = - JsonRequest("1.0", Utils.getUUID, "getbalance", Seq()) - - def getNewAddress = - JsonRequest("1.0", Utils.getUUID, "getnewaddress", Seq()) - - def getRawTransaction(transactionHash: String) = - JsonRequest("1.0", Utils.getUUID, "getrawtransaction", JArray(List(transactionHash, 1))) - - def listUnspentTransactions(minConfirmations: BigDecimal, maxConfirmations: BigDecimal, addresses: Seq[String] = Seq.empty[String]) = { - val params = - if (addresses.isEmpty) JArray(List(minConfirmations, maxConfirmations)) - else JArray(List(minConfirmations, maxConfirmations, addresses)) - JsonRequest("1.0", Utils.getUUID, "listunspent", params) - } - - def sendRawTransaction(signedTransaction: String) = - JsonRequest("1.0", Utils.getUUID, "sendrawtransaction", Seq(signedTransaction)) - - def signRawTransaction(transaction: String) = - JsonRequest("1.0", Utils.getUUID, "signrawtransaction", Seq(transaction)) - - def walletPassPhrase(walletPass: String, timeout: BigDecimal) = - JsonRequest("1.0", Utils.getUUID, "walletpassphrase", JArray(List(walletPass, timeout))) - - def validateAddress(address: String) = - JsonRequest("1.0", Utils.getUUID, "validateaddress", Seq(address)) - } - - private object Utils { - def getUUID = java.util.UUID.randomUUID.toString - } - - // JValue implicits - implicit def intToJInt(i: Int): JInt = JInt(i) - implicit def stringToJString(s: String): JString = JString(s) - implicit def stringToJField(m: (String, String)): JField = JField(m._1, m._2) - - // BigDecimal to JValue implicits - implicit def bigdecimalToJField(m: (String, BigDecimal)): JField = JField(m._1, m._2) - implicit def bigdecimalToJDouble(d: BigDecimal): JDouble = JDouble(d.doubleValue) - - // Scala collections to JArray implicits - implicit def listToJArray(l: List[JValue]): JArray = JArray(l) - implicit def seqStringToJArray(l: Seq[String]): JArray = JArray(l.map(JString).toList) -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/bitcoin/service/WsBitcoinService.scala b/src/main/scala/inc/pyc/bitcoin/service/WsBitcoinService.scala @@ -1,200 +0,0 @@ -package inc.pyc.bitcoin -package service - -import java.net.{URI, ConnectException} -import concurrent._ -import duration._ -import collection._ -import collection.JavaConversions._ -import scala.util.{Success, Failure} -import akka.actor._ -import akka.pattern._ -import akka.util._ -import net.liftweb.json._ -import JsonAST.JValue -import org.java_websocket._ -import client._ -import handshake._ -import drafts._ - - -/** - * Bitcoin service over WebSocket communications. - */ -trait WsBitcoinService extends BitcoinServiceActor { - this: Actor with ActorLogging => - - import context.dispatcher - - protected implicit val timeout: Timeout - - /** - * Service's API - */ - protected val api: URI - - /** - * Handle json messages sent from server. - */ - protected def onMessage(msg: JValue): () => Unit - - /** - * Request headers - */ - protected val headers: Map[String,String] = Map() - - /** - * Maps request IDs to the corresponding response promises - * and a function that converts the JSON RPC response to the final actor response. - */ - protected val requests = mutable.HashMap.empty[String, (Promise[Any], JValue => _, List[String])] - - /** - * Executes on websocket initial connection. - */ - protected def onConnect: () => Unit = () => {} - - /** - * Request and forget. - */ - protected def requestForget(req: JValue): Unit = { - request(req) - } - - /** - * Request and extract promised json response. - * Note: Must be an actor making the request to receive response. - */ - protected def requestExtract[T](id: String, req: JValue, extractor: JValue => T, info: List[String] = Nil): Unit = { - - val p = Promise[Any]() - val f = p.future - requests += id -> (p, extractor, info) - request(req) - - context.system.scheduler.scheduleOnce(timeout.duration) { - p tryFailure { - new TimeoutException(s"Timeout: $serviceName bitcoin wallet did not respond in time") - } - self ! RemoveWsRequest(id) - } - - pipe(f) to sender - } - - override def preStart(): Unit = { - context.become(connecting) - tryToConnect() - } - - /** - * Receive when disconnected. - */ - def connecting: Receive = { - case Connected => - context.become(receive) - onConnect() - - case _ => - val message = s"Cannot process request: no connection to $serviceName websocket." - sender ! Status.Failure(new IllegalStateException(message)) - log.error(message) - } - - /** - * Receive when connected. - */ - val websocket: Receive = { - case RemoveWsRequest(id) => - requests -= id - - case Disconnected(reason) => - log.warning("Connection to {} bitcoin wallet closed: {}", serviceName, reason) - context.become(connecting) - tryToConnect() - } - - /** - * Tries to connect to the websocket. - * @param f function to manipulate the websocket object before trying to connect. - */ - protected def tryToConnect(f: WebSocketBtcWalletClient => WebSocketBtcWalletClient = walletClient => walletClient) { - requests.clear() - walletClient = new WebSocketBtcWalletClient(api, new Draft_17, headers, 0) - walletClient = f(walletClient) - val connected = walletClient.connectBlocking() - - if (connected) { - log.info("Connection to {} bitcoin wallet established", serviceName) - self ! Connected - } else { - new ConnectException(s"$serviceName bitcoin wallet not available: $api") - } - } - - /* Send request down websocket. */ - private def request(req: JValue) { walletClient.send(compact(render(req))) } - - /* Personal websocket class of the java-websocket library. */ - class WebSocketBtcWalletClient(serverUri: URI, protocolDraft: Draft, httpHeaders: Map[String, String], connectTimeout: Int) - extends WebSocketClient(serverUri, protocolDraft, headers, connectTimeout) { - override def onMessage(jsonMessage: String): Unit = { ws.onMessage(parse(jsonMessage)) } - override def onOpen(handshakeData: ServerHandshake) { } - override def onClose(code: Int, reason: String, remote: Boolean) { self ! Disconnected(reason) } - override def onError(ex: Exception) { self ! Disconnected(ex.getMessage()) } - } - - /* The actual websocket. */ - protected var walletClient: WebSocketBtcWalletClient = null - - /* Used by the websocket class to access actor methods with same name. */ - private def ws = this - - /* Websocket Messages */ - case object Connected - case class Disconnected(reason: String) - case class RemoveWsRequest(id: String) -} - -/** - * Bitcoin service over WebSocket communications with SSL. - */ -trait WssBitcoinService extends WsBitcoinService { - this: Actor with ActorLogging => - - import java.io._ - import java.security._ - import javax.net.ssl._ - - /** - * KeyStore file containing trusted certificates. - */ - val keyStoreFile: File - - /** - * Password of the KeyStore `keyStoreFile` - */ - val keyStorePass: String - - private val socketFactory = createSslSocketFactory - - override def tryToConnect(f: WebSocketBtcWalletClient => WebSocketBtcWalletClient = walletClient => walletClient) = - super.tryToConnect( walletClient => { - walletClient.setSocket(socketFactory.createSocket()) - walletClient - }) - - /** - * Creates a ssl socket factory for the websocket using the - * given keystore file and password. - */ - private def createSslSocketFactory: SSLSocketFactory = { - val ks = KeyStore.getInstance("JKS") - ks.load(new FileInputStream(keyStoreFile), keyStorePass.toCharArray) - val tmf = TrustManagerFactory.getInstance("SunX509") - tmf.init(ks) - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, tmf.getTrustManagers, null) - sslContext.getSocketFactory - } -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Bitcoin.scala b/src/main/scala/inc/pyc/chimera/Bitcoin.scala @@ -0,0 +1,160 @@ +package inc.pyc.chimera + +import lycia._ +import minions._ +import System._ +import inc.pyc._ +import bitcoin._ +import BitcoinService._ +import BitcoinJsonRPC._ +import akka.actor._ +import concurrent._ +import duration._ + +/** + * Overlord's extension for bitcoin library. + */ +trait Bitcoin extends Wallet with Ticker { + this: Overlord => + + /** + * Gets fiat valued balance in bitcoin wallet. + */ + def fiatBalance: FiatBalance = + FiatBalance(price.priceWithPercentage * balance.remaining) +} + + +trait Wallet { + this: FSM[State, Data] => + + /** + * Bitcoin wallet service + */ + var walletService: BitcoinService.Value = BlockChain + + /** + * Balance in bitcoin wallet + */ + var balance: Balance = Balance(0) + + /** + * Creates a Wallet minion. + */ + def walletMinion = + context.actorOf(Props(classOf[WalletMinion], walletService)) + + /** + * Run this function in preStart to enable the wallet. + */ + def initWallet { + walletService = Lycia.serviceWallet + self ! Lycia.percentProfit + walletMinion ! GetBalance + setTimer("wallet-balance", GetBalance, 3 minutes, true) + } + + /** + * Run this function in postStop to disable the wallet. + */ + def stopWallet { + cancelTimer("wallet-balance") + } + + /** + * Handle bitcoin wallet commands. + */ + def handleWallet: StateFunction = { + case Event(GetBalance, _) => + walletMinion ! GetBalance + stay + + case Event(newBalance: Balance, _) => + balance = newBalance + stay + } + + /** + * Validates bitcoin address in the QR code. + */ + def validateQr(qrcode: QrCode) { + walletMinion ! qrcode + } + + /** + * Sells bitcoins to the user. + */ + def sell(tx: IncompleteTx) { + walletMinion ! tx + } +} + + +trait Ticker { + this: FSM[State, Data] => + + /** + * Bitcoin price ticker service + */ + var tickerService: BitcoinService.Value = BitStamp + + /** + * Price per bitcoin + */ + var price: Price = Price(999999999) + + /** + * Creates a Ticker minion. + */ + def tickerMinion = + context.actorOf(Props(classOf[TickerMinion], tickerService)) + + /** + * Run this function in preStart to enable the ticker. + */ + def initTicker { + tickerService = Lycia.servicePriceTicker + tickerMinion ! Tick + setTimer("ticker", Tick, 1 minute, true) + } + + /** + * Run this function in postStop to disable the ticker. + */ + def stopTicker { + cancelTimer("ticker") + } + + /** + * Handle price ticker commands. + */ + def handleTicker: StateFunction = { + case Event(Tick, _) => + tickerMinion ! Tick + stay + + case Event(GetPrice, _) => + client.updatePrice(price format) + stay + + case Event(Price(newPrice, _), _) => + price = price.copy(price = newPrice) + stay + + case Event(Percentage(value), _) => + price = price.copy(percentage = value) + stay + } +} + +/** + * Command to send the buy price. + */ +case object GetPrice + +/** + * Command to set a percentage price over market. + * If price over market is 5%, send 5, not 0.05, as the profit value. + * @param profit profit value + */ +case class Percentage(profit: Double) +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Bus.scala b/src/main/scala/inc/pyc/chimera/Bus.scala @@ -0,0 +1,26 @@ +package inc.pyc.chimera + +import inc.pyc.lift_akka._ + +/** + * Main Chimera PubSub used to communicate with client comets. + */ +class LookupSystem extends LiftActorEventBus with PublishHelper { + override protected def mapSize: Int = 10 +} + +/** + * Bus in-built functions to make it easier to publish events. + */ +sealed trait PublishHelper { + this: LiftActorEventBus => + + def updateState[T >: State](state: T) = + publish(EventUpdate(Topic.stateUpdate, state)) + + def updateData[T >: Data](data: T) = + publish(EventUpdate(Topic.dataUpdate, data)) + + def updatePrice(price: String) = + publish(EventUpdate(Topic.priceUpdate, price)) +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Data.scala b/src/main/scala/inc/pyc/chimera/Data.scala @@ -0,0 +1,227 @@ +package inc.pyc.chimera + +import lycia._ +import inc.pyc._ +import currency._ +import bitcoin._ +import System._ +import org.joda.time._ +import format._ + +/** + * Data that can be transfered across + * state transitions. + */ +trait Data + +/** + * Null. + */ +case object NullData extends Data + +/** + * A valid bitcoin address. + * + * @param data bitcoin address + */ +case class BitcoinAddress(data: String) extends Data + +/** + * Data to check for history audit. + * + * @param address bitcoin address + * @param userInfo registered user information + */ +case class AuditData( + address: BitcoinAddress, + userInfo: Option[UserInfo] = None) extends Data { + + def addresses: List[String] = + address.data :: userInfo.map(_. + addresses.filterNot(_ == address.data)).getOrElse(Nil) +} + +/** + * Carries enough data to create a local (incomplete) + * transaction at the current price. + * + * @param audit audit data + * @param limit amount the user can spend + * @param price buy price + */ +case class CreatorTx( + audit: AuditData, + limit: LeftToSpend, + price: Price) extends Data + +/** + * An incomplete transaction. + * + * @param address user's bitcoin address + * @param price price per bitcoin at the time of transaction + * @param currency currency of the bills used + * @param currencySymbol symbol of currency + * @param bills sequence of bills inserted into the machine + * @param limit buy limit for this transaction + * @param userInfo information about registered user + * @param total sum of the bills inserted + * @param bitcoins calculated total of bitcoins that will be sold + * @param buyPrice agreed final price per bitcoin with fee included + */ +case class IncompleteTx( + address: String, + price: Price, + currency: Currency, + currencySymbol: String, + bills: List[Int], + limit: Int, + userInfo: Option[UserInfo], + total: Int, + bitcoins: Double, + buyPrice: Double) extends Data { + + /** Last time the transaction was updated. */ + val date = new DateTime + + def prettyTotal = currency.symbol + total + + def prettyDate = { + val fmt = DateTimeFormat forPattern "MMMM dd, yyyy" + fmt print date + } + + def prettyTime = { + val fmt = DateTimeFormat forPattern "HH:mm a z" + fmt print date + } + + /** + * Adds a bill and returns an updated IncompleteTx + * @param bill bill to add + */ + def insertBill(bill: Currency#Value): IncompleteTx = { + val bills = this.bills :+ bill.id + copy( + bills = bills, + total = bills.sum, + bitcoins = bills.sum / price.priceWithPercentage) + } + + /** + * Increases the purchase limit. + * ie. If purchase limit is $1,000 and it needs to be set to $3,000, + * but $50 is the spending limit in the current transaction, then purchase + * limit is set to $3,000 and spending limit is set to $2,050. + */ + def setLimit(value: Int): IncompleteTx = { + val purchaseLimit = userInfo.flatMap(_.purchaseLimit) getOrElse Lycia.buyLimit + val increase = increaseLimit(value) + copy(limit = limit + increase) + } + + /** + * Updates the user info and modifies the purchase + * limit and spending limit accordingly. + */ + def withUserData(data: UserData): IncompleteTx = { + if(data.info.isDefined) { + val newUserInfo = data.info.get + val newUserLimit = newUserInfo.purchaseLimit getOrElse Lycia.buyLimit + val newTx = setLimit(newUserLimit) + newTx.copy(userInfo = data.info) + } else this + } + + /** + * Increases the purchase limit but takes into account of the current + * standing limit. + * ie. If purchase limit is $1,000 and it needs to be set to $3,000, + * then it'll return an increase of $2,000. + */ + private def increaseLimit(value: Int): Int = { + val purchaseLimit = userInfo.flatMap(_.purchaseLimit) getOrElse Lycia.buyLimit + val increase = value - purchaseLimit + if(increase > 0) increase else 0 + } +} + +case object IncompleteTx { + def apply( + address: String, + price: Price, + currency: Currency, + bills: List[Int], + limit: Int, + userInfo: Option[UserInfo]): IncompleteTx = IncompleteTx( + address, + price, + currency, + currency.symbol, + bills, + limit, + userInfo, + bills.sum, + bills.sum / price.priceWithPercentage, + price.priceWithPercentage) +} + +/** + * A completed transaction. + * + * @param address buyer's bitcoin address + * @param date date transaction was completed + * @param txid blockchain transaction ID + * @param price price per bitcoin at the time of transaction + * @param currency currency of the bills used + * @param bills sequence of bills inserted into the machine + * @param chimera machine name of where transaction occurred + * @param ticker bitcoin price ticker service + */ +case class CompleteTx( + address: String, + date: DateTime, + txid: String, + price: Price, + currency: Currency, + bills: List[Int], + chimera: String, + ticker: BitcoinService.Value) extends Data { + + def total = bills.sum + def bitcoins = total / price.priceWithPercentage + def prettyTotal = currency.symbol + total + + def dateISO8601 = { + val fmt = ISODateTimeFormat.basicDateTime + fmt print date + } + + def prettyDate = { + val fmt = DateTimeFormat forPattern "MMMM dd, yyyy" + fmt print date + } + + def prettyTime = { + val fmt = DateTimeFormat forPattern "HH:mm a z" + fmt print date + } +} + +object CompleteTx { + def apply(tx: IncompleteTx, txid: String): CompleteTx = + CompleteTx( + tx.address, + new DateTime, + txid, + tx.price, + tx.currency, + tx.bills, + Settings(system).name, + Lycia.servicePriceTicker) +} + +/** + * Reason why it is in the current state. + * @param reason reason + */ +case class Reason(data: String) extends Data +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Messages.scala b/src/main/scala/inc/pyc/chimera/Messages.scala @@ -0,0 +1,107 @@ +package inc.pyc.chimera + +import inc.pyc.bill.acceptor.Events._ + +/** + * Start the process of buying. + */ +case object Start + +/** + * User scanned a QR code. + * + * @param qr data in QR code + */ +case class QrCode(qr: String) + +/** + * Buy Bitcoin! + */ +case object Buy + +/** + * Log in the registered user. + * + * @param email registered email + * @param password one-time password + */ +case class UserVerify(email: String, password: String) + +/** + * Go to wizard screen. + * + * @param page name of the screen + * @param reason message explaining why the action is occurring + */ +case class Goto(screen: Screen, reason: String = "") + +/** + * Commands minion to check if bill should be + * accepted. + * + * @param inserted inserted bill + * @param tx incomplete transaction + * @param balance balance in bitcoin wallet + */ +case class InspectBill( + inserted: Inserted, + tx: IncompleteTx, + balance: FiatBalance) + +/** + * Bill is acceptable. + */ +case object ValidBill + +/** + * Bill is unacceptable because it is passed purchase limit + */ +case object InvalidBill + +/** + * Container for phone number. Used by client to send to `Overlord` + */ +case class Phone(data: String, sms: Boolean = true) + +/** + * Container for email. Used by client to send to `Overlord` + */ +case class Email(data: String) + +/** + * Command from client to Overlord to skip the current state + * and go to the next one. + */ +case object Continue + +/** + * Command from client to Overlord to go to the previous state. + */ +case object Previous + +/** + * When a state has a state timeout, the client can send the wait + * command to reset the time. + * This may be helpful if user is typing something in. + */ +case object Wait + +/** + * Balance in bitcoin wallet, measured by fiat currency. + * + * @param remaining the balance in wallet + */ +case class FiatBalance(remaining: Double) + +/** + * QR code does not have a valid bitcoin address. + */ +case object InvalidBitcoinAddress + +/** + * The amount left to spend. + * + * @param left total amount the user can buy right now + * @param limit total amount the user can buy in a 24 hour period + */ +case class LeftToSpend(left: Int, limit: Int) +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Msg.scala b/src/main/scala/inc/pyc/chimera/Msg.scala @@ -0,0 +1,19 @@ +package inc.pyc.chimera + +/** + * This is temporary. Keep this here until I + * move it to Angularjs i18n + */ +object Msg { + val unableToStart = "Unable to start" + val hardwareMalfunction = "Hardware malfunction" + val networkUnreachable = "Network unreachable\nWill try to reconnect every 20 seconds" + val billOverLimit = "The bill inserted passes the buy limit." + val billOverBalance = "We apologize but we ran out of bitcoin." + val idUploadSuccess = "We have received your registration and will notify you very soon." + val unableFulfillRequest = "Unable to fulfill request." + val unableValidateQr = "Unable to validate QR code." + val buyLimitQuota = "Buy limit of $%,d has been reached." + val invalidScannedQr = "Scanned QR code does not have a valid Bitcoin address." + val errorSending = "There may have been a problem sending your bitcoins. PYC has been notified of the issue and will resolve it as soon as possible." +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Network.scala b/src/main/scala/inc/pyc/chimera/Network.scala @@ -0,0 +1,96 @@ +package inc.pyc.chimera + +import akka.actor._ +import concurrent._ +import duration._ +import dispatch._ + +trait NetworkHelper { + this: Overlord with FSM[State, Data] => + + /** + * Interval to ping the network. + */ + val pingInterval: FiniteDuration = 20 seconds + + /** + * Network Actor + */ + lazy val network = context.actorOf( + Props(classOf[Network], pingInterval), "Network") + + /** + * Initializes the network actor + * by sending it a ping. + */ + final def initNetwork { + network ! "ping" + } + + /** + * Stops the network actor from chacking connection. + */ + final def stopNetwork { + network ! PoisonPill + } + + when(NetworkOutage) { + case Event(NetworkEstablished, _) => + log info "Network reestablished" + goto (Idle) using NullData + } + + /** + * Add this to unhandled `StateFunction`. + * Notifies that the network is unreachable and + * becomes `Malfunctioning` + */ + def handleNetworkOutage: StateFunction = { + case Event(NetworkOutage, _) => + log error "Network unreachable" + goto (NetworkOutage) using NullData + } +} + +/** + * Checks network connectivity. + * @param n interval to check connection in seconds. + */ +class Network(n: FiniteDuration) extends Actor { + import Defaults._ + import context.system + + val ping = "https://www.google.com/" + + override def preStart { + system.scheduler. + schedule(0 milli, n, self, "ping") + } + + def receive: Receive = { + case "ping" => + check(operating = true) + } + + def disconnected: Receive = { + case "ping" => + check(operating = false) + } + + def check(operating: Boolean) { + Http(url(ping) OK as.String).either() match { + case Left(_) => + context.become(disconnected) + if(operating) context.parent ! NetworkOutage + + case Right(_) => + context.become(receive) + if(!operating) context.parent ! NetworkEstablished + } + } +} + +/** + * The network is okay. + */ +case object NetworkEstablished +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Overlord.scala b/src/main/scala/inc/pyc/chimera/Overlord.scala @@ -0,0 +1,338 @@ +package inc.pyc.chimera + +import minions._ +import lycia._ +import System.client +import inc.pyc._ +import bill._ +import acceptor._ +import Events._ +import Commands._ +import States.Disconnected +import akka.actor._ +import SupervisorStrategy._ +import FSM._ +import concurrent._ +import duration._ + + +/** + * Respect + */ +class Overlord extends FSM[State, Data] + with MinionFactory + with Bitcoin + with NetworkHelper + with StateWatchHelper { + + import context.{dispatcher, system} + + override val supervisorStrategy = + OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) { + case _: java.util.concurrent.TimeoutException => Restart + case _: Exception => Escalate + } + + /** The bill acceptor */ + lazy val acceptor: ActorRef = + context.actorOf(BillAcceptor.props(system, "Acceptor")) + + /** + * Starts a bunch of services + */ + override def preStart(): Unit = { + watchTransitions(acceptor) + acceptor ! Listen + initNetwork + initWallet + initTicker + } + + override def postStop(): Unit = { + log info "Stopping Overlord ... " + unwatchTransitions(acceptor) + acceptor ! UnListen + acceptor ! PoisonPill + stopNetwork + stopTicker + stopWallet + super.postStop + } + + startWith(Uninitialized, NullData) + + when (Uninitialized, stateTimeout = 30 seconds) { + case Event(Ready, _) => + acceptor ! UnInhibit + goto(Idle) + + case Event(StateTimeout, _) => + goto (Malfunctioning) using Reason(Msg.unableToStart) + } + + when (Idle) { + case Event(Start, _) => + goto (QrScan) + } + + when (QrScan, stateTimeout = 1 minute) (idleFallback orElse { + case Event(qr: QrCode, _) => + validateQr(qr) + goto (QrValidate) + }) + + when (QrValidate, stateTimeout = 3 seconds) (qrScanFallback orElse { + case Event(address: BitcoinAddress, _) => + loginUser(address) + goto (UserLogin) using address + + case Event(InvalidBitcoinAddress, _) => + goto (QrScan) + }) + + when (UserLogin, stateTimeout = 3 seconds) (qrScanFallback orElse { + case Event(UserData(userInfo), address: BitcoinAddress) => + val data = AuditData(address, userInfo) + audit(data) + goto (HistoryAudit) using data + }) + + when (HistoryAudit, stateTimeout = 3 seconds) (qrScanFallback orElse { + case Event(toSpend: LeftToSpend, audit: AuditData) => + val create = CreatorTx(audit, toSpend, price) + createTx(create) + goto (TxCreate) using create + }) + + when (TxCreate, stateTimeout = 1 second) (qrScanFallback orElse { + case Event(tx: IncompleteTx, _) => + client.updateData(tx) + goto (CashInsert) using tx + }) + + when(CashInsert, stateTimeout = 5 minutes)(idleFallback orElse { + case Event(inserted: Inserted, tx: IncompleteTx) => + val inspect = InspectBill(inserted, tx, fiatBalance) + validateBill(inspect) + goto(CashValidate) + + case Event(Buy, tx: IncompleteTx) => + sell(tx) + goto(Sending) + }) + + when(CashValidate, stateTimeout = 5 seconds)(cashInsertFallback orElse { + case Event(ValidBill, _) => + acceptor ! Stack + stay + + case Event(InvalidBill, _) => + acceptor ! Return + goto (LimitReached) + + case Event(InsufficientFunds, _) => + acceptor ! Return + goto (InsufficientFunds) forMax (2 seconds) + + case Event(confirmed: Confirmed, tx: IncompleteTx) => + confirmBill(confirmed, tx) + stay forMax (2 seconds) + + case Event(tx: IncompleteTx, _) => + client.updateData(tx) + goto(CashInsert) using tx + }) + + when (InsufficientFunds) { + case Event(StateTimeout, _) => + stateData match { + case _: IncompleteTx => goto (CashInsert) + case _ => goto (Idle) using NullData + } + } + + when (LimitReached) { + case Event(Wait, _) => + stay + + case Event(Previous, _) => + goto (CashInsert) + + case Event(info: UserVerify, _) => + loginUser(info) + stay + + case Event(phone: Phone, _) => + sender ! verifyNumber(phone) + stay + + case Event(data: UserData, tx: IncompleteTx) => + val newTx = tx.withUserData(data) + client.updateData(newTx) + if(newTx == tx) goto (LimitReached) + else goto (CashInsert) using newTx + + case Event(VerifiedPhone(phone), tx: IncompleteTx) => + val newTx = tx.setLimit(Lycia.phoneVerifiedBuyLimit) + saveNumber(tx.address, phone) + saveTx(newTx) + client.updateData(newTx) + goto (CashInsert) using newTx + } + + when (Sending, stateTimeout = 1 minute) (errorFallback orElse { + case Event(tx: CompleteTx, _) => + completeTx(tx) + client.updateData(tx) + goto (ThankYou) using tx + }) + + when (ThankYou, stateTimeout = 1.5 seconds) { + case Event(StateTimeout, _) => + goto (Receipt) + } + + when (Receipt, stateTimeout = 30 seconds) (idleFallback orElse { + case Event(Wait, _) => + stay + + case Event(Continue, tx: CompleteTx) => + goto (Idle) using NullData // finished + + case Event(email: Email, tx: CompleteTx) => + lib.Receipt.send(email, tx) + // TODO if not logged in, create a user acct but don't send email registratiion + saveReceiptEmail(tx.address, email) + goto (Idle) using NullData + }) + + when (ErrorState, stateTimeout = 2 minute) (idleFallback orElse { + case Event(Wait, _) => + stay + + case Event(phone: Phone, tx: IncompleteTx) => + lib.CustomerSupport.send(phone, tx) + goto (Malfunctioning) using NullData + + case Event(email: Email, tx: IncompleteTx) => + lib.CustomerSupport.send(email, tx) + goto (Malfunctioning) using NullData + }) + + when (Malfunctioning) { + case Event(Listen, _) => + acceptor forward Listen + stay + + case Event(Ready, _) => + log info "Bill acceptor is operating again" + cancelTimer("poll-acceptor") + goto (Idle) using NullData + } + + whenUnhandled ( + handleTicker orElse + handleWallet orElse + handleNetworkOutage orElse { + + case Event(Disconnected, _) => + log error "Bill acceptor not operating" + setTimer ("poll-acceptor", Listen, 1 minute, true) + gotoMalfunctioning(Reason(Msg.hardwareMalfunction)) + + case Event(event, _) => + log warning ("unhandled message: {}/{}", stateName, stateData) + stay + }) + + onTransition { + case TxCreate -> CashInsert => + acceptor ! Inhibit + + case CashInsert -> Sending => + acceptor ! UnInhibit + + case _ -> InsufficientFunds => + log warning ("Insufficient funds: {}/{}", stateName, stateData) + + case _ -> Malfunctioning => + log error ("Malfunctioning: {}/{}", stateName, stateData) + + case Malfunctioning -> Idle => + log info ("Operational: Idle/{}", nextStateData) + + case _ -> ErrorState => + stateData match { + case tx: IncompleteTx => tx.userInfo.foreach { + info => self ! Email(info.email) + } + case _ => + } + } + + /** + * When state times out, return cash in escrow. + */ + def cashInsertFallback: StateFunction = { + case Event(StateTimeout, _) => + acceptor ! Return + goto (CashInsert) + } + + /** + * When state times out, go back to the `Idle` screen. + */ + def idleFallback: StateFunction = { + case Event(StateTimeout, _) => + goto (Idle) using NullData + } + + /** + * When state times out, go back to the `QrScan` screen. + */ + def qrScanFallback: StateFunction = { + case Event(StateTimeout, _) => + goto (QrScan) using NullData + } + + /** + * When state times out in a crucial state, like + * sending bitcoins. + */ + def errorFallback: StateFunction = { + case Event(StateTimeout, _) => + errorNeedsAssistance + goto (ErrorState) + } + + /** + * Before it transitions to Malfunctioning state, it checks + * if the current state of the machine has an active + * transaction. If it does, go to error state. + */ + def gotoMalfunctioning(reason: Reason): State = { + if(sensitiveState) goto (ErrorState) using stateData + else goto (Malfunctioning) using reason + } + + /** + * Checks if the current state is an + * active transaction state. + */ + def sensitiveState: Boolean = { + stateData match { + case _: IncompleteTx => true + case _ => false + } + } + + /** + * Log error that asks for assistance. + */ + def errorNeedsAssistance { + log error ("Manual Assistance Required! "+ + "Failed with {}/{}", stateName, stateData) + } + + initialize +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/State.scala b/src/main/scala/inc/pyc/chimera/State.scala @@ -0,0 +1,100 @@ +package inc.pyc.chimera + +/** + * The state of the machine. + */ +trait State + +/** + * A state that also has a UI display. + */ +trait Screen extends State { + val name: String = getClass().getSimpleName() +} + +/** + * Has just booted but hasn't initialized yet. + */ +case object Uninitialized extends State with Screen + +/** + * Waiting for a user to begin. The most common state. + */ +case object Idle extends State with Screen + +/** + * Ready for the user to scan their QR code. + */ +case object QrScan extends State with Screen + +/** + * Validates a Bitcoin address in QR code. + */ +case object QrValidate extends State + +/** + * Tries to log in a registered user with bitcoin address. + */ +case object UserLogin extends State + +/** + * Checks historical transactions related to a user. + */ +case object HistoryAudit extends State + +/** + * When user reaches buying limit + */ +case object LimitReached extends State with Screen + +/** + * In the process of creating a transaction. + */ +case object TxCreate extends State + +/** + * Ready for the user to insert their cash. + */ +case object CashInsert extends State with Screen + +/** + * Checks if bill inserted should be accepted. + */ +case object CashValidate extends State + +/** + * Sending the bitcoin. + */ +case object Sending extends State with Screen + +/** + * Screen where user can input their email to get + * a copy of the transaction. + */ +case object Receipt extends State with Screen + +/** + * Something bad occurred that interrupted the buying process. + * Manual assistance will be provided for the user. + */ +case object ErrorState extends State with Screen + +/** + * There are insufficient funds in the bitcoin wallet. + */ +case object InsufficientFunds extends State with Screen + +/** + * Something is wrong and the machine cannot be used. + */ +case object Malfunctioning extends State with Screen + +/** + * The network is out. + */ +case object NetworkOutage extends State with Screen + +/** + * Thank you message. + */ +case object ThankYou extends State with Screen +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/System.scala b/src/main/scala/inc/pyc/chimera/System.scala @@ -0,0 +1,69 @@ +package inc.pyc.chimera + +import akka.actor._ +import com.typesafe.config._ +import inc.pyc.currency._ + +/** + * System Configurations. + */ +object System { + + /** + * Main Actor System + */ + val system = ActorSystem("ChimeraSystem", ConfigFactory.load()) + + /** + * The main currency being accepted by this machine. + */ + val currency = findCurrency(Settings(system).currency) + + /** + * Event Bus for Chimera internal to client comets. + */ + val client = new LookupSystem + + /** + * The heart of Chimera: a finite state machine that + * manages how data flows to the client. + */ + lazy val overlord = system.actorOf(Props[Overlord], "Overlord") +} + +/** + * System settings from configuration files. + */ +class SettingsImpl(config: Config) extends Extension { + val chimera = config getConfig "chimera" + val guid = chimera getString "guid" + val secret = chimera getString "secret" + val name = chimera getString "name" + val currency = chimera getString "currency" + val userDb = chimera getString "userDb" + val awsAccess = chimera getString "aws.accessKey" + val awsSecret = chimera getString "aws.secretKey" + val smtpHost = chimera getString "smtp.host" + val smtpPort = chimera getInt "smtp.port" + val smtpAuth = chimera getBoolean "smtp.auth" + val smtpEmail = chimera getBoolean "smtp.email" + val techPhone = chimera getString "techsupport.phone" + val techEmail = chimera getString "techsupport.email" + val twilioSid = chimera getString "twilio.sid" + val twilioToken = chimera getString "twilio.token" + val twilioPhone = chimera getString "twilio.phone" + val twitter = chimera getString "twitter" + val facebook = chimera getString "facebook" + val gplus = chimera getString "gplus" +} + +object Settings extends ExtensionId[SettingsImpl] + with ExtensionIdProvider { + + override def lookup = Settings + + override def createExtension(system: ExtendedActorSystem) = + new SettingsImpl(system.settings.config) + + override def get(system: ActorSystem): SettingsImpl = super.get(system) +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/Topic.scala b/src/main/scala/inc/pyc/chimera/Topic.scala @@ -0,0 +1,23 @@ +package inc.pyc.chimera + +/** + * Subscription Topic so a client comet can + * register for updates with the main bus. + */ +object Topic { + + /** + * Syncs the state of the machine with the client. + */ + val stateUpdate = "stateUpdate" + + /** + * Syncs the data of the machine with the client. + */ + val dataUpdate = "dataUpdate" + + /** + * Update of price per bitcoin. + */ + val priceUpdate = "priceUpdate" +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/User.scala b/src/main/scala/inc/pyc/chimera/User.scala @@ -0,0 +1,129 @@ +package inc.pyc.chimera + +import akka.actor._ +import dispatch._ +import Defaults._ +import net.liftweb.json._ +import com.ning.http._ +import multipart.StringPart +import client.ByteArrayPart +import System._ + +/** + * Access information about registered users. + */ +class User extends Actor with ActorLogging { + + val settings = Settings(system) + import settings._ + + implicit val formats = DefaultFormats + + /** Prefix request sent to the server. */ + val request = url(userDb) / "api" / "btm" / guid / secret + + def receive = { + case UserVerify(email, passwd) => + val res = Http(request / "user" / email / passwd / "login" OK as.lift.Json) + sender ! UserData(parseUserInfo(res())) + + case BitcoinAddress(address) => + val res = Http(request / "address" / address / "login" OK as.lift.Json) + sender ! UserData(parseUserInfo(res())) + + case Phone(number, _) => + val res = Http(request / "phone" / number / "login" OK as.lift.Json) + sender ! UserData(parseUserInfo(res())) + + case CreateUser(email, phone, iD) => + val fn = "uploaded_from_" + name + val req = (request / "create_user"). + addBodyPart(new StringPart("email", email)). + addBodyPart(new StringPart("phone", phone)). + addBodyPart(new ByteArrayPart(fn, fn, iD, "image/jpeg", "ISO-8859-1")). + POST + + val res = Http(req OK as.lift.Json) + implicit val successReason = Msg.idUploadSuccess + sender ! parseReason(res()) + + case _ => + } + + + /** + * Parses user information sent from the server. + */ + def parseUserInfo(res: JValue): Option[UserInfo] = parse(res) match { + case ServerResponseData(_, data, _) => data + case _ => None + } + + + def parseReason(res: JValue)(implicit successReason: String): ServerResponse = parse(res) match { + case ServerResponseData(success, _, _) if success => ResponseSuccess(successReason) + case ServerResponseData(_, _, reason) => ResponseFailure(reason getOrElse Msg.unableFulfillRequest) + } + + + /** + * Parses the response sent from the server. + */ + def parse(res: JValue): ServerResponseData = { + res.extract[ServerResponseData] + } + +} + +/** + * Information about logged in user. Nothing too sensitive. + * + * @param email registered email + * @param fname optional first name + * @param lname optional last name + * @param purchaseLimit amount that can be purchased in 24 hours + * @param addresses registered bitcoin addresses + */ +case class UserInfo( + email: String, + fname: Option[String] = None, + lname: Option[String] = None, + purchaseLimit: Option[Int] = None, + addresses: List[String] = Nil) + + +trait ServerResponse { + val message: String +} + +/** + * Format of the server's response. + */ +case class ServerResponseData(success: Boolean, data: Option[UserInfo], reason: Option[String]) extends ServerResponse { + val message: String = reason getOrElse "" +} + +/** + * Successful response from the server. + */ +case class ResponseSuccess(reason: String) extends ServerResponse { + val message: String = reason +} + +/** + * Failure response from the server. + */ +case class ResponseFailure(reason: String) extends ServerResponse { + val message: String = reason +} + +/** + * Commands the server to register a new user with the following: + * email, phone number and picture ID data in string format. + */ +case class CreateUser(email: String, phone: String, iD: Array[Byte]) + +/** + * Message to send user info (it may not have been found). + */ +case class UserData(info: Option[UserInfo]) +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/config/ErrorHandler.scala b/src/main/scala/inc/pyc/chimera/config/ErrorHandler.scala @@ -1,59 +0,0 @@ -package inc.pyc.chimera -package config - -import net.liftweb._ -import common.{Loggable, MDC} -import http.{Factory, LiftRules, RedirectResponse, Req, S, XhtmlResponse} -import util.Props - -object ErrorHandler extends Factory with Loggable { - // config - val errorUrl = new FactoryMaker[String]("/error") {} // where to send the user when an error occurs - - def init(): Unit = { - LiftRules.exceptionHandler.prepend { - case (Props.RunModes.Development, r, e) => - logException(r, e) - XhtmlResponse( - (<html><body>Exception occured while processing {r.uri}<pre>{showException(e)}</pre></body></html>), - S.htmlProperties.docType, - List("Content-Type" -> "text/html; charset=utf-8"), - Nil, - 500, - S.legacyIeCompatibilityMode - ) - case (_, r, e) => - logException(r, e) - RedirectResponse(errorUrl.vend) - } - } - - /* - * Log the exception with some user info. - */ - def logException(r: Req, e: Throwable) { - import java.net.InetAddress - val srvr = InetAddress.getLocalHost.getHostName - - MDC.put(("Server", srvr)) - logger.error("Exception occurred while processing %s".format(r.uri), e) - } - - /** - * A utility method to convert an exception to a string of stack traces - * @param le the exception - * - * @return the stack trace - */ - def showException(le: Throwable): String = { - val ret = "Message: " + le.toString + "\n\t" + - le.getStackTrace.map(_.toString).mkString("\n\t") + "\n" - - val also = le.getCause match { - case null => "" - case sub: Throwable => "\nCaught and thrown by:\n" + showException(sub) - } - - ret + also - } -} diff --git a/src/main/scala/inc/pyc/chimera/config/Machine.scala b/src/main/scala/inc/pyc/chimera/config/Machine.scala @@ -1,76 +0,0 @@ -package inc.pyc.chimera -package config - -import model._ -import lib._ -import bill.acceptor._ -import currency._ -import akka.actor._ -import akka.agent._ -import com.typesafe.config._ -import inc.pyc.bitcoin._ -import inc.pyc.lift_akka.LiftActorEventBus - -object Machine { - import system.dispatcher - - private val config: Config = ConfigFactory.load().getConfig("chimera") - - val id: String = config.getString("id") - val name: String = config.getString("name") - val currency = findCurrency(config.getString("currency")) - - - /** - * Hardware Actor System - */ - val system = ActorSystem("ChimeraSystem") - - - /** - * Event Bus for Chimera internal. - */ - val bus = new LookupSystem - - - /** - * Subscription topics for event bus. - */ - object topics { - val redirectTo = "redirectTo" - val transactionUpdate = "transactionUpdate" - } - - - /** - * The main bitcoin wallet - */ - val wallet = Wallet(system) - - - /** - * The main bitcoin price ticker - */ - val priceTicker = PriceTicker(system) - - - /** - * The transaction of the current user. - */ - val currentTransaction: Agent[Transaction] = Agent(Transaction.create) - - - /** - * The machine's bill acceptor - */ - val billAcceptor = BillAcceptor(system) -} - - - -/** - * PubSub for hardware related topics. - */ -class LookupSystem extends LiftActorEventBus { - override protected def mapSize: Int = 50 -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/ddb/DDB.scala b/src/main/scala/inc/pyc/chimera/ddb/DDB.scala @@ -0,0 +1,30 @@ +package inc.pyc.chimera +package ddb + +import awscala._ +import dynamodbv2._ +import akka.actor.Actor +import com.typesafe.config._ +import System._ + +private[ddb] trait DDB { + this: Actor => + + val settings = Settings(system) + import settings._ + + /** AWS Region where DynamoDB Table is located. */ + implicit val region: Region = Region.US_EAST_1 + + /** DynamoDB client instance */ + implicit val dynamoDB: DynamoDB = DynamoDB(awsAccess, awsSecret) + + /** DynamoDB table */ + lazy val table: Table = dynamoDB.table(getClass().getSimpleName()).get +} + + +/** + * A bunch of items returned by DynamoDB. + */ +case class DDBItems(items: Seq[Item]) +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/ddb/Expenditure.scala b/src/main/scala/inc/pyc/chimera/ddb/Expenditure.scala @@ -0,0 +1,45 @@ +package inc.pyc.chimera +package ddb + +import akka.actor._ +import awscala.dynamodbv2._ +import org.joda.time._ +import format._ + +/** + * Save and view the amount of money spent on bitcoin. + * Primary Key is the bitcoin address. + */ +class Expenditure extends Actor with ActorLogging with DDB { + + def receive = { + case tx: CompleteTx => + table.put( + tx.address, + tx.dateISO8601, + "txid" -> tx.txid, + "paid" -> tx.bills.sum , + "currency" -> tx.currency.toString) + + case qry @ Past24Hours(addresses) => + val all = addresses.map ("address" -> Condition.eq(_)) + val items = table.query( + keyConditions = (all ::: List("date" -> qry.condition)) toSeq) + sender ! DDBItems(items) + } +} + +/** + * Query to find out how much was spent in buying bitcoin in the past 24 hours. + * @param addresses list of bitcoin addresses to query + */ +case class Past24Hours(addresses: List[String]) { + + def condition = Condition.gt(hoursAgo24) + + private def hoursAgo24 = { + val dt = new DateTime(DateTimeZone.UTC).minusDays(1) + val fmt = ISODateTimeFormat.basicDateTime(); + fmt.print(dt) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/ddb/PhoneNumber.scala b/src/main/scala/inc/pyc/chimera/ddb/PhoneNumber.scala @@ -0,0 +1,15 @@ +package inc.pyc.chimera +package ddb + +import akka.actor.Actor + +/** + * Save phone numbers when verified with an address. + * Primary Key is the bitcoin address. + */ +class PhoneNumber extends Actor with DDB { + def receive = { + case (address: String, phone: Phone) => + table.put(address, "phone" -> phone.data) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/ddb/ReceiptEmail.scala b/src/main/scala/inc/pyc/chimera/ddb/ReceiptEmail.scala @@ -0,0 +1,15 @@ +package inc.pyc.chimera +package ddb + +import akka.actor.Actor + +/** + * Saves email when receipt is sent. + * Primary Key is the bitcoin address. + */ +class ReceiptEmail extends Actor with DDB { + def receive = { + case (address: String, email: Email) => + table.put(address, "email" -> email.data) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/ddb/Transaction.scala b/src/main/scala/inc/pyc/chimera/ddb/Transaction.scala @@ -0,0 +1,26 @@ +package inc.pyc.chimera +package ddb + +import akka.actor.Actor + +/** + * Save transactions for historical reasons. + * Primary Key is the txid. + */ +class Transaction extends Actor with DDB { + + def receive = { + case tx: CompleteTx => + table.put( + tx.txid, + "date" -> tx.dateISO8601, + "address" -> tx.address, + "price" -> tx.price.price, + "percent" -> tx.price.percentage, + "currency" -> tx.currency.toString, + "bills" -> tx.bills.map(_.toString), + "chimera" -> tx.chimera, + "bitcoin" -> tx.bitcoins, + "ticker" -> tx.ticker.toString) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/CRC.scala b/src/main/scala/inc/pyc/chimera/lib/CRC.scala @@ -1,64 +0,0 @@ -package inc.pyc.chimera.lib - -/** - * Computes the CCITT-KERMIT CRC - * http://stackoverflow.com/questions/5059268/c-sharp-crc-implementation - */ -class CRC { - - val table = Array( - 0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF, - 0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7, - 0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E, - 0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876, - 0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD, - 0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5, - 0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C, - 0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974, - 0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB, - 0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3, - 0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A, - 0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72, - 0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9, - 0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1, - 0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738, - 0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70, - 0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7, - 0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF, - 0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036, - 0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E, - 0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5, - 0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD, - 0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134, - 0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C, - 0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3, - 0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB, - 0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232, - 0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A, - 0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1, - 0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9, - 0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330, - 0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78) - - def compute(buf: Array[Byte]): Array[Byte] = { - var crc = 0x00 - - for(i <- 0 until buf.length) { - crc = (crc >> 8) ^ table((crc ^ buf(i)) & 0xff) - } - - intToByteArray(crc) - } - - private def intToByteArray(value: Int): Array[Byte] = { - var b = Array[Byte](0,0,0,0) - - for (i <- 0 until b.length) { - val offset = (b.length - 1 - i) * 8; - b(i) = ((value >>> offset) & 0xFF).asInstanceOf[Byte] - } - - b - } - -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/ErrorHandler.scala b/src/main/scala/inc/pyc/chimera/lib/ErrorHandler.scala @@ -0,0 +1,59 @@ +package inc.pyc.chimera +package lib + +import net.liftweb._ +import common.{Loggable, MDC} +import http.{Factory, LiftRules, RedirectResponse, Req, S, XhtmlResponse} +import util.Props + +object ErrorHandler extends Factory with Loggable { + // config + val errorUrl = new FactoryMaker[String]("/error") {} // where to send the user when an error occurs + + def init(): Unit = { + LiftRules.exceptionHandler.prepend { + case (Props.RunModes.Development, r, e) => + logException(r, e) + XhtmlResponse( + (<html><body>Exception occured while processing {r.uri}<pre>{showException(e)}</pre></body></html>), + S.htmlProperties.docType, + List("Content-Type" -> "text/html; charset=utf-8"), + Nil, + 500, + S.legacyIeCompatibilityMode + ) + case (_, r, e) => + logException(r, e) + RedirectResponse(errorUrl.vend) + } + } + + /* + * Log the exception with some user info. + */ + def logException(r: Req, e: Throwable) { + import java.net.InetAddress + val srvr = InetAddress.getLocalHost.getHostName + + MDC.put(("Server", srvr)) + logger.error("Exception occurred while processing %s".format(r.uri), e) + } + + /** + * A utility method to convert an exception to a string of stack traces + * @param le the exception + * + * @return the stack trace + */ + def showException(le: Throwable): String = { + val ret = "Message: " + le.toString + "\n\t" + + le.getStackTrace.map(_.toString).mkString("\n\t") + "\n" + + val also = le.getCause match { + case null => "" + case sub: Throwable => "\nCaught and thrown by:\n" + showException(sub) + } + + ret + also + } +} diff --git a/src/main/scala/inc/pyc/chimera/lib/Network.scala b/src/main/scala/inc/pyc/chimera/lib/Network.scala @@ -1,80 +0,0 @@ -package inc.pyc.chimera -package lib - -import config._ -import inc.pyc.lift_akka.EventUpdate -import akka.actor._ -import scala.concurrent.duration._ -import dispatch._ - -/** - * Checking network connectivity every n seconds. - */ - -object Network extends ExtensionId[NetworkImpl] with ExtensionIdProvider { - override def lookup = Network - override def createExtension(system: ExtendedActorSystem) = new NetworkImpl(system) -} - -class NetworkImpl(system: ExtendedActorSystem) extends Extension { - import system.dispatcher - import commands._ - - /** - * The main actor that handles communications. - */ - private val actor = system.actorOf(Props[Network], "PingActor") - - /** - * The ticker that checks network connection every 20 seconds. - */ - private val ticker = - system.scheduler.schedule(0 milliseconds, 20 seconds, actor, commands.Ping) - - def ping(): Unit = actor ! Ping -} - -class Network extends Actor with ActorLogging { - import commands._ - import Defaults._ - - val ping = "https://www.google.com/" - - def receive: Receive = { - case Ping => - check(ping, fail = Some("/disconnected")) - - case "init" => - log.info("Network Connectivity Actor Activated") - } - - def disconnected: Receive = { - case Ping => - check(ping, succeed = Some("/index")) - } - - def check(link: String, fail: Option[String] = None, succeed: Option[String] = None) { - Http(url(link) OK as.String).either() match { - case Left(_) => - fail map { - redirect => - log.info("Network unreachable: redirecting to {}", redirect) - context.become(disconnected) - Machine.bus.publish(EventUpdate(Machine.topics.redirectTo, redirect)) - } - - case Right(_) => - succeed map { - redirect => - log.info("Network reconnected: redirecting to {}", redirect) - context.become(receive) - Machine.bus.publish(EventUpdate(Machine.topics.redirectTo, redirect)) - } - } - } -} - - -private object commands { - case object Ping - } -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/Receipt.scala b/src/main/scala/inc/pyc/chimera/lib/Receipt.scala @@ -0,0 +1,46 @@ +package inc.pyc.chimera +package lib + +import net.liftweb._ +import http._ +import util.Mailer._ +import xml._ +import common._ +import util._ +import Helpers._ +import System._ + +object Receipt { + + /** + * Creates Transaction Receipt HTML message. + */ + def create(tx: CompleteTx): Box[NodeSeq] = { + Templates("templates-hidden" :: "email" :: "receipt" :: Nil) map { + ".txid" #> tx.txid & + ".txaddress" #> tx.address & + ".txbtc" #> tx.bitcoins & + ".txpurchase" #> tx.prettyTotal & + ".txdate" #> tx.prettyDate & + ".txtime" #> tx.prettyTime & + ".txlocation" #> Settings(system).name & + "#twitter-link" #> Settings(system).twitter & + "#facebook-link" #> Settings(system).facebook & + "#gplus-link" #> Settings(system).gplus + } + } + + /** + * Sends Transaction Receipt message. + */ + def send(email: Email, tx: CompleteTx): Unit = { + create(tx) map { + sendMail( + From("PYC <%s>" format Settings(system).smtpEmail), + Subject("PYC Transaction Receipt"), + To(email.data), + _ + ) + } + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/SmtpMailer.scala b/src/main/scala/inc/pyc/chimera/lib/SmtpMailer.scala @@ -0,0 +1,34 @@ +package inc.pyc.chimera +package lib + +import System._ +import javax.mail.{Authenticator, PasswordAuthentication} +import javax.mail.internet.MimeMessage +import net.liftweb.util.Mailer +import net.liftweb.common.Full + + +/** + * Configures SMTP Client + */ +object SmtpMailer { + def init(): Unit = { + + val auth = Settings(system).smtpAuth + + Mailer.customProperties = Map( + "mail.smtp.host" -> Settings(system).smtpHost, + "mail.smtp.port" -> Settings(system).smtpPort.toString, + "mail.smtp.auth" -> auth.toString + ) + + if (auth) { + Mailer.authenticator = Full(new Authenticator() { + override def getPasswordAuthentication = + new PasswordAuthentication( + Settings(system).awsAccess, + Settings(system).awsSecret) + }) + } + } +} diff --git a/src/main/scala/inc/pyc/chimera/lib/Support.scala b/src/main/scala/inc/pyc/chimera/lib/Support.scala @@ -0,0 +1,57 @@ +package inc.pyc.chimera +package lib + +import net.liftweb._ +import http._ +import util.Mailer._ +import xml._ +import common._ +import util._ +import Helpers._ +import System._ + +object CustomerSupport { + + private def name(tx: IncompleteTx) = + tx.userInfo flatMap (_.fname) getOrElse "" + + /** + * Creates Transaction Receipt HTML message. + */ + def create(tx: IncompleteTx): Box[NodeSeq] = { + Templates("templates-hidden" :: "email" :: "support" :: Nil) map { + ".fname" #> name(tx) & + ".txaddress" #> tx.address & + ".txamount" #> tx.prettyTotal & + ".txbtc" #> tx.bitcoins & + ".txdate" #> tx.prettyDate & + ".txtime" #> tx.prettyTime & + ".txlocation" #> Settings(system).name & + ".tech-phone" #> Settings(system).techPhone + } + } + + /** + * Sends Transaction Receipt message. + */ + def send(email: Email, tx: IncompleteTx): Unit = { + create(tx) map { + sendMail( + From("PYC <%s>" format Settings(system).smtpEmail), + Subject("PYC Customer Support"), + To(email.data), + _ + ) + } + } + + def send(phone: Phone, tx: IncompleteTx): Unit = { + if(phone.sms) { + val msg = "Hi %s, PYC will revise your bitcoin transaction of %s and "+ + "contact you shortly. Feel free to call tech support at %s. Sorry for"+ + " the trouble." format (name(tx), tx.prettyTotal, Settings(system).techPhone) + + val sent = Twilio.sms(phone.data, msg) + } + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/Twilio.scala b/src/main/scala/inc/pyc/chimera/lib/Twilio.scala @@ -0,0 +1,22 @@ +package inc.pyc.chimera +package lib + +import dispatch._, Defaults._ +import com.ning.http.client.Response +import System._ + +/** + * Sends SMS with Twilio. + */ +object Twilio { + + def sid = Settings(system).twilioSid + def token = Settings(system).twilioToken + def phone = Settings(system).twilioPhone + + def sms(to: String, body: String)(implicit countrycode: String = "+1"): Future[Boolean] = { + val host = :/("api.twilio.com").secure / "2010-04-01" / "Accounts" / sid as_!(sid,token) + val r: Future[Response] = Http(host / "Messages.json" << Map("From" -> phone, "To" -> to, "Body" -> body)) + r.map (_.getStatusCode() == 201) // CREATED + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/AcceptorCommands.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/AcceptorCommands.scala @@ -1,54 +0,0 @@ -package inc.pyc.chimera -package lib -package bill.acceptor - -import currency.Currency -import driver.DriverCommands._ - -object AcceptorCommands { - - /** - * Command to notify that a bill was inserted - * and the value of the bill is `bill` - */ - case class Inserted(bill: Currency#Value) extends Data - - - /** - * Command to notify that a bill has been accepted - * and cannot be returned. - */ - case class Confirmed(bill: Currency#Value) - - - /** - * Command to turn on the driver and - * listen for inserted bills. - */ - case object Listen - - - /** - * Command to turn off the driver and - * stop listening for inserted bills. - */ - case object UnListen - - - /** - * Accept a bill in bill acceptor (escrow mode). - */ - case object Accept - - - /** - * Reject a bill in bill acceptor (escrow mode). - */ - case object Reject - - - /** - * Notification hat the bill acceptor can now be configured - */ - case object Configurable -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/BillAcceptor.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/BillAcceptor.scala @@ -1,248 +0,0 @@ -package inc.pyc.chimera -package lib -package bill.acceptor - -import currency._ -import AcceptorCommands._ -import driver._ -import DriverCommands._ -import akka.actor._ -import akka.util.Timeout -import SupervisorStrategy._ -import scala.concurrent.duration._ -import inc.pyc.lift_akka.EventUpdate -import com.typesafe.config._ -import inc.pyc.lift_akka.LiftActorEventBus - - -object BillAcceptor extends ExtensionId[BillAcceptorImpl] with ExtensionIdProvider { - override def lookup = BillAcceptor - override def createExtension(system: ExtendedActorSystem) = new BillAcceptorImpl(system) - - /** - * Subscription topics for event bus. - */ - val insertedBill = "insertedBill" - val confirmedBill = "confirmedBill" - - - /** - * Event Bus for Chimera internal. - */ - val bus = new LookupBillAcceptor - - - /** - * Configuration for the bill acceptor. - */ - val config = ConfigFactory.load().getConfig("bill-acceptor") -} - - -/** - * Bill Acceptor API implementation - */ -sealed class BillAcceptorImpl(system: ExtendedActorSystem) extends Extension { - - /** - * The main actor that handles communications. - */ - private val actor = system.actorOf(Props[BillAcceptor], "BillAcceptor") - - def listen(): Unit = actor ! Listen - - def unlisten(): Unit = actor ! UnListen -} - - -class BillAcceptor extends Actor with ActorLogging { - import config.Machine.topics._ // TODO get rid of this dependency - import BillAcceptor._ - import context.dispatcher - import context.system - - implicit val timeout = Timeout(2 seconds) - - - override val supervisorStrategy = - OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) { - case ex => - log.error("Driver unexpectedly crashed: {}", ex.getMessage) - context become receive - driverMalfunction() - Restart - } - - - /** The currency that is currently accepted. */ - private var currency: Currency = findCurrency(config.getString("currency")) - - - /** Bill Acceptor Driver */ - private val driverName = config.getString("driver") - - - /** Whether the bill acceptor is working. */ - private var operating = true - - - /** Tries to get the driver to operate if it's not operating. */ - private val tryTicker = system.scheduler.schedule(0 seconds, 1 minute)(tryDriver) - - - /** - * Messages when not listening for inserted bills. - */ - def receive = { - - case Listen => - val fsm = initDriver(driverName) - context become waiting(fsm) - context watch fsm - fsm ! Listen - - - case m => - log.warning("Received Unknown Message: {}", m) - } - - - /** - * Bill Acceptor is waiting for the FSM to respond - * so it can start listening - */ - def waiting(fsm: ActorRef): Receive = { - - case Ready => - val fsm = sender - startListening(fsm) - - - case Terminated(`fsm`) => - context become receive - driverMalfunction() - - - case Configurable => - context become configurable - - - case m => - log.warning("Received Unknown Message (Waiting): {}", m) - } - - - /** - * Bill Acceptor is in listening mode, - * ready for someone to insert a bill. - */ - def listening(fsm: ActorRef): Receive = { - - case Accept => - fsm ! Accept - - - case Reject => - fsm ! Reject - - - case Configurable => - context become configurable - - - case Inserted(bill) => - log.info(s"Inserted bill: $bill $currency") - bus.publish(EventUpdate(insertedBill, bill)) - - - case Confirmed(bill) => - log.info(s"Confirmed bill: $bill $currency") - bus.publish(EventUpdate(confirmedBill, bill)) - - - case UnListen => - log.info("Stopped listening for bills") - fsm ! UnListen - - - case Disconnected => - context unwatch fsm - fsm ! Shutdown - context become receive - - - case Terminated(`fsm`) => - log.error("Driver unexpectedly crashed") - context become receive - driverMalfunction() - - - case m => - log.warning("Received Unknown Message (Listening): {}", m) - } - - - /** - * State where bill acceptor can be given - * commands so it can be configured. - */ - def configurable: Receive = { - - case Ready => - val fsm = sender - startListening(fsm) - - } - - - /** - * Bill acceptor will begin to listen for bills. - */ - def startListening(fsm: ActorRef): Unit = - if (!operating) { - operating = true - log.info("Bill acceptor is operating again. Redirecting to index...") - bus.publish(EventUpdate(redirectTo, "/index")) - fsm ! UnListen - } else { - log.info("Listening for bills") - context become listening(fsm) - context watch fsm - } - - - /** - * Run this function when driver fails for whatever reason. - */ - def driverMalfunction(): Unit = { - log.error("Bill acceptor not operating. Redirecting to malfunction... ") - operating = false - bus.publish(EventUpdate(redirectTo, "/malfunction")) - } - - - /** - * Tries to get the driver to operate if it's not operating. - */ - def tryDriver(): Unit = - if(!operating) self ! Listen - - - - /** - * Initiates a bill acceptor finite state machine actor that will communicate with the driver - * given the driver's protocol name. - */ - private def initDriver(service: String): ActorRef = { - context.actorOf(BillAcceptorDriver.getDriver(service), service.toString) - } -} - - - -/** - * PubSub for Bill Acceptor topics. - */ -class LookupBillAcceptor extends LiftActorEventBus { - override protected def mapSize: Int = 10 -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/Driver.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/Driver.scala @@ -1,181 +0,0 @@ -package inc.pyc.chimera.lib -package bill.acceptor -package driver - -import AcceptorCommands._ -import akka.actor._ -import akka.io.IO -import akka.util.ByteString -import com.github.jodersky.flow._ -import com.github.jodersky.flow.Serial._ -import scala.concurrent.duration._ - - -/** - * The different supported of bill acceptors. - */ -object BillAcceptorDriver extends Enumeration { - type BillAcceptorDriver = Value - val ID003, APEX = Value - - def drivers = Map( - ID003 -> Props[id003.ID003FSM] - ) - - def getDriver(s: String): Props = { - drivers.filter(_._1.toString == s).map(_._2).head - } -} - - -/** - * Main Bill Acceptor Driver trait. - */ -private[driver] trait Driver { - this: Actor with ActorLogging => - - import DriverCommands._ - import context.system - import context.dispatcher - - - /** The FSM is the the parent actor */ - val fsm = context parent - - - /** - * Command to poll bill acceptor's status. - */ - val poll: Input - - - /** Serial port (/dev/tty*) */ - val port: String = BillAcceptor.config.getString("port") - - - /** Size of buffer */ - val bufferSize: Int = BillAcceptor.config.getInt("buffer-size") - - - /** Settings for serial */ - val settings = SerialSettings( - baud = BillAcceptor.config.getInt("baud"), - characterSize = BillAcceptor.config.getInt("char-size"), - twoStopBits = BillAcceptor.config.getBoolean("two-stop-bits"), - parity = Parity(BillAcceptor.config.getInt("parity")) - ) - - - /** - * Handle data passing with this partial function - * when it's in listening mode. - */ - val handleData: Receive - - - /** - * Polls the bill acceptor every 100 milliseconds. - */ - lazy val startPoll = system.scheduler.schedule( - 1 second, 100 milliseconds, self, poll) - - - /** - * When driver is not in listening mode. - */ - def receive: Receive = { - - case Listen => - log.debug("Requesting manager to open port: {}, baud: {}", port, settings.baud) - IO(Serial) ! Open(port, settings, bufferSize) - - - case CommandFailed(cmd, reason) => - throw new CommandFailedException(reason) - - - case Opened(port) => - log.debug("Port {} is now open", port) - val operator = sender - context become opened(operator) - context watch operator - fsm ! Ready - startPoll - - - case Input(_) => - // ignore; not connected - - - case m => - log.warning("Received unknown message with port closed: {}", m) - } - - - /** - * When driver is in listening mode. - */ - def opened(operator: ActorRef): Receive = handleData orElse { - - case Received(data) => - log.debug("Received data: {}", formatData(data)) - - - case Closed => - log.debug("Operator closed normally, exiting driver") - context unwatch operator - context become receive - fsm ! Closed - - - case Terminated(`operator`) => - throw new OperatorCrashException - - - case Shutdown => - log.debug("Trying to shutdown operator") - startPoll.cancel - operator ! PoisonPill - context become shuttingdown(operator) - - - case ForceShutdown => - log.debug("Trying to terminate operator") - startPoll.cancel - operator ! Kill - self ! Kill - - - case UnListen => - log.debug("Driver's serial connection is closing") - operator ! Close - - - case Input(data) => - if(data != poll.input) log.info("Writing data: {}", data) - operator ! Write(data) - - - case m => - log.warning("Received unknown message with port open: {}", m) - - } - - - /** - * When driver is shutting down. - */ - def shuttingdown(operator: ActorRef): Receive = { - - case Terminated(`operator`) => - log.debug("Operator terminated normally, exiting driver") - context stop self - - case m => - log.warning("Received unknown message while shutting down: {}", m) - } - - - protected def formatData(data: ByteString) = - data.map("0x"+Integer.toHexString(_)).mkString("[", ",", "]") -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/DriverCommands.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/DriverCommands.scala @@ -1,82 +0,0 @@ -package inc.pyc.chimera -package lib -package bill.acceptor -package driver - -import akka.AkkaException -import scala.util.control.NoStackTrace -import akka.util.ByteString -import com.github.jodersky.flow.Serial.Event -import currency.Currency - - -private[acceptor] object DriverCommands { - - /** - * Shutdown actor - */ - case object Shutdown - - - /** - * Force Shutdown actor - */ - case object ForceShutdown - - - /** - * When the driver is ready to send out commands. - */ - case object Ready - - - /** - * Command to send data to serial. - */ - case class Input(input: ByteString) - - - /** - * Command when writing to the serial actor. - */ - case class Wrote(data: ByteString) extends Event - - - /** - * Data for Driver FSM's - */ - trait Data - - - /** - * The driver sent a message with no data. - */ - case object NoData extends Data - - - /** - * String message to send as data. - */ - case class Message(data: String) extends Data - - - /** - * Status (or state) for Driver FSM's - */ - trait Status - - - /** - * When the driver is not listening to the serial port. - */ - case object Disconnected extends Status - - - // exceptions - class CommandFailedException(reason: Throwable) extends - AkkaException("Connection failed, stopping driver. Reason: "+reason , reason) with NoStackTrace - - class OperatorCrashException extends - AkkaException("Operator crashed, exiting driver", new Throwable) with NoStackTrace - -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Commands.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Commands.scala @@ -1,525 +0,0 @@ -package inc.pyc.chimera -package lib -package bill.acceptor -package driver -package id003 - -import currency._ -import ID003Decoder._ -import DriverCommands._ -import akka.util.ByteString -import scala.collection._ - - -/** - * Refer to page 9 of the PDF. - */ -private[id003] object ID003Commands { - - /** - * Messages from Host to Acceptor - */ - sealed trait Request { - val cmd: ByteString - } - - - /** - * Messages from Acceptor to Host - */ - sealed trait Response - - - /** - * Poll message requesting for status - */ - case object StatusRequest extends Request { - val cmd = ByteString(0x11) - } - - - /** - * Acknowledgement - */ - case object Ack extends Request with Response { - val cmd = ByteString(0x50) - } - - - /** - * Operation command from the Host to the Acceptor. - */ - sealed trait OperationCommand extends Request - - - /** - * Reset the acceptor. - * Command is used to clear power up status and enter INITIALIZING status - */ - case object Reset extends OperationCommand { - val cmd = ByteString(0x40) - } - - - /** - * Stack the bill in escrow (or accept the bill). - * Command is valid only when Acceptor status is ESCROW - */ - case object Stack extends OperationCommand { - val cmd = ByteString(0x41) - } - - - /** - * Return the bill in escrow (or reject the bill). - * Command is valid only when Acceptor status is ESCROW - */ - case object Return extends OperationCommand { - val cmd = ByteString(0x43) - } - - - /** - * A bill in the escrow position will be held for 10 seconds. - * Command is valid only when Acceptor status is ESCROW - */ - case object Hold extends OperationCommand { - val cmd = ByteString(0x44) - } - - - /** - * Acceptor will stop and wait for 3 seconds. - * Command is valid while a bill is being processed. - * (ACCEPTING, ESCROW, STACKING, STACKED etc) - */ - case object Wait extends OperationCommand { - val cmd = ByteString(0x45) - } - - - /** - * ..... ? - */ - case object Signature extends OperationCommand { - val cmd = ByteString(0xdc) - } - - - /** - * Any request that requires data input. - */ - sealed trait SetRequest extends Request { - def data: ByteString - } - - - /** - * Response Echo - */ - sealed trait EchoResponse extends Response with SetRequest - - - /** - * Set Command is used to change the functionality of the Acceptor. - * Most of these Set Commands are valid only when the Acceptor status is INITIALIZING - * and will respond with an echo of the command. - */ - sealed trait SetCommand extends SetRequest - - - /** - * Commands are used to interrogate the acceptor - * for current setting information - */ - sealed trait SetStatus extends SetRequest - - - /** - * Enable/disable each denomination that the Acceptor is programmed to recognize. - */ - trait DenominationCommand extends SetCommand with SetStatus { - val cmd = ByteString(0xc0) - } - - - case object Denominations extends DenominationCommand with EchoResponse { - def data = ByteString() - } - - - case object EnableAllUSD extends DenominationCommand { - def data = ByteString(0x82, 0x00) - } - - - /** - * To enable only a specific values of a currency, send this command. - * - * ie. Allow only $10, $20, $50 and $100 bills. - * - * val disable = USD.`1` + USD.`5` - * BillAcceptor ! Denominations(USD, disable) - */ - case class Denominations[T <: Currency](currency: T, values: Currency#ValueSet) extends DenominationCommand with SetCommand with SetStatus { - - def data: ByteString = { - - val currencyValues = CURRENCY_VALUE(currency) - - // don't mind the confusion - val indices = values.map((v: Currency#Value) => v.id) map { - value => currencyValues.find(_._2 == value) - } flatMap (_.map(_._1)) map (currencyValues.keyIndex) - - val byte = indices map (1 << _) sum - - // java will reset to 0x00 if greater than 0xff :( - val byte2 = byte - 0xff - - if(byte2 < 0) ByteString(byte, 0x00) - else ByteString(byte, byte2) - } - } - - - /** - * Validation security level for each denomination - */ - trait Security extends SetCommand with SetStatus { - val cmd = ByteString(0xc1) - } - - - object Security extends Security with EchoResponse { - def data = ByteString() - } - - - case object StandardSecurity extends Security { - def data = ByteString(0x00, 0x00) - } - - - case object SecurityHighUSD extends Security { - def data = ByteString(0x7d, 0x00) - } - - - /** - * Set the communication mode to be used - */ - trait CommunicationMode extends SetCommand { - val cmd = ByteString(0xc2) - } - - - object CommunicationMode extends CommunicationMode with EchoResponse { - def data = ByteString() - } - - - case object CommunicationPolled extends CommunicationMode { - def data = ByteString(0x00) - } - - - case object CommunicationInterrupt1 extends CommunicationMode { - def data = ByteString(0x01) - } - - - case object CommunicationInterrupt2 extends CommunicationMode { - def data = ByteString(0x02) - } - - - /** - * Turn on/off acceptance of all currency and coupons. - * Inhibit enables and uninhibit disables. - */ - trait InhibitCMD extends SetCommand with SetStatus { - val cmd = ByteString(0xc3) - } - - - object InhibitCMD extends InhibitCMD with EchoResponse { - def data = ByteString() - } - - - case object Inhibit extends InhibitCMD { - def data = ByteString(0x01) - } - - - case object UnInhibit extends InhibitCMD { - def data = ByteString(0x00) - } - - - /** - * Set the directions in which currency will be accepted - */ - trait DirectionCMD extends SetCommand with SetStatus { - val cmd = ByteString(0xc4) - } - - - case object DirectionCMD extends DirectionCMD with EchoResponse { - def data = ByteString() - } - - - case object DirectionAll extends DirectionCMD { - def data = ByteString(0x00) - } - - - case class Direction( - faceUpLeft: Boolean = true, - faceUpRight: Boolean = true, - faceDownLeft: Boolean = true, - faceDownRight: Boolean = true) extends DirectionCMD { - - def data = { - var result = 0x00 - if(!faceUpLeft) result += 0x01 - if(!faceUpRight) result += 0x02 - if(!faceDownLeft) result += 0x04 - if(!faceDownRight) result += 0x08 - ByteString(result) - } - } - - - /** - * Request Escrow code / Denomination assignments from the Acceptor - * TODO Not yet implemented.... - */ - case class CurrencyAssignment(bytes: Byte) extends SetStatus { - val cmd = ByteString(0x8a) - def data = ByteString() - } - - - /** - * Command sent by the Host is not valid. - */ - case object InvalidCommand extends Response - - - /** - * following messages are used to reply to a - * STATUS REQUEST [11H] message from the Host - */ - sealed trait StatusResponse extends Status with Response - - - /** - * The Acceptor is ready to accept currency - */ - case object Idle extends StatusResponse - - - /** - * The Acceptor is drawing the bill in and - * examining it with validation sensors - */ - case object Accepting extends StatusResponse - - - /** - * Bill validation is complete. - * One byte of data is sent with this status - * to indicate the inserted denomination. - */ - case object Escrow extends StatusResponse { - - def extract(data: Byte, currency: Currency) = { - CURRENCY_VALUE(currency)(data) - } - - } - - - /** - * A bill is being transported to the cashbox - */ - case object Stacking extends StatusResponse - - - /** - * Bill accepted confirmation signal - * (The Acceptor can no longer return the bill). - * Vend Valid status is held until an ACK message is received from the Host. - */ - case object VendValid extends StatusResponse - - - /** - * Status is reported after VEND VALID status - * until the Acceptor returns to IDLE status - */ - case object Stacked extends StatusResponse - - - /** - * The Acceptor judged a bill invalid or the Host - * disabled acceptance of a specific denomination via command - */ - case object Rejecting extends StatusResponse { - - def extract(data: Byte) = { - - val REJECTION_REASONS = Map( - 0x71 -> "insertion", - 0x72 -> "mug", - 0x73 -> "head", - 0x74 -> "calibration", - 0x75 -> "conveying", - 0x76 -> "discrimination", - 0x77 -> "photoPattern", - 0x78 -> "photoLevel", - 0x79 -> "inhibit", - 0x7a -> "unknown", - 0x7b -> "operation", - 0x7c -> "stacker", - 0x7d -> "length", - 0x7e -> "photoPattern", - 0x7f -> "trueBill") - - REJECTION_REASONS(data) - } - } - - - /** - * During ESCROW status, the acceptor received a RETURN - * command from the Host; the bill is being returned. - */ - case object Returning extends StatusResponse - - - /** - * During ESCROW status, the Acceptor received a HOLD - * command from the Host; the acceptor is holding the bill in escrow - */ - case object Holding extends StatusResponse - - - /** - * The Acceptor will not draw any notes into the path during this status. - */ - case object Disabled extends StatusResponse - - - /** - * The Acceptor is conducting some initial self-tests - */ - case object Initializing extends StatusResponse - - - /** - * The Acceptor is calculating a CRC for the Host. - */ - case object SigBusy extends StatusResponse - - - /** - * The Acceptor has finished calculating a CRC for the Host - */ - case object SigEnd extends StatusResponse - - - /** - * Standard response when the acceptor receives power - */ - case object PowerUp extends StatusResponse - - - /** - * Upon receiving power, the acceptor detected a bill in the head unit. - * When a RESET message is received from the Host, the Acceptor will - * first return the bill and then enter INITIALIZING status. - */ - case object PowerUpBillInAcceptor extends StatusResponse - - - /** - * Upon receiving power, the Acceptor detected a bill in the transport unit. - * When a RESET command is received from the Host, the Acceptor will finish - * stacking the bill and then enter INITIALIZING status - */ - case object PowerUpBillInStacker extends StatusResponse - - - /** - * The cashbox is full - */ - case object StackerFull extends StatusResponse - - - /** - * The cashbox is either removed or not completely installed - */ - case object StackerOpen extends StatusResponse - - - /** - * A bill is jammed inside the Acceptor head - */ - case object JamInAcceptor extends StatusResponse - - - /** - * A bill jam occurred in the transport section of the Acceptor or - * an abnormality was detected while the bill was being pushed inside the cashbox - */ - case object JamInStacker extends StatusResponse - - - /** - * The Acceptor has stopped because a second bill was inserted while - * the first bill is being transported and stacked. - * When the second bill is removed, the Acceptor will continue - * with transporting and stacking the first bill. - */ - case object Pause extends StatusResponse - - - /** - * The Acceptor has detected an action thought to be mischievous - */ - case object Cheated extends StatusResponse - - - /** - * Normal Acceptor operation can not continue because of a - * failure, abnormal condition or incorrect setting - */ - case object Failure extends StatusResponse { - - def extract(data: Byte) = { - - val FAILURE_REASONS = Map( - 0xa2 -> "stack motor failure", - 0xa5 -> "Transport (feed) Motor speed failure", - 0xa6 -> "Transport (feed) Motor failure", - 0xab -> "Cashbox not ready", - 0xaf -> "Validator head is removed or wrong type isinstalled", - 0xb0 -> "Boot ROM failure", - 0xb1 -> "External ROM failure", - 0xb2 -> "ROM failure", - 0xb3 -> "External ROM writing failure") - - FAILURE_REASONS(data) - } - } - - - /** - * An error has developed in the communication data - */ - case object CommunicationError extends StatusResponse -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Decoder.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Decoder.scala @@ -1,90 +0,0 @@ -package inc.pyc.chimera -package lib -package bill.acceptor -package driver -package id003 - -import currency._ -import ID003Commands._ -import scala.collection.immutable.SortedMap - -private[id003] object ID003Decoder { - - - /** - * Status codes - */ - val STATUS = Map( - 0x11 -> Idle, - 0x12 -> Accepting, - 0x13 -> Escrow, - 0x14 -> Stacking, - 0x15 -> VendValid, - 0x16 -> Stacked, - 0x17 -> Rejecting, - 0x18 -> Returning, - 0x19 -> Holding, - 0x1a -> Disabled, - 0x1b -> Initializing, - 0xde -> SigBusy, - 0xdf -> SigEnd, - 0x40 -> PowerUp, - 0x41 -> PowerUpBillInAcceptor, - 0x42 -> PowerUpBillInStacker, - 0x43 -> StackerFull, - 0x44 -> StackerOpen, - 0x45 -> JamInAcceptor, - 0x46 -> JamInStacker, - 0x47 -> Pause, - 0x48 -> Cheated, - 0x49 -> Failure, - 0x4a -> CommunicationError) - - - - val RESPONSE = Map( - 0xc0 -> Denominations, - 0xc1 -> Security, - 0xc2 -> CommunicationMode, - 0xc3 -> InhibitCMD, - 0xc4 -> DirectionCMD - ) - - - - /** - * Decode and encode currency value - */ - def CURRENCY_VALUE(currency: Currency) = SortedMap( - 0x61 -> CURRENCY_VALUES(currency)(0), - 0x62 -> CURRENCY_VALUES(currency)(1), - 0x63 -> CURRENCY_VALUES(currency)(2), - 0x64 -> CURRENCY_VALUES(currency)(3), - 0x65 -> CURRENCY_VALUES(currency)(4), - 0x66 -> CURRENCY_VALUES(currency)(5), - 0x67 -> CURRENCY_VALUES(currency)(6), - 0x68 -> CURRENCY_VALUES(currency)(7)) - - - /** - * Country specific data codes. - * The values of the array is the value of the bill. - * If value is zero, bit is reserved. - * - * Refer to Appendix E in the PDF. - */ - val CURRENCY_VALUES: Map[Currency, Array[Int]] = Map( - USD -> Array( 1, 0, 5, 10, 20, 50, 100, 0) - ) - - - /** - * Country Number Code - */ - val CURRENCY: Map[Int, Currency] = Map( - 0x01 -> USD, - 0x27 -> USD, - 0x46 -> USD - ) - -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Driver.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Driver.scala @@ -1,195 +0,0 @@ -package inc.pyc.chimera.lib -package bill.acceptor -package driver -package id003 - -import ID003Decoder._ -import AcceptorCommands._ -import DriverCommands._ -import ID003Commands._ -import akka.actor._ -import FSM.Event -import com.github.jodersky.flow.Serial._ -import akka.util.ByteString - - -/** - * One of JCM's Bill Acceptor protocol: - * ftp://67.205.101.207/Pripherals/BillAcceptors/JCM/ID003/ID-003%20Protocol%20Spec.pdf - * - * Used by Lamassu. - */ -class ID003Driver extends Actor with ActorLogging with Driver { - - import context.system - import context.dispatcher - - - val handleData: Receive = { - - case Received(data) => - buffer = buffer ++ data - //log.info("putting {} into {}", data, buffer) - buffer = syncrhronize(buffer) - - if(bufferIsReady) - parsePacket(buffer(1)) - - - case request: SetRequest => - send(request) - - - case request: Request => - send(request) - - } - - - /** Data buffer incoming from serial */ - var buffer = ByteString() - - - /** - * Currency that is currently being accepted. - * USD (0x01) is default. - */ - var currency = CURRENCY(0x01) - - - /** Message start code */ - val SYNC = ByteString(0xfc) - - - /** Cyclic redundancy check */ - val CRC = new CRC - - - /** sync + length + command + CRC */ - val poll = Input( - SYNC ++ - ByteString(0x05) ++ - StatusRequest.cmd ++ - ByteString(0x27, 0x56) - ) - - - /** - * Checks if buffer is ready to be parsed. - * Byte after `SYNC` is length of packet. - * - * Requirements are: - * 1. Buffer data must begin with a legitimate packet or `SYNC` - * 2. Entire packet must be available in the buffer. - */ - def bufferIsReady = - buffer.length > 1 && - buffer.length >= buffer(1) && - ByteString(buffer(0)) == SYNC - - - /** - * Tries to parse the next available packet in the buffer. - * @length the length of the packet - */ - def parsePacket(length: Int): Unit = { - val packet = buffer slice (0, length) - buffer = buffer drop length - parse (packet) - } - - - /** - * Tries to parse the packet. - * State is available in the 3rd byte. - */ - def parse(packet: ByteString): Unit = - if(validPacket(packet)) { - - STATUS get(packet(2)) map(_ match { - - case Escrow => - val value = Escrow.extract(packet(3), currency) - if(value != 0) { - val bill = currency(value) - fsm ! Event(Escrow, Inserted(bill)) - } - - - case Rejecting => - val reason = Rejecting.extract(packet(3)) - fsm ! Event(Rejecting, Message(reason)) - - - case Failure => - val reason = Failure.extract(packet(3)) - fsm ! Event(Failure, Message(reason)) - - - case status => - fsm ! Event(status, NoData) - }) - - - RESPONSE get(packet(2)) map(_ match { - case r => fsm ! r - }) - - - // TODO handle Ack and InvalidCommand - } - - - /** - * Validates the packet by computing and checking the CRC. - * CRC is 2 bytes in length. - */ - def validPacket(packet: ByteString): Boolean = { - val payload = packet dropRight 2 - val crc = packet takeRight 2 - val compute = CRC.compute(payload toArray) takeRight 2 - val realcrc = ByteString(compute) - realcrc == crc.reverse - } - - - /** - * Generates CRC for the payload. - * CRC is 2 bytes in length. - */ - def crc(payload: ByteString): ByteString = { - val compute = CRC.compute(payload toArray) takeRight 2 - val crc = ByteString(compute) - crc.reverse - } - - - /** - * Synchronizes the data in the buffer by finding - * `SYNC` in data and returning all bytes after that. - * - * ie. - * jiberish + garbage + sync + data => sync + data - */ - def syncrhronize(buffer: ByteString): ByteString = { - val i = buffer indexOf SYNC(0) - if(i < 0) buffer - else buffer drop i - } - - - /** - * Builds a packet and sends it to the bill acceptor. - */ - def send(request: Request, data: ByteString = ByteString()): Unit = { - val length = ByteString(0x05 + data.size) - val payload = SYNC ++ length ++ request.cmd ++ data - self ! Input(payload ++ crc(payload)) - } - - - /** - * Builds a packet and sends it to the bill acceptor. - */ - def send(request: SetRequest): Unit = send(request, request.data) -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Fsm.scala b/src/main/scala/inc/pyc/chimera/lib/bill/acceptor/driver/id003/Fsm.scala @@ -1,313 +0,0 @@ -package inc.pyc.chimera -package lib -package bill.acceptor -package driver -package id003 - -import currency._ -import AcceptorCommands._ -import DriverCommands._ -import ID003Commands._ -import akka.actor._ -import akka.pattern._ -import scala.concurrent._, duration._ -import com.github.jodersky.flow.Serial.Closed - - -/** - * The interface to communicate with the ID003 Bill Acceptor. - * @master Actor to report all updates from the driver - * @driver Actor that communicates with ID003 device - */ -class ID003FSM extends Actor with FSM[Status, Data] { - - - /** - * Status where bill acceptor is trying to recover. - */ - case object Trying extends Status - - - /** - * The last request is kept to re-send in case of a communication error - */ - private var lastRequest: Any = Listen - - - /** - * The last inserted bill. - */ - private var lastBill: Option[Currency#Value] = None - - - /** - * Report important updates from the bill acceptor to the master. - */ - val master: ActorRef = context parent - - - /** - * The driver that communicates with the bill acceptor. - */ - val driver: ActorRef = context.actorOf(Props[ID003Driver], "ID003Driver") - - - context.watch(driver) - - - startWith(Disconnected, NoData) - - - when (Disconnected) { - case Event(Listen, _) => - requestDriver (Listen) - stay - - case Event(Ready, _) => - stay forMax (4 second) - - - case Event(Shutdown, _) => - context stop driver - context stop self - stay - - case Event(StateTimeout, _) => - requestDriver(Reset) - goto (Trying) - } - - - when(Trying, stateTimeout = 2 second) { - case Event(StateTimeout, _) => - driver ! ForceShutdown - failFSM("Driver is not responding") - } - - - when (Accepting) { - case Event(Escrow, Inserted(bill)) => - lastBill = Some(bill) - master ! Inserted(bill) - goto (Escrow) using Inserted(bill) - } - - - when (Escrow, stateTimeout = 8 second) { - case Event(Accept, _) => - requestDriver (Stack) - stay - - case Event(Reject, _) => - requestDriver (Return) - stay - - case Event(StateTimeout, _) => - requestDriver(Hold) - stay forMax (9 second) - } - - - // re-send VendValid Ack - when(VendValid, stateTimeout = 1 second) { - case Event(StateTimeout, _) => - requestDriver(Ack) - stay forMax (1 second) - } - - - when(CommunicationError, stateTimeout = 2 second) { - case Event(StateTimeout, _) => - requestDriver(Reset) - stay - } - - - when(Disabled)(FSM.NullFunction) - when(Idle)(FSM.NullFunction) - when(Stacking)(FSM.NullFunction) - when(Stacked)(FSM.NullFunction) - when(Rejecting)(FSM.NullFunction) - when(Returning)(FSM.NullFunction) - when(Holding)(FSM.NullFunction) - when(Initializing)(FSM.NullFunction) - when(SigBusy)(FSM.NullFunction) - when(SigEnd)(FSM.NullFunction) - when(PowerUp)(FSM.NullFunction) - when(PowerUpBillInAcceptor)(FSM.NullFunction) - when(PowerUpBillInStacker)(FSM.NullFunction) - when(StackerFull)(FSM.NullFunction) - when(StackerOpen)(FSM.NullFunction) - when(JamInAcceptor)(FSM.NullFunction) - when(JamInStacker)(FSM.NullFunction) - when(Pause)(FSM.NullFunction) - when(Cheated)(FSM.NullFunction) - when(Failure)(FSM.NullFunction) - - - onTransition { - - case _ -> VendValid => - log.info("VendValid Mode") - requestDriver(Ack) - lastBill.map(master ! Confirmed(_)) - lastBill = None - - case _ -> Escrow => // justa testing - log.info("Going in Escrow Mode...") - master ! nextStateData - - case _ -> Initializing => - log.info("Initializing") - master ! Configurable - - - case Disconnected -> Idle => - log.info("Disconnected to Idle Mode") - master ! Ready - - - case Initializing -> Idle => - log.info("Initializing to Idle Mode") - master ! Ready - - - case _ -> Disabled => - log.info("Disabled") - requestDriver(UnInhibit) - - - case _ -> PowerUpBillInStacker => - log.info("Powering up bill acceptor with bill in stacker. Trying to reset ...") - driver ! Reset - - - case PowerUpBillInStacker -> Initializing => - log.info("Bill stacker has been reset successfully") - - - case _ -> PowerUpBillInAcceptor => - log.info("Powering up bill acceptor with bill in acceptor. Trying to reset ...") - driver ! Reset - - - case PowerUpBillInAcceptor -> Initializing => - log.info("Bill acceptor has been reset successfully") - - - case _ -> StackerFull => - stackerUnavailable("Stacker is full", fail = false) - - - case Rejecting -> JamInAcceptor => - stackerUnavailable("Acceptor is jammed (while returning the bill)") - - - case _ -> JamInAcceptor => - stackerUnavailable("Acceptor is jammed") - - - case _ -> JamInStacker => - stackerUnavailable("Stacker is jammed") - - - case _ -> Rejecting => - log.warning("Bill is being rejected") - - - case _ -> Cheated => - log.warning("Someone is cheating") - - - case _ -> StackerOpen => - log.info("Cashbox is open") - // TODO reset amount of cash to zero - - - case StackerOpen -> _ => - log.info("Cash box is closed") - - - case a -> b => - log.info("Transition from {} to {}", a, b) - - } - - - whenUnhandled { - - case Event(UnListen, _) => - driver ! UnListen - stay - - - case Event(Closed, _) => - master ! Disconnected - goto (Disconnected) - - - case Event(e: EchoResponse, _) => - log.info("echo: cmd = {} data = {}", e.cmd.toArray, e.data.toArray) - stay - - - case Event(Terminated(`driver`), _) => - context stop self - goto (Disconnected) - - - case Event(e, s) => e match { - - - case Event(Failure, Message(reason)) => - failFSM("Failure in bill acceptor: " + reason) - - - case Event(CommunicationError, _) => - requestDriver(lastRequest) - stay - - - case Event(s: Status, _) => - if (stateName == s) stay - else goto (s) using (stateData) - - - case _ => - log.warning("received unhandled request {} in state {}/{}", e, stateName, s) - stay - } - - - } - - - /** - * Reports the problem to Lycia when the stacker - * is unavailable for whatever reason. - */ - private def stackerUnavailable(reason: String, fail: Boolean = true) = { - // TODO notify lycia - if(fail) failFSM(reason) - } - - - /** - * Fails this Finite State Machine. - */ - private def failFSM(reason: String) = { - stop(FSM.Failure(reason)) - } - - - /** - * Sends a request to the driver actor. - */ - private def requestDriver(any: Any): Unit = { - driver ! any - lastRequest = any - } - - - initialize() -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/currency/Currency.scala b/src/main/scala/inc/pyc/chimera/lib/currency/Currency.scala @@ -1,12 +0,0 @@ -package inc.pyc.chimera.lib -package currency - -abstract class Currency extends Enumeration { - def self = this - - - /** - * Symbol of the currency - */ - val symbol: String -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/currency/USD.scala b/src/main/scala/inc/pyc/chimera/lib/currency/USD.scala @@ -1,12 +0,0 @@ -package inc.pyc.chimera.lib -package currency - -object USD extends Currency { - val symbol = "$" - val `1` = Value(1, "$1") - val `5` = Value(5, "$5") - val `10` = Value(10, "$10") - val `20` = Value(20, "$20") - val `50` = Value(50, "$50") - val `100` = Value(100, "$100") -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lib/currency/package.scala b/src/main/scala/inc/pyc/chimera/lib/currency/package.scala @@ -1,47 +0,0 @@ -package inc.pyc.chimera.lib - -import scala.collection.SortedSet - - -package object currency { - - - /** - * All supported currencies. - */ - val currencies: Map[String, Currency] = Map( - "USD" -> USD - ) - - - /** - * Finds the currency given the string value. - */ - def findCurrency(s: String): Currency = - currencies.filter(_._1 == s).map(_._2).head - - - /** - * Finds the sum of a set of currency values. - */ - def sum(set: Currency#ValueSet): Int = - set.map((x: Currency#Value) => x.id).sum - - - /** - * Finds the index of a map value given the key or key and value. - * TODO this belongs somewhere else - */ - implicit class keyIndexing[A, B](val m: Map[A, B]) extends AnyVal { - - /** Find index with key */ - def keyIndex(key: A) = { - m.toArray.indexWhere { case (k, v) => key == k } - } - - /** Find index with key and value */ - def keyIndex(key: A, value: B) = { - m.toArray.indexWhere { case (k, v) => key == k && value == v } - } - } -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/lycia/Lycia.scala b/src/main/scala/inc/pyc/chimera/lycia/Lycia.scala @@ -1,18 +1,28 @@ package inc.pyc.chimera package lycia +import System._ import inc.pyc.bitcoin._ +import akka.actor._ object Lycia { - def buyLimit: Double = { + val stateWatcher = system.actorOf(Props[StateWatcher]) + + def buyLimit: Int = { 1000 } - def percentProfit: Double = { - 5.0 + def phoneVerifiedBuyLimit: Int = { + 3000 + } + + def maxBuyLimit: Int = { + 3000 } + def percentProfit: Percentage = Percentage(5.0) + def servicePriceTicker: BitcoinService.Value = { // TODO get service configured BitcoinService.BitStamp diff --git a/src/main/scala/inc/pyc/chimera/lycia/StateWatcher.scala b/src/main/scala/inc/pyc/chimera/lycia/StateWatcher.scala @@ -0,0 +1,74 @@ +package inc.pyc.chimera +package lycia + +import inc.pyc.chimera.State +import System._ +import akka.actor._ +import FSM._ + +trait StateWatchHelper { + this: FSM[State, Data] => + + /** + * State Watcher actor + */ + def stateWatcher: ActorRef = Lycia.stateWatcher + + /** + * Subscribes state transitions for state watcher + */ + def watchTransitions(refs: ActorRef*) { + refs foreach (_ ! SubscribeTransitionCallBack(stateWatcher)) + } + + /** + * Unsubscribes state transitions for state watcher + */ + def unwatchTransitions(refs: ActorRef*) { + refs foreach (_ ! UnsubscribeTransitionCallBack(stateWatcher)) + } +} + +/** + * Responsible for monitoring state transitions + * for finite state machines in the system. This + * includes so far: + * + * - Overlord + * - Bill Acceptor (reported by Overlord) + */ +class StateWatcher extends Actor { + + //val remote = context.actorSelection( + // "akka.tcp://actorSystemName@10.0.0.1:2552/user/actorName") + + override def preStart: Unit = { + overlord ! SubscribeTransitionCallBack(self) + } + + def receive = { + case CurrentState(ref, state) => send(ref, state) + case Transition(ref, _, state) => send(ref, state) + } + + def send(ref: ActorRef, state: Any) { + // remote ! StateReport(name(ref), state.toString) + } + + def name(ref: ActorRef): String = { + if(ref equals overlord) "Overlord" + else if(sender equals overlord) "Bill Acceptor" + else "Unknown" + } + + override def postStop: Unit = { + overlord ! UnsubscribeTransitionCallBack(self) + } +} + +/** + * Message to send to Lycia. + * @param name name of the actor + * @param state name of the state + */ +case class StateReport(name: String, state: String) +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/DDB.scala b/src/main/scala/inc/pyc/chimera/minions/DDB.scala @@ -0,0 +1,19 @@ +package inc.pyc.chimera +package minions + +import akka.actor._ +import SupervisorStrategy._ +import concurrent._ +import duration._ + +private[minions] trait DDBMinion { + this: Actor with ActorLogging with Minion => + + override val supervisorStrategy = + OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) { + case _: org.apache.http.conn.HttpHostConnectException => + log warning "Cannot connect to DynamoDB. Trying again ..." + Restart + case _: Exception => Escalate + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Expenditure.scala b/src/main/scala/inc/pyc/chimera/minions/Expenditure.scala @@ -0,0 +1,55 @@ +package inc.pyc.chimera +package minions + +import ddb._ +import lycia._ +import akka.actor._ +import akka.pattern._ +import concurrent.Await +import concurrent.duration._ + +/** + * Minion that has access to the DDB Expenditure Table. + */ +class ExpenditureMinion + extends Actor + with ActorLogging + with Minion + with MinionWorker + with DDBMinion { + + def props: Props = Props[Expenditure] + def name: String = "Expenditure-"+util.Random.nextInt + + val tasks: Receive = { + case audit: AuditData => calculateBuyLimit(audit) + case tx: CompleteTx => saveExpenditure(tx) + } + + /** + * Saves completed transaction to Dynamo DB's Expenditure table. + * @param tx completed transaction + */ + def saveExpenditure(tx: CompleteTx) = workout(_ ! tx) + + /** + * Calculates how many bitcoins a user can buy. + * @param audit data about user processing transaction + */ + def calculateBuyLimit(audit: AuditData) = workout { + worker => + val f = ask(worker, Past24Hours(audit.addresses)).mapTo[DDBItems] + val res = Await.result(f, 3 seconds) + val spent = res.items flatMap { + item => + item.attributes. + filter(_.name == "paid"). + map(_.value.getN().toInt) + } sum + + val limit = audit.userInfo flatMap (_.purchaseLimit) getOrElse Lycia.buyLimit + val left = limit - spent + + sender ! LeftToSpend(left, limit) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Factory.scala b/src/main/scala/inc/pyc/chimera/minions/Factory.scala @@ -0,0 +1,157 @@ +package inc.pyc.chimera +package minions + +import akka.actor._ +import util.Random +import inc.pyc.bill._ +import acceptor._ +import Events._ + +/** + * Creates minions. + */ +trait MinionFactory { + this: Actor => + + /** + * Name for actor minions. + */ + val minionName: String = "Minion-"+Random.nextInt + + /** + * Summons a minion + */ + def summon(minion: Props) = context.actorOf(minion, minionName) + + /** + * Summons a SMS Minion. + */ + def summonSMS: ActorRef = summon(Props[SMSMinion]) + + /** + * Summons a User Minion. + */ + def summonUser: ActorRef = summon(Props[UserMinion]) + + /** + * Summons a General Minion. + */ + def summonGeneral: ActorRef = summon(Props[GeneralMinion]) + + /** + * Summons a User Minion. + */ + def summonLocalDb: ActorRef = summon(Props[LocalDBMinion]) + + /** + * Summons a Transaction Minion. + */ + def summonTransaction: ActorRef = summon(Props[TransactionMinion]) + + /** + * Summons an Expenditure Minion. + */ + def summonExpenditure: ActorRef = summon(Props[ExpenditureMinion]) + + /** + * Summons a Phone Number Minion. + */ + def summonPhoneNumber: ActorRef = summon(Props[PhoneNumberMinion]) + + /** + * Summons a Receipt Email Minion. + */ + def summonReceiptEmail: ActorRef = summon(Props[ReceiptEmailMinion]) + + /** + * Logs in registered user with bitcoin address. + */ + def loginUser(address: BitcoinAddress) { + summonUser ! address + } + + /** + * Logs in registered user. + */ + def loginUser(info: UserVerify) { + summonUser ! info + } + + /** + * Searches for information to allow or + * disallow user to buy bitcoin. + */ + def audit(data: AuditData) { + summonExpenditure ! data + } + + /** + * Creates a transaction at current price. Recover + * any incomplete transactions if they exist. + */ + def createTx(creator: CreatorTx) { + summonLocalDb ! creator + } + + /** + * Verifies a phone number via sms. + * @param phone phone number to verify + * @return SMS Minion actor reference + */ + def verifyNumber(phone: Phone): ActorRef = { + val sms = summonSMS + sms ! phone + sms + } + + /** + * Saves a phone number to storage. + * @param address bitcoin address linked to the phone number + * @param phone phone number + */ + def saveNumber(address: String, phone: Phone) { + summonPhoneNumber ! (address, phone) + } + + /** + * Saves aan email address to storage. + * @param address bitcoin address linked to the phone number + * @param email email address + */ + def saveReceiptEmail(address: String, email: Email) { + summonReceiptEmail ! (address, email) + } + + /** + * Checks whether inserted bill should be accepted. + */ + def validateBill(inspect: InspectBill) { + summonGeneral ! inspect + } + + /** + * Saves an incomplete tx to local database + */ + def saveTx(tx: IncompleteTx) { + summonLocalDb ! tx + } + + /** + * Gives credit for the confirmed bill. + */ + def confirmBill(confirmed: Confirmed, tx: IncompleteTx) { + summonLocalDb ! (confirmed, tx) + } + + /** + * Once transaction is complete, it does the following: + * - saves tx to local database and remove incomplete transaction + * - saves tx to Dynamo's Expenditure table + * - saves tx to Dynamo's Transaction table + */ + def completeTx(tx: CompleteTx) { + summonLocalDb ! tx + summonTransaction ! tx + summonExpenditure ! tx + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/General.scala b/src/main/scala/inc/pyc/chimera/minions/General.scala @@ -0,0 +1,34 @@ +package inc.pyc.chimera +package minions + +import akka.actor._ + +/** + * Just a general minion. Has nothing special about it. + * Might remove this in the future. + */ +class GeneralMinion + extends Actor + with ActorLogging + with Minion { + + val tasks: Receive = { + case insp: InspectBill => isBillAcceptable(insp) + } + + /** + * Checks if the inserted bill should be accepted + * @param inserted bill inserted + * @param tx incomplete transaction + */ + def isBillAcceptable(inspect: InspectBill) = runTask { + val tx = inspect.tx + val inserted = inspect.inserted + val balance = inspect.balance.remaining + val sum = (tx.bills :+ inserted.bill.id) sum + val lim = tx.limit + if(sum > lim) sender ! InvalidBill + else if(sum > balance) sender ! InsufficientFunds + else sender ! ValidBill + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Http.scala b/src/main/scala/inc/pyc/chimera/minions/Http.scala @@ -0,0 +1,19 @@ +package inc.pyc.chimera +package minions + +import akka.actor._ +import SupervisorStrategy._ +import concurrent._ +import duration._ + +private[minions] trait HttpMinion { + this: Actor with ActorLogging with Minion => + + override val supervisorStrategy = + OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) { + case _: java.net.ConnectException => + log warning ("Cannot connect to service with {}", getClass.getSimpleName) + Restart + case _: Exception => Escalate + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/LocalDB.scala b/src/main/scala/inc/pyc/chimera/minions/LocalDB.scala @@ -0,0 +1,79 @@ +package inc.pyc.chimera +package minions + +import model._ +import System._ +import akka.actor._ +import inc.pyc.bill._ +import acceptor._ +import Events._ + +class LocalDBMinion + extends Actor + with ActorLogging + with Minion { + + val tasks: Receive = { + case tx: IncompleteTx => save(tx) + case tx: CompleteTx => save(tx) + case creator: CreatorTx => create(creator) + case (cnf: Confirmed, tx: IncompleteTx) => saveBill(cnf,tx) + } + + /** + * Saves incomplete transaction to disk. + * @param tx incomplete transaction + */ + def save(tx: IncompleteTx) { + IncompleteTransaction.save(tx) + } + + /** + * Removes incomplete transaction and saves completed + * transaction to local database. + * @param tx completed transaction + */ + def save(tx: CompleteTx) { + IncompleteTransaction find (tx.address) map (_.delete_!) + CompletedTransaction.save(tx) + } + + /** + * Saves confirmed inserted bill to current transaction + * and saves the current transaction to disk. + * @param confirmed confirmed bill + * @param tx transaction in process + */ + def saveBill(confirmed: Confirmed, tx: IncompleteTx) = runTask { + val newTx = tx.insertBill(confirmed.bill) + IncompleteTransaction.save(newTx) + sender ! newTx + } + + /** + * Creates a local, incomplete transaction. + * @param creator contains info to create a transaction: audit and buy limit + */ + def create(creator: CreatorTx) = runTask { + val audit = creator.audit + val toSpend = creator.limit + val price = creator.price + val tx = + IncompleteTransaction.find(audit.address.data) map { + tx => + tx.asIncompleteTx.copy( + limit = toSpend.left, + userInfo = audit.userInfo) + } openOr { + IncompleteTx( + address = audit.address.data, + price = price, + currency = currency, + bills = Nil, + limit = toSpend.left, + userInfo = audit.userInfo) + } + + sender ! tx + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Minion.scala b/src/main/scala/inc/pyc/chimera/minions/Minion.scala @@ -0,0 +1,119 @@ +package inc.pyc.chimera +package minions + +import akka.actor._ +import SupervisorStrategy._ +import akka.util.Timeout +import concurrent.duration._ + +/** + * Minion does all the workload for Overlord. In case disaster + * occurs, the minion dies. + */ +private[minions] trait Minion { + this: Actor with ActorLogging => + + import context.dispatcher + + // Those damn minions better hurry + implicit val timeout = Timeout(4 seconds) + + /** + * Tasks that can be done by the minion. + */ + val tasks: Receive + + /** + * Minion kills itself + */ + def die = context stop self + + /** + * Minion runs its task and kills itself afterwards. + */ + def runTask (func: => Unit) { + func + die + } + + /** + * Minion runs its task and kills itself after timer ends or + * something else kills it. + */ + def runTask_Wait (duration: FiniteDuration) (func: => Unit) { + func + context.system.scheduler.scheduleOnce(duration)(die) + } + + /** + * Only Overlord creates and runs minions. + */ + def overlord = context parent + + + def receive: Receive = tasks orElse { + case term: Terminated => + log error "Minion could not complete its task" + context stop self + } +} + +/** + * Minion that launches a worker that can do the + * job. + */ +private[minions] trait MinionWorker { + this: Actor with Minion => + + /** + * Props for worker actor + */ + def props: Props + + /** + * Name for worker actor + */ + def name: String + + /** + * Creates a worker and kills it after it's + * done doing its job. + */ + def workout (func: ActorRef => Unit) { + val worker = context.actorOf(props, name) + context watch worker + func(worker) + kill(worker) + } + + /** + * Creates a worker and kills it after it's + * done doing its job. Returns a result. + */ + def workout_[T] (func: ActorRef => T): T = { + val worker = context.actorOf(props, name) + context watch worker + val result = func(worker) + kill(worker) + result + } + + /** + * Kills the worker actor and becomes ready to die. + * If the minion never hears from the worker, it kills itself. + * @param worker worker actor + */ + def kill(worker: ActorRef) = { + context become dying(worker) + worker ! PoisonPill + } + + /** + * Minion is dying. + * Once worker is dead, minion kills itself. + */ + def dying(worker: ActorRef): Receive = { + case Terminated(`worker`) => die + case _ => + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/PhoneNumber.scala b/src/main/scala/inc/pyc/chimera/minions/PhoneNumber.scala @@ -0,0 +1,30 @@ +package inc.pyc.chimera +package minions + +import ddb._ +import akka.actor._ + +/** + * Minion that has access to the DDB PhoneNumber Table. + */ +class PhoneNumberMinion + extends Actor + with ActorLogging + with Minion + with MinionWorker + with DDBMinion { + + def props: Props = Props[PhoneNumber] + def name: String = "PhoneNumber-"+util.Random.nextInt + + val tasks: Receive = { + case (address: String, phone: Phone)=> saveNumber(address, phone) + } + + /** + * Saves phone number with bitcoin address to Dynamo DB's PhoneNumber table. + * @param adress bitcoin address + * @param phone phone number + */ + def saveNumber(address: String, phone: Phone) = workout(_ ! (address, phone)) +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/ReceiptEmail.scala b/src/main/scala/inc/pyc/chimera/minions/ReceiptEmail.scala @@ -0,0 +1,30 @@ +package inc.pyc.chimera +package minions + +import ddb._ +import akka.actor._ + +/** + * Minion that has access to the DDB ReceiptEmail Table. + */ +class ReceiptEmailMinion + extends Actor + with ActorLogging + with Minion + with MinionWorker + with DDBMinion { + + def props: Props = Props[ReceiptEmail] + def name: String = "ReceiptEmail-"+util.Random.nextInt + + val tasks: Receive = { + case (address: String, email: Email)=> saveEmail(address, email) + } + + /** + * Saves email with bitcoin address to Dynamo DB's ReceiptEmail table. + * @param adress bitcoin address + * @param email email address + */ + def saveEmail(address: String, email: Email) = workout(_ ! (address, email)) +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Ticker.scala b/src/main/scala/inc/pyc/chimera/minions/Ticker.scala @@ -0,0 +1,37 @@ +package inc.pyc.chimera +package minions + +import inc.pyc._ +import bitcoin._ +import akka.actor._ +import akka.pattern._ + +/** + * Minion that has access to bitcoin price ticker + * @param service bitcoin price ticker service + */ +class TickerMinion(service: BitcoinService.Value) + extends Actor + with ActorLogging + with Minion + with MinionWorker + with HttpMinion { + + import context.dispatcher + + def name: String = "Ticker-"+util.Random.nextInt + def props: Props = BitcoinService.props(service, name) + + val tasks: Receive = { + case Tick => tick + } + + /** + * Gets the updated price from the price ticker. + */ + def tick = workout { + worker => + val future = ask(worker, Tick).mapTo[Price] + pipe (future) to sender + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Transaction.scala b/src/main/scala/inc/pyc/chimera/minions/Transaction.scala @@ -0,0 +1,29 @@ +package inc.pyc.chimera +package minions + +import ddb._ +import akka.actor._ + +/** + * Minion that has access to the DDB Transaction Table. + */ +class TransactionMinion + extends Actor + with ActorLogging + with Minion + with MinionWorker + with DDBMinion { + + def props: Props = Props[Transaction] + def name: String = "Transaction-"+util.Random.nextInt + + val tasks: Receive = { + case tx: CompleteTx => saveTransaction(tx) + } + + /** + * Saves completed transaction to Dynamo DB's Transaction table. + * @param tx completed transaction + */ + def saveTransaction(tx: CompleteTx) = workout(_ ! tx) +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/User.scala b/src/main/scala/inc/pyc/chimera/minions/User.scala @@ -0,0 +1,35 @@ +package inc.pyc.chimera +package minions + +import akka.actor._ +import akka.pattern._ + +/** + * Minion that has access to registered user information. + */ +class UserMinion + extends Actor + with ActorLogging + with Minion + with MinionWorker { + + import context.dispatcher + + def props: Props = Props[User] + def name: String = "User-"+util.Random.nextInt + + val tasks: Receive = { + case address: BitcoinAddress => loginUser(address) + case credentials: UserVerify => loginUser(credentials) + } + + /** + * Logs in registered user. + * @param info information details to login + */ + def loginUser[T](info: T) = workout { + worker => + val future = ask(worker, info).mapTo[UserData] + pipe (future) to sender + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Verify.scala b/src/main/scala/inc/pyc/chimera/minions/Verify.scala @@ -0,0 +1,62 @@ +package inc.pyc.chimera +package minions + +import lib._ +import akka.actor._ +import concurrent._ +import duration._ + +class SMSMinion + extends Actor + with ActorLogging + with Minion { + + /* 6-digit generated code */ + lazy val smscode = (100000 + new scala.util.Random().nextInt(900000)).toString + + val tasks: Receive = { + case phone: Phone => send(phone) + case code: SMSCode => verify(code) + } + + /** + * Verifies if the sms code entered matches + * the one sent. + * @param sms sms code + */ + def verify(sms: SMSCode) = runTask { + if(sms.data == smscode) { + overlord ! VerifiedPhone(sms.phone) + sender ! VerifiedPhone(sms.phone) + } else { + sender ! InvalidSMSCode + } + } + + /** + * Sends an sms code to a phone number. + * @param phone phone number + */ + def send(phone: Phone) = runTask_Wait (3 minutes) { + if(phone.sms) { + val msg = s"Hi, enter $smscode to verify with PYC Bitcoin ATM. This is a 1-time message." + val sent = Twilio.sms(phone.data, msg) + } + } + +} + +/** + * Code sent via SMS to verify phone number. + */ +case class SMSCode(phone: Phone, data: String) + +/** + * Phone number is verified. + */ +case class VerifiedPhone(phone: Phone) + +/** + * SMS code entered does not verify the phone number. + */ +case object InvalidSMSCode +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/minions/Wallet.scala b/src/main/scala/inc/pyc/chimera/minions/Wallet.scala @@ -0,0 +1,99 @@ +package inc.pyc.chimera +package minions + +import inc.pyc._ +import bitcoin._ +import service._ +import BitcoinJsonRPC._ +import akka.actor._ +import akka.pattern._ +import scala.concurrent.Future + +/** + * Minion that has access to bitcoin wallet + * @param service bitcoin wallet service + */ +class WalletMinion(service: BitcoinService.Value) + extends Actor + with ActorLogging + with Minion + with MinionWorker + with HttpMinion { + + import context.dispatcher + + def name: String = "Wallet-"+util.Random.nextInt + def props: Props = BitcoinService.props(service, name) + + val tasks: Receive = { + case GetBalance => getBalance + case qr: QrCode => validateQr(qr) + case tx: IncompleteTx => sell(tx) + } + + /** + * Validates bitcoin address in the QR code. + * @param code scanned QR code + */ + def validateQr(code: QrCode) { + val future = validateAddress(code.qr) map { + validation => + if (!validation.isvalid) InvalidBitcoinAddress + else BitcoinAddress(validation.address) + } + pipe (future) to sender + } + + /** + * Sells bitcoins to the user. + */ + def sell(tx: IncompleteTx) = workout { + worker => + // TODO sell bitcoins + // listen for the confirmation from the network, + // reply when available + val txid = "" + sender ! CompleteTx(tx, txid) + } + + /** + * Gets the balance in the wallet. + */ + def getBalance = workout { + worker => + val future = ask(worker, GetBalance).mapTo[String] map { + s => Balance(s.toDouble) + } + pipe (future) to sender + } + + /** + * Validates the bitcoin address with the wallet service. + * @param address bitcoin address + */ + def validateAddress(data: String) = workout_ { + val address = extractFromBitcoinUri(data) + ask(_, ValidateAddress(address)).mapTo[AddressValidation] + } + + /** + * Extracts bitcoin address from URI. + * + * Note the regex is not what the bitcoin address should exactly be, + * but it works for extraction. + */ + def extractFromBitcoinUri(uri: String) = { + val r = "(bitcoin:)?([a-zA-Z0-9]{1,60})(/*?.*)?".r + uri match { + case r(_, address, _) => address + case _ => "" + } + } +} + + +/** + * Balance remaining in bitcoin wallet. + * @param remaining balance remaining in bitcoin + */ +case class Balance(remaining: Double) +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/model/CompletedTransaction.scala b/src/main/scala/inc/pyc/chimera/model/CompletedTransaction.scala @@ -0,0 +1,38 @@ +package inc.pyc.chimera +package model + +import inc.pyc.currency._ +import net.liftweb._ +import mapper._ + +/** + * Completed customer transaction. Only shows txid. + */ +class CompletedTransaction extends KeyedMapper[String, CompletedTransaction] { + def getSingleton = CompletedTransaction + def primaryKeyField = txid + + /** + * Transaction ID in the Bitcoin network + */ + object txid extends MappedStringIndex(this, 256) { + override def writePermission_? = true + override def dbAutogenerated_? = false + override def dbNotNull_? = true + } + + def save(tx: CompleteTx): Boolean = { + txid(tx.txid) + save() + } + +} + +object CompletedTransaction extends CompletedTransaction + with KeyedMetaMapper[String, CompletedTransaction] { + + override def save(tx: CompleteTx): Boolean = { + val complete = find(tx.txid) openOr create + complete.save(tx) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/model/IncompleteTransaction.scala b/src/main/scala/inc/pyc/chimera/model/IncompleteTransaction.scala @@ -0,0 +1,80 @@ +package inc.pyc.chimera +package model + +import inc.pyc.currency._ +import net.liftweb._ +import mapper._ +import inc.pyc.bitcoin.Price + +/** + * The information of a completed customer transaction. + */ +class IncompleteTransaction extends KeyedMapper[String, IncompleteTransaction] { + def getSingleton = IncompleteTransaction + def primaryKeyField = address + + /** + * Bitcoin address to send bitcoin + */ + object address extends MappedStringIndex(this, 256) { + override def writePermission_? = true + override def dbAutogenerated_? = false + override def dbNotNull_? = true + } + + /** + * Market buy price + */ + object price extends MappedDouble(this) + + /** + * Percentage over market buy price + */ + object percentage extends MappedDouble(this) + + /** + * Currency used to make the transaction + */ + object currency extends MappedString(this, 5) { + def apply(c: Currency) = super.apply(c.toString) + def asCurrency: Currency = findCurrency(get) + } + + /** + * The bills the user has inserted into the bill acceptor + */ + object bills extends MappedString(this, 2048) { + + def apply(b: List[Int]) = + super.apply(b mkString ",") + + def asList: List[Int] = + if (get.isEmpty) Nil + else get.split(",").toList.map(_.toInt) + + } + + def asIncompleteTx: IncompleteTx = + IncompleteTx(address.get, Price(price.get, percentage.get), + currency.asCurrency, bills.asList, 0, None) + + def save(tx: IncompleteTx): Boolean = { + address(tx.address) + price(tx.price.price) + percentage(tx.price.percentage) + currency(tx.currency) + bills(tx.bills) + save() + } + +} + +object IncompleteTransaction extends IncompleteTransaction + with KeyedMetaMapper[String, IncompleteTransaction] { + + override def save(tx: IncompleteTx) = { + val incomplete = find(tx.address) openOr create + incomplete.save(tx) + } + +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/model/Transaction.scala b/src/main/scala/inc/pyc/chimera/model/Transaction.scala @@ -1,135 +0,0 @@ -package inc.pyc.chimera -package model - -import lib.currency.Currency -import net.liftweb._ -import mapper._ -import json._ -import JsonDSL._ -import java.util.Date - -/** - * The information of the current customer transaction. - */ -class Transaction extends KeyedMapper[String, Transaction] { - def getSingleton = Transaction - def primaryKeyField = address - - /** - * Bitcoin address to send bitcoin - */ - object address extends MappedStringIndex(this, 256) { - override def writePermission_? = true - override def dbAutogenerated_? = false - override def dbNotNull_? = true - } - - /** - * Accepted static buy price - */ - object price extends MappedString(this, 256) - - /** - * Buy limit - */ - object limit extends MappedDouble(this) - - /** - * Currency used to make the transaction - */ - object currency extends MappedString(this, 5) { - def apply(c: Currency) = { - currencySymbol(c) - super.apply(c.toString) - } - } - - /** - * Symbol used by the currency. - */ - object currencySymbol extends MappedString(this, 5) { - def apply(c: Currency) = super.apply(c.symbol) - } - - /** - * Whether we ever scanned the address before - */ - object newAddress extends MappedBoolean(this) - - /** - * The bills the user has inserted into the bill acceptor - */ - object billsInserted extends MappedString(this, 2048) { - def asList: List[Int] = - if(get.isEmpty) Nil - else get.split(",").map(_.toInt).toList - } - - /** - * Whether the bitcoin were sent or not - */ - object sent extends MappedBoolean(this) - - /** - * Time this transaction started - */ - object startTime extends MappedDateTime(this) { - override def defaultValue = new Date() - } - - /** - * Time this transaction stopped - */ - object stopTime extends MappedDateTime(this) { - def option: Option[Date] = - if(get == null) None - else Some(get) - - def optionString: Option[String] = option.map(_.toString) - } - - /** - * The wizard page the user is currently seeing. - */ - object currentPage extends MappedString(this, 256) - - /** - * Registered user's first name - */ - object fname extends MappedString(this, 256) - - /** - * Registered user's last name - */ - object lname extends MappedString(this, 256) - - /** - * Registered user's email address - */ - object email extends MappedEmail(this, 256) - - def asJValue: JValue = - (address.name -> address.get) ~ - (price.name -> price.get) ~ - (limit.name -> limit.get) ~ - (currency.name -> currency.get) ~ - (currencySymbol.name -> currencySymbol.get) ~ - (newAddress.name -> newAddress.get) ~ - (billsInserted.name -> billsInserted.asList) ~ - (sent.name -> sent.get) ~ - (startTime.name -> startTime.get.toString) ~ - (stopTime.name -> stopTime.optionString) ~ - (currentPage.name -> currentPage.get) ~ - (fname.name -> option(fname.get)) ~ - (lname.name -> option(lname.get)) ~ - (email.name -> option(email.get)) - - - def option(get: String): Option[String] = - if(get isEmpty) None - else Some(get) - -} - -object Transaction extends Transaction - with KeyedMetaMapper[String, Transaction] -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/BitcoinAddressScanner.scala b/src/main/scala/inc/pyc/chimera/snippet/BitcoinAddressScanner.scala @@ -1,65 +0,0 @@ -package inc.pyc.chimera -package snippet - -import config._ -import Machine._ -import system.dispatcher -import model._ -import lycia._ -import net.liftweb._ -import http._ -import json.JsonAST._ -import json.JsonDSL._ -import scala.concurrent.Future -import inc.pyc.lift_akka._ - - -class BitcoinAddressScanner extends RoundTripSnippet { - - def roundTrips: List[RoundTripInfo] = List("submitAddress" -> submitAddress _) - - def submitAddress(json: JValue): Message = - for (JString(address) <- json) yield - for { - validation <- wallet.validateAddress(address) - price <- priceTicker.price() - } yield - if (!validation.isvalid) { - Message("failure") - } else { - val address = validation.address - val tx = - Transaction.create - .address(address) - .price(price.format) - .currency(Machine.currency) - .limit(Lycia.buyLimit) - .currentPage("Insert Bills") - - currentTransaction send tx - - Future { - // TODO check website database if address exists - // TODO check if address is owned by a user - - // load any existing incomplete transactions - Transaction find(address) map { - tx => - val ctx = currentTransaction() - if(ctx.billsInserted.asList.isEmpty) { - currentTransaction send tx - bus.publish(EventUpdate(topics.transactionUpdate, tx.asJValue)) - } - } - } - - Message("success", data = Some(tx.asJValue)) - } - /* - def listenBillAcceptor(json: JValue): Message = { - info("justa testin") - Machine.billAcceptor.listen() - Message("success") - } - */ -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/Comet.scala b/src/main/scala/inc/pyc/chimera/snippet/Comet.scala @@ -1,84 +0,0 @@ -package inc.pyc.chimera -package snippet - -import config._ -import lib._ -import bill.acceptor._ -import xml._ -import Machine.topics._ -import BillAcceptor.insertedBill -import inc.pyc.lift_akka.EventRegister -import inc.pyc.bitcoin._ -import net.liftweb.json._ -import JsonDSL._ - - -/** - * Snippet to update the client-side transaction. - */ -class Transaction extends EventRegister { - - val system = Machine.system - val bus = Machine.bus - val topic = transactionUpdate - - override val receive: Receive = { - case JField("msg", JString("buyBitcoin")) => - // TODO buy the bitcoin - } - - def render(in: NodeSeq) = comet(in) -} - - -/** - * Snippet to update screen when bills are inserted. - */ -class BillAcceptor extends EventRegister { - - val system = Machine.system - val bus = Machine.bus - val topic = insertedBill - - override val receive: Receive = { - case JString("listen") => - //Machine.billAcceptor.listen() - - case JString("unlisten") => - Machine.billAcceptor.unlisten() - } - - def render(in: NodeSeq) = comet(in) -} - - -/** - * Snippet to update the price ticker on screen. - */ -class PriceTicker extends EventRegister { - - val system = Machine.system - val bus = BitcoinSystem.bus - val topic = PriceTicker.newPrice - - override val postSubscribe = - /*TraceRecorder.withNewTraceContext("priceticker")*/ () => { - Machine.priceTicker.gossipPrice - } - - def render(in: NodeSeq) = comet(in) -} - - -/** - * Snippet to redirect the client when needed. - */ -class Redirect extends EventRegister { - - val system = Machine.system - val bus = Machine.bus - val topic = redirectTo - - - def render(in: NodeSeq) = comet(in) -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/Logger.scala b/src/main/scala/inc/pyc/chimera/snippet/Logger.scala @@ -0,0 +1,38 @@ +package inc.pyc.chimera +package snippet + +import akka.actor._ +import net.liftweb.json._ + +object Logger { + val logger = System.system.actorOf(Props[LoggerActor], "ClientLogger") +} + +import Logger._ + +/** + * Snippet to allow client to send console logs to + * chimera's akka system. + */ +class Logger extends ChimeraEventRegister { + val topic = "" + + override val receive: Receive = { + case JField("debug", JString(msg)) => logger ! ("debug", msg) + case JField("info", JString(msg)) => logger ! ("info", msg) + case JField("warn", JString(msg)) => logger ! ("warn", msg) + case JField("error", JString(msg)) => logger ! ("error", msg) + } +} + +/** + * Forwards logs. + */ +class LoggerActor extends Actor with ActorLogging { + def receive = { + case ("debug", msg: String) => log debug msg + case ("info", msg: String) => log info msg + case ("warn", msg: String) => log warning msg + case ("error", msg: String) => log error msg + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/Overlord.scala b/src/main/scala/inc/pyc/chimera/snippet/Overlord.scala @@ -0,0 +1,32 @@ +package inc.pyc.chimera +package snippet + +import System._ +import net.liftweb.json._ +import minions.SMSCode + +/** + * Snippet to allow client to communicate with + * Overlord. + */ +class Overlord extends ChimeraEventRegister { + val topic = "" + + override val receive: Receive = { + case JField("msg", jval: JValue) => jval match { + + case JString("wait") => overlord ! Wait + case JString("continue") => overlord ! Continue + case JString("previous") => overlord ! Previous + case JString("buy") => overlord ! Buy + case JObject(List(JField("qrcode", JString(data)))) => overlord ! QrCode(data) + case JObject(List(JField("phone", JString(phone)), + JField("smscode", JString(code)))) => overlord ! SMSCode(Phone(phone),code) + case JObject(List(JField("phone", JString(data)))) => overlord ! Phone(data) + case JObject(List(JField("email", JString(data)))) => overlord ! Email(data) + case JObject(List(JField("email", JString(email)), + JField("password", JString(password)))) => overlord ! UserVerify(email, password) + + } + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/PriceTicker.scala b/src/main/scala/inc/pyc/chimera/snippet/PriceTicker.scala @@ -0,0 +1,16 @@ +package inc.pyc.chimera +package snippet + +import Topic._ +import System._ + +/** + * Snippet to update the price ticker on screen. + */ +class PriceTicker extends ChimeraEventRegister { + val topic = priceUpdate + + override val postSubscribe = () => { + overlord ! GetPrice + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/RoundTripSnippet.scala b/src/main/scala/inc/pyc/chimera/snippet/RoundTripSnippet.scala @@ -1,33 +0,0 @@ -package inc.pyc.chimera -package snippet - -import xml._ -import net.liftweb._ -import http._ -import common._ -import js._ -import JE._ -import JsCmds._ -import json.JsonAST._ -import dispatch._, Defaults._ - -/** - * Shortcut to build lift roundtrips using the pattern 'window.classname' javascript object. - */ -trait RoundTripSnippet { - implicit def boxedMessageToMessage(in: Box[Message]): Message = in openOr Message.fail - implicit def listMessageToMessage(in: List[Message]): Message = in.headOption.getOrElse(Message.fail) - implicit def listFutureMessageToMessage(in: List[Future[Message]]): Message = in.headOption.getOrElse(Future(Message.fail))() - - def roundTrips: List[RoundTripInfo] - - def render(in: NodeSeq): NodeSeq = { - for (sess <- S.session) yield { - val roundtrips = sess.buildRoundtrip(roundTrips) - val className: String = (RoundTripSnippet.this.getClass.getName.split("""\.""")).last - val script = SetExp(JsVar("window", className), roundtrips) - S.appendGlobalJs(script) - } - in - } -} -\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/StateWatcher.scala b/src/main/scala/inc/pyc/chimera/snippet/StateWatcher.scala @@ -0,0 +1,53 @@ +package inc.pyc.chimera +package snippet + +import System._ +import Topic._ +import akka.actor._ +import FSM._ + +/** + * Snippet to update the machine's data. + */ +class DataManager extends ChimeraEventRegister { + val topic = dataUpdate +} + +/** + * Snippet to update the machine's state. + */ +class StateManager extends ChimeraEventRegister { + val topic = stateUpdate +} + +object StateWatcher { + val watcher = System.system.actorOf(Props[StateWatcher], "ClientWatcher") +} + +/** + * Message to send to client comet. + */ +case class StateUpdate(from: Option[String], to: String) + +/** + * Watches the state of Overlord to update + * the client. + */ +class StateWatcher extends Actor { + override def preStart: Unit = { + overlord ! SubscribeTransitionCallBack(self) + } + + def receive = { + case CurrentState(_, state) => send(None, state.toString) + case Transition(_, oldState, state) => send(Some(oldState.toString), state.toString) + } + + def send(from: Option[String], to: String) { + client.updateState(StateUpdate(from, to)) + } + + override def postStop: Unit = { + overlord ! UnsubscribeTransitionCallBack(self) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/chimera/snippet/UtilSnips.scala b/src/main/scala/inc/pyc/chimera/snippet/UtilSnips.scala @@ -1,27 +1,17 @@ package inc.pyc.chimera package snippet -import config.Machine._ -import scala.xml.NodeSeq -import net.liftweb._ -import util._ -import json._ -import Serialization.write import net.liftmodules.extras.snippet._ +import inc.pyc.lift_akka.EventRegister object Assets extends AssetLoader -object ProductionOnly { - def render(in: NodeSeq): NodeSeq = - if (Props.productionMode) in - else NodeSeq.Empty -} - -/* - * This is used to send messages to client. +/** + * Hooks up the lift_akka event register to Chimera. */ -case class Message(msg_type: String, msg: Option[String] = None, data: Option[JValue] = None) - -object Message { - def fail = Message("failure") +trait ChimeraEventRegister extends EventRegister { + val system = System.system + val bus = System.client + + def render(in: xml.NodeSeq) = comet(in) } \ No newline at end of file diff --git a/src/main/scala/inc/pyc/lift_akka/ClientActorBridge.scala b/src/main/scala/inc/pyc/lift_akka/ClientActorBridge.scala @@ -47,7 +47,7 @@ trait ClientEventPushBridge extends ClientActorBridge { * coming from the server or client. */ val receive: Receive = { - case JString(str) => info("he he he he: "+str) + case JString(str) => } protected def connectActorBridge(in: NodeSeq): NodeSeq = @@ -122,6 +122,6 @@ abstract class LiftActorEventBus extends EventBus with LookupClassification { a.compareTo(b) } -sealed trait LiftActorCompare extends LiftActor with java.lang.Comparable[LiftActor] { +trait LiftActorCompare extends LiftActor with java.lang.Comparable[LiftActor] { override def compareTo(a: LiftActor): Int = -1 } \ No newline at end of file diff --git a/src/main/webapp/404.html b/src/main/webapp/404.html @@ -1,3 +0,0 @@ -<div data-lift="surround?with=base-default;at=content"> - <p style="font-size: 1.2em;">We're sorry but the page you are trying to access does not exist.</p> -</div> diff --git a/src/main/webapp/app/test/App.spec.js b/src/main/webapp/app/test/App.spec.js @@ -2,14 +2,7 @@ describe("App", function() { var scope, - NearAtmNotifyCtrl, - AtmApplicationCtrl, - UserRegistrationCtrl, - PasswordRecoveryCtrl, - PasswordChangeCtrl, - UserLoginCtrl, - FindAtmCtrl, - GMapCtrl; + WalletScannerCtrl; beforeEach(function() { module("app"); @@ -17,62 +10,13 @@ describe("App", function() { inject(function ($controller, $rootScope) { scope = $rootScope.$new(); - NearAtmNotifyCtrl = $controller('NearAtmNotifyCtrl', {$scope: scope}); - AtmApplicationCtrl = $controller('AtmApplicationCtrl', {$scope: scope}); - UserRegistrationCtrl = $controller('UserRegistrationCtrl', {$scope: scope}); - PasswordRecoveryCtrl = $controller('PasswordRecoveryCtrl', {$scope: scope}); - PasswordChangeCtrl = $controller('PasswordChangeCtrl', {$scope: scope}); - UserLoginCtrl = $controller('UserLoginCtrl', {$scope: scope}); - FindAtmCtrl = $controller('FindAtmCtrl', {$scope: scope}); - GMapCtrl = $controller('GMapCtrl', {$scope: scope}); + WalletScannerCtrl = 1; }); }); - describe("NearAtmNotifyCtrl", function() { + describe("WalletScannerCtrl", function() { it("should exist", function() { - expect(NearAtmNotifyCtrl).toBeDefined(); - }); - }); - - describe("AtmApplicationCtrl", function() { - it("should exist", function() { - expect(AtmApplicationCtrl).toBeDefined(); - }); - }); - - describe("UserRegistrationCtrl", function() { - it("should exist", function() { - expect(UserRegistrationCtrl).toBeDefined(); - }); - }); - - describe("PasswordRecoveryCtrl", function() { - it("should exist", function() { - expect(PasswordRecoveryCtrl).toBeDefined(); - }); - }); - - describe("PasswordChangeCtrl", function() { - it("should exist", function() { - expect(PasswordChangeCtrl).toBeDefined(); - }); - }); - - describe("UserLoginCtrl", function() { - it("should exist", function() { - expect(UserLoginCtrl).toBeDefined(); - }); - }); - - describe("FindAtmCtrl", function() { - it("should exist", function() { - expect(FindAtmCtrl).toBeDefined(); - }); - }); - - describe("GMapCtrl", function() { - it("should exist", function() { - expect(GMapCtrl).toBeDefined(); + expect(WalletScannerCtrl).toBeDefined(); }); }); }); diff --git a/src/main/webapp/disconnected.html b/src/main/webapp/disconnected.html @@ -1,3 +0,0 @@ -<div data-lift="surround?with=base-default;at=content"> - <p style="font-size: 1.2em;">We're sorry but there's no Internet.</p> -</div> -\ No newline at end of file diff --git a/src/main/webapp/malfunction.html b/src/main/webapp/malfunction.html @@ -1,3 +0,0 @@ -<div data-lift="surround?with=base-default;at=content"> - <p style="font-size: 1.2em;">We're sorry but there's a hardware malfunction.</p> -</div> -\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/email/receipt.html b/src/main/webapp/templates-hidden/email/receipt.html @@ -0,0 +1,660 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title><span class="ptitle"></span></title> + <style type="text/css"> + /* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */ + #outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */ + .ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */ + .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */ + body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */ + table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */ + img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */ + + /* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */ + body{margin:0; padding:0;} + img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;} + table{border-collapse:collapse !important;} + body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;} + + /* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */ + + /* ========== Page Styles ========== */ + + #bodyCell{padding:20px;} + #templateContainer{width:500px;} + + /** + * @tab Page + * @section background style + * @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding. + * @theme page + */ + body, #bodyTable{ + /*@editable*/ background-color:#FFFFFF; + } + + /** + * @tab Page + * @section background style + * @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding. + * @theme page + */ + #bodyCell{ + /*@editable*/ border-top:4px solid #FFFFFF; + } + + /** + * @tab Page + * @section email border + * @tip Set the border for your email. + */ + #templateContainer{ + /*@editable*/ border:1px solid #BBBBBB; + } + + /** + * @tab Page + * @section heading 1 + * @tip Set the styling for all first-level headings in your emails. These should be the largest of your headings. + * @style heading 1 + */ + h1{ + /*@editable*/ color:#202020 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:26px; + /*@editable*/ font-style:normal; + /*@editable*/ font-weight:bold; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /** + * @tab Page + * @section heading 2 + * @tip Set the styling for all second-level headings in your emails. + * @style heading 2 + */ + h2{ + /*@editable*/ color:#404040 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:20px; + /*@editable*/ font-style:normal; + /*@editable*/ font-weight:400; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /** + * @tab Page + * @section heading 3 + * @tip Set the styling for all third-level headings in your emails. + * @style heading 3 + */ + h3{ + /*@editable*/ color:#606060 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:16px; + /*@editable*/ font-style:italic; + /*@editable*/ font-weight:normal; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /** + * @tab Page + * @section heading 4 + * @tip Set the styling for all fourth-level headings in your emails. These should be the smallest of your headings. + * @style heading 4 + */ + h4{ + /*@editable*/ color:#808080 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:14px; + /*@editable*/ font-style:italic; + /*@editable*/ font-weight:normal; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /* ========== Header Styles ========== */ + + /** + * @tab Header + * @section preheader style + * @tip Set the background color and bottom border for your email's preheader area. + * @theme header + */ + #templatePreheader{ + /*@editable*/ background-color:#3E648D; + /*@editable*/ border-bottom:1px solid #CCCCCC; + } + + /** + * @tab Header + * @section preheader text + * @tip Set the styling for your email's preheader text. Choose a size and color that is easy to read. + */ + .preheaderContent{ + /*@editable*/ color:#ffffff; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:10px; + /*@editable*/ line-height:125%; + /*@editable*/ text-align:left; + } + + /** + * @tab Header + * @section preheader link + * @tip Set the styling for your email's preheader links. Choose a color that helps them stand out from your text. + */ + .preheaderContent a:link, .preheaderContent a:visited, /* Yahoo! Mail Override */ .preheaderContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#ffffff; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /** + * @tab Header + * @section header style + * @tip Set the background color and borders for your email's header area. + * @theme header + */ + #templateHeader{ + /*@editable*/ background-color:#FFFFFF; + } + + /** + * @tab Header + * @section header text + * @tip Set the styling for your email's header text. Choose a size and color that is easy to read. + */ + .headerContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:20px; + /*@editable*/ font-weight:bold; + /*@editable*/ line-height:100%; + /*@editable*/ padding-top:10px; + /*@editable*/ padding-right:10px; + /*@editable*/ padding-bottom:10px; + /*@editable*/ padding-left:10px; + /*@editable*/ text-align:left; + /*@editable*/ vertical-align:middle; + } + + /** + * @tab Header + * @section header link + * @tip Set the styling for your email's header links. Choose a color that helps them stand out from your text. + */ + .headerContent a:link, .headerContent a:visited, /* Yahoo! Mail Override */ .headerContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#EB4102; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + #headerImage{ + height:auto; + max-width:160px; + } + + /* ========== Body Styles ========== */ + + /** + * @tab Body + * @section body style + * @tip Set the background color and borders for your email's body area. + */ + #templateBody{ + /*@editable*/ background-color:#FFFFFF; + } + + /** + * @tab Body + * @section body text + * @tip Set the styling for your email's main content text. Choose a size and color that is easy to read. + * @theme main + */ + .bodyContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:16px; + /*@editable*/ line-height:150%; + padding-top:20px; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:left; + } + + .bodyp { + text-align:center; + } + + .txtable { + margin-left:auto; + margin-right:auto; + } + + .txtable td.title { + text-align:right; + padding-right:10px; + } + + /** + * @tab Body + * @section body link + * @tip Set the styling for your email's main content links. Choose a color that helps them stand out from your text. + */ + .bodyContent a:link, .bodyContent a:visited, /* Yahoo! Mail Override */ .bodyContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#3E648D; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + .bodyContent img{ + display:inline; + height:auto; + max-width:560px; + } + + /* ========== Column Styles ========== */ + + .templateColumnContainer{width:260px;} + + /** + * @tab Columns + * @section column style + * @tip Set the background color and borders for your email's column area. + */ + #templateColumns{ + /*@editable*/ background-color:#FFFFFF; + /*@editable*/ border-top:1px solid #FFFFFF; + /*@editable*/ border-bottom:1px solid #CCCCCC; + } + + /** + * @tab Columns + * @section left column text + * @tip Set the styling for your email's left column content text. Choose a size and color that is easy to read. + */ + .leftColumnContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:14px; + /*@editable*/ line-height:150%; + padding-top:0; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:left; + } + + /** + * @tab Columns + * @section left column link + * @tip Set the styling for your email's left column content links. Choose a color that helps them stand out from your text. + */ + .leftColumnContent a:link, .leftColumnContent a:visited, /* Yahoo! Mail Override */ .leftColumnContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#3E648D; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /** + * @tab Columns + * @section right column text + * @tip Set the styling for your email's right column content text. Choose a size and color that is easy to read. + */ + .rightColumnContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:14px; + /*@editable*/ line-height:150%; + padding-top:0; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:left; + } + + /** + * @tab Columns + * @section right column link + * @tip Set the styling for your email's right column content links. Choose a color that helps them stand out from your text. + */ + .rightColumnContent a:link, .rightColumnContent a:visited, /* Yahoo! Mail Override */ .rightColumnContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#3E648D; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + .leftColumnContent img, .rightColumnContent img{ + display:inline; + height:auto; + max-width:260px; + } + + /* ========== Footer Styles ========== */ + + /** + * @tab Footer + * @section footer style + * @tip Set the background color and borders for your email's footer area. + * @theme footer + */ + #templateFooter{ + /*@editable*/ background-color:#FFFFFF; + /*@editable*/ border-top:1px solid #FFFFFF; + } + + /** + * @tab Footer + * @section footer text + * @tip Set the styling for your email's footer text. Choose a size and color that is easy to read. + * @theme footer + */ + .footerContent{ + /*@editable*/ color:#808080; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:10px; + /*@editable*/ line-height:150%; + padding-top:20px; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:center; + } + + /** + * @tab Footer + * @section footer link + * @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text. + */ + .footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{ + /*@editable*/ color:#606060; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */ + + @media only screen and (max-width: 480px){ + /* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */ + body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */ + body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */ + + /* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */ + #bodyCell{padding:10px !important;} + + /* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */ + + /* ======== Page Styles ======== */ + + /** + * @tab Mobile Styles + * @section template width + * @tip Make the template fluid for portrait or landscape view adaptability. If a fluid layout doesn't work for you, set the width to 300px instead. + */ + #templateContainer{ + max-width:500px !important; + /*@editable*/ width:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 1 + * @tip Make the first-level headings larger in size for better readability on small screens. + */ + h1{ + /*@editable*/ font-size:24px !important; + /*@editable*/ line-height:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 2 + * @tip Make the second-level headings larger in size for better readability on small screens. + */ + h2{ + /*@editable*/ font-size:20px !important; + /*@editable*/ line-height:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 3 + * @tip Make the third-level headings larger in size for better readability on small screens. + */ + h3{ + /*@editable*/ font-size:18px !important; + /*@editable*/ line-height:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 4 + * @tip Make the fourth-level headings larger in size for better readability on small screens. + */ + h4{ + /*@editable*/ font-size:16px !important; + /*@editable*/ line-height:100% !important; + } + + /* ======== Header Styles ======== */ + + #templatePreheader{display:none !important;} /* Hide the template preheader to save space */ + + /** + * @tab Mobile Styles + * @section header image + * @tip Make the main header image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead. + */ + #headerImage{ + height:auto !important; + /*@editable*/ max-width:160px !important; + /*@editable*/ width:100% !important; + } + + /** + * @tab Mobile Styles + * @section header text + * @tip Make the header content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .headerContent{ + /*@editable*/ font-size:20px !important; + /*@editable*/ line-height:125% !important; + } + + /* ======== Body Styles ======== */ + + /** + * @tab Mobile Styles + * @section body text + * @tip Make the body content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .bodyContent{ + /*@editable*/ font-size:18px !important; + /*@editable*/ line-height:125% !important; + } + + /* ======== Column Styles ======== */ + + .templateColumnContainer{display:block !important; width:100% !important;} + + /** + * @tab Mobile Styles + * @section column image + * @tip Make the column image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead. + */ + .columnImage{ + height:auto !important; + /*@editable*/ max-width:480px !important; + /*@editable*/ width:100% !important; + } + + /** + * @tab Mobile Styles + * @section left column text + * @tip Make the left column content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .leftColumnContent{ + /*@editable*/ font-size:16px !important; + /*@editable*/ line-height:125% !important; + } + + /** + * @tab Mobile Styles + * @section right column text + * @tip Make the right column content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .rightColumnContent{ + /*@editable*/ font-size:16px !important; + /*@editable*/ line-height:125% !important; + } + + /* ======== Footer Styles ======== */ + + /** + * @tab Mobile Styles + * @section footer text + * @tip Make the body content text larger in size for better readability on small screens. + */ + .footerContent{ + /*@editable*/ font-size:14px !important; + /*@editable*/ line-height:115% !important; + } + + .footerContent a{display:block !important;} /* Place footer social and utility links on their own lines, for easier access */ + } + </style> + </head> + <body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;margin: 0;padding: 0;background-color: #FFFFFF;height: 100% !important;width: 100% !important;"> + <center> + <table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;margin: 0;padding: 0;background-color: #FFFFFF;border-collapse: collapse !important;height: 100% !important;width: 100% !important;"> + <tr> + <td align="center" valign="top" id="bodyCell" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;margin: 0;padding: 20px;border-top: 4px solid #FFFFFF;height: 100% !important;width: 100% !important;"> + <!-- BEGIN TEMPLATE // --> + <table border="0" cellpadding="0" cellspacing="0" id="templateContainer" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 600px;border: 1px solid #BBBBBB;border-collapse: collapse !important;"> + + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN HEADER // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-collapse: collapse !important;"> + <tr> + <td valign="top" class="headerContent" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 20px;font-weight: bold;line-height: 100%;padding-top: 10px;padding-right: 10px;padding-bottom: 10px;padding-left: 10px;text-align: center;vertical-align: middle;"> + <img src="https://s3.amazonaws.com/assets-pyc/logo-text.png" style="max-width: 160px;-ms-interpolation-mode: bicubic;border: 0;height: auto;line-height: 100%;outline: none;text-decoration: none;" id="headerImage" mc:label="header_image" mc:edit="header_image" mc:allowdesigner mc:allowtext> + </td> + </tr> + <tr> + <td valign="top" class="headerContent" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 20px;font-weight: bold;line-height: 100%;padding-top: 10px;padding-right: 10px;padding-bottom: 10px;padding-left: 10px;text-align: center;vertical-align: middle;"> + <h2 style="text-align: center;display: block;font-family: Helvetica;font-size: 20px;font-style: normal;font-weight: 400;line-height: 100%;letter-spacing: normal;margin-top: 0;margin-right: 0;margin-bottom: 10px;margin-left: 0;color: #404040 !important;">Transaction Receipt</h2> + </td> + </tr> + </table> + <!-- // END HEADER --> + </td> + </tr> + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN BODY // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-collapse: collapse !important;"> + <tr> + <td valign="top" class="bodyContent" mc:edit="body_content" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 16px;line-height: 150%;padding-top: 20px;padding-right: 20px;padding-bottom: 20px;padding-left: 20px;text-align: left;"> + + <span id="pbody"></span>You bought <span class="txbtc"></span> bitcoins from a PYC Bitcoin ATM. For more information, visit <a href="https://www.pycbitcoin.com/" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;color: #3E648D;font-weight: normal;text-decoration: underline;">www.pycbitcoin.com</a>. + </td> + </tr> + <tr> + <td valign="top" class="bodyContent" mc:edit="body_content" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 16px;line-height: 150%;padding-top: 20px;padding-right: 20px;padding-bottom: 20px;padding-left: 20px;"> + + <table style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;border-collapse: collapse !important;margin-left:auto;margin-right:auto;"> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Bitcoins</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txbtc"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Purchased</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txpurchase"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Address</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txaddress"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Txid</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txid"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Date</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txdate"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Time</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txtime"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Location</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txlocation"></span></td></tr> + + </table> + </td> + </tr> + </table> + <!-- // END BODY --> + </td> + </tr> + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN COLUMNS // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateColumns" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-top: 1px solid #FFFFFF;border-bottom: 1px solid #CCCCCC;border-collapse: collapse !important;"> + <tr mc:repeatable> + <td align="center" valign="top" class="templateColumnContainer" style="padding-top: 20px;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 260px;"> + <table border="0" cellpadding="20" cellspacing="0" width="100%" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;border-collapse: collapse !important;"> + <tr id="pleftcolumn"></tr> + </table> + </td> + <td align="center" valign="top" class="templateColumnContainer" style="padding-top: 20px;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 260px;"> + <table border="0" cellpadding="20" cellspacing="0" width="100%" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;border-collapse: collapse !important;"> + <tr id="prightcolumn"></tr> + </table> + </td> + </tr> + </table> + <!-- // END COLUMNS --> + </td> + </tr> + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN FOOTER // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-top: 1px solid #FFFFFF;border-collapse: collapse !important;"> + <tr> + <td valign="top" class="footerContent" mc:edit="footer_content00" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #808080;font-family: Helvetica;font-size: 10px;line-height: 150%;padding-top: 20px;padding-right: 20px;padding-bottom: 20px;padding-left: 20px;text-align: center;"> + <a id="twitter-link" href="#" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;color: #606060;font-weight: normal;text-decoration: underline;">Follow on Twitter</a>&nbsp;&nbsp;&nbsp;<a id="facebook-link" href="#" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;color: #606060;font-weight: normal;text-decoration: underline;">Friend on Facebook</a>&nbsp;&nbsp;&nbsp;<a id="gplus-link" href="#" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;color: #606060;font-weight: normal;text-decoration: underline;">+1 on Google Plus</a>&nbsp; + </td> + </tr> + </table> + <!-- // END FOOTER --> + </td> + </tr> + </table> + <!-- // END TEMPLATE --> + </td> + </tr> + </table> + </center> + </body> +</html> diff --git a/src/main/webapp/templates-hidden/email/support.html b/src/main/webapp/templates-hidden/email/support.html @@ -0,0 +1,652 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title><span class="ptitle"></span></title> + <style type="text/css"> + /* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */ + #outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */ + .ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */ + .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */ + body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */ + table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */ + img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */ + + /* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */ + body{margin:0; padding:0;} + img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;} + table{border-collapse:collapse !important;} + body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;} + + /* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */ + + /* ========== Page Styles ========== */ + + #bodyCell{padding:20px;} + #templateContainer{width:500px;} + + /** + * @tab Page + * @section background style + * @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding. + * @theme page + */ + body, #bodyTable{ + /*@editable*/ background-color:#FFFFFF; + } + + /** + * @tab Page + * @section background style + * @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding. + * @theme page + */ + #bodyCell{ + /*@editable*/ border-top:4px solid #FFFFFF; + } + + /** + * @tab Page + * @section email border + * @tip Set the border for your email. + */ + #templateContainer{ + /*@editable*/ border:1px solid #BBBBBB; + } + + /** + * @tab Page + * @section heading 1 + * @tip Set the styling for all first-level headings in your emails. These should be the largest of your headings. + * @style heading 1 + */ + h1{ + /*@editable*/ color:#202020 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:26px; + /*@editable*/ font-style:normal; + /*@editable*/ font-weight:bold; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /** + * @tab Page + * @section heading 2 + * @tip Set the styling for all second-level headings in your emails. + * @style heading 2 + */ + h2{ + /*@editable*/ color:#404040 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:20px; + /*@editable*/ font-style:normal; + /*@editable*/ font-weight:400; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /** + * @tab Page + * @section heading 3 + * @tip Set the styling for all third-level headings in your emails. + * @style heading 3 + */ + h3{ + /*@editable*/ color:#606060 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:16px; + /*@editable*/ font-style:italic; + /*@editable*/ font-weight:normal; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /** + * @tab Page + * @section heading 4 + * @tip Set the styling for all fourth-level headings in your emails. These should be the smallest of your headings. + * @style heading 4 + */ + h4{ + /*@editable*/ color:#808080 !important; + display:block; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:14px; + /*@editable*/ font-style:italic; + /*@editable*/ font-weight:normal; + /*@editable*/ line-height:100%; + /*@editable*/ letter-spacing:normal; + margin-top:0; + margin-right:0; + margin-bottom:10px; + margin-left:0; + /*@editable*/ text-align:left; + } + + /* ========== Header Styles ========== */ + + /** + * @tab Header + * @section preheader style + * @tip Set the background color and bottom border for your email's preheader area. + * @theme header + */ + #templatePreheader{ + /*@editable*/ background-color:#3E648D; + /*@editable*/ border-bottom:1px solid #CCCCCC; + } + + /** + * @tab Header + * @section preheader text + * @tip Set the styling for your email's preheader text. Choose a size and color that is easy to read. + */ + .preheaderContent{ + /*@editable*/ color:#ffffff; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:10px; + /*@editable*/ line-height:125%; + /*@editable*/ text-align:left; + } + + /** + * @tab Header + * @section preheader link + * @tip Set the styling for your email's preheader links. Choose a color that helps them stand out from your text. + */ + .preheaderContent a:link, .preheaderContent a:visited, /* Yahoo! Mail Override */ .preheaderContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#ffffff; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /** + * @tab Header + * @section header style + * @tip Set the background color and borders for your email's header area. + * @theme header + */ + #templateHeader{ + /*@editable*/ background-color:#FFFFFF; + } + + /** + * @tab Header + * @section header text + * @tip Set the styling for your email's header text. Choose a size and color that is easy to read. + */ + .headerContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:20px; + /*@editable*/ font-weight:bold; + /*@editable*/ line-height:100%; + /*@editable*/ padding-top:10px; + /*@editable*/ padding-right:10px; + /*@editable*/ padding-bottom:10px; + /*@editable*/ padding-left:10px; + /*@editable*/ text-align:left; + /*@editable*/ vertical-align:middle; + } + + /** + * @tab Header + * @section header link + * @tip Set the styling for your email's header links. Choose a color that helps them stand out from your text. + */ + .headerContent a:link, .headerContent a:visited, /* Yahoo! Mail Override */ .headerContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#EB4102; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + #headerImage{ + height:auto; + max-width:160px; + } + + /* ========== Body Styles ========== */ + + /** + * @tab Body + * @section body style + * @tip Set the background color and borders for your email's body area. + */ + #templateBody{ + /*@editable*/ background-color:#FFFFFF; + } + + /** + * @tab Body + * @section body text + * @tip Set the styling for your email's main content text. Choose a size and color that is easy to read. + * @theme main + */ + .bodyContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:16px; + /*@editable*/ line-height:150%; + padding-top:20px; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:left; + } + + .bodyp { + text-align:center; + } + + .txtable { + margin-left:auto; + margin-right:auto; + } + + .txtable td.title { + text-align:right; + padding-right:10px; + } + + /** + * @tab Body + * @section body link + * @tip Set the styling for your email's main content links. Choose a color that helps them stand out from your text. + */ + .bodyContent a:link, .bodyContent a:visited, /* Yahoo! Mail Override */ .bodyContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#3E648D; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + .bodyContent img{ + display:inline; + height:auto; + max-width:560px; + } + + /* ========== Column Styles ========== */ + + .templateColumnContainer{width:260px;} + + /** + * @tab Columns + * @section column style + * @tip Set the background color and borders for your email's column area. + */ + #templateColumns{ + /*@editable*/ background-color:#FFFFFF; + /*@editable*/ border-top:1px solid #FFFFFF; + /*@editable*/ border-bottom:1px solid #CCCCCC; + } + + /** + * @tab Columns + * @section left column text + * @tip Set the styling for your email's left column content text. Choose a size and color that is easy to read. + */ + .leftColumnContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:14px; + /*@editable*/ line-height:150%; + padding-top:0; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:left; + } + + /** + * @tab Columns + * @section left column link + * @tip Set the styling for your email's left column content links. Choose a color that helps them stand out from your text. + */ + .leftColumnContent a:link, .leftColumnContent a:visited, /* Yahoo! Mail Override */ .leftColumnContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#3E648D; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /** + * @tab Columns + * @section right column text + * @tip Set the styling for your email's right column content text. Choose a size and color that is easy to read. + */ + .rightColumnContent{ + /*@editable*/ color:#505050; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:14px; + /*@editable*/ line-height:150%; + padding-top:0; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:left; + } + + /** + * @tab Columns + * @section right column link + * @tip Set the styling for your email's right column content links. Choose a color that helps them stand out from your text. + */ + .rightColumnContent a:link, .rightColumnContent a:visited, /* Yahoo! Mail Override */ .rightColumnContent a .yshortcuts /* Yahoo! Mail Override */{ + /*@editable*/ color:#3E648D; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + .leftColumnContent img, .rightColumnContent img{ + display:inline; + height:auto; + max-width:260px; + } + + /* ========== Footer Styles ========== */ + + /** + * @tab Footer + * @section footer style + * @tip Set the background color and borders for your email's footer area. + * @theme footer + */ + #templateFooter{ + /*@editable*/ background-color:#FFFFFF; + /*@editable*/ border-top:1px solid #FFFFFF; + } + + /** + * @tab Footer + * @section footer text + * @tip Set the styling for your email's footer text. Choose a size and color that is easy to read. + * @theme footer + */ + .footerContent{ + /*@editable*/ color:#808080; + /*@editable*/ font-family:Helvetica; + /*@editable*/ font-size:10px; + /*@editable*/ line-height:150%; + padding-top:20px; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + /*@editable*/ text-align:center; + } + + /** + * @tab Footer + * @section footer link + * @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text. + */ + .footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{ + /*@editable*/ color:#606060; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */ + + @media only screen and (max-width: 480px){ + /* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */ + body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */ + body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */ + + /* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */ + #bodyCell{padding:10px !important;} + + /* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */ + + /* ======== Page Styles ======== */ + + /** + * @tab Mobile Styles + * @section template width + * @tip Make the template fluid for portrait or landscape view adaptability. If a fluid layout doesn't work for you, set the width to 300px instead. + */ + #templateContainer{ + max-width:500px !important; + /*@editable*/ width:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 1 + * @tip Make the first-level headings larger in size for better readability on small screens. + */ + h1{ + /*@editable*/ font-size:24px !important; + /*@editable*/ line-height:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 2 + * @tip Make the second-level headings larger in size for better readability on small screens. + */ + h2{ + /*@editable*/ font-size:20px !important; + /*@editable*/ line-height:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 3 + * @tip Make the third-level headings larger in size for better readability on small screens. + */ + h3{ + /*@editable*/ font-size:18px !important; + /*@editable*/ line-height:100% !important; + } + + /** + * @tab Mobile Styles + * @section heading 4 + * @tip Make the fourth-level headings larger in size for better readability on small screens. + */ + h4{ + /*@editable*/ font-size:16px !important; + /*@editable*/ line-height:100% !important; + } + + /* ======== Header Styles ======== */ + + #templatePreheader{display:none !important;} /* Hide the template preheader to save space */ + + /** + * @tab Mobile Styles + * @section header image + * @tip Make the main header image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead. + */ + #headerImage{ + height:auto !important; + /*@editable*/ max-width:160px !important; + /*@editable*/ width:100% !important; + } + + /** + * @tab Mobile Styles + * @section header text + * @tip Make the header content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .headerContent{ + /*@editable*/ font-size:20px !important; + /*@editable*/ line-height:125% !important; + } + + /* ======== Body Styles ======== */ + + /** + * @tab Mobile Styles + * @section body text + * @tip Make the body content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .bodyContent{ + /*@editable*/ font-size:18px !important; + /*@editable*/ line-height:125% !important; + } + + /* ======== Column Styles ======== */ + + .templateColumnContainer{display:block !important; width:100% !important;} + + /** + * @tab Mobile Styles + * @section column image + * @tip Make the column image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead. + */ + .columnImage{ + height:auto !important; + /*@editable*/ max-width:480px !important; + /*@editable*/ width:100% !important; + } + + /** + * @tab Mobile Styles + * @section left column text + * @tip Make the left column content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .leftColumnContent{ + /*@editable*/ font-size:16px !important; + /*@editable*/ line-height:125% !important; + } + + /** + * @tab Mobile Styles + * @section right column text + * @tip Make the right column content text larger in size for better readability on small screens. We recommend a font size of at least 16px. + */ + .rightColumnContent{ + /*@editable*/ font-size:16px !important; + /*@editable*/ line-height:125% !important; + } + + /* ======== Footer Styles ======== */ + + /** + * @tab Mobile Styles + * @section footer text + * @tip Make the body content text larger in size for better readability on small screens. + */ + .footerContent{ + /*@editable*/ font-size:14px !important; + /*@editable*/ line-height:115% !important; + } + + .footerContent a{display:block !important;} /* Place footer social and utility links on their own lines, for easier access */ + } + </style> + </head> + <body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;margin: 0;padding: 0;background-color: #FFFFFF;height: 100% !important;width: 100% !important;"> + <center> + <table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;margin: 0;padding: 0;background-color: #FFFFFF;border-collapse: collapse !important;height: 100% !important;width: 100% !important;"> + <tr> + <td align="center" valign="top" id="bodyCell" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;margin: 0;padding: 20px;border-top: 4px solid #FFFFFF;height: 100% !important;width: 100% !important;"> + <!-- BEGIN TEMPLATE // --> + <table border="0" cellpadding="0" cellspacing="0" id="templateContainer" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 600px;border: 1px solid #BBBBBB;border-collapse: collapse !important;"> + + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN HEADER // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-collapse: collapse !important;"> + <tr> + <td valign="top" class="headerContent" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 20px;font-weight: bold;line-height: 100%;padding-top: 10px;padding-right: 10px;padding-bottom: 10px;padding-left: 10px;text-align: center;vertical-align: middle;"> + <img src="https://s3.amazonaws.com/assets-pyc/logo-text.png" style="max-width: 160px;-ms-interpolation-mode: bicubic;border: 0;height: auto;line-height: 100%;outline: none;text-decoration: none;" id="headerImage" mc:label="header_image" mc:edit="header_image" mc:allowdesigner mc:allowtext> + </td> + </tr> + <tr> + <td valign="top" class="headerContent" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 20px;font-weight: bold;line-height: 100%;padding-top: 10px;padding-right: 10px;padding-bottom: 10px;padding-left: 10px;text-align: center;vertical-align: middle;"> + <h2 style="text-align: center;display: block;font-family: Helvetica;font-size: 20px;font-style: normal;font-weight: 400;line-height: 100%;letter-spacing: normal;margin-top: 0;margin-right: 0;margin-bottom: 10px;margin-left: 0;color: #404040 !important;">Customer Support</h2> + </td> + </tr> + </table> + <!-- // END HEADER --> + </td> + </tr> + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN BODY // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-collapse: collapse !important;"> + <tr> + <td valign="top" class="bodyContent" mc:edit="body_content" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 16px;line-height: 150%;padding-top: 20px;padding-right: 20px;padding-bottom: 20px;padding-left: 20px;text-align: left;"> + + Hi <span class="fname"></span>, PYC will revise your bitcoin transaction of <span class="txamount"></span> and contact you shortly. If you have any questions, please feel free to call technical support at <span class="tech-phone"></span>. + </td> + </tr> + <tr> + <td valign="top" class="bodyContent" mc:edit="body_content" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 16px;line-height: 150%;padding-top: 20px;padding-right: 20px;padding-bottom: 20px;padding-left: 20px;"> + + <table style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;border-collapse: collapse !important;margin-left:auto;margin-right:auto;"> + + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Address</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txaddress"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Amount</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txamount"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Bitcoins</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txbtc"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Date</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txdate"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Time</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txtime"></span></td></tr> + +<tr><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;text-align:right;padding-right:10px">Location</td><td style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"><span class="txlocation"></span></td></tr> + + </table> + </td> + </tr> + <tr> + <td valign="top" class="bodyContent" mc:edit="body_content" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;color: #505050;font-family: Helvetica;font-size: 16px;line-height: 150%;padding-top: 20px;padding-right: 20px;padding-bottom: 20px;padding-left: 20px;text-align: center;font-style:italic;"> + + Sorry for the trouble. + </td> + </tr> + </table> + <!-- // END BODY --> + </td> + </tr> + <tr> + <td align="center" valign="top" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;"> + <!-- BEGIN COLUMNS // --> + <table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateColumns" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;background-color: #FFFFFF;border-top: 1px solid #FFFFFF;border-bottom: 1px solid #CCCCCC;border-collapse: collapse !important;"> + <tr mc:repeatable> + <td align="center" valign="top" class="templateColumnContainer" style="padding-top: 20px;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 260px;"> + <table border="0" cellpadding="20" cellspacing="0" width="100%" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;border-collapse: collapse !important;"> + <tr id="pleftcolumn"></tr> + </table> + </td> + <td align="center" valign="top" class="templateColumnContainer" style="padding-top: 20px;-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 260px;"> + <table border="0" cellpadding="20" cellspacing="0" width="100%" style="-webkit-text-size-adjust: 100%;-ms-text-size-adjust: 100%;mso-table-lspace: 0pt;mso-table-rspace: 0pt;border-collapse: collapse !important;"> + <tr id="prightcolumn"></tr> + </table> + </td> + </tr> + </table> + <!-- // END COLUMNS --> + </td> + </tr> + </table> + <!-- // END TEMPLATE --> + </td> + </tr> + </table> + </center> + </body> +</html> diff --git a/src/test/scala/inc/pyc/chimera/BaseSpec.scala b/src/test/scala/inc/pyc/chimera/BaseSpec.scala @@ -0,0 +1,18 @@ +package inc.pyc.chimera + +import akka.actor._ +import akka.testkit._ +import org.scalatest._ +import matchers._ +import MockConfig._ +import com.typesafe.config._ + +abstract class BaseSpec(config: Config) + extends TestKit(ActorSystem("BaseSpec", config)) + with ImplicitSender + with WordSpecLike + with ShouldMatchers + with BeforeAndAfterAll { + + override def afterAll = TestKit.shutdownActorSystem(system) +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/MinionSpec.scala b/src/test/scala/inc/pyc/chimera/MinionSpec.scala @@ -0,0 +1,393 @@ +package inc.pyc.chimera + +import model._ +import minions._ +import akka.actor._ +import akka.testkit._ +import inc.pyc.currency._ +import inc.pyc.bitcoin._ +import inc.pyc.bill.acceptor._ +import Events._ +import awscala.dynamodbv2._ +import net.liftweb._ +import util._ +import mapper._ +import common.Empty +import concurrent.duration._ +import org.joda.time._ + +class MinionSpec extends BaseSpec(MockConfig.config) with SpecHelper { + + implicit val dynamoDB = DynamoDB.local() + val expenditure = "Expenditure" + val transaction = "Transaction" + + val vendor = new StandardDBVendor( + "org.h2.Driver", + "jdbc:h2:lift_proto.db;AUTO_SERVER=TRUE", + Empty, Empty) + + def initializeLocalDB = { + DB.defineConnectionManager(util.DefaultConnectionIdentifier, vendor) + Schemifier.schemify(true, Schemifier.infoF _, CompletedTransaction) + Schemifier.schemify(true, Schemifier.infoF _, IncompleteTransaction) + } + + def shutdownLocalDB = { + vendor.closeAllConnections_! + } + + def testGeneral (f: TestActorRef[GeneralMinion] => Unit) { + f(TestActorRef[GeneralMinion]) + } + + def testLocalDb (f: TestActorRef[LocalDBMinion] => Unit) { + f(TestActorRef[LocalDBMinion]) + } + + def testExpenditure (f: TestActorRef[MockExpenditureMinion] => Unit) { + f(TestActorRef[MockExpenditureMinion]) + } + + def testTransaction (f: TestActorRef[MockTransactionMinion] => Unit) { + f(TestActorRef[MockTransactionMinion]) + } + + override def beforeAll = { + + val createdExpTableMeta: TableMeta = dynamoDB.createTable( + name = expenditure, + hashPK = "address" -> AttributeType.String, + rangePK = "date" -> AttributeType.String, + otherAttributes = Seq(), + indexes = Seq()) + + val createdTxTableMeta: TableMeta = dynamoDB.createTable( + name = transaction, + hashPK = "txid" -> AttributeType.String, + rangePK = "date" -> AttributeType.String, + otherAttributes = Seq(), + indexes = Seq()) + + var isExpActivated, isTxActivated = false + while (!isExpActivated && !isTxActivated) { + dynamoDB.describe(createdExpTableMeta.table).map { meta => + isExpActivated = meta.status.toString == "ACTIVE" + } + dynamoDB.describe(createdTxTableMeta.table).map { meta => + isTxActivated = meta.status.toString == "ACTIVE" + } + Thread.sleep(1000L) + print(".") + } + + initializeLocalDB + super.beforeAll + } + + override def afterAll = { + IncompleteTransaction.findAll.foreach(_.delete_!) + CompletedTransaction.findAll.foreach(_.delete_!) + shutdownLocalDB + dynamoDB.table(expenditure).get.destroy + dynamoDB.table(transaction).get.destroy + super.afterAll + } + + "Minion" should { + + /* + "log user in" in test { + minion => + // TODO how to mock http rest interface + } + * + */ + + "calculate buy limit in the past 24 hours" in { + val address = BitcoinAddress("~address18~") + val userinfo = UserInfo("test@example.com", + purchaseLimit = Some(3000)) // cannot pass $3,000 + val audit = AuditData(address, Some(userinfo)) + + def tx(bills: List[Int]) = CompleteTx(IncompleteTx( + audit.address.data, + 123.45, + System.currency, + bills, + 0, // ignored + audit.userInfo), "~txid~") // txid ignored + + val table = dynamoDB.table(expenditure).get + + def save(tx: CompleteTx) = + table.put(tx.address, tx.dateISO8601, "txid" -> tx.txid, + "paid" -> tx.bills.sum , "currency" -> tx.currency.toString) + + + // transaction over the purchase limit + // seconds later from 24 hours ago, so tests should still pass + val hours24ago = new DateTime(DateTimeZone.UTC).minusDays(1) + save(tx(List(3001)).copy(date = hours24ago)) + + // spent $1,000 -- left $2,000 -- limit is $3,000 + save(tx(List(100,100,100,100,100,100,100,100,100,100))) + val minion1 = TestActorRef[MockExpenditureMinion] + terminates (minion1) (minion1 ! audit) + expectMsg(LeftToSpend(2000, 3000)) + + // spent $2,000 -- left $1,000 -- limit is $3,000 + save(tx(List(100,100,100,100,100,100,100,100,100,100))) + val minion2 = TestActorRef[MockExpenditureMinion] + terminates(minion2) (minion2 ! audit) + expectMsg(LeftToSpend(1000, 3000)) + + // spent $950 -- left $50 -- limit is $3,000 + save(tx(List(100,100,100,100,100,100,100,100,100,50))) + val minion3 = TestActorRef[MockExpenditureMinion] + terminates(minion3) (minion3 ! audit) + expectMsg(LeftToSpend(50, 3000)) + + // spent $50 -- left $0 -- limit is $3,000 + save(tx(List(50))) + val minion4 = TestActorRef[MockExpenditureMinion] + terminates(minion4) (minion4 ! audit) + expectMsg(NothingToSpend(3000)) + } + + "create a new transaction" in testLocalDb { + minion => + val address = BitcoinAddress("~address~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + terminates(minion) (minion ! creator) + expectMsg(IncompleteTx( + audit.address.data, + price.double, + System.currency, + Nil, + toSpend.left, + audit.userInfo)) + } + + "open existing incomplete transaction" in testLocalDb { + minion => + val address = BitcoinAddress("~address~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + // saved tx will have $100 + val tx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(100), + toSpend.left, + audit.userInfo) + + IncompleteTransaction.save(tx) + + terminates(minion) (minion ! creator) + expectMsg(tx) + } + + "accept bills under the spending limit" in testGeneral { + minion => + val inserted = Inserted(USD(100)) + val balance = FiatBalance(10000) + + val address = BitcoinAddress("~address~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val tx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(100), + toSpend.left, + audit.userInfo) + + val inspect = InspectBill(inserted, tx, balance) + + terminates(minion) (minion ! inspect) + expectMsg(BillOkay) + } + + "reject bills over the spending limit" in testGeneral { + minion => + val inserted = Inserted(USD(100)) + val balance = FiatBalance(10000) + + val address = BitcoinAddress("~address~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val tx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(100, 1), + toSpend.left, + audit.userInfo) + + val inspect = InspectBill(inserted, tx, balance) + + terminates(minion) (minion ! inspect) + expectInvalidBill(Bad(Msg.billOverLimit)) + } + + "reject bills over the wallet's balance" in testGeneral { + minion => + val inserted = Inserted(USD(5)) + val balance = FiatBalance(100) + + val address = BitcoinAddress("~address~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val tx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(50, 10, 10, 10, 10, 5, 1), // $96 + toSpend.left, + audit.userInfo) + + val inspect = InspectBill(inserted, tx, balance) + + terminates(minion) (minion ! inspect) + expectInvalidBill(Bad(Msg.billOverBalance)) + } + + "save confirmed bill and modified transaction to disk" in testLocalDb { + minion => + val confirmed = Confirmed(USD(100)) + + val address = BitcoinAddress("~address2~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val tx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(50), + toSpend.left, + audit.userInfo) + + terminates(minion) (minion ! (confirmed, tx)) + expectMsgPF() { + case CashSaved(newTx) => + newTx.bills.sorted should be (List(50,100)) + } + IncompleteTransaction.find(address.data).isDefined should be (true) + } + + "save local completed transaction" in testLocalDb { + minion => + val address = BitcoinAddress("~address3~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val incTx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(50), + toSpend.left, + audit.userInfo) + + val tx = CompleteTx(incTx, "~txid~") + + IncompleteTransaction.save(incTx) + terminates(minion) (minion ! ("save", tx)) + IncompleteTransaction.find(incTx.address).isDefined should be (false) + CompletedTransaction.find(tx.txid).isDefined should be (true) + } + + "save transaction to DDB Expenditure table" in testExpenditure { + minion => + val address = BitcoinAddress("~address4~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val incTx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(50, 100, 10), // $160 + toSpend.left, + audit.userInfo) + + val tx = CompleteTx(incTx, "~txid~") + val table = dynamoDB.table(expenditure).get + + terminates(minion) (minion ! ("expenditure", tx)) + val result = table.query(Seq("address" -> Condition.eq(tx.address))).headOption + result.isDefined should be (true) + result.get.attributes.find(_.name == "txid").get.value.s.get should equal(tx.txid) + result.get.attributes.find(_.name == "paid").get.value.n.get should equal("160") + result.get.attributes.find(_.name == "currency").get.value.s.get should equal(tx.currency.toString) + } + + "save transaction to DDB Transaction table" in testTransaction { + minion => + val address = BitcoinAddress("~address5~") + val userinfo = UserInfo("test@example.com", purchaseLimit = Some(3000)) + val toSpend = LeftToSpend(200, userinfo.purchaseLimit.get) + val price = ticker.Price("123.45") + val audit = AuditData(address, Some(userinfo)) + val creator = CreatorTx(audit, toSpend, price) + + val incTx = IncompleteTx( + audit.address.data, + price.double, + System.currency, + List(50, 100, 10), // $160 + toSpend.left, + audit.userInfo) + + val tx = CompleteTx(incTx, "~txid~") + val table = dynamoDB.table(transaction).get + + terminates(minion) (minion ! ("transaction", tx)) + val result = table.query(Seq("txid" -> Condition.eq(tx.txid))).headOption + result.isDefined should be (true) + result.get.attributes.find(_.name == "address").get.value.s.get should equal(tx.address) + result.get.attributes.find(_.name == "price").get.value.n.get should equal(tx.price.toString) + result.get.attributes.find(_.name == "currency").get.value.s.get should equal(tx.currency.toString) + result.get.attributes.find(_.name == "chimera").get.value.s.get should equal(System.name) + result.get.attributes.find(_.name == "bitcoin").get.value.n.get should equal((160 / tx.price) toString) + + // awscala library doesn't read number attributes correctly :/ + result.get.attributes.find(_.name == "bills").get.value.ss should equal(tx.bills.map(_.toString)) + } + } + +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/MockComet.scala b/src/test/scala/inc/pyc/chimera/MockComet.scala @@ -0,0 +1,20 @@ +package inc.pyc.chimera + +import System._ +import akka.actor._ +import akka.testkit._ +import net.liftweb.http.ScopedLiftActor +import inc.pyc.lift_akka.LiftActorCompare + +class MockComet(system: ActorSystem) extends TestProbe(system) { + + val comet = new ScopedLiftActor with LiftActorCompare { + override def lowPriority = { + case event => ref ! event + } + } + + def subcribe(topics: String*) = topics map { + topic => client.subscribe(comet, topic) + } +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/MockConfig.scala b/src/test/scala/inc/pyc/chimera/MockConfig.scala @@ -0,0 +1,27 @@ +package inc.pyc.chimera + +import com.typesafe.config.ConfigFactory + +object MockConfig { + val config = ConfigFactory.parseString(""" +akka { + loglevel = "INFO" + loggers = [akka.testkit.TestEventListener] + stdout-loglevel = "OFF" +} +chimera { + guid = "testguid" + name = "TestName" + currency = "USD" + + user { + url = "http://127.0.0.1:8080" + secret = "secretpass" + } +} +aws { + accessKey = "" + secretKey = "" +} +""") +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/MockMinion.scala b/src/test/scala/inc/pyc/chimera/MockMinion.scala @@ -0,0 +1,24 @@ +package inc.pyc.chimera + +import ddb._ +import minions._ +import awscala.dynamodbv2._ +import akka.actor._ + +class MockExpenditureMinion extends ExpenditureMinion { + override def props: Props = Props[MockExpenditure] +} + +class MockTransactionMinion extends ExpenditureMinion { + override def props: Props = Props[MockTransaction] +} + +class MockExpenditure extends Expenditure { + override implicit val dynamoDB: DynamoDB = DynamoDB.local() + override lazy val table: Table = dynamoDB.table("Expenditure").get +} + +class MockTransaction extends Transaction { + override implicit val dynamoDB: DynamoDB = DynamoDB.local() + override lazy val table: Table = dynamoDB.table("Transaction").get +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/MockOverlord.scala b/src/test/scala/inc/pyc/chimera/MockOverlord.scala @@ -0,0 +1,24 @@ +package inc.pyc.chimera + +import akka.actor._ +import inc.pyc.bill.acceptor._ +import Commands._ +import concurrent.duration._ + +class MockOverlord( + billAcceptor: ActorRef, + minion: ActorRef, + watcher: ActorRef, + walletr: ActorRef, + ticker: ActorRef, + networkr: ActorRef) extends Overlord { + + import context.dispatcher + + override lazy val acceptor: ActorRef = billAcceptor + override lazy val priceTicker: ActorRef = ticker + override lazy val wallet: ActorRef = walletr + override lazy val network: ActorRef = networkr + override def summon: ActorRef = minion + override def stateWatcher: ActorRef = watcher +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/OverlordSpec.scala b/src/test/scala/inc/pyc/chimera/OverlordSpec.scala @@ -0,0 +1,461 @@ +package inc.pyc.chimera + +import akka.actor._ +import akka.testkit._ +import lycia._ +import inc.pyc.bitcoin._ +import inc.pyc.currency._ +import inc.pyc.bill._ +import acceptor._ +import Commands._ +import Events._ +import States.Disconnected +import concurrent.duration._ +import scala.language.reflectiveCalls + +class OverlordSpec extends BaseSpec(MockConfig.config) with SpecHelper { + + val comet = new MockComet(system) + comet.subcribe( + Topic.goto, + Topic.transactionUpdate, + Topic.redirectTo, + Topic.newPrice) + + val network = TestProbe() + val wallet = TestProbe() + val ticker = TestProbe() + val watcher = TestProbe() + val minion = TestProbe() + val acceptor = TestProbe() + val overlord = TestFSMRef( + new MockOverlord( + acceptor.ref, + minion.ref, + watcher.ref, + wallet.ref, + ticker.ref, + network.ref)) + + // test data + val qr = QrCode("~qrdata~") + val address = BitcoinAddress("~valid~") + val auditData = AuditData(address, None) + val toSpend = LeftToSpend(500, 1000) + val price = Price("123.45") + val txCreated = CreatorTx(auditData, toSpend, price) + val userInfo = UserInfo("test@example.com", None, None, None, Nil) + + val tx = IncompleteTx( + address.data, + 123.45, + System.currency, + Nil, + toSpend.left, + auditData.userInfo) + + val txFinal = CompleteTx(tx, "~txid~") + + + def state(s: State, d: Data = NullData)(func: => Unit) { + overlord.setState(s,d) + func + } + + overlord ! Start + + "Overlord" should { + + "begin uninitialized" in { + overlord.stateName should be (Uninitialized) + overlord.stateData should be (NullData) + } + + "subscribe to bill acceptor's state transitions when initializing" in { + acceptor.expectMsg(FSM.SubscribeTransitionCallBack(overlord)) + } + + "listen to bill acceptor's serial port when initializing" in { + acceptor.expectMsg(Listen) + } + + "ping the network when initializing" in { + network.expectMsg("ping") + } + + "set price ticker service provider when initializing" in { + ticker.expectMsg(ChangeBitcoinService(Lycia.servicePriceTicker)) + } + + "set bitcoin wallet service provider when initializing" in { + wallet.expectMsg(ChangeBitcoinService(Lycia.serviceWallet)) + } + + "set price ticker percentage over market value when initializing" in { + ticker.expectMsg(Lycia.percentProfit) + } + + "tick the price ticker when initializing" in { + ticker.expectMsg(Tick) + } + + "get the price per bitcoin when a gossip message is received" in { + overlord ! GossipPrice + ticker.expectMsgPF(){ + case GetPrice => overlord ! Price("123.45") + } + comet.expectMsg("123.45") + } + + "initialize bitcoin wallet balance when initializing" in { + wallet.expectMsg(GetBalance) + } + + "be malfunctioning if initialization does not idle" in + state (Uninitialized) { + overlord ! FSM.StateTimeout + comet.expectMsg("/malfunction") + overlord.stateName should be (Malfunctioning) + overlord.stateData should be (Reason(Msg.unableToStart)) + } + + "warn about unknown message" in { + def test = warn ("unhandled") { + overlord ! "unknown" + } + + test + overlord.setState(CashInsert) + test + overlord.setState(Idle) + test + } + + "idle when bill acceptor is ready" in state (Uninitialized) { + overlord ! Ready + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "uninhibit all bills when bill acceptor is ready" in { + acceptor.expectMsg(UnInhibit) + } + + "be ready to scan qr code when session is started" in + state (Idle) { + overlord ! Start + overlord.stateName should be (QrScan) + overlord.stateData should be (NullData) + } + + "go back to idling if qr code is not scanned" in state (QrScan) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(Idle,"")) + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "be able to scan qr code and validate bitcoin address" in + state (QrScan) { + overlord ! qr + wallet.expectMsg(ValidateAddress(qr.qr)) + overlord.stateName should be (QrValidate) + overlord.stateData should be (NullData) + } + + "go back to scan qr code again if validation takes too long" in + state (QrValidate) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(QrScan, Msg.unableValidateQr)) + overlord.stateName should be (QrScan) + overlord.stateData should be (NullData) + } + + "send minion to log user in when qr code is valid" in + state (QrValidate) { + overlord ! address + minion.expectMsg(address) + overlord.stateName should be (UserLogin) + overlord.stateData should be (address) + } + + "go back to scan qr code if scanned code is invalid" in + state (QrValidate) { + overlord ! InvalidBitcoinAddress + comet.expectMsg(Goto(QrScan, Msg.invalidScannedQr)) + overlord.stateName should be (QrScan) + overlord.stateData should be (NullData) + } + + "go back to scan qr code again if logging user in takes too long" in + state (UserLogin, address) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(QrScan, Msg.unableValidateQr)) + overlord.stateName should be (QrScan) + overlord.stateData should be (NullData) + } + + "send minion to audit user data if it exists" in + state (UserLogin, address) { + overlord ! UserData(None) + minion.expectMsg(auditData) + overlord.stateName should be (HistoryAudit) + overlord.stateData should be (auditData) + } + + "go back to scan qr code again if auditting user takes too long" in + state (HistoryAudit, auditData) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(QrScan, Msg.unableValidateQr)) + overlord.stateName should be (QrScan) + overlord.stateData should be (NullData) + } + + "go back to idle if user has reached bitcoin buying quota" in + state (HistoryAudit, auditData) { + overlord ! NothingToSpend(1000) + comet.expectMsg(Goto(Idle, Msg.buyLimitQuota format 1000)) + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "send minion to create a transaction if user has not passed quota" in + state (HistoryAudit, auditData) { + overlord ! toSpend + minion.expectMsg(txCreated) + overlord.stateName should be (TxCreate) + overlord.stateData should be (txCreated) + } + + "go back to scan qr code again if creating transaction takes too long" in + state (TxCreate, txCreated) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(QrScan, Msg.unableValidateQr)) + overlord.stateName should be (QrScan) + overlord.stateData should be (NullData) + } + + "be ready for user to insert cash when transaction is created" in + state (TxCreate, txCreated) { + overlord ! tx + acceptor.expectMsg(Inhibit) + comet.expectMsg(tx) + comet.expectMsg(Goto(CashInsert, "")) + overlord.stateName should be (CashInsert) + overlord.stateData should be (tx) + } + + "go back to idling if there is no activity during cash insert state" in + state (CashInsert, tx) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(Idle,"")) + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "send minion to check if inserted bill does not pass the quota" in + state (CashInsert, tx) { + overlord ! Inserted(USD(10)) + minion.expectMsg((Inserted(USD(10)), tx)) + overlord.stateName should be (CashValidate) + overlord.stateData should be (tx) + } + + "be able to buy bitcoin after user is done inserting cash" in + state (CashInsert, tx) { + overlord ! Buy + acceptor.expectMsg(UnInhibit) + // TODO wallet should expect a message here + overlord.stateName should be (Sending) + overlord.stateData should be (tx) + } + + "go back to inserting cash if cash validation takes too long" in + state (CashValidate, tx) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + acceptor.expectMsg(Return) + overlord.stateName should be (CashInsert) + overlord.stateData should be (tx) + } + + "return bill if it passes the buying quota" in + state (CashValidate, tx) { + overlord ! InvalidBill + acceptor.expectMsg(Return) + overlord.stateName should be (CashInsert) + overlord.stateData should be (tx) + } + + "stack bill if it does not pass the buying quota" in + state (CashValidate, tx) { + overlord ! BillOkay + acceptor.expectMsg(Stack) + overlord.stateName should be (CashValidate) + overlord.stateData should be (tx) + } + + "wait until bill acceptor confirms the bill has been stacked" in + state (CashValidate, tx) { + val confirmation = Confirmed(USD(10)) + overlord ! confirmation + minion.expectMsg((confirmation, tx)) + overlord.stateName should be (CashValidate) + overlord.stateData should be (tx) + } + + "be ready for next bill when updated transaction is saved to disk" in + state (CashValidate, tx) { + overlord ! CashSaved(tx) + comet.expectMsg(tx) + overlord.stateName should be (CashInsert) + overlord.stateData should be (tx) + } + + "assume something is wrong and report error when sending bitcoins takes too long" in + state (Sending, tx) { + error ("Manual Assistance Required") { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(ErrorState, Msg.errorSending)) + overlord.stateName should be (ErrorState) + overlord.stateData should be (tx) + } + } + + "automatically send an email when sending bitcoins fails and user is logged in" in + state (Sending, tx.copy(userInfo = Some(userInfo))) { + overlord ! FSM.StateTimeout + // TODO test that minion gets email + comet.expectMsg(Goto(ErrorState, Msg.errorSending)) + } + + "receive finalized transaction when bitcoins are sent" in + state (Sending, tx) { + overlord ! txFinal + minion.expectMsg(("save", txFinal)) + minion.expectMsg(("expenditure", txFinal)) + minion.expectMsg(("transaction", txFinal)) + comet.expectMsg(Goto(Receipt,"")) + overlord.stateName should be (Receipt) + overlord.stateData should be (txFinal) + } + + "idle if there is no activity in error state" in + state (ErrorState) { + overlord.isStateTimerActive should be (true) + overlord ! FSM.StateTimeout + comet.expectMsg(Goto(Idle,"")) + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "allow client to reset error state timer" in + state (ErrorState, tx) { + overlord ! Wait + overlord.stateName should be (ErrorState) + overlord.stateData should be (tx) + } + + "be able to sms user to notify that we know about the error" in + state (ErrorState, tx) { + val number = "3051234567" + overlord ! Phone(number, true) + // TODO test that minion gets sms + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "be able to email user to notify that we know about the error" in + state (ErrorState, tx) { + val address = "test@example.com" + overlord ! Email(address) + // TODO test that minion gets email + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "allow client to reset receipt state timer" in + state (Receipt, txFinal) { + overlord ! Wait + overlord.stateName should be (Receipt) + overlord.stateData should be (txFinal) + } + + "be able to email user to send receipt" in + state (Receipt, txFinal) { + val address = "test@example.com" + overlord ! Email(address) + // TODO test that minion gets email + // TODO test that email is saved + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "be able to finalize transaction without sending a receipt" in + state (Receipt, txFinal) { + overlord ! Continue + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + + "be malfunctioning when bill acceptor is disconnected" in + state (Idle, address) { + error ("Bill acceptor not operating") { + overlord ! Disconnected + overlord.isTimerActive("poll-acceptor") should be (true) + comet.expectMsg("/malfunction") + overlord.stateName should be (Malfunctioning) + overlord.stateData should be (Reason(Msg.hardwareMalfunction)) + } + } + + "be able to recover from malfunctioning bill acceptor" in + state (Malfunctioning) { + info ("Bill acceptor is operating again") { + overlord ! Listen + acceptor.expectMsg(Listen) + overlord ! Ready + overlord.isTimerActive("poll-acceptor") should be (false) + comet.expectMsg("/index") + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + } + + "be malfunctioning when network is unreachable" in + state (Idle, auditData) { + error ("Network unreachable") { + overlord ! NetworkOutage + comet.expectMsg("/malfunction") + overlord.stateName should be (Malfunctioning) + overlord.stateData should be (Reason(Msg.networkUnreachable)) + } + } + + "be able to recover from unreachable network" in + state (Malfunctioning) { + info ("Network reestablished") { + overlord ! NetworkEstablished + comet.expectMsg("/index") + overlord.stateName should be (Idle) + overlord.stateData should be (NullData) + } + } + + "forward bill acceptor's current state and transitions to state watcher" in { + val currentState = FSM.CurrentState(acceptor.ref, "mystate") + val transition = FSM.Transition(acceptor.ref, "oldstate", "mystate") + overlord ! currentState + watcher.expectMsg(currentState) + overlord ! transition + watcher.expectMsg(transition) + } + } +} +\ No newline at end of file diff --git a/src/test/scala/inc/pyc/chimera/SpecHelper.scala b/src/test/scala/inc/pyc/chimera/SpecHelper.scala @@ -0,0 +1,35 @@ +package inc.pyc.chimera + +import akka.actor._ +import akka.testkit._ +import concurrent._ +import duration._ + +trait SpecHelper { + this: BaseSpec => + + def terminates (ref: ActorRef, max: FiniteDuration = 3 seconds) (func: => Unit) { + val test = TestProbe() + test.watch(ref) + func + test.expectTerminated(ref, max) + } + + def warn (start: String) (func: => Unit) { + EventFilter.warning(occurrences = 1, start = start) intercept { + func + } + } + + def error (start: String) (func: => Unit) { + EventFilter.error(occurrences = 1, start = start) intercept { + func + } + } + + def info (start: String) (func: => Unit) { + EventFilter.info(occurrences = 1, start = start) intercept { + func + } + } +} +\ No newline at end of file